Skip to content
Closed
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
6 changes: 6 additions & 0 deletions cli/azd/extensions/azure.ai.agents/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Release History

## 1.0.0-beta.12 (Unreleased)

### Bugs Fixed

- Diagnose enabled standalone `azure.ai.connection` services in Doctor and next-step, and suggest `azd provision` after init writes a connection service.

## 1.0.0-beta.11 (2026-08-20)

### Features Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ type foundryConnectionsProbeFn func(
accountName, projectName string,
) ([]string, error)

// newCheckConnections produces Check `remote.connections` (P5.1
// C15). For each `ConnectionResource` declared in any service's
// `agent.manifest.yaml` (collected by the C2 manifest walker), the
// check queries the Foundry project's connection list and verifies a
// connection with the matching name exists. The check Passes when
// every manifest-declared connection has a corresponding entry;
// Fails when one or more are missing.
// newCheckConnections produces Check `remote.connections`. For each
// enabled connection collected from unified azure.yaml services or
// compatible sources, the check queries the Foundry project's
// connection list and verifies a connection with the matching name
// exists. The check Passes when every configured connection has a
// corresponding entry; Fails when one or more are missing or when
// connection configuration cannot be loaded.
//
// # Skip cascade
//
Expand All @@ -55,26 +55,27 @@ type foundryConnectionsProbeFn func(
// let the auth check own the diagnosis.
// - `remote.foundry-endpoint` failed → same root cause, same
// remediation.
// - state.HasConnections == false → no manifest connection
// declarations; the check has nothing to verify. Surface as
// Skip with a short explanation rather than a vacuous Pass.
// - state.ConnectionLoadErrors set → configuration could not be
// read. Fail without probing so a bad $ref is not Skip.
// - state.HasConnections == false → no enabled connection
// services or legacy resources; Skip rather than a vacuous Pass.
// - `AZURE_AI_PROJECT_ID` not set / cannot be parsed → can not
// derive the account + project to probe. Skip cleanly; the
// rbac check already emits the canonical `azd env set` fix.
//
// # Classification
//
// - Every manifest connection matches a Foundry connection name →
// Pass with the matched count.
// - Every configured connection matches a Foundry connection
// name → Pass with the matched count.
// - One or more missing → Fail with the missing names listed in
// the Message and structured under `Details["missingConnections"]`
// (each entry carries Name, ServiceName, Detail — the manifest's
// "<Category> | <Target>" identifier surfaced by the C2 walker).
// (each entry carries Name, ServiceName, Detail — the
// "<Category> | <Target>" identifier from collected state).
// - Probe error → Skip with the underlying error verbatim.
func newCheckConnections(deps Dependencies) Check {
return Check{
ID: "remote.connections",
Name: "Manifest connections exist on Foundry project",
Name: "Configured connections exist on Foundry project",
Remote: true,
Fn: func(ctx context.Context, _ Options, prior []Result) Result {
if deps.AzdClient == nil {
Expand Down Expand Up @@ -125,10 +126,26 @@ func newCheckConnections(deps Dependencies) Check {
Suggestion: "Re-run `azd ai agent doctor`; the state assembly returned nil unexpectedly.",
}
}
if len(state.ConnectionLoadErrors) > 0 {
return Result{
Status: StatusFail,
Message: fmt.Sprintf(
"failed to load configured connections: %s",
strings.Join(state.ConnectionLoadErrors, "; "),
),
Suggestion: "Fix azure.yaml, its $ref files, or the " +
"legacy agent.manifest.yaml, then retry " +
"`azd ai agent doctor`.",
Details: map[string]any{
"loadErrors": state.ConnectionLoadErrors,
},
}
}
if !state.HasConnections {
return Result{
Status: StatusSkip,
Message: "skipped: no connection resources declared in any service's agent.manifest.yaml.",
Status: StatusSkip,
Message: "skipped: no enabled connection services " +
"or legacy connection resources found.",
}
}

Expand Down Expand Up @@ -281,11 +298,12 @@ func classifyConnections(
return Result{
Status: StatusFail,
Message: fmt.Sprintf(
"%d connection(s) referenced by agent.manifest.yaml are missing on project %s: %s",
"%d configured connection(s) are missing on project %s: %s",
len(missing), project, sb.String()),
Suggestion: "Run `azd provision` to create the missing connection(s), " +
"or update the agent.manifest.yaml `resources[].name` entries to " +
"match connections that already exist on the Foundry project.",
Suggestion: "Run `azd provision` to create or reconcile the " +
"missing connection(s), or update the configured connection " +
"services or legacy manifest resources to match connections " +
"that already exist on the Foundry project.",
Details: map[string]any{
"missingConnections": missing,
"matchedCount": matched,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,51 @@ func TestCheckConnections_SkipsCascadeFromUpstream(t *testing.T) {

func TestCheckConnections_SkipsWhenNoManifestConnections(t *testing.T) {
t.Parallel()
var probeCalls int
deps := Dependencies{
assembleState: fixedAssembler(&nextstep.State{HasConnections: false}),
probeFoundryConnections: fixedConnectionsProbe(nil, nil, nil),
assembleState: fixedAssembler(&nextstep.State{HasConnections: false}),
probeFoundryConnections: func(
_ context.Context, _, _ string,
) ([]string, error) {
probeCalls++
return nil, nil
},
}
res := runConnectionsCheck(t, deps, healthyConnectionsPrior())
require.Equal(t, StatusSkip, res.Status)
require.Contains(t, res.Message, "no connection resources declared")
require.Contains(t, res.Message, "no enabled connection services")
require.Contains(t, res.Message, "legacy connection resources found")
require.Equal(t, 0, probeCalls)
}

func TestCheckConnections_FailsOnLoadErrorsBeforeSkip(t *testing.T) {
t.Parallel()
var probeCalls int
deps := Dependencies{
assembleState: fixedAssembler(&nextstep.State{
HasConnections: false,
ConnectionLoadErrors: []string{
`connection service "bad-conn" has an invalid deployment condition: malformed condition template`,
},
}),
probeFoundryConnections: func(
_ context.Context, _, _ string,
) ([]string, error) {
probeCalls++
return nil, nil
},
}
res := runConnectionsCheck(t, deps, healthyConnectionsPrior())
require.Equal(t, StatusFail, res.Status)
require.Equal(t, 0, probeCalls)
require.Contains(t, res.Message, "failed to load configured connections")
require.Contains(t, res.Message, `connection service "bad-conn"`)
require.Contains(t, res.Suggestion, "Fix azure.yaml")
require.Contains(t, res.Suggestion, "azd ai agent doctor")
require.NotContains(t, res.Suggestion, "azd deploy")
require.Equal(t, []string{
`connection service "bad-conn" has an invalid deployment condition: malformed condition template`,
}, res.Details["loadErrors"])
}

func TestCheckConnections_FailsWhenAssemblerReturnsNilState(t *testing.T) {
Expand Down Expand Up @@ -220,11 +258,13 @@ func TestCheckConnections_FailsWithMissing(t *testing.T) {
}
res := runConnectionsCheck(t, deps, healthyConnectionsPrior())
require.Equal(t, StatusFail, res.Status)
require.Contains(t, res.Message, "2 connection(s)")
require.Contains(t, res.Message, "2 configured connection(s)")
require.Contains(t, res.Message, "openai-default [AzureOpenAI | https://openai.test] (service chat)")
require.Contains(t, res.Message, "search-conn [CognitiveSearch | search.test] (service search)")
require.NotContains(t, res.Message, "blob-storage")
require.Contains(t, res.Suggestion, "azd provision")
require.NotContains(t, res.Suggestion, "azd deploy")
require.Contains(t, res.Suggestion, "configured connection services")
require.EqualValues(t, 1, res.Details["matchedCount"])
}

Expand All @@ -243,7 +283,7 @@ func TestCheckConnections_FailsWhenAllMissing(t *testing.T) {
}
res := runConnectionsCheck(t, deps, healthyConnectionsPrior())
require.Equal(t, StatusFail, res.Status)
require.Contains(t, res.Message, "1 connection(s)")
require.Contains(t, res.Message, "1 configured connection(s)")
require.Contains(t, res.Message, "blob-storage (service chat)")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func TestNewRemoteChecks_HasAuthFoundryEndpointRBACAgentStatusConnections(t *tes
require.True(t, got[3].Remote, "remote.agent-status must declare Remote=true")
require.NotNil(t, got[3].Fn, "remote.agent-status must have a non-nil Fn")
require.Equal(t, "remote.connections", got[4].ID)
require.Equal(t, "Manifest connections exist on Foundry project", got[4].Name)
require.Equal(t, "Configured connections exist on Foundry project", got[4].Name)
require.True(t, got[4].Remote, "remote.connections must declare Remote=true")
require.NotNil(t, got[4].Fn, "remote.connections must have a non-nil Fn")
}
Expand Down
13 changes: 10 additions & 3 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -3412,14 +3412,21 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa
// Emit the sibling Foundry resource services (project + deployments,
// connections, toolboxes) and wire the agent's uses: to them. A selected
// existing project contributes its endpoint so provision reuses it.
if err := emitResourceServices(
emittedConnections, err := emitResourceServices(
ctx, a.azdClient, a.serviceNameOverride,
projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject),
a.selectedFoundryProject.Endpoint(),
resourceDeployments, resourceConnections, resourceToolboxes,
); err != nil {
)
if err != nil {
return err
}
recordPendingConnectionProvision(
ctx,
a.azdClient,
a.environment.Name,
emittedConnections,
)

printAgentAddedMessage(agentDef.Name)

Expand Down Expand Up @@ -3491,7 +3498,7 @@ func (a *InitAction) addVoiceAgentToProject(
// project. Voice init emits no deployment/connection/toolbox siblings; managed
// models are service-hosted, and BYOM model deployments are referenced from
// azure.yaml and must already exist.
if err := emitResourceServices(
if _, err := emitResourceServices(
ctx, a.azdClient, a.serviceNameOverride,
projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject),
a.selectedFoundryProject.Endpoint(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,7 @@ func (a *InitFromCodeAction) addToProject(
// Emit the sibling azure.ai.project service carrying the model deployments
// and wire the agent's uses: to it. A selected existing project contributes
// its endpoint so provision reuses it instead of creating a new project.
if err := emitResourceServices(
if _, err := emitResourceServices(
ctx, a.azdClient, agentServiceName,
projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject),
a.selectedFoundryProject.Endpoint(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@ import (
"context"
"fmt"
"os"
"strconv"
"strings"

"github.com/azure/azure-dev/cli/azd/pkg/foundry"
"google.golang.org/protobuf/types/known/structpb"
)

Expand All @@ -32,66 +29,50 @@ func isServiceEnabled(
return true, nil
}

condition, err := conditionValueString(value)
if err != nil {
return false, err
}
if strings.TrimSpace(condition) == "" {
return true, nil
}

expanded, err := expandServiceCondition(
ctx,
src,
envName,
condition,
var lookupErr error
enabled, err := evaluateCondition(
conditionRawValue(value),
serviceConditionLookup(ctx, src, envName, &lookupErr),
)
if err != nil {
return false, err
}
return isTruthyCondition(expanded), nil
if lookupErr != nil {
return false, lookupErr
}
return enabled, nil
}

func conditionValueString(value *structpb.Value) (string, error) {
func conditionRawValue(value *structpb.Value) any {
if value == nil {
return "", nil
return nil
}

switch kind := value.Kind.(type) {
case *structpb.Value_StringValue:
return kind.StringValue, nil
case *structpb.Value_BoolValue:
return strconv.FormatBool(kind.BoolValue), nil
case *structpb.Value_NumberValue:
return strconv.FormatFloat(kind.NumberValue, 'g', -1, 64), nil
switch value.Kind.(type) {
case *structpb.Value_NullValue:
return "", nil
return nil
default:
return "", fmt.Errorf(
"condition must be a string, boolean, or number",
)
return value.AsInterface()
}
}

func expandServiceCondition(
func serviceConditionLookup(
ctx context.Context,
src Source,
envName string,
condition string,
) (string, error) {
lookupErr *error,
) func(string) string {
if envName == "" {
return foundry.ExpandEnv(condition, os.Getenv)
return os.Getenv
}

values := map[string]string{}
var lookupErr error
expanded, err := foundry.ExpandEnv(condition, func(name string) string {
return func(name string) string {
if value, ok := values[name]; ok {
return value
}
value, err := src.EnvValue(ctx, envName, name)
if err != nil {
lookupErr = fmt.Errorf(
*lookupErr = fmt.Errorf(
"read condition environment variable %q: %w",
name,
err,
Expand All @@ -100,21 +81,5 @@ func expandServiceCondition(
}
values[name] = value
return value
})
if err != nil {
return "", err
}
if lookupErr != nil {
return "", lookupErr
}
return expanded, nil
}

func isTruthyCondition(value string) bool {
switch value {
case "1", "true", "TRUE", "True", "yes", "YES", "Yes":
return true
default:
return false
}
}
Loading
Loading