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
47 changes: 47 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
36 changes: 24 additions & 12 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 @@ -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),
},
})
Expand All @@ -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))
}
}

Expand Down Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
hund030 marked this conversation as resolved.

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)
}
Comment thread
hund030 marked this conversation as resolved.

return &def, nil
}
Loading
Loading