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 884ca71f428..16bcd021548 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1046,6 +1046,35 @@ func runInitFromManifest( return action.Run(ctx) } +// agentDefiningFlagsSet reports whether the caller passed any flag that +// describes the agent to set up. +// +// Reusing an already-configured project is only safe when the command was not +// told what to build. Each of these flags feeds a value init would otherwise +// prompt for, so reusing while one is set would silently discard it — notably +// under --no-prompt, where reuse is unconditional. +// +// srcExplicit must come from cmd.Flags().Changed("src"), not from flags.src: +// applyPositionalArg folds a positional directory into flags.src, so testing +// the field would make `azd ai agent init .` — a documented form — skip reuse +// and re-prompt, which is the very behavior issue #9154 reports. +// +// --env and --infra are deliberately absent: they describe the environment and +// the IaC output rather than the agent, and both stay meaningful on a reuse run. +func agentDefiningFlagsSet(flags *initFlags, srcExplicit bool) bool { + return flags.agentName != "" || + flags.deployMode != "" || + flags.runtime != "" || + flags.entryPoint != "" || + flags.depResolution != "" || + flags.model != "" || + flags.modelDeployment != "" || + flags.projectResourceId != "" || + flags.image != "" || + srcExplicit || + len(flags.protocols) > 0 +} + func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &initFlags{} extCtx = ensureExtensionContext(extCtx) @@ -1295,6 +1324,55 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } } + // When the project's own manifest already declares agent + // service(s), the values init would prompt for (agent name, + // protocols, deploy mode) are already recorded there. Offer to + // reuse that configuration instead of re-asking (issue #9154). + // This is the unified-format counterpart to the bare agent.yaml + // reuse above: the definition now lives inline on the service + // entry rather than in a separate file. + // + // Any flag that describes the agent to set up states intent to + // configure that agent, so it opts out of reuse and falls through + // to the normal flow. Without that, a scripted + // `--no-prompt --deploy-mode code --runtime ...` in a repo that + // already declares an agent would silently no-op instead of + // honoring the flags the caller passed. + if flags.manifestPointer == "" && !manifestDetectedButDeclined && + !agentDefiningFlagsSet(flags, cmd.Flags().Changed("src")) { + detection := detectProjectAgentServices(ctx, azdClient) + if len(detection.services) > 0 && + !positionalSourceOptsOutOfReuse(flags.src, detection.projectRoot) { + useExisting := flags.noPrompt + if !flags.noPrompt { + confirmResp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf( + "This project already configures %s. Use it?", + describeProjectAgentServices(detection.services), + ), + DefaultValue: new(true), + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return exterrors.Cancelled("initialization was cancelled") + } + return fmt.Errorf("prompting for project agent reuse: %w", promptErr) + } + useExisting = *confirmResp.Value + } + if useExisting { + if err := runReuseProjectAgentServices( + ctx, flags, azdClient, detection.services, + ); err != nil { + return err + } + return ejectInfraAfterInit(infraProvider) + } + } + } + if flags.manifestPointer != "" { // Fail fast when the user accidentally passes a directory // instead of a manifest file — before downloading templates. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go new file mode 100644 index 00000000000..3c9366e5b54 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "slices" + "strings" + + "azureaiagent/internal/cmd/nextstep" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" +) + +// projectAgentService is an agent already declared in the project manifest. +// ServiceName is the key under services:; AgentName is the agent's own name from +// the definition, which may differ from the service key. +type projectAgentService struct { + ServiceName string + AgentName string +} + +type projectAgentDetection struct { + services []projectAgentService + projectRoot string +} + +// detectProjectAgentServices returns the agent services the azd host reports for +// the current project, sorted by service name so output is deterministic. +// +// Project discovery is left to the host: it resolves the manifest by walking up +// from the working directory and accepts both azure.yaml and azure.yml, so this +// sees exactly the manifest every other azd command does, including when init +// runs from a subdirectory of the project. +// +// The agent definition is carried inline on the service entry in the unified +// format and nested under config: in older projects; adoptedAgentNameConfig +// resolves the name from either shape. +// +// A project that cannot be loaded (none present, or a manifest azd rejects) +// yields no detections, so init falls through to its normal prompts rather than +// hard-failing on a file the user has not been asked about yet. The cause is +// logged so --debug still surfaces a typo'd manifest. +func detectProjectAgentServices(ctx context.Context, azdClient *azdext.AzdClient) projectAgentDetection { + projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + log.Printf("agent reuse: project config unavailable, continuing with normal init: %v", err) + return projectAgentDetection{} + } + + project := projectResponse.GetProject() + return projectAgentDetection{ + services: projectAgentServicesFrom(project.GetServices()), + projectRoot: project.GetPath(), + } +} + +// projectAgentServicesFrom selects the agent services out of a project's service +// map, resolving each display name and sorting by service name. +func projectAgentServicesFrom(services map[string]*azdext.ServiceConfig) []projectAgentService { + var found []projectAgentService + for serviceName, svc := range services { + if svc.GetHost() != AiAgentHost { + continue + } + + agentName, _ := adoptedAgentNameConfig(svc) + if agentName == "" { + agentName = serviceName + } + + found = append(found, projectAgentService{ServiceName: serviceName, AgentName: agentName}) + } + + slices.SortFunc(found, func(a, b projectAgentService) int { + return strings.Compare(a.ServiceName, b.ServiceName) + }) + + return found +} + +// positionalSourceOptsOutOfReuse reports whether a positional source directory +// selects something below or outside the active project root. +// +// `azd ai agent init .` from the project root is a documented way to initialize +// the current project and may reuse its configured agent. A positional +// `./agents/new`, however, explicitly selects source for a new agent and must +// not be silently ignored by reuse. +func positionalSourceOptsOutOfReuse(src, projectRoot string) bool { + if src == "" { + return false + } + if projectRoot == "" { + return true + } + + srcPath, err := filepath.Abs(src) + if err != nil { + return true + } + rootPath, err := filepath.Abs(projectRoot) + if err != nil { + return true + } + + srcInfo, err := os.Stat(srcPath) + if err != nil { + return true + } + rootInfo, err := os.Stat(rootPath) + if err != nil { + return true + } + + return !os.SameFile(srcInfo, rootInfo) +} + +// describeProjectAgentServices renders the detected agent services for a prompt +// or status line, e.g. `"chat" (agent: my-chat-agent)`. +func describeProjectAgentServices(services []projectAgentService) string { + parts := make([]string, 0, len(services)) + for _, svc := range services { + if svc.AgentName != "" && svc.AgentName != svc.ServiceName { + parts = append(parts, fmt.Sprintf("%q (agent: %s)", svc.ServiceName, svc.AgentName)) + } else { + parts = append(parts, fmt.Sprintf("%q", svc.ServiceName)) + } + } + return strings.Join(parts, ", ") +} + +// runReuseProjectAgentServices completes init for a project whose manifest +// already declares its agents, without re-asking for the values the manifest +// already answers (agent name, protocols, deploy mode, ...). +// +// The definitions already live in the project manifest, so there is nothing to +// write: this ensures an azd environment exists and then hands off to the shared +// next-step resolver. It mirrors runReuseDefinition (issue #7268), which does +// the same for a bare on-disk agent.yaml; the unified format moved the +// definition inline, and this is the inline equivalent. +// +// The caller reaches this function only after detectProjectAgentServices has +// loaded the project through the azd host, so no project setup is needed here. +func runReuseProjectAgentServices( + ctx context.Context, + flags *initFlags, + azdClient *azdext.AzdClient, + services []projectAgentService, +) error { + fmt.Println(color.HiBlackString( + "Detected existing agent configuration: %s.", + describeProjectAgentServices(services), + )) + + env := getExistingEnvironment(ctx, flags.env, azdClient) + if env == nil { + envName := flags.env + if envName == "" { + envName = sanitizeAgentName(services[0].AgentName + "-dev") + } + var err error + env, err = createNewEnvironment(ctx, azdClient, envName) + if err != nil { + return fmt.Errorf("failed to create azd environment: %w", err) + } + flags.env = env.Name + } + + fmt.Println(color.HiBlackString("Reusing the agent configuration already in this project.")) + + state, _ := nextstep.AssembleState(ctx, azdClient) + _ = printAllNextIfTerminal(os.Stdout, nextstep.ResolveAfterInit(state, readmeExistsForProject(ctx, azdClient))) + + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go new file mode 100644 index 00000000000..15e890c68da --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go @@ -0,0 +1,274 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +// legacyConfigAgentService builds the deprecated shape, where the agent +// definition is nested under config: rather than inline on the service entry. +func legacyConfigAgentService(serviceName, agentName string) *azdext.ServiceConfig { + return &azdext.ServiceConfig{ + Name: serviceName, + Host: AiAgentHost, + Config: &structpb.Struct{Fields: map[string]*structpb.Value{ + "kind": structpb.NewStringValue(string(agent_yaml.AgentKindHosted)), + "name": structpb.NewStringValue(agentName), + }}, + } +} + +func TestProjectAgentServicesFrom(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + services map[string]*azdext.ServiceConfig + want []projectAgentService + }{ + { + name: "inline agent definition on the service entry", + services: map[string]*azdext.ServiceConfig{ + "chat": inlineAgentService(t, "chat", "my-chat-agent"), + }, + want: []projectAgentService{{ServiceName: "chat", AgentName: "my-chat-agent"}}, + }, + { + name: "deprecated config-nested definition", + services: map[string]*azdext.ServiceConfig{ + "chat": legacyConfigAgentService("chat", "legacy-agent"), + }, + want: []projectAgentService{{ServiceName: "chat", AgentName: "legacy-agent"}}, + }, + { + name: "falls back to the service key when the definition lives in agent.yaml", + services: map[string]*azdext.ServiceConfig{ + "chat": {Name: "chat", Host: AiAgentHost}, + }, + want: []projectAgentService{{ServiceName: "chat", AgentName: "chat"}}, + }, + { + name: "multiple agents are sorted by service name", + services: map[string]*azdext.ServiceConfig{ + "zeta": inlineAgentService(t, "zeta", "zeta-agent"), + "alpha": inlineAgentService(t, "alpha", "alpha-agent"), + }, + want: []projectAgentService{ + {ServiceName: "alpha", AgentName: "alpha-agent"}, + {ServiceName: "zeta", AgentName: "zeta-agent"}, + }, + }, + { + name: "non-agent Foundry services are ignored", + services: map[string]*azdext.ServiceConfig{ + "project": {Name: "project", Host: "azure.ai.project"}, + "api": {Name: "api", Host: "containerapp"}, + }, + want: nil, + }, + { + name: "project with no services", + services: nil, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, projectAgentServicesFrom(tt.services)) + }) + } +} + +// Ordering must not depend on Go's randomized map iteration, so the same input +// is evaluated repeatedly. +func TestProjectAgentServicesFrom_OrderingIsStable(t *testing.T) { + t.Parallel() + + services := map[string]*azdext.ServiceConfig{ + "delta": {Name: "delta", Host: AiAgentHost}, + "alpha": {Name: "alpha", Host: AiAgentHost}, + "charlie": inlineAgentService(t, "charlie", "c-agent"), + "bravo": {Name: "bravo", Host: AiAgentHost}, + } + want := []projectAgentService{ + {ServiceName: "alpha", AgentName: "alpha"}, + {ServiceName: "bravo", AgentName: "bravo"}, + {ServiceName: "charlie", AgentName: "c-agent"}, + {ServiceName: "delta", AgentName: "delta"}, + } + + for range 20 { + assert.Equal(t, want, projectAgentServicesFrom(services)) + } +} + +// Reuse is only safe when the caller did not describe an agent to set up. +// Under --no-prompt reuse is unconditional, so any of these flags being ignored +// would silently no-op a scripted run. +func TestAgentDefiningFlagsSet(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flags *initFlags + srcExplicit bool + want bool + }{ + {name: "no flags", flags: &initFlags{}, want: false}, + {name: "agent-name", flags: &initFlags{agentName: "my-agent"}, want: true}, + {name: "deploy-mode", flags: &initFlags{deployMode: "code"}, want: true}, + {name: "runtime", flags: &initFlags{runtime: "python_3_13"}, want: true}, + {name: "entry-point", flags: &initFlags{entryPoint: "app.py"}, want: true}, + {name: "dep-resolution", flags: &initFlags{depResolution: "bundled"}, want: true}, + {name: "model", flags: &initFlags{model: "gpt-5.4-mini"}, want: true}, + {name: "model-deployment", flags: &initFlags{modelDeployment: "my-deployment"}, want: true}, + {name: "project-id", flags: &initFlags{projectResourceId: "/subscriptions/x"}, want: true}, + {name: "image", flags: &initFlags{image: "myacr.azurecr.io/agent:1"}, want: true}, + {name: "protocol", flags: &initFlags{protocols: []string{"responses"}}, want: true}, + + // An explicit --src names where a new agent's source goes, so it opts + // out. A positional path is classified separately against the project + // root after detection. + { + name: "explicit --src", + flags: &initFlags{src: "agents/chat"}, + srcExplicit: true, + want: true, + }, + { + name: "src folded from a positional arg is not an explicit flag", + flags: &initFlags{src: "."}, + want: false, + }, + + // Neither describes the agent, so both stay compatible with reuse. + {name: "env alone does not opt out", flags: &initFlags{env: "dev"}, want: false}, + {name: "infra alone does not opt out", flags: &initFlags{infra: "bicep"}, want: false}, + {name: "no-prompt alone does not opt out", flags: &initFlags{noPrompt: true}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, agentDefiningFlagsSet(tt.flags, tt.srcExplicit)) + }) + } +} + +// Regression guard for the positional form documented in the command's Use +// string: `azd ai agent init .` resolves to flags.src via applyPositionalArg, +// which must not be mistaken for an explicit --src before it is compared with +// the active project root (#9154). +func TestAgentDefiningFlagsSet_PositionalPathKeepsReuse(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + flags := &initFlags{} + cmd := newInitCommand(&azdext.ExtensionContext{}) + + require.NoError(t, applyPositionalArg(dir, flags, cmd)) + require.Equal(t, dir, flags.src, "a positional directory is folded into flags.src") + + assert.False(t, agentDefiningFlagsSet(flags, cmd.Flags().Changed("src")), + "a positional path must not be treated as an explicit --src flag") +} + +func TestPositionalSourceOptsOutOfReuse(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + agentDir := filepath.Join(projectRoot, "agents", "new") + require.NoError(t, os.MkdirAll(agentDir, 0o750)) + + tests := []struct { + name string + src string + projectRoot string + want bool + }{ + { + name: "project root keeps reuse", + src: projectRoot, + projectRoot: projectRoot, + want: false, + }, + { + name: "selected agent directory opts out", + src: agentDir, + projectRoot: projectRoot, + want: true, + }, + { + name: "no positional source keeps reuse", + src: "", + projectRoot: projectRoot, + want: false, + }, + { + name: "missing project root opts out conservatively", + src: projectRoot, + projectRoot: "", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, positionalSourceOptsOutOfReuse(tt.src, tt.projectRoot)) + }) + } +} + +func TestDescribeProjectAgentServices(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + services []projectAgentService + want string + }{ + { + name: "agent name differs from the service key", + services: []projectAgentService{{ServiceName: "chat", AgentName: "my-chat-agent"}}, + want: `"chat" (agent: my-chat-agent)`, + }, + { + name: "agent name matching the service key is not repeated", + services: []projectAgentService{{ServiceName: "chat", AgentName: "chat"}}, + want: `"chat"`, + }, + { + name: "multiple services are comma separated", + services: []projectAgentService{ + {ServiceName: "alpha", AgentName: "alpha"}, + {ServiceName: "zeta", AgentName: "zeta-agent"}, + }, + want: `"alpha", "zeta" (agent: zeta-agent)`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, describeProjectAgentServices(tt.services)) + }) + } +}