From 64573668f738fc8a1c9494e18246450dea87afe3 Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 16 Jul 2026 18:02:30 +0800 Subject: [PATCH 1/4] fix: resolve unified agent configuration --- cli/azd/.vscode/cspell.yaml | 2 + .../azure.ai.agents/internal/cmd/helpers.go | 64 ++- .../internal/cmd/helpers_test.go | 38 ++ .../azure.ai.agents/internal/cmd/listen.go | 113 +++++- .../internal/cmd/listen_test.go | 123 ++++++ .../internal/cmd/optimize_apply.go | 26 +- .../internal/cmd/resource_services.go | 135 ++++++- .../internal/cmd/resource_services_test.go | 67 ++++ .../azure.ai.agents/internal/cmd/run.go | 112 +++++- .../azure.ai.agents/internal/cmd/run_test.go | 64 +++ .../internal/pkg/projectconfig/environment.go | 128 ++++++ .../pkg/projectconfig/environment_test.go | 116 ++++++ .../internal/project/agent_definition.go | 365 +++++++++++++++++- .../internal/project/agent_definition_test.go | 248 ++++++++++++ .../internal/project/config.go | 16 + .../project/foundry_provisioning_provider.go | 157 ++++++-- .../foundry_provisioning_provider_test.go | 57 ++- .../project/resource_group_location_check.go | 14 +- ...urce_group_location_check_validate_test.go | 94 +++++ .../internal/project/service_target_agent.go | 133 +++++-- .../project/service_target_agent_test.go | 243 +++++++++++- .../internal/project/service_update.go | 29 ++ .../internal/synthesis/synthesizer.go | 156 +++++++- .../internal/synthesis/synthesizer_test.go | 239 ++++++++++++ .../azure.ai.agents/schemas/Agent.json | 4 +- .../schemas/azure.ai.agent.json | 2 +- 26 files changed, 2607 insertions(+), 138 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/projectconfig/environment.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/projectconfig/environment_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/service_update.go diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 24b626dd6f8..3617edde302 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -82,6 +82,8 @@ words: - protoimpl - protojson - protoreflect + - protowire + - projectconfig - SNAPPROCESS - structpb - subtest diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go index 496df5a09ad..8c029569025 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "log" + "maps" "net/http" "net/url" "os" @@ -22,6 +23,7 @@ import ( "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/paths" + "azureaiagent/internal/pkg/projectconfig" projectpkg "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -706,6 +708,7 @@ type ServiceRunContext struct { ServiceName string // the resolved service name (from azure.yaml) ProjectDir string // absolute path to the service source directory StartupCommand string // startupCommand from AdditionalProperties (may be empty) + Environment map[string]string // Definition is the resolved agent definition (from the inline azure.yaml // entry or a legacy agent.yaml). It is nil when no definition can be resolved. Definition *agent_yaml.ContainerAgent @@ -718,6 +721,20 @@ func resolveServiceRunContext(ctx context.Context, azdClient *azdext.AzdClient, if err != nil { return nil, err } + if err := projectpkg.ResolveServiceConfigInPlace( + svc, + project.Path, + ); err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf( + "failed to resolve agent service %s: %s", + svc.Name, + err, + ), + "fix the agent service configuration in azure.yaml", + ) + } projectDir, err := paths.JoinAllowRoot(project.Path, svc.RelativePath) if err != nil { @@ -729,8 +746,28 @@ func resolveServiceRunContext(ctx context.Context, azdClient *azdext.AzdClient, } var startupCmd string - if agentConfig, cfgErr := projectpkg.LoadServiceTargetAgentConfig(svc); cfgErr == nil { + serviceEnv := map[string]string{} + if agentConfig, cfgErr := projectpkg.LoadServiceTargetAgentConfig( + svc, + ); cfgErr == nil { startupCmd = agentConfig.StartupCommand + maps.Copy(serviceEnv, agentConfig.Environment) + } + serviceEnv, err = loadServiceRunEnvironment( + project.Path, + svc, + serviceEnv, + ) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf( + "failed to load environment for %s: %s", + svc.Name, + err, + ), + "fix the service env configuration in azure.yaml", + ) } var definition *agent_yaml.ContainerAgent @@ -745,10 +782,35 @@ func resolveServiceRunContext(ctx context.Context, azdClient *azdext.AzdClient, ServiceName: svc.Name, ProjectDir: projectDir, StartupCommand: startupCmd, + Environment: serviceEnv, Definition: definition, }, nil } +func loadServiceRunEnvironment( + projectRoot string, + svc *azdext.ServiceConfig, + base map[string]string, +) (map[string]string, error) { + env := maps.Clone(base) + if env == nil { + env = map[string]string{} + } + raw, err := projectconfig.LoadServiceEnvironment( + projectRoot, + svc.GetName(), + ) + if err != nil { + return nil, err + } + if raw == nil { + maps.Copy(env, svc.GetEnvironment()) + } else { + maps.Copy(env, raw) + } + return env, nil +} + // toServiceKey converts a service name into the env var key format (uppercase, underscores). func toServiceKey(serviceName string) string { key := strings.ReplaceAll(serviceName, " ", "_") diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go index dbad549aea1..6b2067f4c6e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers_test.go @@ -517,3 +517,41 @@ func TestResolveAgentProtocol_MultipleServicesPromptsOnce(t *testing.T) { require.Equal(t, int32(1), promptServer.selectCalls.Load(), "resolveAgentProtocol should trigger exactly one prompt") } + +func TestLoadServiceRunEnvironmentUsesRawValues(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "azure.yaml"), + []byte(`services: + agent: + host: azure.ai.agent + env: + PROJECT: ${{project.endpoint}} + ENABLED: true + SHARED: direct +`), + 0o600, + )) + svc := &azdext.ServiceConfig{ + Name: "agent", + Environment: map[string]string{ + "PROJECT": "", + "ENABLED": "", + "SHARED": "expanded", + }, + } + + env, err := loadServiceRunEnvironment( + root, + svc, + map[string]string{"CONFIG_ONLY": "config", "SHARED": "config"}, + ) + + require.NoError(t, err) + require.Equal(t, "${{project.endpoint}}", env["PROJECT"]) + require.Equal(t, "true", env["ENABLED"]) + require.Equal(t, "direct", env["SHARED"]) + require.Equal(t, "config", env["CONFIG_ONLY"]) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 9684774eddd..98ca42ad5b2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -68,11 +68,17 @@ func configureExtensionHost(host *azdext.ExtensionHost) { } func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { - deployments, err := collectProjectDeployments(args.Project.Services) + deployments, err := collectProjectDeploymentsAtRoot( + args.Project.Services, + args.Project.Path, + ) if err != nil { return err } - connections, err := collectConnections(args.Project.Services) + connections, err := collectConnectionsAtRoot( + args.Project.Services, + args.Project.Path, + ) if err != nil { return err } @@ -80,7 +86,12 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args for _, svc := range args.Project.Services { switch svc.Host { case AiAgentHost: - if err := populateContainerSettings(ctx, azdClient, svc); err != nil { + if err := populateContainerSettings( + ctx, + azdClient, + svc, + args.Project.Path, + ); err != nil { return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) } if err := envUpdate(ctx, azdClient, args.Project, svc, deployments, connections); err != nil { @@ -194,16 +205,27 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az warnDuplicateAgentNames(args.Project) }) - deployments, err := collectProjectDeployments(args.Project.Services) + deployments, err := collectProjectDeploymentsAtRoot( + args.Project.Services, + args.Project.Path, + ) if err != nil { return err } - connections, err := collectConnections(args.Project.Services) + connections, err := collectConnectionsAtRoot( + args.Project.Services, + args.Project.Path, + ) if err != nil { return err } - if err := populateContainerSettings(ctx, azdClient, svc); err != nil { + if err := populateContainerSettings( + ctx, + azdClient, + svc, + args.Project.Path, + ); err != nil { return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) } if err := envUpdate(ctx, azdClient, args.Project, svc, deployments, connections); err != nil { @@ -716,10 +738,62 @@ func setEnvVar(ctx context.Context, azdClient *azdext.AzdClient, envName string, return nil } -func populateContainerSettings(ctx context.Context, azdClient *azdext.AzdClient, svc *azdext.ServiceConfig) error { +func populateContainerSettings( + ctx context.Context, + azdClient *azdext.AzdClient, + svc *azdext.ServiceConfig, + projectRoot string, +) error { + persisted, err := prepareContainerSettings(svc, projectRoot) + if err != nil { + return err + } + if persisted == nil { + return nil + } + + if err := project.AddServiceSerialized( + ctx, + azdClient, + persisted, + ); err != nil { + return fmt.Errorf("adding agent service to project: %w", err) + } + + return nil +} + +func prepareContainerSettings( + svc *azdext.ServiceConfig, + projectRoot string, +) (*azdext.ServiceConfig, error) { + rawAdditional := svc.GetAdditionalProperties() + rawConfig := svc.GetConfig() + hasRootFileRef := rawAdditional != nil && + rawAdditional.GetFields()["$ref"] != nil || + rawConfig != nil && rawConfig.GetFields()["$ref"] != nil + if hasRootFileRef { + if err := project.ResolveServiceConfigInPlace( + svc, + projectRoot, + ); err != nil { + return nil, fmt.Errorf( + "failed to resolve agent config: %w", + err, + ) + } + } else if err := project.NormalizeServiceConfigInPlace(svc); err != nil { + return nil, fmt.Errorf( + "failed to normalize agent config: %w", + err, + ) + } foundryAgentConfig, err := project.LoadServiceTargetAgentConfig(svc) if err != nil { - return fmt.Errorf("failed to parse foundry agent config: %w", err) + return nil, fmt.Errorf( + "failed to parse foundry agent config: %w", + err, + ) } // Resolve the container resources, applying defaults when unset. @@ -738,20 +812,19 @@ func populateContainerSettings(ctx context.Context, azdClient *azdext.AzdClient, result.Cpu = project.DefaultCpu } - // Persist the resolved container settings back onto the service's inline - // properties, preserving the agent definition and other config keys. - if err := project.SetAgentContainerSettings(svc, &project.ContainerSettings{Resources: result}); err != nil { - return fmt.Errorf("failed to update agent container settings: %w", err) + if err := project.SetAgentContainerSettings( + svc, + &project.ContainerSettings{Resources: result}, + ); err != nil { + return nil, fmt.Errorf( + "failed to update agent container settings: %w", + err, + ) } - - // Need to add the service config back to the project for use further down the pipeline - req := &azdext.AddServiceRequest{Service: svc} - - if _, err := azdClient.Project().AddService(ctx, req); err != nil { - return fmt.Errorf("adding agent service to project: %w", err) + if hasRootFileRef { + return nil, nil } - - return nil + return svc, nil } // resolveToolboxEnvVars resolves ${VAR} references in toolbox name, description, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go index 81d63020a88..26ee87c99ad 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go @@ -17,6 +17,7 @@ import ( "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" ) // TestPostdeployHandler_NonHostedAgent_NoOp verifies postdeployHandler returns nil @@ -130,6 +131,128 @@ func TestIsHostedAgentServiceRejectsTraversal(t *testing.T) { } } +func TestPopulateContainerSettings_DoesNotPersistResolvedFileRef( + t *testing.T, +) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "agent.yaml"), + []byte( + "kind: hosted\n"+ + "name: echo\n"+ + "project: src/echo\n"+ + "container:\n"+ + " resources:\n"+ + " cpu: \"2\"\n"+ + " memory: 4Gi\n", + ), + 0o600, + )) + props, err := structpb.NewStruct(map[string]any{ + "$ref": "./agent.yaml", + }) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "echo", + Host: AiAgentHost, + AdditionalProperties: props, + } + + err = populateContainerSettings(t.Context(), nil, svc, root) + + require.NoError(t, err) + require.Equal(t, "src/echo", svc.GetRelativePath()) + cfg, err := project.LoadServiceTargetAgentConfig(svc) + require.NoError(t, err) + require.NotNil(t, cfg.Container) + require.NotNil(t, cfg.Container.Resources) + require.Equal(t, "2", cfg.Container.Resources.Cpu) + require.Equal(t, "4Gi", cfg.Container.Resources.Memory) +} + +func TestPrepareContainerSettings_PreservesNestedFileRef(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "name": "echo", + "deployments": []any{ + map[string]any{"$ref": "./missing-deployment.yaml"}, + }, + }) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "echo", + Host: AiAgentHost, + AdditionalProperties: props, + } + + persisted, err := prepareContainerSettings(svc, t.TempDir()) + + require.NoError(t, err) + require.Same(t, svc, persisted) + deployments, ok := persisted.GetAdditionalProperties(). + AsMap()["deployments"].([]any) + require.True(t, ok) + require.Len(t, deployments, 1) + deployment, ok := deployments[0].(map[string]any) + require.True(t, ok) + require.Equal( + t, + "./missing-deployment.yaml", + deployment["$ref"], + ) + cfg, err := project.LoadServiceTargetAgentConfig(persisted) + require.NoError(t, err) + require.NotNil(t, cfg.Container) + require.NotNil(t, cfg.Container.Resources) + require.Equal(t, project.DefaultCpu, cfg.Container.Resources.Cpu) + require.Equal(t, project.DefaultMemory, cfg.Container.Resources.Memory) +} + +func TestPrepareContainerSettings_NormalizesInlineEnvironment(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "name": "echo", + "env": map[string]any{ + "ENABLED": true, + }, + }) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "echo", + Host: AiAgentHost, + AdditionalProperties: props, + } + + persisted, err := prepareContainerSettings(svc, t.TempDir()) + + require.NoError(t, err) + require.Equal( + t, + "true", + persisted.GetAdditionalProperties(). + GetFields()["env"].GetStructValue(). + GetFields()["ENABLED"].GetStringValue(), + ) +} + +func TestPrepareContainerSettings_WithoutProperties(t *testing.T) { + t.Parallel() + + persisted, err := prepareContainerSettings( + &azdext.ServiceConfig{Name: "echo", Host: AiAgentHost}, + t.TempDir(), + ) + + require.NoError(t, err) + require.NotNil(t, persisted) +} + func TestKindEnvUpdateRejectsTraversal(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go index cb8d97e8100..fbfd1358c46 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/optimize_apply.go @@ -120,7 +120,31 @@ func (a *OptimizeApplyAction) apply( return err } - serviceDir, err := paths.JoinAllowRoot(project.Path, svc.RelativePath) + usesFileRef, err := projectpkg.AgentDefinitionUsesFileRef( + svc, + project.Path, + ) + if err != nil { + return fmt.Errorf("failed to resolve agent definition: %w", err) + } + if usesFileRef { + return fmt.Errorf( + "agent service %q defines its agent via $ref; "+ + "'optimize apply' cannot update a referenced file. "+ + "Add OPTIMIZATION_LOCAL_DIR and "+ + "OPTIMIZATION_CANDIDATE_ID to the referenced agent "+ + "file, or inline the definition in azure.yaml", + svc.Name, + ) + } + servicePath, err := projectpkg.ResolvedServiceProjectPath( + svc, + project.Path, + ) + if err != nil { + return fmt.Errorf("failed to resolve service path: %w", err) + } + serviceDir, err := paths.JoinAllowRoot(project.Path, servicePath) if err != nil { return fmt.Errorf("invalid service path for %s: %w", svc.Name, err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index bce529919dd..074a0cd85de 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -13,6 +13,8 @@ import ( "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" ) @@ -355,10 +357,26 @@ func reserveServiceName(used map[string]string, name, source string) error { // service carries any, so an azure.yaml written before the per-resource split // still provisions without re-running init. func collectProjectDeployments(services map[string]*azdext.ServiceConfig) ([]project.Deployment, error) { + return collectProjectDeploymentsAtRoot(services, "") +} + +func collectProjectDeploymentsAtRoot( + services map[string]*azdext.ServiceConfig, + projectRoot string, +) ([]project.Deployment, error) { var out []project.Deployment for _, svc := range sortedServices(services) { - props := project.ServiceConfigProps(svc) - if svc.Host != AiProjectHost || props == nil { + if svc.Host != AiProjectHost { + continue + } + props, err := resolvedResourceServiceProps( + svc, + projectRoot, + ) + if err != nil { + return nil, err + } + if props == nil { continue } var cfg *project.ServiceTargetAgentConfig @@ -372,7 +390,10 @@ func collectProjectDeployments(services map[string]*azdext.ServiceConfig) ([]pro if len(out) > 0 { return out, nil } - legacy, err := collectLegacyAgentConfigs(services) + legacy, err := collectLegacyAgentConfigsAtRoot( + services, + projectRoot, + ) if err != nil { return nil, err } @@ -387,10 +408,26 @@ func collectProjectDeployments(services map[string]*azdext.ServiceConfig) ([]pro // agent service when no connection service carries any, so a pre-split // azure.yaml still provisions without re-running init. func collectConnections(services map[string]*azdext.ServiceConfig) ([]project.Connection, error) { + return collectConnectionsAtRoot(services, "") +} + +func collectConnectionsAtRoot( + services map[string]*azdext.ServiceConfig, + projectRoot string, +) ([]project.Connection, error) { var out []project.Connection for _, svc := range sortedServices(services) { - props := project.ServiceConfigProps(svc) - if svc.Host != AiConnectionHost || props == nil { + if svc.Host != AiConnectionHost { + continue + } + props, err := resolvedResourceServiceProps( + svc, + projectRoot, + ) + if err != nil { + return nil, err + } + if props == nil { continue } var conn *project.Connection @@ -398,13 +435,19 @@ func collectConnections(services map[string]*azdext.ServiceConfig) ([]project.Co return nil, fmt.Errorf("parsing connection service %q config: %w", svc.Name, err) } if conn != nil { + if conn.Name == "" { + conn.Name = svc.Name + } out = append(out, *conn) } } if len(out) > 0 { return out, nil } - legacy, err := collectLegacyAgentConfigs(services) + legacy, err := collectLegacyAgentConfigsAtRoot( + services, + projectRoot, + ) if err != nil { return nil, err } @@ -419,10 +462,26 @@ func collectConnections(services map[string]*azdext.ServiceConfig) ([]project.Co // toolbox service carries any, so a pre-split azure.yaml still provisions // without re-running init. func collectToolboxes(services map[string]*azdext.ServiceConfig) ([]project.Toolbox, error) { + return collectToolboxesAtRoot(services, "") +} + +func collectToolboxesAtRoot( + services map[string]*azdext.ServiceConfig, + projectRoot string, +) ([]project.Toolbox, error) { var out []project.Toolbox for _, svc := range sortedServices(services) { - props := project.ServiceConfigProps(svc) - if svc.Host != AiToolboxHost || props == nil { + if svc.Host != AiToolboxHost { + continue + } + props, err := resolvedResourceServiceProps( + svc, + projectRoot, + ) + if err != nil { + return nil, err + } + if props == nil { continue } var toolbox *project.Toolbox @@ -430,13 +489,19 @@ func collectToolboxes(services map[string]*azdext.ServiceConfig) ([]project.Tool return nil, fmt.Errorf("parsing toolbox service %q config: %w", svc.Name, err) } if toolbox != nil { + if toolbox.Name == "" { + toolbox.Name = svc.Name + } out = append(out, *toolbox) } } if len(out) > 0 { return out, nil } - legacy, err := collectLegacyAgentConfigs(services) + legacy, err := collectLegacyAgentConfigsAtRoot( + services, + projectRoot, + ) if err != nil { return nil, err } @@ -468,6 +533,13 @@ func collectAgentToolConnections(services map[string]*azdext.ServiceConfig) ([]p // connections, and toolboxes here rather than in sibling azure.ai. // services, so the collectors fall back to these when no sibling service exists. func collectLegacyAgentConfigs(services map[string]*azdext.ServiceConfig) ([]*project.ServiceTargetAgentConfig, error) { + return collectLegacyAgentConfigsAtRoot(services, "") +} + +func collectLegacyAgentConfigsAtRoot( + services map[string]*azdext.ServiceConfig, + projectRoot string, +) ([]*project.ServiceTargetAgentConfig, error) { var out []*project.ServiceTargetAgentConfig for _, svc := range sortedServices(services) { if svc.Host != AiAgentHost { @@ -476,7 +548,20 @@ func collectLegacyAgentConfigs(services map[string]*azdext.ServiceConfig) ([]*pr if project.ServiceConfigProps(svc) == nil { continue } - cfg, err := project.LoadServiceTargetAgentConfig(svc) + effective := proto.Clone(svc).(*azdext.ServiceConfig) + if projectRoot != "" { + if err := project.ResolveServiceConfigInPlace( + effective, + projectRoot, + ); err != nil { + return nil, fmt.Errorf( + "resolving agent service %q config: %w", + svc.Name, + err, + ) + } + } + cfg, err := project.LoadServiceTargetAgentConfig(effective) if err != nil { return nil, fmt.Errorf("parsing agent service %q config: %w", svc.Name, err) } @@ -487,6 +572,36 @@ func collectLegacyAgentConfigs(services map[string]*azdext.ServiceConfig) ([]*pr return out, nil } +func resolvedResourceServiceProps( + svc *azdext.ServiceConfig, + projectRoot string, +) (*structpb.Struct, error) { + props := project.ServiceConfigProps(svc) + if props == nil || projectRoot == "" { + return props, nil + } + resolved, err := foundry.ResolveFileRefs( + props.AsMap(), + projectRoot, + ) + if err != nil { + return nil, fmt.Errorf( + "resolving service %q config: %w", + svc.Name, + err, + ) + } + out, err := structpb.NewStruct(resolved) + if err != nil { + return nil, fmt.Errorf( + "encoding service %q config: %w", + svc.Name, + err, + ) + } + return out, nil +} + // sortedServices returns the services ordered by their map key so callers that // serialize collected resources produce deterministic output across runs. func sortedServices(services map[string]*azdext.ServiceConfig) []*azdext.ServiceConfig { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go index 0cfd1caacfc..97686db75fa 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -6,6 +6,8 @@ package cmd import ( "context" "net" + "os" + "path/filepath" "sync" "testing" @@ -15,6 +17,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/structpb" ) func mustMarshalConfig[T any](t *testing.T, in *T) *azdext.ServiceConfig { @@ -143,6 +146,70 @@ func TestCollectToolboxes(t *testing.T) { require.Len(t, toolboxes[0].Tools, 1) } +func TestCollectResourceServices_ResolvesFileRefs(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "deployment.yaml"), + []byte( + "name: gpt-4o\n"+ + "model: {name: gpt-4o}\n", + ), + 0o600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(root, "project.yaml"), + []byte( + "deployments:\n"+ + " - $ref: ./deployment.yaml\n", + ), + 0o600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(root, "connection.yaml"), + []byte( + "category: ApiKey\n"+ + "target: https://example.test\n", + ), + 0o600, + )) + projectProps, err := structpb.NewStruct(map[string]any{ + "$ref": "./project.yaml", + }) + require.NoError(t, err) + connectionProps, err := structpb.NewStruct(map[string]any{ + "$ref": "./connection.yaml", + }) + require.NoError(t, err) + services := map[string]*azdext.ServiceConfig{ + "ai-project": { + Name: "ai-project", + Host: AiProjectHost, + AdditionalProperties: projectProps, + }, + "search": { + Name: "search", + Host: AiConnectionHost, + AdditionalProperties: connectionProps, + }, + } + + deployments, err := collectProjectDeploymentsAtRoot( + services, + root, + ) + require.NoError(t, err) + require.Len(t, deployments, 1) + assert.Equal(t, "gpt-4o", deployments[0].Name) + + connections, err := collectConnectionsAtRoot(services, root) + require.NoError(t, err) + require.Len(t, connections, 1) + assert.Equal(t, "search", connections[0].Name) + assert.Equal(t, "ApiKey", connections[0].Category) +} + // TestCollectAgentToolConnections verifies tool connections stay on the agent // service and are sourced from there for toolbox enrichment. func TestCollectAgentToolConnections(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go index 9e93521ea1f..d9a1401ac4e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -47,6 +47,11 @@ type runFlags struct { channel string } +type environmentEntry struct { + key string + value string +} + func newRunCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &runFlags{} extCtx = ensureExtensionContext(extCtx) @@ -205,21 +210,39 @@ func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { fmt.Fprintf(os.Stderr, "Warning: failed to load azd environment values: %s\n", err) } - // Resolve environment_variables from the agent definition (agent.yaml). - // This handles hardcoded values, ${VAR} references (resolved via azd env), - // and ${{connections..credentials.}} references (resolved via - // the Foundry data plane). Agent definition env vars do not override - // values already present in the process environment. endpoint, _ := resolveAgentEndpoint(ctx, "", "") defEnv, defErr := resolveAgentDefinitionEnvVars(ctx, runCtx.Definition, azdEnvVars, endpoint) if defErr != nil { fmt.Fprintf(os.Stderr, "Warning: %s\n", defErr) } - for _, entry := range defEnv { - key, _, _ := strings.Cut(entry, "=") - if !envSliceHasKey(env, key) { - env = append(env, entry) + serviceEnv, serviceEnvErr := resolveServiceEnvironmentVars( + ctx, + runCtx.Environment, + azdEnvVars, + endpoint, + ) + if serviceEnvErr != nil { + fmt.Fprintf(os.Stderr, "Warning: %s\n", serviceEnvErr) + } + configuredEnv := mergeConfiguredEnvironmentEntries( + defEnv, + serviceEnv, + runtime.GOOS == "windows", + ) + keys := make([]string, 0, len(configuredEnv)) + for key := range configuredEnv { + keys = append(keys, key) + } + slices.Sort(keys) + for _, key := range keys { + entry := configuredEnv[key] + if !envSliceHasKey(env, entry.key) { + env = append( + env, + fmt.Sprintf("%s=%s", entry.key, entry.value), + ) } + } // Activity agents bind IPv4 and are reached at 127.0.0.1 everywhere else @@ -561,6 +584,45 @@ func resolveAgentDefinitionEnvVars( return result, nil } +func resolveServiceEnvironmentVars( + ctx context.Context, + values map[string]string, + azdEnvVars map[string]string, + endpoint string, +) ([]string, error) { + if len(values) == 0 { + return nil, nil + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + slices.Sort(keys) + envVars := make([]agent_yaml.EnvironmentVariable, 0, len(keys)) + for _, key := range keys { + value := values[key] + if endpoint != "" { + value = strings.ReplaceAll( + value, + "${{project.endpoint}}", + endpoint, + ) + } + envVars = append(envVars, agent_yaml.EnvironmentVariable{ + Name: key, + Value: value, + }) + } + return resolveAgentDefinitionEnvVars( + ctx, + &agent_yaml.ContainerAgent{ + EnvironmentVariables: &envVars, + }, + azdEnvVars, + endpoint, + ) +} + // findAgentYaml locates the agent definition file in the given directory. // After `azd ai agent init`, agent.yaml (and azure.yaml) are the sources of // truth for the agent configuration. We intentionally do not look at @@ -1021,11 +1083,37 @@ func appendFoundryEnvVars(env []string, azdEnv map[string]string, serviceName st return env } -// envSliceHasKey reports whether the env slice already contains an entry for the given key. +func mergeConfiguredEnvironmentEntries( + definitionEnv []string, + serviceEnv []string, + caseInsensitive bool, +) map[string]environmentEntry { + configuredEnv := map[string]environmentEntry{} + for _, entry := range append(definitionEnv, serviceEnv...) { + key, value, _ := strings.Cut(entry, "=") + lookupKey := key + if caseInsensitive { + lookupKey = strings.ToUpper(key) + } + configuredEnv[lookupKey] = environmentEntry{ + key: key, + value: value, + } + } + return configuredEnv +} + +// envSliceHasKey reports whether env contains an entry for key. func envSliceHasKey(env []string, key string) bool { - prefix := key + "=" return slices.ContainsFunc(env, func(entry string) bool { - return strings.HasPrefix(entry, prefix) + entryKey, _, found := strings.Cut(entry, "=") + if !found { + return false + } + if runtime.GOOS == "windows" { + return strings.EqualFold(entryKey, key) + } + return entryKey == key }) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go index c57cee03665..72861c8ac16 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go @@ -572,6 +572,41 @@ func TestAppendFoundryEnvVars(t *testing.T) { }) } +func TestEnvSliceHasKeyUsesPlatformCasing(t *testing.T) { + t.Parallel() + + env := []string{"Path=process-value"} + if !envSliceHasKey(env, "Path") { + t.Fatal("expected exact-case environment key to match") + } + + got := envSliceHasKey(env, "PATH") + want := runtime.GOOS == "windows" + if got != want { + t.Errorf("envSliceHasKey() = %t, want %t", got, want) + } +} + +func TestMergeConfiguredEnvironmentEntriesUsesServicePrecedence(t *testing.T) { + t.Parallel() + + entries := mergeConfiguredEnvironmentEntries( + []string{"PATH=definition-value"}, + []string{"Path=service-value"}, + true, + ) + + if len(entries) != 1 { + t.Fatalf("expected one entry, got %v", entries) + } + if entries["PATH"].key != "Path" { + t.Errorf("key = %q, want %q", entries["PATH"].key, "Path") + } + if entries["PATH"].value != "service-value" { + t.Errorf("value = %q, want %q", entries["PATH"].value, "service-value") + } +} + func TestAppendPortEnvVars(t *testing.T) { t.Parallel() @@ -993,6 +1028,35 @@ environment_variables: }) } +func TestResolveServiceEnvironmentVars(t *testing.T) { + t.Parallel() + + result, err := resolveServiceEnvironmentVars( + t.Context(), + map[string]string{ + "ENDPOINT": "${FOUNDRY_PROJECT_ENDPOINT}/agents", + "PROJECT": "${{project.endpoint}}", + "STATIC": "value", + }, + map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": "https://example", + }, + "https://example/project", + ) + + if err != nil { + t.Fatalf("resolve service environment: %v", err) + } + want := []string{ + "ENDPOINT=https://example/agents", + "PROJECT=https://example/project", + "STATIC=value", + } + if !slices.Equal(want, result) { + t.Fatalf("expected %v, got %v", want, result) + } +} + func TestVenvPip(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/projectconfig/environment.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/projectconfig/environment.go new file mode 100644 index 00000000000..103509ec4f9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/projectconfig/environment.go @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package projectconfig + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + + "github.com/azure/azure-dev/cli/azd/pkg/foundry" + "go.yaml.in/yaml/v3" +) + +// LoadServiceEnvironment reads raw env values from azure.yaml. +func LoadServiceEnvironment( + projectRoot string, + serviceName string, +) (map[string]string, error) { + if projectRoot == "" || serviceName == "" { + return nil, nil + } + + data, path, err := ReadProjectFile(projectRoot) + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, nil + } + + var document struct { + Services map[string]map[string]any `yaml:"services"` + } + if err := yaml.Unmarshal(data, &document); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + entry := document.Services[serviceName] + if entry == nil { + return nil, nil + } + resolved, err := foundry.ResolveFileRefs(entry, projectRoot) + if err != nil { + return nil, fmt.Errorf( + "resolve service %q config: %w", + serviceName, + err, + ) + } + if err := NormalizeEnvironment(resolved); err != nil { + return nil, fmt.Errorf("service %q: %w", serviceName, err) + } + value, exists := resolved["env"] + if !exists { + return nil, nil + } + raw, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf( + "service %q env must be a mapping", + serviceName, + ) + } + + env := make(map[string]string, len(raw)) + for key, value := range raw { + text, err := scalarString(value) + if err != nil { + return nil, fmt.Errorf( + "service %q env %q: %w", + serviceName, + key, + err, + ) + } + env[key] = text + } + return env, nil +} + +// NormalizeEnvironment converts scalar env values to strings in-place. +func NormalizeEnvironment(properties map[string]any) error { + value, exists := properties["env"] + if !exists { + return nil + } + raw, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("env must be a mapping") + } + for key, value := range raw { + text, err := scalarString(value) + if err != nil { + return fmt.Errorf("env %q: %w", key, err) + } + raw[key] = text + } + return nil +} + +// ReadProjectFile loads the project's azure.yaml or azure.yml. +func ReadProjectFile(projectRoot string) ([]byte, string, error) { + for _, name := range []string{"azure.yaml", "azure.yml"} { + path := filepath.Join(projectRoot, name) + data, err := os.ReadFile(path) //nolint:gosec + if err == nil { + return data, path, nil + } + if !os.IsNotExist(err) { + return nil, path, fmt.Errorf("read %s: %w", path, err) + } + } + return nil, "", nil +} + +func scalarString(value any) (string, error) { + if value == nil { + return "", nil + } + kind := reflect.TypeOf(value).Kind() + if kind == reflect.Map || + kind == reflect.Slice || + kind == reflect.Array { + return "", fmt.Errorf("must be a scalar") + } + return fmt.Sprint(value), nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/projectconfig/environment_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/projectconfig/environment_test.go new file mode 100644 index 00000000000..d9a15dbe34d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/projectconfig/environment_test.go @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package projectconfig + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadServiceEnvironment(t *testing.T) { + t.Parallel() + + t.Run("preserves expressions and converts scalars", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeProjectFile(t, root, `services: + agent: + host: azure.ai.agent + env: + PROJECT: ${{project.endpoint}} + ENABLED: true + RETRIES: 3 + RATIO: 1.5 + EMPTY: +`) + + env, err := LoadServiceEnvironment(root, "agent") + + require.NoError(t, err) + assert.Equal(t, "${{project.endpoint}}", env["PROJECT"]) + assert.Equal(t, "true", env["ENABLED"]) + assert.Equal(t, "3", env["RETRIES"]) + assert.Equal(t, "1.5", env["RATIO"]) + assert.Equal(t, "", env["EMPTY"]) + }) + + t.Run("resolves service file references", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeProjectFile(t, root, `services: + agent: + $ref: ./agent.yaml +`) + require.NoError(t, os.WriteFile( + filepath.Join(root, "agent.yaml"), + []byte(`host: azure.ai.agent +env: + CONNECTION: ${{connections.search.credentials.key}} +`), + 0o600, + )) + + env, err := LoadServiceEnvironment(root, "agent") + + require.NoError(t, err) + assert.Equal( + t, + "${{connections.search.credentials.key}}", + env["CONNECTION"], + ) + }) + + for _, test := range []struct { + name string + value string + }{ + {"map", " BAD:\n nested: value\n"}, + {"sequence", " BAD:\n - value\n"}, + } { + t.Run("rejects "+test.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeProjectFile(t, root, "services:\n"+ + " agent:\n"+ + " host: azure.ai.agent\n"+ + " env:\n"+ + test.value) + + _, err := LoadServiceEnvironment(root, "agent") + + require.ErrorContains(t, err, "must be a scalar") + }) + } +} + +func TestReadProjectFileUsesAzureYml(t *testing.T) { + t.Parallel() + + root := t.TempDir() + path := filepath.Join(root, "azure.yml") + require.NoError(t, os.WriteFile( + path, + []byte("services: {}\n"), + 0o600, + )) + + data, gotPath, err := ReadProjectFile(root) + + require.NoError(t, err) + assert.Equal(t, []byte("services: {}\n"), data) + assert.Equal(t, path, gotPath) +} + +func writeProjectFile(t *testing.T, root string, contents string) { + t.Helper() + require.NoError(t, os.WriteFile( + filepath.Join(root, "azure.yaml"), + []byte(contents), + 0o600, + )) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index 0d97d24c693..ce0d76aaa55 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -13,8 +13,10 @@ import ( "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/paths" + "azureaiagent/internal/pkg/projectconfig" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" "github.com/braydonk/yaml" "google.golang.org/protobuf/types/known/structpb" ) @@ -167,7 +169,8 @@ func LoadAgentDefinition( svc *azdext.ServiceConfig, projectRoot string, ) (agent_yaml.ContainerAgent, bool, AgentDefinitionSource, error) { - ca, isHosted, found, source, err := AgentDefinitionFromService(svc) + ca, isHosted, found, source, err := + AgentDefinitionFromResolvedService(svc, projectRoot) if err != nil { return agent_yaml.ContainerAgent{}, false, source, err } @@ -175,10 +178,95 @@ func LoadAgentDefinition( return ca, isHosted, source, nil } - // Fall back to a legacy agent.yaml/agent.yml on disk. return agentDefinitionFromDisk(svc, projectRoot) } +// AgentDefinitionFromResolvedService expands local file includes. +func AgentDefinitionFromResolvedService( + svc *azdext.ServiceConfig, + projectRoot string, +) ( + agent_yaml.ContainerAgent, + bool, + bool, + AgentDefinitionSource, + error, +) { + candidates := []struct { + props *structpb.Struct + source AgentDefinitionSource + }{ + {svc.GetAdditionalProperties(), AgentDefinitionSourceInline}, + {svc.GetConfig(), AgentDefinitionSourceLegacyConfig}, + } + for _, candidate := range candidates { + if candidate.props == nil || + len(candidate.props.GetFields()) == 0 { + continue + } + resolved, err := resolveServiceProps( + candidate.props, + svc.GetName(), + projectRoot, + ) + if err != nil { + return agent_yaml.ContainerAgent{}, + false, + false, + candidate.source, + err + } + if !structHasKind(resolved) { + continue + } + image := svc.GetImage() + if image == "" { + if value := resolved.GetFields()["image"]; value != nil { + image = value.GetStringValue() + } + } + ca, isHosted, err := agentDefinitionFromStruct( + resolved, + image, + ) + return ca, isHosted, true, candidate.source, err + } + + return agent_yaml.ContainerAgent{}, + false, + false, + AgentDefinitionSourceInline, + nil +} + +// AgentDefinitionUsesFileRef reports whether a root $ref supplies the +// agent definition. +func AgentDefinitionUsesFileRef( + svc *azdext.ServiceConfig, + projectRoot string, +) (bool, error) { + for _, props := range []*structpb.Struct{ + svc.GetAdditionalProperties(), + svc.GetConfig(), + } { + if props == nil || props.GetFields()["$ref"] == nil { + continue + } + resolved, err := resolveServiceProps( + props, + svc.GetName(), + projectRoot, + ) + if err != nil { + return false, err + } + if structHasKind(resolved) { + return true, nil + } + } + return false, nil +} + // AgentDefinitionFromService returns the agent definition carried inline on the // service entry — the unified service-level shape, or the deprecated // config-nested shape. found is false when the entry carries no inline @@ -223,11 +311,232 @@ func LoadServiceTargetAgentConfig(svc *azdext.ServiceConfig) (*ServiceTargetAgen // which shape a project uses. func ServiceConfigProps(svc *azdext.ServiceConfig) *structpb.Struct { if s := svc.GetAdditionalProperties(); s != nil && len(s.GetFields()) > 0 { + if svc.GetHost() == "azure.ai.agent" && + !structHasKind(s) && + structHasKind(svc.GetConfig()) { + return svc.GetConfig() + } return s } return svc.GetConfig() } +// ResolveServiceConfigProps expands local $ref file includes. +func ResolveServiceConfigProps( + svc *azdext.ServiceConfig, + projectRoot string, +) (*structpb.Struct, error) { + props := ServiceConfigProps(svc) + if props == nil { + return nil, nil + } + return resolveServiceProps(props, svc.GetName(), projectRoot) +} + +// ResolvedServiceProjectPath returns the service source path. +func ResolvedServiceProjectPath( + svc *azdext.ServiceConfig, + projectRoot string, +) (string, error) { + if svc.GetRelativePath() != "" { + return svc.GetRelativePath(), nil + } + for _, props := range []*structpb.Struct{ + svc.GetAdditionalProperties(), + svc.GetConfig(), + } { + if props == nil || len(props.GetFields()) == 0 { + continue + } + resolved, err := resolveServiceProps( + props, + svc.GetName(), + projectRoot, + ) + if err != nil { + return "", err + } + if value := resolved.GetFields()["project"]; value != nil { + if path := value.GetStringValue(); path != "" { + return path, nil + } + } + } + return "", nil +} + +// ResolveServiceConfigInPlace expands service-level local includes. +func ResolveServiceConfigInPlace( + svc *azdext.ServiceConfig, + projectRoot string, +) error { + if props := svc.GetAdditionalProperties(); props != nil && + len(props.GetFields()) > 0 { + resolved, err := resolveServiceProps( + props, + svc.GetName(), + projectRoot, + ) + if err != nil { + return err + } + svc.AdditionalProperties = resolved + hydrateResolvedServiceFields(svc, resolved) + } + if config := svc.GetConfig(); config != nil && + len(config.GetFields()) > 0 { + resolved, err := resolveServiceProps( + config, + svc.GetName(), + projectRoot, + ) + if err != nil { + return err + } + svc.Config = resolved + hydrateResolvedServiceFields(svc, resolved) + } + return nil +} + +// NormalizeServiceConfigInPlace converts environment scalars without +// resolving nested file references. +func NormalizeServiceConfigInPlace(svc *azdext.ServiceConfig) error { + if props := svc.GetAdditionalProperties(); props != nil && + len(props.GetFields()) > 0 { + normalized, err := normalizeServiceProps(props, svc.GetName()) + if err != nil { + return err + } + svc.AdditionalProperties = normalized + } + if config := svc.GetConfig(); config != nil && + len(config.GetFields()) > 0 { + normalized, err := normalizeServiceProps(config, svc.GetName()) + if err != nil { + return err + } + svc.Config = normalized + } + return nil +} + +func hydrateResolvedServiceFields( + svc *azdext.ServiceConfig, + props *structpb.Struct, +) { + if svc.GetRelativePath() == "" { + if value := props.GetFields()["project"]; value != nil { + svc.RelativePath = value.GetStringValue() + } + } + if svc.GetImage() == "" { + if value := props.GetFields()["image"]; value != nil { + svc.Image = value.GetStringValue() + } + } + if !hasDockerSettings(svc.GetDocker()) && + props.GetFields()["docker"] != nil && + props.GetFields()["docker"].GetStructValue() != nil { + value := props.GetFields()["docker"] + fields := value.GetStructValue().GetFields() + docker := &azdext.DockerProjectOptions{ + Path: fields["path"].GetStringValue(), + Context: fields["context"].GetStringValue(), + Platform: fields["platform"].GetStringValue(), + Target: fields["target"].GetStringValue(), + Registry: fields["registry"].GetStringValue(), + Image: fields["image"].GetStringValue(), + Tag: fields["tag"].GetStringValue(), + RemoteBuild: fields["remoteBuild"].GetBoolValue(), + Network: fields["network"].GetStringValue(), + } + if buildArgs := fields["buildArgs"].GetListValue(); buildArgs != nil { + for _, arg := range buildArgs.GetValues() { + docker.BuildArgs = append( + docker.BuildArgs, + arg.GetStringValue(), + ) + } + } + svc.Docker = docker + } +} + +func hasDockerSettings(docker *azdext.DockerProjectOptions) bool { + if docker == nil { + return false + } + return docker.GetPath() != "" || + docker.GetContext() != "" || + docker.GetPlatform() != "" || + docker.GetTarget() != "" || + docker.GetRegistry() != "" || + docker.GetImage() != "" || + docker.GetTag() != "" || + docker.GetRemoteBuild() || + len(docker.GetBuildArgs()) > 0 || + docker.GetNetwork() != "" +} + +func resolveServiceProps( + props *structpb.Struct, + serviceName string, + projectRoot string, +) (*structpb.Struct, error) { + resolved, err := foundry.ResolveFileRefs( + props.AsMap(), + projectRoot, + ) + if err != nil { + return nil, fmt.Errorf( + "resolving service %q config: %w", + serviceName, + err, + ) + } + if err := projectconfig.NormalizeEnvironment(resolved); err != nil { + return nil, fmt.Errorf( + "normalizing service %q environment: %w", + serviceName, + err, + ) + } + + out, err := structpb.NewStruct(resolved) + if err != nil { + return nil, fmt.Errorf( + "encoding resolved service %q config: %w", + serviceName, + err, + ) + } + return out, nil +} + +func normalizeServiceProps( + props *structpb.Struct, + serviceName string, +) (*structpb.Struct, error) { + values := props.AsMap() + if err := projectconfig.NormalizeEnvironment(values); err != nil { + return nil, fmt.Errorf( + "normalizing service %q environment: %w", + serviceName, + err, + ) + } + out, err := structpb.NewStruct(values) + if err != nil { + return nil, fmt.Errorf( + "encoding normalized service %q config: %w", + serviceName, + err, + ) + } + return out, nil +} + // UpsertAgentEnvVars adds or updates environment variables on the agent // definition carried inline on the service entry, preserving every other key. // It is used by commands that mutate the definition (e.g. `optimize apply`). @@ -262,21 +571,34 @@ func UpsertAgentEnvVars(svc *azdext.ServiceConfig, kv map[string]string) error { } ca.EnvironmentVariables = &envVars - cfg, err := LoadServiceTargetAgentConfig(svc) - if err != nil { - return err + var props *structpb.Struct + if source == AgentDefinitionSourceLegacyConfig { + props = svc.GetConfig() + } else { + props = svc.GetAdditionalProperties() } - - props, err := AgentDefinitionToServiceProperties(ca, cfg) - if err != nil { - return err + if props == nil { + return fmt.Errorf( + "service %q does not carry an inline agent definition", + svc.GetName(), + ) + } + if props.Fields == nil { + props.Fields = map[string]*structpb.Value{} } - if source == AgentDefinitionSourceLegacyConfig { - svc.Config = props - } else { - svc.AdditionalProperties = props + envValues := make([]*structpb.Value, 0, len(envVars)) + for _, envVar := range envVars { + envValues = append(envValues, structpb.NewStructValue( + &structpb.Struct{Fields: map[string]*structpb.Value{ + "name": structpb.NewStringValue(envVar.Name), + "value": structpb.NewStringValue(envVar.Value), + }}, + )) } + props.Fields["environmentVariables"] = structpb.NewListValue( + &structpb.ListValue{Values: envValues}, + ) return nil } @@ -329,6 +651,23 @@ func agentDefinitionFromStruct(s *structpb.Struct, coreImage string) (agent_yaml } if inline.Kind != agent_yaml.AgentKindHosted { + // Validate non-hosted kinds (e.g. workflow) with the same + // generic rules the on-disk path uses, so an invalid kind or + // name is rejected instead of silently passing as "not a + // container agent". + if defBytes, marshalErr := yaml.Marshal(s.AsMap()); marshalErr != nil { + log.Printf( + "[debug] skipping non-hosted agent validation: %v", + marshalErr, + ) + } else if err := agent_yaml.ValidateAgentDefinition(defBytes); err != nil { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent service definition is not valid: %s", err), + "fix the agent service entry in azure.yaml or "+ + "re-run `azd ai agent init`", + ) + } return agent_yaml.ContainerAgent{}, false, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go index 821f8ad7e18..9ccfc77ddd8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -12,6 +12,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" ) // sampleContainerAgent returns a hosted ContainerAgent with the fields that the @@ -110,6 +111,43 @@ func TestAgentDefinitionFromService_LegacyConfigShape(t *testing.T) { require.Equal(t, "basic-agent", got.Name) } +func TestLoadAgentDefinition_UnrelatedInlineFallsBackToConfig( + t *testing.T, +) { + t.Parallel() + + config, err := AgentDefinitionToServiceProperties( + sampleContainerAgent(), + &ServiceTargetAgentConfig{ + StartupCommand: "python main.py", + }, + ) + require.NoError(t, err) + inline, err := structpb.NewStruct(map[string]any{ + "resumeSessionOnDeploy": true, + }) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + AdditionalProperties: inline, + Config: config, + } + + got, isHosted, source, err := LoadAgentDefinition( + svc, + t.TempDir(), + ) + + require.NoError(t, err) + require.True(t, isHosted) + require.Equal(t, AgentDefinitionSourceLegacyConfig, source) + require.Equal(t, "basic-agent", got.Name) + serviceConfig, err := LoadServiceTargetAgentConfig(svc) + require.NoError(t, err) + require.Equal(t, "python main.py", serviceConfig.StartupCommand) +} + // TestAgentDefinitionFromService_NoDefinition verifies that a service without an // inline definition reports not-found (callers then fall back to disk). func TestAgentDefinitionFromService_NoDefinition(t *testing.T) { @@ -192,6 +230,34 @@ func TestAgentDefinitionFromService_InvalidDefinition(t *testing.T) { require.Error(t, err) } +func TestLoadAgentDefinition_ToolboxServiceReference(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "name": "basic-agent", + "toolboxes": []any{"research-tools"}, + }) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + + _, isHosted, _, err := LoadAgentDefinition( + svc, + t.TempDir(), + ) + require.NoError(t, err) + require.True(t, isHosted) + + cfg, err := LoadServiceTargetAgentConfig(svc) + require.NoError(t, err) + require.Len(t, cfg.Toolboxes, 1) + require.Equal(t, "research-tools", cfg.Toolboxes[0].Name) +} + // TestLoadAgentDefinition_DiskFallback verifies the legacy on-disk agent.yaml // fallback used during the migration window. func TestLoadAgentDefinition_DiskFallback(t *testing.T) { @@ -208,6 +274,159 @@ func TestLoadAgentDefinition_DiskFallback(t *testing.T) { require.Equal(t, "disk-agent", got.Name) } +func TestLoadAgentDefinition_FileRef(t *testing.T) { + dir := t.TempDir() + definitionsDir := filepath.Join(dir, "definitions") + require.NoError(t, os.MkdirAll(definitionsDir, 0o700)) + require.NoError(t, os.WriteFile( + filepath.Join(definitionsDir, "agent.yaml"), + []byte( + "kind: hosted\n"+ + "name: referenced-agent\n"+ + "project: ../src/agent\n"+ + "image: registry.example/agent:v1\n"+ + "docker:\n"+ + " path: docker/Dockerfile\n"+ + " context: docker\n"+ + " remoteBuild: true\n"+ + "startupCommand: python main.py\n"+ + "protocols:\n"+ + " - protocol: responses\n"+ + " version: \"1.0.0\"\n", + ), + 0o600, + )) + + props, err := structpb.NewStruct(map[string]any{ + "$ref": "./definitions/agent.yaml", + "name": "overlay-agent", + }) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "agent-service", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + + got, isHosted, source, err := LoadAgentDefinition(svc, dir) + require.NoError(t, err) + require.True(t, isHosted) + require.Equal(t, AgentDefinitionSourceInline, source) + require.Equal(t, "overlay-agent", got.Name) + require.Equal(t, "responses", got.Protocols[0].Protocol) + require.Equal(t, "registry.example/agent:v1", got.Image) + + usesFileRef, err := AgentDefinitionUsesFileRef(svc, dir) + require.NoError(t, err) + require.True(t, usesFileRef) + + projectPath, err := ResolvedServiceProjectPath(svc, dir) + require.NoError(t, err) + require.Equal(t, "src/agent", projectPath) + + require.NoError(t, ResolveServiceConfigInPlace(svc, dir)) + _, hasRef := svc.GetAdditionalProperties().GetFields()["$ref"] + require.False(t, hasRef) + cfg, err := LoadServiceTargetAgentConfig(svc) + require.NoError(t, err) + require.Equal(t, "python main.py", cfg.StartupCommand) + require.Equal(t, "registry.example/agent:v1", svc.GetImage()) + require.Equal(t, "docker/Dockerfile", svc.GetDocker().GetPath()) + require.Equal(t, "docker", svc.GetDocker().GetContext()) + require.True(t, svc.GetDocker().GetRemoteBuild()) +} + +func TestResolveServiceConfigInPlacePreservesDockerOverride(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "agent.yaml"), + []byte("docker:\n path: referenced.Dockerfile\n context: referenced\n"), + 0o600, + )) + props, err := structpb.NewStruct(map[string]any{ + "$ref": "./agent.yaml", + }) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "agent-service", + AdditionalProperties: props, + Docker: &azdext.DockerProjectOptions{ + Path: "override.Dockerfile", + Context: "override", + }, + } + + require.NoError(t, ResolveServiceConfigInPlace(svc, dir)) + + require.Equal(t, "override.Dockerfile", svc.GetDocker().GetPath()) + require.Equal(t, "override", svc.GetDocker().GetContext()) +} + +func TestAgentDefinitionUsesFileRefIgnoresNestedResourceRefs( + t *testing.T, +) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "deployments": []any{ + map[string]any{"$ref": "./deployment.yaml"}, + }, + }) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "legacy-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + + usesFileRef, err := AgentDefinitionUsesFileRef(svc, t.TempDir()) + + require.NoError(t, err) + require.False(t, usesFileRef) +} + +// TestLoadAgentDefinition_InlineWorkflow verifies a valid inline +// `kind: workflow` definition loads without error and is reported as a +// non-hosted (non-container) agent rather than being rejected. +func TestLoadAgentDefinition_InlineWorkflow(t *testing.T) { + props, err := structpb.NewStruct(map[string]any{ + "kind": "workflow", + "name": "my-workflow", + }) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "my-workflow", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + + _, isHosted, source, err := LoadAgentDefinition(svc, t.TempDir()) + require.NoError(t, err) + require.False(t, isHosted) + require.Equal(t, AgentDefinitionSourceInline, source) +} + +// TestLoadAgentDefinition_InlineWorkflow_InvalidRejected verifies an +// inline non-hosted definition is still validated: an invalid name is +// rejected rather than silently passing as "not a container agent". +func TestLoadAgentDefinition_InlineWorkflow_InvalidRejected(t *testing.T) { + props, err := structpb.NewStruct(map[string]any{ + "kind": "workflow", + "name": "Invalid_Name", + }) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "wf", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + + _, _, _, err = LoadAgentDefinition(svc, t.TempDir()) + require.Error(t, err) +} + // TestUpsertAgentEnvVars verifies that env vars are added/updated on the inline // definition while preserving the other definition keys. func TestUpsertAgentEnvVars(t *testing.T) { @@ -233,3 +452,32 @@ func TestUpsertAgentEnvVars(t *testing.T) { require.Equal(t, "gpt-4o", values["FOUNDRY_MODEL_DEPLOYMENT_NAME"]) require.Equal(t, "cand-1", values["OPTIMIZATION_CANDIDATE_ID"]) } + +func TestUpsertAgentEnvVarsPreservesNestedReferences(t *testing.T) { + props, err := structpb.NewStruct(map[string]any{ + "kind": "hosted", + "name": "basic-agent", + "deployments": []any{ + map[string]any{"$ref": "./deployment.yaml"}, + }, + }) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + + require.NoError(t, UpsertAgentEnvVars(svc, map[string]string{ + "OPTIMIZATION_CANDIDATE_ID": "cand-1", + })) + + deployments := svc.GetAdditionalProperties().GetFields()["deployments"] + require.NotNil(t, deployments) + require.Equal( + t, + "./deployment.yaml", + deployments.GetListValue().GetValues()[0]. + GetStructValue().GetFields()["$ref"].GetStringValue(), + ) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config.go b/cli/azd/extensions/azure.ai.agents/internal/project/config.go index b37b190409e..8a581fc73a8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -113,6 +113,22 @@ type Toolbox struct { Tools []map[string]any `json:"tools"` } +// UnmarshalJSON accepts split-service names and legacy objects. +func (t *Toolbox) UnmarshalJSON(data []byte) error { + var name string + if err := json.Unmarshal(data, &name); err == nil { + t.Name = name + return nil + } + type toolbox Toolbox + var value toolbox + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *t = Toolbox(value) + return nil +} + // MemoryStore represents a Foundry memory store provisioned (create-if-not-exists) // during deployment. It backs the agent's memory_search tool, letting the agent retain // context across sessions. ChatModel and EmbeddingModel reference model deployment names diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go index b6f0663007f..161579bac4d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider.go @@ -18,6 +18,7 @@ import ( "time" "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/projectconfig" "azureaiagent/internal/synthesis" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -28,6 +29,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" "github.com/azure/azure-dev/cli/azd/pkg/grpcbroker" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" @@ -128,14 +130,19 @@ func (p *FoundryProvisioningProvider) Initialize( } p.projectPath = projectPath - azureYamlPath := filepath.Join(projectPath, "azure.yaml") - //nolint:gosec // projectPath is supplied by azd-core over gRPC and is the user's project root - rawYAML, err := os.ReadFile(azureYamlPath) + rawYAML, azureYamlPath, err := projectconfig.ReadProjectFile(projectPath) if err != nil { return exterrors.Validation( exterrors.CodeInvalidAzureYaml, fmt.Sprintf("read %s: %s", azureYamlPath, err), - "verify azure.yaml exists at the project root", + "verify azure.yaml or azure.yml exists at the project root", + ) + } + if azureYamlPath == "" { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("no azure.yaml or azure.yml found in %s", projectPath), + "verify azure.yaml or azure.yml exists at the project root", ) } @@ -150,8 +157,33 @@ func (p *FoundryProvisioningProvider) Initialize( "skipping synthesizer", filepath.Join(projectPath, onDiskInfraDir)) // endpoint: (brownfield) reuse skips provisioning even on the on-disk // path; connect to the existing project instead of compiling Bicep. - if endpoint := foundryServiceEndpoint(rawYAML, svcName); endpoint != "" { - warnNetworkIgnoredInBrownfield(rawYAML, svcName) + endpoint, endpointErr := foundryServiceEndpointAtRoot( + rawYAML, + projectPath, + svcName, + ) + if endpointErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "resolve existing Foundry project endpoint: %s", + endpointErr, + ), + "fix the project service configuration in azure.yaml", + ) + } + if endpoint != "" { + if err := warnNetworkIgnoredInBrownfield( + rawYAML, + projectPath, + svcName, + ); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("resolve Foundry service configuration: %s", err), + "fix the project service configuration in azure.yaml", + ) + } p.brownfieldEndpoint = endpoint if err := p.captureBrownfieldDeployments(ctx, rawYAML, svcName); err != nil { return err @@ -172,8 +204,33 @@ func (p *FoundryProvisioningProvider) Initialize( case errors.Is(err, synthesis.ErrEndpointBrownfield): // endpoint: reuse — connect to the existing project, skip provisioning. // network: has no effect in brownfield mode; warn if both are present. - warnNetworkIgnoredInBrownfield(rawYAML, svcName) - p.brownfieldEndpoint = foundryServiceEndpoint(rawYAML, svcName) + if err := warnNetworkIgnoredInBrownfield( + rawYAML, + projectPath, + svcName, + ); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf("resolve Foundry service configuration: %s", err), + "fix the project service configuration in azure.yaml", + ) + } + endpoint, endpointErr := foundryServiceEndpointAtRoot( + rawYAML, + projectPath, + svcName, + ) + if endpointErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidAzureYaml, + fmt.Sprintf( + "resolve existing Foundry project endpoint: %s", + endpointErr, + ), + "fix the project service configuration in azure.yaml", + ) + } + p.brownfieldEndpoint = endpoint if err := p.captureBrownfieldDeployments(ctx, rawYAML, svcName); err != nil { return err } @@ -249,23 +306,46 @@ func (p *FoundryProvisioningProvider) networkEnvMap(ctx context.Context) map[str // warnNetworkIgnoredInBrownfield logs a warning when a service declares both // endpoint: (brownfield) and network:. The account's network posture is fixed // by whoever created it, so the network: block has no effect. -func warnNetworkIgnoredInBrownfield(rawYAML []byte, svcName string) { +func warnNetworkIgnoredInBrownfield( + rawYAML []byte, + projectRoot string, + svcName string, +) error { type svc struct { Endpoint string `yaml:"endpoint,omitempty"` Network yaml.Node `yaml:"network,omitempty"` } type root struct { - Services map[string]svc `yaml:"services"` + Services map[string]map[string]any `yaml:"services"` } var r root if err := yaml.Unmarshal(rawYAML, &r); err != nil { - return + return err + } + values := r.Services[svcName] + if values == nil { + return nil + } + if projectRoot != "" { + resolved, err := foundry.ResolveFileRefs(values, projectRoot) + if err != nil { + return err + } + values = resolved } - s := r.Services[svcName] - if strings.TrimSpace(s.Endpoint) != "" && !s.Network.IsZero() { + data, err := yaml.Marshal(values) + if err != nil { + return err + } + var service svc + if err := yaml.Unmarshal(data, &service); err != nil { + return err + } + if strings.TrimSpace(service.Endpoint) != "" && !service.Network.IsZero() { log.Printf("[warn] foundry provider: service %q sets both endpoint: and network:; "+ "network: is ignored in brownfield mode (the account's network posture is fixed)", svcName) } + return nil } // or infra/main.bicep exists under p.projectPath. Stat-only. @@ -275,23 +355,44 @@ func (p *FoundryProvisioningProvider) onDiskTemplatePresent() bool { fileExistsAt(filepath.Join(infraDir, onDiskBicepFile)) } -// foundryServiceEndpoint returns the endpoint: value set on the named foundry -// service, or "" when none is set. A non-empty endpoint means bring-your-own -// (brownfield): the provider connects to that existing project instead of -// provisioning a new one. -func foundryServiceEndpoint(rawYAML []byte, svcName string) string { +func foundryServiceEndpointAtRoot( + rawYAML []byte, + projectRoot string, + svcName string, +) (string, error) { type svc struct { Endpoint string `yaml:"endpoint,omitempty"` } type root struct { - Services map[string]svc `yaml:"services"` + Services map[string]map[string]any `yaml:"services"` } var r root if err := yaml.Unmarshal(rawYAML, &r); err != nil { - // Malformed yaml is surfaced upstream; don't mask the parser error. - return "" + return "", err + } + values := r.Services[svcName] + if values == nil { + return "", nil + } + if projectRoot != "" { + resolved, err := foundry.ResolveFileRefs( + values, + projectRoot, + ) + if err != nil { + return "", err + } + values = resolved } - return strings.TrimSpace(r.Services[svcName].Endpoint) + data, err := yaml.Marshal(values) + if err != nil { + return "", err + } + var service svc + if err := yaml.Unmarshal(data, &service); err != nil { + return "", err + } + return strings.TrimSpace(service.Endpoint), nil } // resolveEnvName resolves just the active azd environment name. The brownfield @@ -702,7 +803,11 @@ func (p *FoundryProvisioningProvider) Deploy( func (p *FoundryProvisioningProvider) captureBrownfieldDeployments( ctx context.Context, rawYAML []byte, svcName string, ) error { - deployments, err := synthesis.BrownfieldDeployments(rawYAML, svcName) + deployments, err := synthesis.BrownfieldDeploymentsAtRoot( + rawYAML, + p.projectPath, + svcName, + ) if err != nil { return exterrors.Validation( exterrors.CodeInvalidAzureYaml, @@ -712,7 +817,11 @@ func (p *FoundryProvisioningProvider) captureBrownfieldDeployments( } p.brownfieldDeployments = deployments - connections, err := synthesis.BrownfieldConnections(rawYAML, p.networkEnvMap(ctx)) + connections, err := synthesis.BrownfieldConnectionsAtRoot( + rawYAML, + p.projectPath, + p.networkEnvMap(ctx), + ) if err != nil { return exterrors.Validation( exterrors.CodeInvalidAzureYaml, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go index ee92cf65807..f1ead64bdfd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_provisioning_provider_test.go @@ -803,13 +803,14 @@ func TestResolveTemplate_OnDiskFallsBackWhenSourceLoaderReturnsNil(t *testing.T) assert.Equal(t, templateModeEmbedded, got.mode) } -func TestFoundryServiceEndpoint(t *testing.T) { +func TestFoundryServiceEndpointAtRoot(t *testing.T) { t.Parallel() tests := []struct { name string yaml string svcName string wantEndpoint string + wantErr bool }{ { name: "greenfield (no endpoint:) -> empty", @@ -850,20 +851,64 @@ services: wantEndpoint: "", }, { - name: "malformed yaml -> empty (upstream surfaces parse error)", - yaml: "not: : valid: yaml", - svcName: "foundry", - wantEndpoint: "", + name: "malformed yaml returns parse error", + yaml: "not: : valid: yaml", + svcName: "foundry", + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tt.wantEndpoint, foundryServiceEndpoint([]byte(tt.yaml), tt.svcName)) + endpoint, err := foundryServiceEndpointAtRoot( + []byte(tt.yaml), + "", + tt.svcName, + ) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantEndpoint, endpoint) }) } } +func TestFoundryServiceEndpointAtRoot_ResolvesFileRef( + t *testing.T, +) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "project.yaml"), + []byte( + "endpoint: https://acct.services.ai.azure.com/"+ + "api/projects/existing\n", + ), + 0o600, + )) + raw := []byte(`services: + foundry: + host: azure.ai.project + $ref: ./project.yaml +`) + + endpoint, err := foundryServiceEndpointAtRoot( + raw, + root, + "foundry", + ) + + require.NoError(t, err) + assert.Equal( + t, + "https://acct.services.ai.azure.com/api/projects/existing", + endpoint, + ) +} + func TestProjectNameFromEndpoint(t *testing.T) { t.Parallel() assert.Equal(t, "my-project", projectNameFromEndpoint( diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check.go b/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check.go index 252418d04fc..565833ec7a7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check.go @@ -6,11 +6,10 @@ package project import ( "context" "fmt" - "os" - "path/filepath" "strings" "azureaiagent/internal/pkg/azure" + "azureaiagent/internal/pkg/projectconfig" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" @@ -275,8 +274,8 @@ func (c *ResourceGroupLocationCheck) isBrownfieldFoundryProject(ctx context.Cont return false } - // ProjectConfig.Path is the project directory that contains azure.yaml. - rawYAML, err := os.ReadFile(filepath.Join(resp.GetProject().GetPath(), "azure.yaml")) + projectPath := resp.GetProject().GetPath() + rawYAML, _, err := projectconfig.ReadProjectFile(projectPath) if err != nil { return false } @@ -286,7 +285,12 @@ func (c *ResourceGroupLocationCheck) isBrownfieldFoundryProject(ctx context.Cont return false } - return foundryServiceEndpoint(rawYAML, svcName) != "" + endpoint, err := foundryServiceEndpointAtRoot( + rawYAML, + projectPath, + svcName, + ) + return err == nil && endpoint != "" } // envValueOrEmpty returns the trimmed value of key in the named azd environment, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check_validate_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check_validate_test.go index 2047d9d12fd..a0a3ef867fd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check_validate_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/resource_group_location_check_validate_test.go @@ -159,6 +159,100 @@ func TestValidate_Gates(t *testing.T) { assert.False(t, called, "resource group lookup must not run for a brownfield project") }) + t.Run("skips brownfield project from azure.yml", func(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "azure.yml"), + []byte(`name: rgloc-test +services: + ai-project: + host: azure.ai.project + endpoint: https://acct.services.ai.azure.com/api/projects/p +`), + 0o600, + )) + proj := &validateStubProjectServer{project: &azdext.ProjectConfig{ + Path: root, + Infra: &azdext.InfraOptions{Provider: FoundryProviderName}, + }} + env := &validateStubEnvServer{ + envName: "rgloc-test", + get: map[string]string{}, + } + client := newValidateTestClient(t, proj, env) + + var called bool + c := &ResourceGroupLocationCheck{azdClient: client} + c.resourceGroupLocation = func( + context.Context, + string, + string, + ) (string, bool, error) { + called = true + return "eastus", true, nil + } + + resp, err := c.Validate( + t.Context(), + provisionContext(sub, "westus2", "rg-x"), + &azdext.ValidationCheckRequest{}, + ) + require.NoError(t, err) + assert.Empty(t, resp.Results) + assert.False(t, called) + }) + + t.Run("skips brownfield project with referenced endpoint", func(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "project.yaml"), + []byte( + "endpoint: https://acct.services.ai.azure.com/"+ + "api/projects/p\n", + ), + 0o600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(root, "azure.yaml"), + []byte(`name: rgloc-test +services: + ai-project: + host: azure.ai.project + $ref: ./project.yaml +`), + 0o600, + )) + proj := &validateStubProjectServer{project: &azdext.ProjectConfig{ + Path: root, + Infra: &azdext.InfraOptions{Provider: FoundryProviderName}, + }} + env := &validateStubEnvServer{ + envName: "rgloc-test", + get: map[string]string{}, + } + client := newValidateTestClient(t, proj, env) + + var called bool + c := &ResourceGroupLocationCheck{azdClient: client} + c.resourceGroupLocation = func( + context.Context, + string, + string, + ) (string, bool, error) { + called = true + return "eastus", true, nil + } + + resp, err := c.Validate( + t.Context(), + provisionContext(sub, "westus2", "rg-x"), + &azdext.ValidationCheckRequest{}, + ) + require.NoError(t, err) + assert.Empty(t, resp.Results) + assert.False(t, called) + }) + t.Run("skips when required values are missing", func(t *testing.T) { proj := &validateStubProjectServer{project: &azdext.ProjectConfig{ Path: writeAzureYAML(t, ""), diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 610a6d46e22..f9e881fc8fd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -31,6 +31,7 @@ import ( "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/azure" "azureaiagent/internal/pkg/paths" + "azureaiagent/internal/pkg/projectconfig" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" @@ -207,7 +208,36 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er "run 'azd init' to initialize your project", ) } - servicePath := p.serviceConfig.RelativePath + if err := ResolveServiceConfigInPlace( + p.serviceConfig, + proj.Project.Path, + ); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf( + "failed to resolve service config for %s: %s", + p.serviceConfig.Name, + err, + ), + "fix the agent service configuration in azure.yaml", + ) + } + servicePath, err := ResolvedServiceProjectPath( + p.serviceConfig, + proj.Project.Path, + ) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf( + "failed to resolve service path for %s: %s", + p.serviceConfig.Name, + err, + ), + "fix the agent service configuration in azure.yaml", + ) + } + p.serviceConfig.RelativePath = servicePath fullPath, err := paths.JoinAllowRoot(proj.Project.Path, servicePath) if err != nil { return exterrors.Validation( @@ -300,7 +330,10 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er // Unified shape: the agent definition is carried inline on the service entry, // so no on-disk agent.yaml is required. - if _, _, found, _, defErr := AgentDefinitionFromService(p.serviceConfig); defErr != nil { + if _, _, found, _, defErr := AgentDefinitionFromResolvedService( + p.serviceConfig, + proj.Project.Path, + ); defErr != nil { return defErr } else if found { p.deployContextReady = true @@ -449,6 +482,7 @@ func (p *AgentServiceTargetProvider) GetTargetResource( if err := p.ensureDeployContext(ctx); err != nil { return nil, err } + serviceConfig = p.serviceConfig // Ensure Foundry project is loaded if err := p.ensureFoundryProject(ctx); err != nil { return nil, err @@ -552,7 +586,6 @@ func (p *AgentServiceTargetProvider) Package( Artifacts: []*azdext.Artifact{preBuiltImageArtifact(agentDef.Image)}, }, nil } - var packageArtifact *azdext.Artifact var newArtifacts []*azdext.Artifact @@ -574,12 +607,13 @@ func (p *AgentServiceTargetProvider) Package( } if buildArtifact == nil { + buildRequest := &azdext.ContainerBuildRequest{ + ServiceName: serviceConfig.Name, + ServiceContext: serviceContext, + } buildResponse, err := p.azdClient. Container(). - Build(ctx, &azdext.ContainerBuildRequest{ - ServiceName: serviceConfig.Name, - ServiceContext: serviceContext, - }) + Build(ctx, buildRequest) if err != nil { return nil, exterrors.Internal(exterrors.OpContainerBuild, fmt.Sprintf("container build failed: %s", err)) } @@ -587,12 +621,13 @@ func (p *AgentServiceTargetProvider) Package( serviceContext.Build = append(serviceContext.Build, buildResponse.Result.Artifacts...) } + packageRequest := &azdext.ContainerPackageRequest{ + ServiceName: serviceConfig.Name, + ServiceContext: serviceContext, + } packageResponse, err := p.azdClient. Container(). - Package(ctx, &azdext.ContainerPackageRequest{ - ServiceName: serviceConfig.Name, - ServiceContext: serviceContext, - }) + Package(ctx, packageRequest) if err != nil { return nil, exterrors.Internal(exterrors.OpContainerPackage, fmt.Sprintf("container package failed: %s", err)) } @@ -640,12 +675,13 @@ func (p *AgentServiceTargetProvider) Publish( } progress("Publishing container") + publishRequest := &azdext.ContainerPublishRequest{ + ServiceName: serviceConfig.Name, + ServiceContext: serviceContext, + } publishResponse, err := p.azdClient. Container(). - Publish(ctx, &azdext.ContainerPublishRequest{ - ServiceName: serviceConfig.Name, - ServiceContext: serviceContext, - }) + Publish(ctx, publishRequest) if err != nil { return nil, classifyContainerPublishError(err) @@ -975,7 +1011,11 @@ func (p *AgentServiceTargetProvider) loadContainerAgentDefinition() (agent_yaml. // Prefer the agent definition carried inline on the service entry (the // unified service-level shape, or the deprecated config-nested shape). - if ca, isHosted, found, source, err := AgentDefinitionFromService(p.serviceConfig); found || err != nil { + if ca, isHosted, found, source, err := + AgentDefinitionFromResolvedService( + p.serviceConfig, + p.projectPath, + ); found || err != nil { if found && source.IsLegacy() { WarnLegacyAgentShape(source) } @@ -1000,6 +1040,7 @@ func (p *AgentServiceTargetProvider) Deploy( if err := p.ensureDeployContext(ctx); err != nil { return nil, err } + serviceConfig = p.serviceConfig // Ensure Foundry project is loaded if err := p.ensureFoundryProject(ctx); err != nil { return nil, err @@ -1416,14 +1457,6 @@ func (p *AgentServiceTargetProvider) prepareDeploy( fmt.Fprintf(os.Stderr, "Using endpoint: %s\n", azdEnv["FOUNDRY_PROJECT_ENDPOINT"]) fmt.Fprintf(os.Stderr, "Agent Name: %s\n", agentDef.Name) - // Resolve environment variables from YAML using azd environment values - resolvedEnvVars := make(map[string]string) - if agentDef.EnvironmentVariables != nil { - for _, envVar := range *agentDef.EnvironmentVariables { - resolvedEnvVars[envVar.Name] = p.resolveEnvironmentVariables(envVar.Value, azdEnv) - } - } - // Parse service config for container resource overrides foundryAgentConfig, err := LoadServiceTargetAgentConfig(serviceConfig) if err != nil { @@ -1433,6 +1466,33 @@ func (p *AgentServiceTargetProvider) prepareDeploy( "check the service configuration in azure.yaml", ) } + serviceEnv, err := p.serviceEnvironment(serviceConfig) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf( + "failed to load service environment: %s", + err, + ), + "fix the service env configuration in azure.yaml", + ) + } + + resolvedEnvVars := make(map[string]string) + if agentDef.EnvironmentVariables != nil { + for _, envVar := range *agentDef.EnvironmentVariables { + resolvedEnvVars[envVar.Name] = + p.resolveEnvironmentVariables(envVar.Value, azdEnv) + } + } + for name, value := range foundryAgentConfig.Environment { + resolvedEnvVars[name] = + p.resolveEnvironmentVariables(value, azdEnv) + } + for name, value := range serviceEnv { + resolvedEnvVars[name] = + p.resolveEnvironmentVariables(value, azdEnv) + } warnDeprecatedScaleSettings(ServiceConfigProps(serviceConfig)) @@ -1441,6 +1501,15 @@ func (p *AgentServiceTargetProvider) prepareDeploy( cpu = foundryAgentConfig.Container.Resources.Cpu memory = foundryAgentConfig.Container.Resources.Memory } + // $ref services never persist resolved defaults to azure.yaml, + // so deploy applies the extension defaults here to keep $ref + // and inline services consistent. + if cpu == "" { + cpu = DefaultCpu + } + if memory == "" { + memory = DefaultMemory + } // Build options: env vars + cpu/memory (if set) + caller-provided extras options := []agent_yaml.AgentBuildOption{ @@ -1480,6 +1549,22 @@ func (p *AgentServiceTargetProvider) prepareDeploy( }, nil } +func (p *AgentServiceTargetProvider) serviceEnvironment( + serviceConfig *azdext.ServiceConfig, +) (map[string]string, error) { + raw, err := projectconfig.LoadServiceEnvironment( + p.projectPath, + serviceConfig.GetName(), + ) + if err != nil { + return nil, err + } + if raw != nil { + return raw, nil + } + return serviceConfig.GetEnvironment(), nil +} + // deployResult holds the intermediate results from a deploy method (code or container) // before the common post-deploy steps (polling, patching, finalization) are applied. type deployResult struct { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index af7f0f8cc3a..307571d6023 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -24,6 +24,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" ) func TestApplyAgentMetadata(t *testing.T) { @@ -140,14 +141,18 @@ type stubContainerServer struct { buildCalls atomic.Int32 packageCalls atomic.Int32 publishCalls atomic.Int32 + buildRequest *azdext.ContainerBuildRequest + packRequest *azdext.ContainerPackageRequest + pubRequest *azdext.ContainerPublishRequest publishErr error } func (s *stubContainerServer) Build( _ context.Context, - _ *azdext.ContainerBuildRequest, + request *azdext.ContainerBuildRequest, ) (*azdext.ContainerBuildResponse, error) { s.buildCalls.Add(1) + s.buildRequest = request return &azdext.ContainerBuildResponse{ Result: &azdext.ServiceBuildResult{ Artifacts: []*azdext.Artifact{{ @@ -160,9 +165,10 @@ func (s *stubContainerServer) Build( func (s *stubContainerServer) Package( _ context.Context, - _ *azdext.ContainerPackageRequest, + request *azdext.ContainerPackageRequest, ) (*azdext.ContainerPackageResponse, error) { s.packageCalls.Add(1) + s.packRequest = request return &azdext.ContainerPackageResponse{ Result: &azdext.ServicePackageResult{ Artifacts: []*azdext.Artifact{{ @@ -175,9 +181,10 @@ func (s *stubContainerServer) Package( func (s *stubContainerServer) Publish( _ context.Context, - _ *azdext.ContainerPublishRequest, + request *azdext.ContainerPublishRequest, ) (*azdext.ContainerPublishResponse, error) { s.publishCalls.Add(1) + s.pubRequest = request if s.publishErr != nil { return nil, s.publishErr } @@ -204,6 +211,7 @@ func newServiceTargetTestClient( t *testing.T, containerSrv azdext.ContainerServiceServer, promptSrv azdext.PromptServiceServer, + projectSrvs ...azdext.ProjectServiceServer, ) *azdext.AzdClient { t.Helper() @@ -214,6 +222,9 @@ func newServiceTargetTestClient( if promptSrv != nil { azdext.RegisterPromptServiceServer(srv, promptSrv) } + if len(projectSrvs) > 0 && projectSrvs[0] != nil { + azdext.RegisterProjectServiceServer(srv, projectSrvs[0]) + } lis, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) @@ -1021,6 +1032,228 @@ func TestLoadContainerAgentDefinition_EnvPathOverridesInlineDefinition(t *testin require.Equal(t, "override-agent", got.Name) } +func TestLoadContainerAgentDefinition_FileRef(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "agent.yaml"), + []byte( + "kind: hosted\n"+ + "name: referenced-agent\n"+ + "protocols:\n"+ + " - protocol: responses\n"+ + " version: \"1.0.0\"\n", + ), + 0o600, + )) + props, err := structpb.NewStruct(map[string]any{ + "$ref": "./agent.yaml", + }) + require.NoError(t, err) + provider := &AgentServiceTargetProvider{ + projectPath: dir, + serviceConfig: &azdext.ServiceConfig{ + Name: "referenced-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + }, + } + + got, isHosted, err := provider.loadContainerAgentDefinition() + + require.NoError(t, err) + require.True(t, isHosted) + require.Equal(t, "referenced-agent", got.Name) +} + +func TestPackageBuildsContainerAgent(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + agentPath := writeHostedAgentYAML(t, dir) + containerStub := &stubContainerServer{} + client := newContainerTestClient(t, containerStub) + provider := &AgentServiceTargetProvider{ + azdClient: client, + agentDefinitionPath: agentPath, + env: &azdext.Environment{Name: "test-env"}, + serviceConfig: &azdext.ServiceConfig{ + Name: "referenced-agent", + Host: "azure.ai.agent", + RelativePath: "src/agent", + }, + } + + _, err := provider.Package( + t.Context(), + &azdext.ServiceConfig{Name: "referenced-agent"}, + &azdext.ServiceContext{}, + func(string) {}, + ) + + require.NoError(t, err) + require.Equal(t, int32(1), containerStub.buildCalls.Load()) + require.Equal(t, int32(1), containerStub.packageCalls.Load()) +} + +func TestPrepareDeploy_MergesUnifiedEnvironment(t *testing.T) { + t.Parallel() + + agentDef := sampleContainerAgent() + agentDef.EnvironmentVariables = &[]agent_yaml.EnvironmentVariable{ + {Name: "LEGACY_ONLY", Value: "${LEGACY_VALUE}"}, + {Name: "SHARED", Value: "legacy"}, + } + props, err := AgentDefinitionToServiceProperties( + agentDef, + &ServiceTargetAgentConfig{ + Environment: map[string]string{ + "REF_ONLY": "${REF_VALUE}", + "SHARED": "ref", + }, + }, + ) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + AdditionalProperties: props, + Environment: map[string]string{ + "DIRECT_ONLY": "direct", + "SHARED": "direct", + }, + } + provider := &AgentServiceTargetProvider{} + + prep, err := provider.prepareDeploy( + svc, + agentDef, + map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": "https://example", + "LEGACY_VALUE": "legacy-value", + "REF_VALUE": "ref-value", + }, + []agent_yaml.AgentBuildOption{ + agent_yaml.WithImageURL("registry.example/agent:v1"), + }, + ) + + require.NoError(t, err) + definition, ok := prep.request.Definition.(agent_api.HostedAgentDefinition) + require.True(t, ok) + require.Equal( + t, + "legacy-value", + definition.EnvironmentVariables["LEGACY_ONLY"], + ) + require.Equal( + t, + "ref-value", + definition.EnvironmentVariables["REF_ONLY"], + ) + require.Equal( + t, + "direct", + definition.EnvironmentVariables["DIRECT_ONLY"], + ) + require.Equal( + t, + "direct", + definition.EnvironmentVariables["SHARED"], + ) +} + +func TestPrepareDeployUsesRawUnifiedEnvironment(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "azure.yaml"), + []byte(`services: + basic-agent: + host: azure.ai.agent + env: + PROJECT: ${{project.endpoint}} + ENABLED: true +`), + 0o600, + )) + agentDef := sampleContainerAgent() + props, err := AgentDefinitionToServiceProperties( + agentDef, + &ServiceTargetAgentConfig{}, + ) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + AdditionalProperties: props, + Environment: map[string]string{ + "PROJECT": "", + "ENABLED": "", + }, + } + provider := &AgentServiceTargetProvider{projectPath: root} + + prep, err := provider.prepareDeploy( + svc, + agentDef, + map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": "https://example", + }, + []agent_yaml.AgentBuildOption{ + agent_yaml.WithImageURL("registry.example/agent:v1"), + }, + ) + + require.NoError(t, err) + definition, ok := prep.request.Definition.(agent_api.HostedAgentDefinition) + require.True(t, ok) + require.Equal( + t, + "${{project.endpoint}}", + definition.EnvironmentVariables["PROJECT"], + ) + require.Equal( + t, + "true", + definition.EnvironmentVariables["ENABLED"], + ) +} + +func TestPrepareDeployAppliesDefaultResources(t *testing.T) { + t.Parallel() + + agentDef := sampleContainerAgent() + agentDef.Resources = nil + props, err := AgentDefinitionToServiceProperties( + agentDef, + &ServiceTargetAgentConfig{}, + ) + require.NoError(t, err) + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + AdditionalProperties: props, + } + provider := &AgentServiceTargetProvider{} + + prep, err := provider.prepareDeploy( + svc, + agentDef, + map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": "https://example", + }, + []agent_yaml.AgentBuildOption{ + agent_yaml.WithImageURL("registry.example/agent:v1"), + }, + ) + + require.NoError(t, err) + definition, ok := prep.request.Definition.(agent_api.HostedAgentDefinition) + require.True(t, ok) + require.Equal(t, DefaultCpu, definition.CPU) + require.Equal(t, DefaultMemory, definition.Memory) +} + func TestShouldUsePreBuiltImage_NoImageDefaultsToBuild(t *testing.T) { t.Parallel() @@ -1220,6 +1453,10 @@ func TestPublish_PublishesWhenPackageBuiltFromDockerfile(t *testing.T) { azdClient: client, agentDefinitionPath: agentPath, env: &azdext.Environment{Name: "test-env"}, + serviceConfig: &azdext.ServiceConfig{ + Name: "test-svc", + RelativePath: "src/agent", + }, } result, err := provider.Publish( diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_update.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_update.go new file mode 100644 index 00000000000..13a530114b3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_update.go @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "sync" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +var projectServiceUpdateMu sync.Mutex + +// AddServiceSerialized prevents concurrent azure.yaml writes. +func AddServiceSerialized( + ctx context.Context, + client *azdext.AzdClient, + service *azdext.ServiceConfig, +) error { + projectServiceUpdateMu.Lock() + defer projectServiceUpdateMu.Unlock() + + _, err := client.Project().AddService( + ctx, + &azdext.AddServiceRequest{Service: service}, + ) + return err +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go index 4e7c9071042..293dfed468b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -158,6 +158,7 @@ type serviceBlock struct { Kind string `yaml:"kind,omitempty"` Image string `yaml:"image,omitempty"` CodeConfiguration *codeConfigBlock `yaml:"codeConfiguration,omitempty"` + Config *agentBlock `yaml:"config,omitempty"` Agents []agentBlock `yaml:"agents,omitempty"` } @@ -252,14 +253,26 @@ func Synthesize(in Input) (*Result, error) { return nil, ErrEndpointBrownfield } - includeAcr := deriveIncludeAcr(root.Services, svc) + includeAcr, err := deriveIncludeAcr( + root.Services, + svc, + in.ProjectRoot, + ) + if err != nil { + return nil, err + } deployments := svc.Deployments if deployments == nil { deployments = []Deployment{} } - connections, err := collectConnections(root.Services, in.Env, !in.PreserveVarRefs) + connections, err := collectConnections( + root.Services, + in.ProjectRoot, + in.Env, + !in.PreserveVarRefs, + ) if err != nil { return nil, err } @@ -288,6 +301,15 @@ func Synthesize(in Input) (*Result, error) { // to learn which model deployments to create on the existing account. Returns // nil (not an error) when the service declares no deployments. func BrownfieldDeployments(raw []byte, serviceName string) ([]Deployment, error) { + return BrownfieldDeploymentsAtRoot(raw, "", serviceName) +} + +// BrownfieldDeploymentsAtRoot resolves local deployment includes. +func BrownfieldDeploymentsAtRoot( + raw []byte, + projectRoot string, + serviceName string, +) ([]Deployment, error) { if len(raw) == 0 { return nil, errors.New("synthesis: raw azure.yaml is empty") } @@ -304,6 +326,17 @@ func BrownfieldDeployments(raw []byte, serviceName string) ([]Deployment, error) if !ok { return nil, ErrServiceNotFound } + if projectRoot != "" { + var err error + node, err = resolveServiceRefs( + node, + projectRoot, + serviceName, + ) + if err != nil { + return nil, err + } + } var svc projectService if err := node.Decode(&svc); err != nil { @@ -321,6 +354,15 @@ func BrownfieldDeployments(raw []byte, serviceName string) ([]Deployment, error) // expressions pass through. Returns an empty slice (not an error) when none // are declared. func BrownfieldConnections(raw []byte, env map[string]string) ([]Connection, error) { + return BrownfieldConnectionsAtRoot(raw, "", env) +} + +// BrownfieldConnectionsAtRoot resolves local connection includes. +func BrownfieldConnectionsAtRoot( + raw []byte, + projectRoot string, + env map[string]string, +) ([]Connection, error) { if len(raw) == 0 { return nil, errors.New("synthesis: raw azure.yaml is empty") } @@ -330,7 +372,7 @@ func BrownfieldConnections(raw []byte, env map[string]string) ([]Connection, err return nil, fmt.Errorf("parse azure.yaml: %w", err) } - return collectConnections(root.Services, env, true) + return collectConnections(root.Services, projectRoot, env, true) } // resolveServiceRefs expands $ref file includes in one service entry. It decodes @@ -354,6 +396,46 @@ func resolveServiceRefs(node yaml.Node, projectRoot, serviceName string) (yaml.N return out, nil } +func serviceForHost( + node yaml.Node, + projectRoot string, + serviceName string, + expectedHost string, +) (yaml.Node, bool, error) { + var selector struct { + Host string `yaml:"host"` + Ref string `yaml:"$ref"` + } + if err := node.Decode(&selector); err != nil { + return node, false, nil + } + if selector.Host != "" && selector.Host != expectedHost { + return node, false, nil + } + if selector.Host == "" && selector.Ref == "" { + return node, false, nil + } + if projectRoot != "" { + resolved, err := resolveServiceRefs( + node, + projectRoot, + serviceName, + ) + if err != nil { + return node, false, err + } + node = resolved + } + if err := node.Decode(&selector); err != nil { + return node, false, fmt.Errorf( + "decode service %q selector: %w", + serviceName, + err, + ) + } + return node, selector.Host == expectedHost, nil +} + // deriveIncludeAcr reports whether provisioning should create an ACR. An ACR is // needed when any agent is a hosted, build-from-source agent: azd builds its // image and pushes it to the registry. Agents live on sibling azure.ai.agent @@ -367,28 +449,59 @@ func resolveServiceRefs(node yaml.Node, projectRoot, serviceName string) (yaml.N // Keying on image/codeConfiguration rather than the optional docker: block is // deliberate: docker: is not in the agent schema and is dropped by omitempty // when remoteBuild is false, so it is not a reliable build signal. -func deriveIncludeAcr(services map[string]yaml.Node, svc projectService) bool { +func deriveIncludeAcr( + services map[string]yaml.Node, + svc projectService, + projectRoot string, +) (bool, error) { if slices.ContainsFunc(svc.Agents, agentNeedsAcr) { - return true + return true, nil } - for _, node := range services { - var service serviceBlock - if err := node.Decode(&service); err != nil { - continue + for serviceName, node := range services { + var matches bool + var err error + node, matches, err = serviceForHost( + node, + projectRoot, + serviceName, + "azure.ai.agent", + ) + if err != nil { + return false, err } - if service.Host != "azure.ai.agent" { + if !matches { continue } - if agentNeedsAcr(agentBlock{ + var service serviceBlock + if err := node.Decode(&service); err != nil { + return false, fmt.Errorf( + "decode service %q: %w", + serviceName, + err, + ) + } + agent := agentBlock{ Kind: service.Kind, Image: service.Image, CodeConfiguration: service.CodeConfiguration, - }) { - return true + } + if service.Config != nil { + if agent.Kind == "" { + agent.Kind = service.Config.Kind + } + if agent.Image == "" { + agent.Image = service.Config.Image + } + if agent.CodeConfiguration == nil { + agent.CodeConfiguration = service.Config.CodeConfiguration + } + } + if agentNeedsAcr(agent) { + return true, nil } } - return false + return false, nil } // agentNeedsAcr reports whether a single agent entry builds a container image @@ -415,16 +528,25 @@ func agentNeedsAcr(a agentBlock) bool { // ${{...}} expressions are always preserved, mirroring synthesizeNetwork. func collectConnections( services map[string]yaml.Node, + projectRoot string, env map[string]string, resolve bool, ) ([]Connection, error) { connections := []Connection{} for name, node := range services { - var host struct { - Host string `yaml:"host"` + var matches bool + var err error + node, matches, err = serviceForHost( + node, + projectRoot, + name, + aiConnectionHost, + ) + if err != nil { + return nil, err } - if err := node.Decode(&host); err != nil || host.Host != aiConnectionHost { + if !matches { continue } var svc connectionService diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go index 2440f4b73c9..c8104218b9b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go @@ -244,6 +244,38 @@ services: wantDeployLen: 1, wantIncludeAcr: false, }, + { + name: "legacy configured agent with image => no ACR", + yaml: ` +services: + assistant: + host: azure.ai.agent + config: + kind: hosted + image: myprivacr.azurecr.io/agents/assistant:v1 + ai-project: + host: azure.ai.project +`, + serviceName: "ai-project", + wantIncludeAcr: false, + }, + { + name: "legacy configured agent with codeConfiguration => no ACR", + yaml: ` +services: + assistant: + host: azure.ai.agent + config: + kind: hosted + codeConfiguration: + runtime: python_3_13 + entryPoint: app.py + ai-project: + host: azure.ai.project +`, + serviceName: "ai-project", + wantIncludeAcr: false, + }, { name: "inline hosted agent with codeConfiguration => no ACR", yaml: ` @@ -629,6 +661,47 @@ services: }) } +func TestSynthesizeConnectionsAtRootResolvesFileRef(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "connection.yaml"), + []byte(`host: azure.ai.connection +category: CognitiveSearch +target: https://search.example +authType: ApiKey +credentials: + key: ${SEARCH_KEY} +`), + 0o600, + )) + raw := []byte(`services: + project: + host: azure.ai.project + search: + uses: [project] + $ref: ./connection.yaml +`) + + result, err := Synthesize(Input{ + RawAzureYAML: raw, + ServiceName: "project", + AcceptedHosts: []string{"azure.ai.project"}, + ProjectRoot: root, + Env: map[string]string{"SEARCH_KEY": "secret"}, + }) + + require.NoError(t, err) + connections, ok := result.Parameters["connections"].([]Connection) + require.True(t, ok) + require.Len(t, connections, 1) + assert.Equal(t, "search", connections[0].Name) + assert.Equal(t, "CognitiveSearch", connections[0].Category) + assert.Equal(t, "https://search.example", connections[0].Target) + assert.Equal(t, "secret", connections[0].Credentials["key"]) +} + // TestBrownfieldConnections verifies connection services are collected for a // brownfield (endpoint:) project, with ${VAR} resolved (brownfield provisions // so references must be concrete) and Foundry ${{...}} preserved. @@ -680,6 +753,41 @@ services: _, err := BrownfieldConnections(nil, nil) require.Error(t, err) }) + + t.Run("resolves connection file references", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "connection.yaml"), + []byte(`category: CognitiveSearch +target: https://search.example +authType: ApiKey +credentials: + key: ${SEARCH_KEY} +`), + 0o600, + )) + raw := []byte(`services: + project: + host: azure.ai.project + endpoint: https://existing.example/api/projects/p1 + search: + host: azure.ai.connection + uses: [project] + $ref: ./connection.yaml +`) + + connections, err := BrownfieldConnectionsAtRoot( + raw, + root, + map[string]string{"SEARCH_KEY": "secret"}, + ) + + require.NoError(t, err) + require.Len(t, connections, 1) + assert.Equal(t, "CognitiveSearch", connections[0].Category) + assert.Equal(t, "secret", connections[0].Credentials["key"]) + }) } func TestBrownfieldDeployments(t *testing.T) { @@ -792,6 +900,45 @@ func TestBrownfieldDeployments_EmptyRaw(t *testing.T) { require.Error(t, err) } +func TestBrownfieldDeploymentsAtRoot_ResolvesFileRef( + t *testing.T, +) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "deployment.yaml"), + []byte( + "name: gpt-4o\n"+ + "model:\n"+ + " format: OpenAI\n"+ + " name: gpt-4o\n"+ + " version: \"2024-08-06\"\n"+ + "sku:\n"+ + " capacity: 10\n"+ + " name: GlobalStandard\n", + ), + 0o600, + )) + raw := []byte(`services: + my-project: + host: azure.ai.project + endpoint: https://example + deployments: + - $ref: ./deployment.yaml +`) + + deployments, err := BrownfieldDeploymentsAtRoot( + raw, + root, + "my-project", + ) + + require.NoError(t, err) + require.Len(t, deployments, 1) + assert.Equal(t, "gpt-4o", deployments[0].Name) +} + func TestSynthesize_NetworkPreserveVarRefs(t *testing.T) { // Eject path: ${VAR} references must pass through verbatim (and skip the // format checks that cannot run on an unexpanded placeholder), so the @@ -870,6 +1017,98 @@ services: assert.Equal(t, 10, deployments[0].Sku.Capacity) } +func TestSynthesize_ResolvesAgentRefForAcr(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "agent.yaml"), + []byte( + "kind: hosted\n"+ + "name: referenced-agent\n"+ + "image: registry.example/agent:v1\n", + ), + 0o600, + )) + raw := []byte(`services: + my-project: + host: azure.ai.project + referenced-agent: + host: azure.ai.agent + $ref: ./agent.yaml +`) + + result, err := Synthesize(Input{ + RawAzureYAML: raw, + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + ProjectRoot: root, + }) + + require.NoError(t, err) + includeAcr, ok := result.Parameters["includeAcr"].(bool) + require.True(t, ok) + assert.False(t, includeAcr) +} + +func TestSynthesize_ResolvesAgentHostFromRefForAcr(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "agent.yaml"), + []byte( + "host: azure.ai.agent\n"+ + "kind: hosted\n"+ + "name: referenced-agent\n", + ), + 0o600, + )) + raw := []byte(`services: + my-project: + host: azure.ai.project + referenced-agent: + $ref: ./agent.yaml +`) + + result, err := Synthesize(Input{ + RawAzureYAML: raw, + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + ProjectRoot: root, + }) + + require.NoError(t, err) + includeAcr, ok := result.Parameters["includeAcr"].(bool) + require.True(t, ok) + assert.True(t, includeAcr) +} + +func TestSynthesize_SkipsRefsOnUnrelatedServices(t *testing.T) { + t.Parallel() + + raw := []byte(`services: + my-project: + host: azure.ai.project + unrelated: + host: containerapp + api: + $ref: ./missing-openapi.json +`) + + result, err := Synthesize(Input{ + RawAzureYAML: raw, + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + ProjectRoot: t.TempDir(), + }) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, false, result.Parameters["includeAcr"]) + assert.Empty(t, result.Parameters["connections"]) +} + func TestSynthesize_InputValidation(t *testing.T) { tests := []struct { name string diff --git a/cli/azd/extensions/azure.ai.agents/schemas/Agent.json b/cli/azd/extensions/azure.ai.agents/schemas/Agent.json index b9881b7850e..11afae8e357 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/Agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/Agent.json @@ -18,7 +18,9 @@ "env": { "type": "object", "description": "Environment variables for the agent runtime. Values may use ${VAR} (azd env) or ${{...}} (Foundry server-side resolution).", - "additionalProperties": { "type": "string" } + "additionalProperties": { + "type": ["string", "boolean", "number", "null"] + } }, "toolboxes": { "type": "array", diff --git a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json index a6ac2d23f0d..229d9ff4d1d 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json @@ -8,7 +8,7 @@ "type": "object", "description": "Environment variables as key-value pairs", "additionalProperties": { - "type": "string" + "type": ["string", "boolean", "number", "null"] } }, "container": { From 8a3f4e9096c3212003d8f45e97344d4a6f937874 Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 16 Jul 2026 18:54:23 +0800 Subject: [PATCH 2/4] fix: restrict root reference fields --- .../internal/project/agent_definition.go | 125 ++++++------------ .../internal/project/agent_definition_test.go | 73 +++++----- 2 files changed, 82 insertions(+), 116 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index ce0d76aaa55..613a5b2601f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -338,31 +338,8 @@ func ResolvedServiceProjectPath( svc *azdext.ServiceConfig, projectRoot string, ) (string, error) { - if svc.GetRelativePath() != "" { - return svc.GetRelativePath(), nil - } - for _, props := range []*structpb.Struct{ - svc.GetAdditionalProperties(), - svc.GetConfig(), - } { - if props == nil || len(props.GetFields()) == 0 { - continue - } - resolved, err := resolveServiceProps( - props, - svc.GetName(), - projectRoot, - ) - if err != nil { - return "", err - } - if value := resolved.GetFields()["project"]; value != nil { - if path := value.GetStringValue(); path != "" { - return path, nil - } - } - } - return "", nil + _ = projectRoot + return svc.GetRelativePath(), nil } // ResolveServiceConfigInPlace expands service-level local includes. @@ -381,7 +358,6 @@ func ResolveServiceConfigInPlace( return err } svc.AdditionalProperties = resolved - hydrateResolvedServiceFields(svc, resolved) } if config := svc.GetConfig(); config != nil && len(config.GetFields()) > 0 { @@ -394,7 +370,6 @@ func ResolveServiceConfigInPlace( return err } svc.Config = resolved - hydrateResolvedServiceFields(svc, resolved) } return nil } @@ -421,69 +396,18 @@ func NormalizeServiceConfigInPlace(svc *azdext.ServiceConfig) error { return nil } -func hydrateResolvedServiceFields( - svc *azdext.ServiceConfig, - props *structpb.Struct, -) { - if svc.GetRelativePath() == "" { - if value := props.GetFields()["project"]; value != nil { - svc.RelativePath = value.GetStringValue() - } - } - if svc.GetImage() == "" { - if value := props.GetFields()["image"]; value != nil { - svc.Image = value.GetStringValue() - } - } - if !hasDockerSettings(svc.GetDocker()) && - props.GetFields()["docker"] != nil && - props.GetFields()["docker"].GetStructValue() != nil { - value := props.GetFields()["docker"] - fields := value.GetStructValue().GetFields() - docker := &azdext.DockerProjectOptions{ - Path: fields["path"].GetStringValue(), - Context: fields["context"].GetStringValue(), - Platform: fields["platform"].GetStringValue(), - Target: fields["target"].GetStringValue(), - Registry: fields["registry"].GetStringValue(), - Image: fields["image"].GetStringValue(), - Tag: fields["tag"].GetStringValue(), - RemoteBuild: fields["remoteBuild"].GetBoolValue(), - Network: fields["network"].GetStringValue(), - } - if buildArgs := fields["buildArgs"].GetListValue(); buildArgs != nil { - for _, arg := range buildArgs.GetValues() { - docker.BuildArgs = append( - docker.BuildArgs, - arg.GetStringValue(), - ) - } - } - svc.Docker = docker - } -} - -func hasDockerSettings(docker *azdext.DockerProjectOptions) bool { - if docker == nil { - return false - } - return docker.GetPath() != "" || - docker.GetContext() != "" || - docker.GetPlatform() != "" || - docker.GetTarget() != "" || - docker.GetRegistry() != "" || - docker.GetImage() != "" || - docker.GetTag() != "" || - docker.GetRemoteBuild() || - len(docker.GetBuildArgs()) > 0 || - docker.GetNetwork() != "" -} - func resolveServiceProps( props *structpb.Struct, serviceName string, projectRoot string, ) (*structpb.Struct, error) { + if err := validateRootRefCoreFields(props, projectRoot); err != nil { + return nil, fmt.Errorf( + "validating service %q config: %w", + serviceName, + err, + ) + } resolved, err := foundry.ResolveFileRefs( props.AsMap(), projectRoot, @@ -514,6 +438,37 @@ func resolveServiceProps( return out, nil } +func validateRootRefCoreFields( + props *structpb.Struct, + projectRoot string, +) error { + ref := props.GetFields()["$ref"] + if ref == nil || ref.GetStringValue() == "" { + return nil + } + referenced, err := foundry.ResolveFileRefs( + map[string]any{"$ref": ref.GetStringValue()}, + projectRoot, + ) + if err != nil { + return err + } + for _, field := range []string{ + "project", + "language", + "image", + "docker", + } { + if _, found := referenced[field]; found { + return fmt.Errorf( + "root $ref must not provide core field %q; declare it in azure.yaml", + field, + ) + } + } + return nil +} + func normalizeServiceProps( props *structpb.Struct, serviceName string, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go index 9ccfc77ddd8..552e002123a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -283,12 +283,6 @@ func TestLoadAgentDefinition_FileRef(t *testing.T) { []byte( "kind: hosted\n"+ "name: referenced-agent\n"+ - "project: ../src/agent\n"+ - "image: registry.example/agent:v1\n"+ - "docker:\n"+ - " path: docker/Dockerfile\n"+ - " context: docker\n"+ - " remoteBuild: true\n"+ "startupCommand: python main.py\n"+ "protocols:\n"+ " - protocol: responses\n"+ @@ -303,8 +297,15 @@ func TestLoadAgentDefinition_FileRef(t *testing.T) { }) require.NoError(t, err) svc := &azdext.ServiceConfig{ - Name: "agent-service", - Host: "azure.ai.agent", + Name: "agent-service", + Host: "azure.ai.agent", + RelativePath: "src/agent", + Image: "registry.example/agent:v1", + Docker: &azdext.DockerProjectOptions{ + Path: "docker/Dockerfile", + Context: "docker", + RemoteBuild: true, + }, AdditionalProperties: props, } @@ -336,32 +337,42 @@ func TestLoadAgentDefinition_FileRef(t *testing.T) { require.True(t, svc.GetDocker().GetRemoteBuild()) } -func TestResolveServiceConfigInPlacePreservesDockerOverride(t *testing.T) { +func TestResolveServiceConfigInPlaceRejectsCoreFieldsFromRootRef(t *testing.T) { t.Parallel() - dir := t.TempDir() - require.NoError(t, os.WriteFile( - filepath.Join(dir, "agent.yaml"), - []byte("docker:\n path: referenced.Dockerfile\n context: referenced\n"), - 0o600, - )) - props, err := structpb.NewStruct(map[string]any{ - "$ref": "./agent.yaml", - }) - require.NoError(t, err) - svc := &azdext.ServiceConfig{ - Name: "agent-service", - AdditionalProperties: props, - Docker: &azdext.DockerProjectOptions{ - Path: "override.Dockerfile", - Context: "override", - }, + tests := []struct { + name string + value string + }{ + {name: "project", value: "project: src/agent\n"}, + {name: "language", value: "language: docker\n"}, + {name: "image", value: "image: registry.example/agent:v1\n"}, + {name: "docker", value: "docker:\n path: Dockerfile\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "agent.yaml"), + []byte(tt.value), + 0o600, + )) + props, err := structpb.NewStruct(map[string]any{ + "$ref": "./agent.yaml", + }) + require.NoError(t, err) + + err = ResolveServiceConfigInPlace( + &azdext.ServiceConfig{ + Name: "agent-service", + AdditionalProperties: props, + }, + dir, + ) + + require.ErrorContains(t, err, "core field \""+tt.name+"\"") + }) } - - require.NoError(t, ResolveServiceConfigInPlace(svc, dir)) - - require.Equal(t, "override.Dockerfile", svc.GetDocker().GetPath()) - require.Equal(t, "override", svc.GetDocker().GetContext()) } func TestAgentDefinitionUsesFileRefIgnoresNestedResourceRefs( From 359f10ffb394ed32422631924bb4e70d15698121 Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 16 Jul 2026 20:19:52 +0800 Subject: [PATCH 3/4] fix: validate referenced agent fields --- .../internal/cmd/listen_test.go | 2 +- .../internal/synthesis/synthesizer.go | 57 +++++++++++++++++++ .../internal/synthesis/synthesizer_test.go | 37 +++++++++++- schemas/alpha/azure.yaml.json | 2 +- schemas/v1.0/azure.yaml.json | 2 +- 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go index 26ee87c99ad..d2bd648cc39 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go @@ -142,7 +142,6 @@ func TestPopulateContainerSettings_DoesNotPersistResolvedFileRef( []byte( "kind: hosted\n"+ "name: echo\n"+ - "project: src/echo\n"+ "container:\n"+ " resources:\n"+ " cpu: \"2\"\n"+ @@ -157,6 +156,7 @@ func TestPopulateContainerSettings_DoesNotPersistResolvedFileRef( svc := &azdext.ServiceConfig{ Name: "echo", Host: AiAgentHost, + RelativePath: "src/echo", AdditionalProperties: props, } diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go index 293dfed468b..1f7f0a4481d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -415,6 +415,15 @@ func serviceForHost( if selector.Host == "" && selector.Ref == "" { return node, false, nil } + if expectedHost == "azure.ai.agent" && projectRoot != "" { + if err := validateAgentRootRefCoreFields( + node, + projectRoot, + serviceName, + ); err != nil { + return node, false, err + } + } if projectRoot != "" { resolved, err := resolveServiceRefs( node, @@ -436,6 +445,54 @@ func serviceForHost( return node, selector.Host == expectedHost, nil } +func validateAgentRootRefCoreFields( + node yaml.Node, + projectRoot string, + serviceName string, +) error { + var raw map[string]any + if err := node.Decode(&raw); err != nil { + return nil + } + ref, _ := raw["$ref"].(string) + if ref == "" { + return nil + } + referenced, err := foundry.ResolveFileRefs( + map[string]any{"$ref": ref}, + projectRoot, + ) + if err != nil { + return fmt.Errorf( + "resolve $ref includes for service %q: %w", + serviceName, + err, + ) + } + host, _ := raw["host"].(string) + if host == "" { + host, _ = referenced["host"].(string) + } + if host != "azure.ai.agent" { + return nil + } + for _, field := range []string{ + "project", + "language", + "image", + "docker", + } { + if _, found := referenced[field]; found { + return fmt.Errorf( + "service %q root $ref must not provide core field %q; declare it in azure.yaml", + serviceName, + field, + ) + } + } + return nil +} + // deriveIncludeAcr reports whether provisioning should create an ACR. An ACR is // needed when any agent is a hosted, build-from-source agent: azd builds its // image and pushes it to the registry. Agents live on sibling azure.ai.agent diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go index c8104218b9b..ca02a3d1b4b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go @@ -1017,7 +1017,7 @@ services: assert.Equal(t, 10, deployments[0].Sku.Capacity) } -func TestSynthesize_ResolvesAgentRefForAcr(t *testing.T) { +func TestSynthesize_RequiresAgentImageInline(t *testing.T) { t.Parallel() root := t.TempDir() @@ -1025,8 +1025,7 @@ func TestSynthesize_ResolvesAgentRefForAcr(t *testing.T) { filepath.Join(root, "agent.yaml"), []byte( "kind: hosted\n"+ - "name: referenced-agent\n"+ - "image: registry.example/agent:v1\n", + "name: referenced-agent\n", ), 0o600, )) @@ -1035,6 +1034,7 @@ func TestSynthesize_ResolvesAgentRefForAcr(t *testing.T) { host: azure.ai.project referenced-agent: host: azure.ai.agent + image: registry.example/agent:v1 $ref: ./agent.yaml `) @@ -1051,6 +1051,37 @@ func TestSynthesize_ResolvesAgentRefForAcr(t *testing.T) { assert.False(t, includeAcr) } +func TestSynthesize_RejectsCoreFieldsFromAgentRef(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(root, "agent.yaml"), + []byte( + "kind: hosted\n"+ + "name: referenced-agent\n"+ + "image: registry.example/agent:v1\n", + ), + 0o600, + )) + raw := []byte(`services: + my-project: + host: azure.ai.project + referenced-agent: + host: azure.ai.agent + $ref: ./agent.yaml +`) + + _, err := Synthesize(Input{ + RawAzureYAML: raw, + ServiceName: "my-project", + AcceptedHosts: []string{"azure.ai.project"}, + ProjectRoot: root, + }) + + require.ErrorContains(t, err, `core field "image"`) +} + func TestSynthesize_ResolvesAgentHostFromRefForAcr(t *testing.T) { t.Parallel() diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 398df6e49d7..f4ef24eee5c 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -294,7 +294,7 @@ "title": "Environment variables for the service", "description": "Optional. A map of environment variable names to values. Supports environment variable substitution.", "additionalProperties": { - "type": "string" + "type": ["string", "boolean", "number", "null"] } }, "hooks": { diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index fdeea9980ea..a4f80dc67ca 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -254,7 +254,7 @@ "title": "Environment variables for the service", "description": "Optional. A map of environment variable names to values. Supports environment variable substitution.", "additionalProperties": { - "type": "string" + "type": ["string", "boolean", "number", "null"] } }, "hooks": { From 7b5581992a7d2ffcd91c8ccc5846f9b356baa3c7 Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 16 Jul 2026 20:56:20 +0800 Subject: [PATCH 4/4] fix: avoid rewriting service configuration --- .../azure.ai.agents/internal/cmd/listen.go | 23 +++------------ .../internal/project/service_update.go | 29 ------------------- 2 files changed, 4 insertions(+), 48 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/service_update.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 98ca42ad5b2..24ca1875859 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -739,28 +739,13 @@ func setEnvVar(ctx context.Context, azdClient *azdext.AzdClient, envName string, } func populateContainerSettings( - ctx context.Context, - azdClient *azdext.AzdClient, + _ context.Context, + _ *azdext.AzdClient, svc *azdext.ServiceConfig, projectRoot string, ) error { - persisted, err := prepareContainerSettings(svc, projectRoot) - if err != nil { - return err - } - if persisted == nil { - return nil - } - - if err := project.AddServiceSerialized( - ctx, - azdClient, - persisted, - ); err != nil { - return fmt.Errorf("adding agent service to project: %w", err) - } - - return nil + _, err := prepareContainerSettings(svc, projectRoot) + return err } func prepareContainerSettings( diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_update.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_update.go deleted file mode 100644 index 13a530114b3..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_update.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package project - -import ( - "context" - "sync" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" -) - -var projectServiceUpdateMu sync.Mutex - -// AddServiceSerialized prevents concurrent azure.yaml writes. -func AddServiceSerialized( - ctx context.Context, - client *azdext.AzdClient, - service *azdext.ServiceConfig, -) error { - projectServiceUpdateMu.Lock() - defer projectServiceUpdateMu.Unlock() - - _, err := client.Project().AddService( - ctx, - &azdext.AddServiceRequest{Service: service}, - ) - return err -}