Skip to content
Open
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
173 changes: 173 additions & 0 deletions cli/azd/docs/design/azd-ai-agent-init-proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# PM Review: Explicit agent initialization and addition

**Status:** Proposed, awaiting approval

**Tracking issue:** [Azure/azure-dev#8383](https://github.com/Azure/azure-dev/issues/8383)

## Problem statement

`azd ai agent init` currently performs different operations depending on the directory where it runs.

The same command can:

- create a new azd project and its first agent;
- add an agent to an existing project found in the current or a parent directory;
- initialize from local code, an agent manifest, or a project template;
- generate Bicep or Terraform when `--infra` is present.

This contextual behavior makes the command difficult to predict and debug. In particular, a user can intend to create a new project but unknowingly modify a parent project's `azure.yaml`.

## Solution

Separate new-project initialization from adding an agent:

```text
azd ai agent init Create a new agent project
azd ai agent add Add an agent to an existing project
```

Keep `--infra` on `init` for backward compatibility:

```text
azd ai agent init <source> --infra # Initialize, then generate Bicep
azd ai agent init <source> --infra=terraform
azd ai agent init --infra # Generate IaC for an existing project
```

The command name determines whether agent setup creates or modifies a project. Filesystem detection may improve interactive choices, but it cannot silently switch `init` into `add`.

## Goals

- Make project creation versus modification explicit.
- Prevent `init` from silently adding an agent to a current or parent project.
- Preserve the existing `--infra` workflows.
- Make interactive and non-interactive behavior predictable.
- Return actionable replacement commands when users invoke the wrong operation.
- Preserve existing project configuration when adding another agent.

## Scope

### In scope

- Add `azd ai agent add`.
- Restrict agent setup through `init` to new projects.
- Retain standalone and post-init `--infra` behavior.
- Support explicit sources: template, local code, agent manifest, existing definition, and container image.
- Use one visible source-selection prompt in interactive mode.
- Require an explicit source in non-interactive setup.
- Detect current and parent projects consistently.
- Preserve existing services and shared Foundry configuration when adding an agent.
- Provide compatibility guidance for existing `init` scripts and arguments.

### Non-scope

- Merging a complete sample `azure.yaml` into an existing project. This remains tracked by [#8884](https://github.com/Azure/azure-dev/issues/8884).
- Moving agent addition into core `azd add`.
- Moving `--infra` to a third command.
- Automatically modifying existing Bicep or Terraform to add Foundry resources.
- Changing deployment behavior or when a remote Foundry agent is created.
- Supporting unsafe project shapes such as Aspire projects, multiple Foundry project services, or conflicting shared resources.

## Current behavior

| Context | Current `azd ai agent init` behavior | User risk |
|---|---|---|
| Empty directory | Starts from a template | Mode is inferred from directory state. |
| Non-empty directory | Offers local code or template | The same command follows a different flow. |
| Existing project in current/parent directory | Adds the agent to that project | A parent project can be modified unintentionally. |
| Agent manifest/definition detected | Prompts to reuse it; may auto-accept in no-prompt mode | Adding a file changes command semantics. |
| Existing project plus `--infra` | Generates IaC only | `init` performs no initialization. |
| New project plus `--infra` | Initializes and then generates IaC | Useful workflow that should remain supported. |

## Command contract

### Commands

```text
azd ai agent init [source] [--infra[=bicep|terraform]] [options]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[source] is written positionally here and in the add signature on the next line, and both tables follow it with init <source> / add <source>. But none of the five entries in the Sources block is positional. They're all flags. No row in either table can be typed as written.

The slot isn't free either. On main, init's Use is init [<path>] [-m <manifest pointer>] [--src <source directory>] with Args: cobra.MaximumNArgs(1), and applyPositionalArg resolves that one positional into either --manifest or --src depending on what it points at. Review question 4 proposes keeping that form temporarily with deprecation warnings, so this signature makes the spelling you're deprecating the canonical one.

add has the sharper version of the problem. It's a new command with no legacy positional to preserve, so add [source] hands it a slot that nothing in the doc defines.

There's also a rule neither table can express. Today the positional and its matching flag are mutually exclusive: applyPositionalArg returns CodeConflictingArguments for init <path> -m <manifest>. If the positional survives on either command, that rule survives with it, and a yes/no Source supplied column can't say which spelling was used, let alone that both at once is an error.

Writing the signatures as init --<source> / add --<source> and giving the legacy positional its own row would settle all three.

azd ai agent add [source] [options]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[options] never gets defined, which matters more here than on init because add becomes the only way to touch an existing project.

init ships 14 flags on main. This doc accounts for four of them: --infra, --manifest, --image, and --src via review question 4. The other ten don't appear anywhere: --agent-name, --deploy-mode, --runtime, --entry-point, --dep-resolution, --protocol, --project-id, --model, --model-deployment, --force.

Most of those shape the agent rather than the project, so they either survive on add or the capability disappears for existing projects. One is a hard dependency rather than a nice to have: --image requires --agent-name when --manifest is absent (init.go:71). Under this split, today's init --image X --agent-name Y against an existing project becomes add --image X, and --agent-name has no declared home, so the add contract can't express a working call to its own --image source.

--project-id cuts the other way. It's the brownfield Foundry input that Decision 7 and review question 6 are about, so whether it lives on add decides if an agent can be attached to an already provisioned Foundry project.

azd-update.md in this same directory handles this with a flag table. One row per shipping flag with its owning command would settle the add surface and close review question 4's migration mapping at the same time.

```

### Sources

```text
--template
--from-code <directory>
--manifest <path-or-url>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On main today -m/--manifest isn't one source, it's two. The flag's own help text calls it "Path or URI to an agent manifest, or to a sample's unified azure.yaml to adopt as the project manifest" (cli/azd/extensions/azure.ai.agents/internal/cmd/init.go:1541), and the code branches on exactly that at line 1305, detecting whether the pointer is a unified Foundry azure.yaml and adopting it as the project manifest instead of treating it as an agent manifest (#8798).

The Sources block lists it flat, and add <source> on line 114 takes any source in an existing project. So add --manifest <sample azure.yaml> resolves to adopting a complete project manifest into an existing project. Line 64 hands that exact case to #8884, and review question 7 answers it directly with unified-project adoption "for new projects only". The contract as written routes it the other way.

Neither table catches this, because both key on Source supplied as a yes/no and can't tell the two things --manifest accepts apart.

Splitting the entry would close it: --manifest <agent-manifest> is a source for both commands, unified azure.yaml adoption stays init-only per review question 7, and add rejects a unified pointer with a message pointing at #8884.

--definition <path>
--image <registry/image[:tag]>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--image already ships on init and isn't a standalone source today. At this PR's base commit, internal/cmd/init.go documents two distinct behaviors: set without --manifest, init synthesizes a minimal hosted container manifest and routes it through the manifest flow, and requires --agent-name in that case; set with --manifest, the manifest is the source and --image only overrides the image the manifest carries.

Listing it flat here makes --manifest X --image Y read as two sources, which collides with the one-source requirement on line 107 and with the single source-selection prompt in Scope. That combination works today, so as written this is a silent breaking change for anyone scripting it.

Splitting the entry would fix it: --image is a source only when --manifest is absent, and in that case it also needs --agent-name; otherwise it's a modifier on the manifest source. --manifest has a related gap since it already ships as -m, so the compatibility mapping in review question 4 should say whether the short form survives.

```

Interactive use may omit the source and select one from a prompt. Non-interactive agent setup must supply a source. Existing-project `init --infra --no-prompt` is the exception because `--infra` completely identifies the requested operation.

### Behavioral rules

| Invocation | Contract |
|---|---|
| `init <source>` with no project | Create a project and its initial agent. |
| `init <source> --infra` with no project | Create the project/agent, then generate Bicep. |
| `init <source> --infra=terraform` with no project | Create the project/agent, then generate Terraform. |
| `init --infra` in an existing compatible project | Generate Bicep only; do not add an agent. |
| `init --infra=terraform` in an existing compatible project | Generate Terraform only; do not add an agent. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#9291 landed on main one commit after this PR's base, and it settles the Project found question for the --infra path in a way this row doesn't reflect. The standalone eject gate went from fileExists("azure.yaml"), which is cwd-only, to azdext.GetProjectDir(). That reads AZD_EXEC_PROJECT_DIR first and otherwise walks up from cwd via FindFileUpward (cli/azd/pkg/azdext/project.go:22). ejectInfra then writes into the resolved root rather than cwd.

So this row is wider than it reads. Run init --infra=terraform from a subdirectory of an existing project and the eject targets the parent root. For terraform that isn't read-only: ejectInfra stamps infra.provider: terraform into the parent's azure.yaml, and the code comment calls it "the one path that mutates azure.yaml". That's the same shape as the problem on line 18, a user unknowingly modifying a parent project's azure.yaml, except it arrives through --infra instead of agent registration. The goal on line 42 only covers adding an agent, so nothing in the contract rules this out.

Worth stating directly whether standalone --infra uses the same upward resolution, and if it does, whether it should print the resolved project root before writing or require cwd to be the root. AZD_EXEC_PROJECT_DIR is also a third input to Project found that neither table accounts for.

| `init <source>` in an existing project | Fail before mutation and suggest the equivalent `add` command. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rule closes the silent-mutation hole from line 18, but it also closes the path the user in that scenario actually wanted. The problem statement is a user who intends to create a new project and instead modifies a parent's azure.yaml. Under this row that user gets a failure, and the only replacement offered is add, which mutates the parent. The intent still has nowhere to go.

How wide that is depends on how Project found resolves. If it's the upward walk (azdext.GetProjectDir, then FindFileUpward in cli/azd/pkg/azdext/project.go), the search runs to the filesystem root, so every directory at any depth under an azd project counts as an existing project and can never run init <source>. In a monorepo with azure.yaml at the root, that's every subdirectory.

Core azd init made the opposite call on purpose. On main, cli/azd/cmd/init.go:321 builds its context with azdcontext.NewAzdContextWithDirectory(wd), pinned to cwd, while cli/azd/cmd/container.go:285 gives every other command azdcontext.NewAzdContext() and its upward search. Core init is the one command that deliberately ignores parent projects, so a nested project can still be created. This proposal would make the agents extension stricter than core azd in exactly the case core carved out.

Worth a row or an eighth review question: is nested project creation supported, and if so what is the gesture, a flag or cwd-only resolution for init <source> the way core does it? If it is intentionally unsupported, saying so in Non-scope keeps it from reading as an oversight.

| `init <source> --infra` in an existing project | Fail and suggest `add <source>`, followed by standalone `init --infra`. |
| `add <source>` in an existing project | Add the agent without initializing a project. |
| `add <source>` with no project | Fail and suggest the equivalent `init` command. |
| `add --infra` | Fail because infrastructure is project-wide and remains an init option. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other failure in both tables names the replacement: line 113 gives add <source> followed by standalone init --infra, and the decision table says "Fail with add guidance" (line 127) and "Fail with init guidance" (line 131). This row and its twin on line 132 stop at "fail".

That leaves the goal on line 45 unmet for the case a user is most likely to hit, since add <source> --infra is the natural way to ask for "add this agent and regenerate infra". It also leaves a real string undefined: extension validation errors are built from exterrors.Validation(code, message, suggestion), and the existing init failures all fill the third slot, for example "pass --infra=bicep or --infra=terraform (a bare --infra ejects Bicep)". Saying what this one suggests, presumably add <source> then init --infra, would settle it.


## Decision table

| Command | Project found | Source supplied | `--infra` | Result |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both tables key on a single Project found value, but the extension doesn't have one definition of that today, and the definitions it does have disagree on exactly the parent case this proposal opens with.

At this PR's base commit (19afc57), the standalone eject gate in internal/cmd/init.go is fileExists("azure.yaml"), and fileExists is a plain os.Stat on a relative path (internal/cmd/helpers.go:540), so it only ever sees the current directory. azd's own project resolution walks upward instead: NewAzdContextFromWd in pkg/environment/azdcontext/azdcontext.go searches the working directory and then each parent up to root, and the extension SDK exposes the same behavior through FindFileUpward in pkg/azdext/project.go.

Run init --infra one directory below a project root and those two answers differ: Project found = No under the current gate, Project found = Yes under azd's resolver. The tables route those to opposite outcomes, so the rows don't have a determinate meaning until the predicate is fixed.

Scope says "Detect current and parent projects consistently" but doesn't say which semantics win. Worth stating it directly: cwd-only or upward search, whether init and add resolve it the same way, and what the result is when the nearest project sits above the cwd rather than in it.

|---|---:|---:|---:|---|
| `init` | No | Yes | No | Initialize project and agent. |
| `init` | No | Yes | Yes | Initialize, then generate IaC. |
| `init` | No | No | No | Interactive: choose source; non-interactive: fail. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Behavioral rules table above and this Decision table encode the same init/add contract with different columns, and they've already drifted. Behavioral rules restrict standalone --infra to an existing compatible project (lines 116-117), but this table's init | Yes | No | Yes row drops the compatibility qualifier and just says IaC only. The missing bare init in an existing project row that others flagged is the same failure mode. Two hand-maintained tables for one contract will keep diverging as the design evolves. Consider making one the single source of truth and deriving or dropping the other.

| `init` | No | No | Yes | Interactive: choose source, initialize, generate IaC; non-interactive: fail. |
| `init` | Yes | No | Yes | Generate IaC only. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The behavioral rules scope IaC-only to an existing compatible project (lines 110-111), and Non-scope rejects unsafe shapes like Aspire projects or multiple Foundry project services. This table only keys on Project found, so init --infra against an incompatible existing project resolves to Generate IaC only here instead of failing. Consider carrying the compatibility gate into the table (or a note) so an unsupported project shape maps to a fail-with-guidance result rather than IaC generation.

| `init` | Yes | Yes | No | Fail with `add` guidance. |
| `init` | Yes | Yes | Yes | Fail with `add`, then standalone `init --infra` guidance. |
| `add` | Yes | Yes | No | Add agent. |
| `add` | Yes | No | No | Interactive: choose source; non-interactive: fail. |
| `add` | No | Any | No | Fail with `init` guidance. |
| `add` | Any | Any | Yes | Fail; `--infra` is init-only. |

## Architecture

The implementation separates project setup from agent registration:

```text
init + source -> validate no project -> create project -> register agent
-> optional --infra

init --infra -> validate existing project -> generate IaC only

add + source -> validate existing project -> merge/register agent
```

Both `init` and `add` share source resolution and agent-registration logic. Registration merges the complete service graph instead of replacing existing Foundry services one at a time. The detailed engineering design defines concurrency, retry, and compatibility mechanics.

## Decisions

1. **Add a separate `add` command.** Project modification should be explicit instead of inferred from a parent `azure.yaml`.
2. **Keep `init` as the new-project workflow.** New users still initialize a project and first agent in one command.
3. **Retain `--infra` on init.** It is an explicit, established workflow and avoids a larger breaking change.
4. **Do not automatically redirect `init` to `add`.** Automatic routing would preserve contextual mutation and misleading telemetry.
5. **Require explicit sources in non-interactive setup.** Directory contents must not silently select code, manifest, or template behavior.
6. **Preserve shared project resources.** Adding another agent must not erase existing project settings, deployments, connections, or toolboxes.
7. **Reject unsupported existing-project infrastructure changes.** Existing Bicep/Terraform projects may use already provisioned Foundry resources, but setup will not pretend to modify their IaC.

## Review questions

1. **Is `azd ai agent add` the right command name and location?** Proposed: Yes. It clearly communicates project modification while keeping the change inside the agents extension.

2. **Should `--infra` remain on `init`?** Proposed: Yes. Preserve both post-init and standalone behavior with strict argument validation.

3. **Should `init` ever automatically call `add` when it finds a project?** Proposed: No. Fail before mutation and print the exact replacement command.

4. **Should existing positional and `--src` forms remain temporarily?** Proposed: Yes, with deprecation warnings and deterministic translation to the new source options.

5. **Should non-interactive setup require an explicit source?** Proposed: Yes, except standalone existing-project `init --infra`.

6. **Should adding to existing Bicep/Terraform projects create new Foundry infrastructure?** Proposed: No. Support existing/brownfield Foundry resources only; automatic IaC composition needs a separate design.

7. **Should complete unified-project adoption ship in the first implementation?** Proposed: Yes, for new projects only. Existing-project merge remains out of scope.
Loading