fix(agents): reuse existing azure.yaml agent config on init - #9404
fix(agents): reuse existing azure.yaml agent config on init#9404glharper wants to merge 4 commits into
Conversation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fed9e97b-e79b-4889-ac76-0d9a428599cd
📋 Prioritization NoteThanks for the contribution! The linked issue isn't in the current milestone yet. |
|
Azure Pipelines: Successfully started running 1 pipeline(s). 21 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
Adds reuse of inline agent configuration from an existing project manifest during azd ai agent init.
Changes:
- Detects inline and legacy nested agent definitions.
- Reuses existing configuration without prompting.
- Adds manifest parsing and formatting tests.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
internal/cmd/init.go |
Integrates existing-project reuse detection. |
internal/cmd/init_reuse_project_agent.go |
Implements detection and reuse. |
internal/cmd/init_reuse_project_agent_test.go |
Tests manifest discovery and parsing. |
jongio
left a comment
There was a problem hiding this comment.
Two things worth a second look before this merges.
The new azure.yaml reader re-implements parsing the extension already gets from the azd host, and it reads from --src rather than the project root. Details inline on init_reuse_project_agent.go.
The reuse gate only opts out on --agent-name, but --no-prompt makes reuse unconditional, so a scripted run in a repo that already declares an agent silently ignores every other agent-defining flag instead of adding a second agent. Details inline on init.go.
Two smaller ones inline: a validatePostInit call that can't do anything, and a swallowed YAML parse error.
| // A manifest that cannot be parsed yields no services rather than an error: the | ||
| // caller treats "nothing detected" as "fall through to the normal init prompts", | ||
| // which is the safe outcome for a malformed file. | ||
| func findProjectAgentServices(path string) ([]projectAgentService, error) { |
There was a problem hiding this comment.
This re-implements reading agent services out of azure.yaml, which the extension already gets from the host. helpers.go around line 689 iterates azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) and filters on s.Host == AiAgentHost, and adoptedAgentNameConfig in init_adopt.go already resolves the agent name from both the inline additionalProperties shape and the deprecated config: nested shape.
Going through the host would also drop the azure.yml candidate list and fix the project-root lookup already raised below, since the host resolves the project by walking up from the cwd, and azdcontext handles azure.yml with azure.yaml taking precedence (which azdext.GetProjectDir on its own does not).
The tradeoff: Project().Get() probably hard-fails on a malformed azure.yaml where this returns no services and falls through to the normal init flow. Was that the reason for parsing directly, or was standalone parsing just simpler here?
There was a problem hiding this comment.
Good call — switched to the host in 00f592d. findProjectManifest and the hand-rolled service struct are gone; detection is now azdClient.Project().Get(ctx, ...) with adoptedAgentNameConfig resolving the name from both shapes.
To your question: it was just simpler, not a considered tradeoff — so thanks for pushing. Your hunch about Project().Get() is right, though: it goes through lazyProjectConfig → project.Load, so a malformed manifest is a hard error rather than an empty project. That doesn't force the choice, because the extension can decide what the error means here — any failure to load is treated as "no detections" and init falls through to the normal prompts, same as before. The difference is it's no longer silent (see the reply on the swallowed-error thread).
Dropping the private reader also fixed the --src bug from the sibling thread and the azure.yml gap for free, since the host walks up from the cwd and azdcontext already handles both filenames with azure.yaml taking precedence.
One consequence worth flagging: ProjectConfig.Path is filepath.Dir(projectFilePath), so the actual filename isn't recoverable from the response. Rather than reintroduce candidate-list guessing just to render it, the prompt is now file-agnostic: This project already configures "chat" (agent: my-chat-agent). Use it?
| // agent, so it opts out of reuse and falls through to the normal | ||
| // flow rather than silently adopting whatever azure.yaml already | ||
| // declares. | ||
| if flags.manifestPointer == "" && !manifestDetectedButDeclined && flags.agentName == "" { |
There was a problem hiding this comment.
The --agent-name opt-out makes sense, but the other agent-defining flags don't get the same treatment. --deploy-mode, --runtime, --entry-point, --protocols, --model, --model-deployment, --project-resource-id, and --dep-resolution are all silently ignored once this block fires.
That's a behavior change for scripts. promptInitMode returns initModeFromCode under --no-prompt in a non-empty directory, so today azd ai agent init --no-prompt --deploy-mode code --runtime python_3_13 --entry-point app.py in a repo that already declares an agent runs the from-code path and honors those flags. After this, useExisting := flags.noPrompt makes reuse unconditional and the command no-ops.
Either extend the opt-out to any agent-defining flag, or fail when reuse would discard flags the caller explicitly passed.
There was a problem hiding this comment.
Agreed — that scripted no-op is the worse failure, since nothing tells the caller their flags were dropped. Fixed in 00f592d by extending the opt-out rather than erroring: passing any flag that describes the agent to set up falls through to the normal flow.
New agentDefiningFlagsSet covers --agent-name, --deploy-mode, --runtime, --entry-point, --dep-resolution, --model, --model-deployment, --project-id, --image, --protocol, and --src. Your example now runs the from-code path and honors the flags exactly as it does today.
--env and --infra are deliberately excluded — they describe the environment and the IaC output, not the agent, and both stay meaningful on a reuse run.
One subtlety I got wrong on the first pass and then caught: --src has to be tested via cmd.Flags().Changed("src"), not flags.src != "". applyPositionalArg folds a positional directory into the same field, so testing the field made azd ai agent init . skip reuse and re-prompt — reintroducing #9154 through the documented positional form. There's now a regression test driving the real applyPositionalArg path for it, plus a note on the helper so it doesn't come back.
|
|
||
| // Advisory only, matching the other reuse paths. The deploy-mode specific | ||
| // checks need a CodeConfiguration, which is not re-parsed here. | ||
| validatePostInit(srcDir, nil) |
There was a problem hiding this comment.
validatePostInit returns immediately when codeConfig is nil (init_validate.go:23), so this call can never do anything. Worth deleting rather than leaving a no-op behind a comment explaining why it's a no-op.
There was a problem hiding this comment.
Confirmed and deleted in 00f592d — validatePostInit returns immediately on a nil codeConfig (init_validate.go:23), so the call could never do anything. The srcDir parameter it was the only consumer of is gone from runReuseProjectAgentServices too.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
cli/azd/extensions/azure.ai.agents/internal/cmd/init.go:1313
- This fallback misses an
azure.ymlproject when the command is run from a subdirectory.azdext.GetProjectDironly walks upward forazure.yaml(cli/azd/pkg/azdext/project.go:16-35), socheckDirbecomes.and the subsequent shallow scan never reaches the parent manifest. Resolve the root using both supported project filenames and add a nested-directoryazure.ymltest.
if errors.Is(projectErr, azdext.ErrProjectNotFound) {
checkDir = "."
cli/azd/extensions/azure.ai.agents/internal/cmd/init.go:1311
- This line is not
gofmt-formatted, so the repository's formatting check will fail. Indent it consistently with the surrounding block.
checkDir, projectErr := azdext.GetProjectDir()
cli/azd/extensions/azure.ai.agents/internal/cmd/init.go:1357
- No test executes this new
RunEbranch orrunReuseProjectAgentServices; the added tests only validate YAML scanning and display formatting. Add command-level coverage for interactive accept/decline, no-prompt reuse, explicit-option opt-out, environment creation/reuse, and invocation from a nested project directory so the orchestration—not just its parser—is verified.
if err := runReuseProjectAgentServices(
ctx, flags, azdClient, checkDir, displayPath, agentServices,
cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go:157
ensureProjectprintsFound existing azd project ... Adding agent to it.for an existing project (init.go:1876-1879), but this reuse path intentionally writes no service. Avoid that helper's mutation-oriented status message here, or refactor it so this path can report that the existing configuration is being reused rather than claiming an agent was added.
if _, err := ensureProject(ctx, flags, azdClient, "."); err != nil {
…arser Addresses review feedback on #9404. Detection went through its own azure.yaml reader rooted at --src, which is the agent source directory rather than the project root, so a project found by walking up from the cwd was missed and init re-prompted anyway. It also carried a private azure.yaml/azure.yml candidate list and a hand-rolled service struct. Ask the host instead: Project().Get() resolves the manifest the same way every other azd command does (walking up from the cwd, honoring azure.yml), and adoptedAgentNameConfig already reads the agent name from both the inline and the deprecated config: shapes. A project the host cannot load still yields no detections so init falls through to its normal prompts, but the cause is now logged instead of silently swallowed. Reuse also opted out only on --agent-name. Since --no-prompt makes reuse unconditional, a scripted run passing --deploy-mode/--runtime/--entry-point or any other agent-defining flag would silently no-op; every such flag now opts out. --src counts only when passed explicitly, because applyPositionalArg folds a positional path into the same field and `azd ai agent init .` must keep reusing. Also drops a validatePostInit call that could never run, since it returns immediately when its codeConfig argument is nil. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d5b46e6-fc04-48bb-9a2c-156105966b48
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (2)
cli/azd/extensions/azure.ai.agents/internal/cmd/init.go:1074
- A positional directory is also an explicit source selection:
applyPositionalArgmapsazd ai agent init ./agents/newtoflags.src, just like--src. BecausesrcExplicitonly checks Cobra's flag state, this invocation is treated as having no agent-defining input; in--no-promptmode it silently reuses the existing service instead of initializing the supplied source directory. Preserve reuse only when the positional path resolves to the active project root (theinit .case), and treat other positional source paths as opt-outs. [azd-code-reviewer]
srcExplicit ||
cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go:114
ensureProject's existing-project branch always printsFound existing azd project ... Adding agent to it.(init.go:1882-1885), but this reuse path deliberately writes no service. Every successful reuse therefore emits a false mutation status and may show add-agent-specific infra guidance. Use a read-only project check here, or split the generic project lookup from the add-agent messaging. [azd-code-reviewer]
if _, err := ensureProject(ctx, flags, azdClient, "."); err != nil {
return err
}
jongio
left a comment
There was a problem hiding this comment.
One leftover from the rework, not a blocker.
runReuseProjectAgentServices still calls ensureProject, but that call can't do anything on this path anymore. Detection now runs through the host, so reaching the reuse branch already proves Project().Get() succeeded, which makes the scaffold branch inside ensureProject unreachable and its return value discarded.
| describeProjectAgentServices(services), | ||
| )) | ||
|
|
||
| if _, err := ensureProject(ctx, flags, azdClient, "."); err != nil { |
There was a problem hiding this comment.
This can't reach its scaffold branch. ensureProject only scaffolds when azdClient.Project().Get() returns an error (init.go:1841), and getting here already requires that same call to have succeeded and returned at least one agent service, since findProjectAgentServices returns nil on any error and the caller gates on len(agentServices) > 0. So this is a second round-trip whose only possible outcome is success, with the project config thrown away via _.
runReuseDefinition is where the same call earns its keep: a bare agent.yaml can sit in a directory with no project yet, so scaffolding is reachable there, and it uses the returned projectConfig (init_from_code_reuse.go:88) rather than discarding it.
Dropping it looks safe here.
There was a problem hiding this comment.
Fixed in 23bd0ae — dropped the ensureProject call.
Your reachability proof is exactly right: detectProjectAgentServices now obtains the services and project root from Project().Get(), and the caller only enters runReuseProjectAgentServices when that detection returned at least one agent service. A second Project().Get() inside ensureProject therefore could not reach scaffolding, discarded its result, and printed the misleading Adding agent to it. status even though this path writes no service.
The reuse helper now documents that precondition and starts directly with environment lookup/creation.
While validating the flow I also tightened the positional-source distinction: positional . (or the active project-root path) may reuse, but positional ./agents/new opts out so the selected source is not silently ignored. That comparison uses os.SameFile against the project root returned by the same host response, with regression coverage for both cases.
Host-based detection already proves Project().Get succeeded before the reuse path runs. Calling ensureProject again could not reach its scaffold branch, discarded the returned project, and printed the false status that an agent was being added even though reuse intentionally writes no service. Remove the second round-trip and document the precondition. Also distinguish a positional project-root path from a positional agent source directory. `init .` at the project root can reuse its configured agent, while `init ./agents/new` must fall through and honor the selected source instead of silently ignoring it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d5b46e6-fc04-48bb-9a2c-156105966b48
| if flags.manifestPointer == "" && !manifestDetectedButDeclined && | ||
| !agentDefiningFlagsSet(flags, cmd.Flags().Changed("src")) { |
Summary
azd ai agent initin a project that already has anazure.yamlre-prompted for agent name, protocols, and deploy mode instead of reading them from the manifest.Init already detects and reuses an existing
agent.manifest.yamlor a bareagent.yaml(the latter added for #7268 — "less to ask and just setup azure.yaml"). But the unified manifest work (d8f3abe) moved the agent definition inline onto theazure.yamlservice entry and stopped writing a standaloneagent.yaml:The reuse detection was never extended to look there, so a modern project matches neither existing check, falls through to
promptInitMode→ the from-code path, and re-asks for everythingazure.yamlalready answers.Changes
findProjectAgentServices— asks the azd host for the project (Project().Get()) and selects the services withhost: azure.ai.agent. Name resolution is delegated to the existingadoptedAgentNameConfig, which already handles both the inline definition and the deprecatedconfig:-nested shape.runReuseProjectAgentServices— completes init without re-prompting: host-based detection already proves the project exists, so it only ensures an azd environment exists and hands off to the shared next-step resolverRunEdirectly after the existingagent.yamlreuse block, guarded byflags.manifestPointer == "", so every current flow is untouchedInteractive runs get a confirm (defaulting to yes) matching the sibling detection prompts; declining falls through to today's behavior, so adding a second agent to an existing project still works.
Notes
Project().Get()rather than readingazure.yamldirectly means init sees the same manifest every other azd command does: resolved by walking up from the working directory, and honoringazure.ymlas well asazure.yaml. It also reuses core's parsing instead of carrying a second, hand-rolled view of the service schema.Project().Get()errors on a malformed manifest (it goes throughproject.Load). Init treats that as nothing detected and falls through to the normal prompts, rather than failing on a file the user hasn't been asked about yet. The cause is logged, so--debugstill surfaces a typo.--no-promptmakes reuse unconditional, so reusing while the caller passed--deploy-mode,--runtime,--entry-point,--agent-name,--protocol,--model,--model-deployment,--project-id,--image, or--srcwould silently discard them. Those runs fall through to the normal flow instead.--envand--infradescribe the environment and the IaC output rather than the agent, so they stay compatible with reuse.--srcalways opts out. A positional project-root path (for exampleazd ai agent init .from the root) may reuse; a positional agent directory such as./agents/newopts out so the selected source is not silently ignored.runInitFromAzureYaml) is deliberately not reused here: it refuses when a project manifest already exists, since merging a sample's services into an existingazure.yamlis tracked separately as [ext-agents]: azd ai agent init -m <azure.yaml> should merge into an existing project's azure.yaml #8884. This change is about respecting the manifest already present, not merging a new one in.Testing
go test ./... -count=1(all extension packages)go build ./...golangci-lint run ./internal/cmd/...— 0 issuesgofmt -s -l ./internal— cleancspell linton the changed files — 0 issuesNew coverage: inline and
config:-nested definitions, fallback to the service key when the definition lives in an on-diskagent.yaml, deterministic ordering under Go's randomized map iteration, non-agent hosts ignored, the full agent-defining-flag matrix, the realapplyPositionalArgpath, and positional project-root versus agent-source directory classification.Fixes #9154