Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 1 addition & 1 deletion cli/azd/docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ Metadata requests are unauthenticated when no matching token is set.
| `AZURE_AI_PROJECT_NAME` | The Microsoft Foundry project name. |
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | The default model deployment name used for generated agent code and templates. |
| `AZURE_AI_PROJECT_ACR_CONNECTION_NAME` | The Azure Container Registry connection name used by the extension for hosted agents. |
| `AI_PROJECT_DEPLOYMENTS` | JSON-encoded deployment metadata populated by the extension for agent workflows. |
| `AI_PROJECT_DEPLOYMENTS` | User-settable, extension-managed active-environment provisioning override for embedded Foundry model deployments. Blank values fall back to `azure.yaml`; `[]` intentionally disables deployments. Malformed values cause an error. |
| `AI_PROJECT_DEPENDENT_RESOURCES` | JSON-encoded dependent resource metadata populated by the extension for agent workflows. |
| `AZD_AGENT_SKIP_ACR` | If `true`, signals the Bicep template to skip Azure Container Registry creation during provisioning. Automatically set by `azd ai agent init` for code-deploy, pre-built image, and managed voice scenarios. |
| `ENABLE_HOSTED_AGENTS` | If set, indicates that hosted agents are enabled for the current azd environment. |
Expand Down
1 change: 1 addition & 0 deletions cli/azd/extensions/ai-non-interactive.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,5 @@ no non-interactive equivalent is a bug.
| --- | --- | --- | --- |
| `microsoft.foundry` provisioning | Azure subscription | `AZURE_SUBSCRIPTION_ID` in the active azd environment | Returns actionable missing-subscription guidance. |
| `microsoft.foundry` provisioning | Azure location | `AZURE_LOCATION` in the active azd environment | Returns actionable missing-location guidance. |
| Projects provisioning and legacy agents provisioning | Model deployments for the current subscription/location | `AI_PROJECT_DEPLOYMENTS` JSON array in the active azd environment (`[]` means no deployments) | With `--no-prompt` / `AZD_NO_PROMPT=true`, saves YAML deployments unchanged when unset or blank. Existing environment overrides are preserved; embedded infrastructure uses them instead of YAML defaults. Ejected user parameters retain precedence. |
| `azd down` with `microsoft.foundry` | Delete the Foundry resource group | `azd down --force` | Refuses deletion and instructs the caller to pass `--force`. |
72 changes: 72 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,12 +227,22 @@ func currentEnvName(ctx context.Context, azdClient *azdext.AzdClient) (string, e
return resp.Environment.Name, nil
}

// legacyProjectDeploymentsMu serializes the environment read, prompt, and
// write of AI_PROJECT_DEPLOYMENTS. Each legacy agent service runs predeploy
// in parallel when an explicit uses graph allows it, and they share one
// project-wide list. Overlapping handlers can both observe a missing value,
// prompt separately, and let the last write discard the other selection.
var legacyProjectDeploymentsMu sync.Mutex

func updateLegacyProjectDeployments(
ctx context.Context,
azdClient *azdext.AzdClient,
services map[string]*azdext.ServiceConfig,
projectRoot string,
) error {
legacyProjectDeploymentsMu.Lock()
defer legacyProjectDeploymentsMu.Unlock()

deployments, err := collectLegacyProjectDeployments(
services,
projectRoot,
Expand All @@ -251,6 +261,68 @@ func updateLegacyProjectDeployments(
err,
)
}
response, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{Name: envName})
if err != nil {
return fmt.Errorf("read environment for legacy deployments: %w", err)
}
values := map[string]string{}
for _, kv := range response.GetKeyValues() {
values[kv.GetKey()] = kv.GetValue()
}
if strings.TrimSpace(values["AI_PROJECT_DEPLOYMENTS"]) != "" {
return nil
}
if !azdext.DetectInteractive().NoPrompt {
Comment thread
Copilot marked this conversation as resolved.
location := values["AZURE_AI_DEPLOYMENTS_LOCATION"]
if location == "" {
location = values["AZURE_LOCATION"]
}
azureContext := &azdext.AzureContext{Scope: &azdext.AzureScope{
SubscriptionId: values["AZURE_SUBSCRIPTION_ID"],
TenantId: values["AZURE_TENANT_ID"],
Location: location,
}}
Comment thread
huimiu marked this conversation as resolved.
resolved := make([]project.Deployment, 0, len(deployments))
for _, declaration := range deployments {
model, err := azdClient.Prompt().PromptAiModel(ctx, &azdext.PromptAiModelRequest{
AzureContext: azureContext,
DefaultValue: declaration.Model.Name,
Filter: &azdext.AiModelFilterOptions{
Locations: []string{azureContext.Scope.Location},
},
Quota: &azdext.QuotaCheckOptions{MinRemainingCapacity: 1},
})
if err != nil {
return exterrors.FromPrompt(err, "select legacy deployment model")
}
if model.GetModel().GetName() == "" {
return fmt.Errorf("no model selected for deployment %q", declaration.Name)
}
response, err := azdClient.Prompt().PromptAiDeployment(ctx, &azdext.PromptAiDeploymentRequest{
AzureContext: azureContext,
ModelName: model.GetModel().GetName(),
Options: &azdext.AiModelDeploymentOptions{
Locations: []string{azureContext.Scope.Location},
},
Quota: &azdext.QuotaCheckOptions{MinRemainingCapacity: 1},
})
if err != nil {
return exterrors.FromPrompt(err, "select legacy model deployment")
}
selected := response.GetDeployment()
if selected.GetModelName() == "" || selected.GetVersion() == "" ||
selected.GetSku().GetName() == "" || selected.GetCapacity() <= 0 {
return fmt.Errorf("incomplete model selection for deployment %q", declaration.Name)
}
declaration.Model.Name = selected.GetModelName()
declaration.Model.Format = selected.GetFormat()
declaration.Model.Version = selected.GetVersion()
declaration.Sku.Name = selected.GetSku().GetName()
declaration.Sku.Capacity = int(selected.GetCapacity())
resolved = append(resolved, declaration)
}
deployments = resolved
}
return deploymentEnvUpdate(
ctx,
deployments,
Expand Down
258 changes: 258 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package cmd

import (
"bytes"
"context"
"encoding/json"
"errors"
"maps"
Expand All @@ -13,7 +14,9 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

"azureaiagent/internal/exterrors"
"azureaiagent/internal/pkg/agents/agent_yaml"
Expand All @@ -26,6 +29,261 @@ import (
"google.golang.org/protobuf/types/known/structpb"
)

type legacyDeploymentPromptServer struct {
recordingPromptAiModelServer
deployments []*azdext.PromptAiDeploymentRequest
failAt int
}

func (s *legacyDeploymentPromptServer) PromptAiDeployment(
_ context.Context, request *azdext.PromptAiDeploymentRequest,
) (*azdext.PromptAiDeploymentResponse, error) {
s.deployments = append(s.deployments, request)
if s.failAt == len(s.deployments) {
return nil, errors.New("deployment selection cancelled")
}
return &azdext.PromptAiDeploymentResponse{Deployment: &azdext.AiModelDeployment{
ModelName: request.ModelName, Format: "OpenAI", Version: "env-version",
Sku: &azdext.AiModelSku{Name: "Standard"}, Capacity: 2,
}}, nil
}

func TestUpdateLegacyProjectDeploymentsPrecedence(t *testing.T) {
for _, test := range []struct {
name, value string
noPrompt, wantPrompt bool
failAt int
}{
{name: "interactive missing", wantPrompt: true},
{name: "interactive blank", value: " \t", wantPrompt: true},
{name: "no-prompt YAML", noPrompt: true},
{name: "set preserves override", value: `[{\"name\":\"env-b\"}]`},
{name: "empty skips prompts", value: "[]"},
{name: "no-prompt preserves override", noPrompt: true, value: "[]"},
{name: "legacy leaves malformed for provider validation", value: "{"},
{name: "selection failure is atomic", wantPrompt: true, failAt: 2},
} {
t.Run(test.name, func(t *testing.T) {
t.Setenv("AZD_NO_PROMPT", strconv.FormatBool(test.noPrompt))
t.Setenv("AI_PROJECT_DEPLOYMENTS", `[{"name":"process-must-not-win"}]`)
env := &testEnvironmentServiceServer{
current: &azdext.Environment{Name: "env-b"},
values: map[string]map[string]string{"env-b": {
"AI_PROJECT_DEPLOYMENTS": test.value,
"AZURE_SUBSCRIPTION_ID": "sub-b",
"AZURE_LOCATION": "westus",
}},
}
prompt := &legacyDeploymentPromptServer{failAt: test.failAt}
client := newTestAzdClient(t, env, nil, prompt)
declarations := []project.Deployment{
{Name: "chat", Model: project.DeploymentModel{Name: "yaml-model", Version: "yaml-version"},
Sku: project.DeploymentSku{Name: "YamlSku", Capacity: 100}},
{Name: "embedding", Model: project.DeploymentModel{Name: "yaml-embedding"}},
}
service := mustMarshalConfig(t, &project.ServiceTargetAgentConfig{Deployments: declarations})
service.Name, service.Host = "agent", AiAgentHost
service.AdditionalProperties, service.Config = service.Config, nil
services := map[string]*azdext.ServiceConfig{"agent": service}
err := updateLegacyProjectDeployments(t.Context(), client, services, "")
if test.failAt > 0 {
require.Error(t, err)
assert.Empty(t, env.setKeys, "do not persist a partial selection")
assert.Equal(t, test.value, env.values["env-b"]["AI_PROJECT_DEPLOYMENTS"])
return
}
require.NoError(t, err)
if test.wantPrompt {
require.Len(t, prompt.requests, 2)
require.Len(t, prompt.deployments, 2)
assert.Equal(t, "yaml-model", prompt.requests[0].DefaultValue)
assert.Equal(t, "yaml-embedding", prompt.requests[1].DefaultValue)
for _, request := range prompt.requests {
assert.Equal(t, "sub-b", request.AzureContext.Scope.SubscriptionId)
assert.Equal(t, "westus", request.AzureContext.Scope.Location)
assert.Equal(t, []string{"westus"}, request.Filter.Locations)
assert.Empty(t, request.Filter.Capabilities, "do not exclude non-agent models such as embeddings")
assert.Equal(t, float64(1), request.Quota.MinRemainingCapacity)
}
for _, request := range prompt.deployments {
assert.Equal(t, []string{"westus"}, request.Options.Locations)
assert.Empty(t, request.Options.Versions)
assert.Empty(t, request.Options.Skus)
assert.Nil(t, request.Options.Capacity)
assert.Equal(t, float64(1), request.Quota.MinRemainingCapacity)
}
value := env.values["env-b"]["AI_PROJECT_DEPLOYMENTS"]
assert.Contains(t, value, `\"name\":\"chat\"`)
assert.Contains(t, value, `\"name\":\"embedding\"`)
assert.Contains(t, value, `\"version\":\"env-version\"`)
require.NoError(t, updateLegacyProjectDeployments(t.Context(), client, services, ""))
assert.Len(t, prompt.requests, 2)
} else if test.value == "" {
assert.Empty(t, prompt.requests)
expected, err := json.Marshal(declarations)
require.NoError(t, err)
escaped := strings.ReplaceAll(string(expected), "\\", "\\\\")
escaped = strings.ReplaceAll(escaped, "\"", "\\\"")
assert.Equal(t, escaped, env.values["env-b"]["AI_PROJECT_DEPLOYMENTS"])
} else {
assert.Empty(t, prompt.requests)
assert.Empty(t, env.setKeys)
assert.Equal(t, test.value, env.values["env-b"]["AI_PROJECT_DEPLOYMENTS"])
}
})
}
}

type concurrentLegacyEnvServer struct {
azdext.UnimplementedEnvironmentServiceServer
mu sync.Mutex
current *azdext.Environment
values map[string]map[string]string
reads atomic.Int32
writes atomic.Int32
}

func (s *concurrentLegacyEnvServer) GetCurrent(
context.Context, *azdext.EmptyRequest,
) (*azdext.EnvironmentResponse, error) {
return &azdext.EnvironmentResponse{Environment: s.current}, nil
}

func (s *concurrentLegacyEnvServer) GetValues(
_ context.Context, req *azdext.GetEnvironmentRequest,
) (*azdext.KeyValueListResponse, error) {
s.reads.Add(1)
s.mu.Lock()
defer s.mu.Unlock()
values := s.values[req.Name]
keyValues := make([]*azdext.KeyValue, 0, len(values))
for key, value := range values {
keyValues = append(keyValues, &azdext.KeyValue{Key: key, Value: value})
}
return &azdext.KeyValueListResponse{KeyValues: keyValues}, nil
}

func (s *concurrentLegacyEnvServer) SetValue(
_ context.Context, req *azdext.SetEnvRequest,
) (*azdext.EmptyResponse, error) {
s.writes.Add(1)
s.mu.Lock()
defer s.mu.Unlock()
if s.values[req.EnvName] == nil {
s.values[req.EnvName] = map[string]string{}
}
s.values[req.EnvName][req.Key] = req.Value
return &azdext.EmptyResponse{}, nil
}

type gatingLegacyPromptServer struct {
legacyDeploymentPromptServer
mu sync.Mutex
entered chan struct{}
release chan struct{}
once sync.Once
modelCalls atomic.Int32
}

func (s *gatingLegacyPromptServer) PromptAiModel(
ctx context.Context, request *azdext.PromptAiModelRequest,
) (*azdext.PromptAiModelResponse, error) {
s.modelCalls.Add(1)
wait := false
s.once.Do(func() { wait = true })
if wait {
close(s.entered)
<-s.release
}
s.mu.Lock()
defer s.mu.Unlock()
return s.legacyDeploymentPromptServer.PromptAiModel(ctx, request)
}

func (s *gatingLegacyPromptServer) PromptAiDeployment(
ctx context.Context, request *azdext.PromptAiDeploymentRequest,
) (*azdext.PromptAiDeploymentResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.legacyDeploymentPromptServer.PromptAiDeployment(ctx, request)
}

func TestUpdateLegacyProjectDeploymentsSerializesConcurrentHandlers(t *testing.T) {
t.Setenv("AZD_NO_PROMPT", "false")
t.Setenv("AI_PROJECT_DEPLOYMENTS", `[{"name":"process-must-not-win"}]`)
env := &concurrentLegacyEnvServer{
current: &azdext.Environment{Name: "env-b"},
values: map[string]map[string]string{"env-b": {
"AZURE_SUBSCRIPTION_ID": "sub-b",
"AZURE_LOCATION": "westus",
}},
}
prompt := &gatingLegacyPromptServer{
entered: make(chan struct{}),
release: make(chan struct{}),
}
var releaseOnce sync.Once
release := func() {
releaseOnce.Do(func() { close(prompt.release) })
}
t.Cleanup(release)
client := newTestAzdClient(t, env, nil, prompt)
declarations := []project.Deployment{
{Name: "chat", Model: project.DeploymentModel{Name: "yaml-model"}},
{Name: "embedding", Model: project.DeploymentModel{Name: "yaml-embedding"}},
}
service := mustMarshalConfig(t, &project.ServiceTargetAgentConfig{Deployments: declarations})
service.Name, service.Host = "agent", AiAgentHost
service.AdditionalProperties, service.Config = service.Config, nil
services := map[string]*azdext.ServiceConfig{"agent": service}

errs := make(chan error, 2)
go func() {
errs <- updateLegacyProjectDeployments(t.Context(), client, services, "")
}()
select {
case <-prompt.entered:
case <-time.After(5 * time.Second):
t.Fatal("first handler did not reach model selection")
}
started := make(chan struct{})
go func() {
close(started)
errs <- updateLegacyProjectDeployments(t.Context(), client, services, "")
}()
<-started
require.Never(t, func() bool {
return prompt.modelCalls.Load() > 1 || env.reads.Load() > 1
}, 200*time.Millisecond, 10*time.Millisecond)

release()
require.NoError(t, <-errs)
require.NoError(t, <-errs)
assert.Equal(t, int32(2), prompt.modelCalls.Load())
assert.Len(t, prompt.requests, 2)
assert.Len(t, prompt.deployments, 2)
assert.Equal(t, int32(1), env.writes.Load())
env.mu.Lock()
value := env.values["env-b"]["AI_PROJECT_DEPLOYMENTS"]
env.mu.Unlock()
assert.Contains(t, value, `\"name\":\"chat\"`)
assert.Contains(t, value, `\"name\":\"embedding\"`)
}

func TestUpdateLegacyProjectDeploymentsSplitNoOp(t *testing.T) {
t.Parallel()
services := map[string]*azdext.ServiceConfig{
"project": {Host: AiProjectHost},
"agent": {
Host: AiAgentHost,
AdditionalProperties: mustStruct(t, map[string]any{
"deployments": []any{map[string]any{"name": "ignored"}},
}),
},
}
require.NoError(t, updateLegacyProjectDeployments(t.Context(), nil, services, ""))
}

func TestPrepareContainerSettings_AppliesSettingsInMemory(t *testing.T) {
t.Parallel()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ post:

goals:
- "Wait for 'azd provision' to complete. This creates the Azure infrastructure (Foundry project, model deployment, resource group) defined by the Tier 1 scaffold."
- "Confirm provision did not prompt for an AI model, model version, deployment SKU, or capacity: the pre-hook's deployment add must seed AI_PROJECT_DEPLOYMENTS in the active environment. Run 'azd env get-value AI_PROJECT_DEPLOYMENTS' and verify the saved list still contains model '{model}' and SKU '{model_sku}', with the deployment name matching 'azd env get-value AZURE_AI_MODEL_DEPLOYMENT_NAME'."
- "After provision succeeds, run 'azd deploy' and wait for it to complete. This deploys the agent to the provisioned Foundry project."
- "Run 'azd ai agent show' and verify the agent is accessible — confirm it reports an agent name and endpoint URL."
- "Run `azd ai agent invoke \"Hello, are you there?\"` and confirm the agent responds with a non-empty reply containing no authentication or endpoint errors. This validates the agent is functional, not just deployed."
Expand Down
Loading
Loading