diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 941dd97a5f9..832b09da97f 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -2,6 +2,10 @@ ## 1.0.0-beta.6 (Unreleased) +### Bugs Fixed + +- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Store hosted-agent runtime variables in the standard service-level `env` object and read the expanded values forwarded by azd core. `azd ai agent init` no longer writes the unsupported inline `environmentVariables` shape; legacy inline and on-disk agent definitions remain readable. + ### Other Changes - [[#9049]](https://github.com/Azure/azure-dev/pull/9049) Switch the `invocations_ws` agent endpoint from the preview dispatcher form to the GA path-based route. `azd deploy` now registers `AGENT_{KEY}_INVOCATIONS_WS_ENDPOINT` (and `azd ai agent show` displays `Endpoint (invocations_ws)`) as `wss://.services.ai.azure.com/api/projects//agents//endpoint/protocols/invocations_ws?api-version=v1`, carrying the project and agent as path segments to mirror the HTTP `invocations` route. The previous form embedded them as `project_name`/`agent_name` query parameters on a single literal `/api/projects/agents/...` path. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 158474be4ee..51def1c2cee 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2857,6 +2857,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa if err != nil { return err } + agentEnvironment := project.AgentEnvironment(containerDef) serviceConfig := &azdext.ServiceConfig{ Name: a.serviceNameOverride, @@ -2890,6 +2891,14 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa if _, err := a.azdClient.Project().AddService(ctx, req); err != nil { return fmt.Errorf("adding agent service to project: %w", err) } + if err := setServiceEnvironment( + ctx, + a.azdClient, + a.serviceNameOverride, + agentEnvironment, + ); err != nil { + return err + } // Emit the sibling Foundry resource services (project + deployments, // connections, toolboxes) and wire the agent's uses: to them. A selected diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 7eb3264bd54..8d6089c8798 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -832,6 +832,7 @@ func (a *InitFromCodeAction) addToProject( if err != nil { return err } + agentEnvironment := project.AgentEnvironment(*definition) language := "python" if !isCodeDeploy { @@ -841,8 +842,9 @@ func (a *InitFromCodeAction) addToProject( language = "csharp" } + agentServiceName := strings.ReplaceAll(agentName, " ", "") serviceConfig := &azdext.ServiceConfig{ - Name: strings.ReplaceAll(agentName, " ", ""), + Name: agentServiceName, RelativePath: targetDir, Host: AiAgentHost, Language: language, @@ -869,11 +871,18 @@ func (a *InitFromCodeAction) addToProject( if _, err := a.azdClient.Project().AddService(ctx, req); err != nil { return fmt.Errorf("adding agent service to project: %w", err) } + if err := setServiceEnvironment( + ctx, + a.azdClient, + agentServiceName, + agentEnvironment, + ); err != nil { + return err + } // Emit the sibling azure.ai.project service carrying the model deployments // and wire the agent's uses: to it. A selected existing project contributes // its endpoint so provision reuses it instead of creating a new project. - agentServiceName := strings.ReplaceAll(agentName, " ", "") if err := emitResourceServices( ctx, a.azdClient, agentServiceName, projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go index 2d865a57ee1..920b1532f64 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -364,6 +364,9 @@ func TestAddToProjectPreBuiltImageWritesServiceImage(t *testing.T) { Protocols: []agent_yaml.ProtocolVersionRecord{ {Protocol: "responses", Version: "2.0.0"}, }, + EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{ + {Name: "LOG_LEVEL", Value: "info"}, + }, }, } @@ -387,9 +390,16 @@ func TestAddToProjectPreBuiltImageWritesServiceImage(t *testing.T) { require.Equal(t, "docker", agentService.GetLanguage()) require.NotNil(t, agentService.GetDocker()) require.NotNil(t, agentService.GetAdditionalProperties()) + require.Empty(t, agentService.GetEnvironment()) + require.Equal(t, map[string]any{ + "LOG_LEVEL": "info", + }, server.env["my-agent"]) _, hasInlineImage := agentService.GetAdditionalProperties().GetFields()["image"] require.False(t, hasInlineImage, "pre-built image must ride on the top-level service image field") + _, hasInlineEnvironment := agentService.GetAdditionalProperties(). + GetFields()["environmentVariables"] + require.False(t, hasInlineEnvironment) } func TestValidateInitAgentName(t *testing.T) { 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..34b220eb497 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 @@ -7,6 +7,7 @@ import ( "context" "fmt" "os" + "regexp" "slices" "strings" @@ -35,6 +36,10 @@ const ( aiProjectServiceName = "ai-project" ) +var envReferencePattern = regexp.MustCompile( + `\$\{([A-Za-z_][A-Za-z0-9_]*)\}`, +) + // emitResourceServices writes the Foundry resource sibling services that the // agent depends on (one azure.ai.project carrying the model deployments, one // azure.ai.connection per connection, one azure.ai.toolbox per toolbox) and @@ -275,6 +280,7 @@ func addResourceService( cfg *structpb.Struct, uses []string, ) error { + environment := serviceEnvironmentTemplates(cfg) svc := &azdext.ServiceConfig{ Name: name, Host: host, @@ -285,6 +291,15 @@ func addResourceService( return fmt.Errorf("adding %s service %q: %w", host, name, err) } + if err := setServiceEnvironment( + ctx, + azdClient, + name, + environment, + ); err != nil { + return err + } + if len(uses) > 0 { if err := setServiceUses(ctx, azdClient, name, uses); err != nil { return err @@ -294,6 +309,86 @@ func addResourceService( return nil } +func serviceEnvironmentTemplates(cfg *structpb.Struct) map[string]string { + if cfg == nil { + return nil + } + + environment := map[string]string{} + collectEnvironmentTemplates(cfg.AsMap(), environment) + if len(environment) == 0 { + return nil + } + return environment +} + +func collectEnvironmentTemplates(value any, environment map[string]string) { + switch typed := value.(type) { + case string: + for _, match := range envReferencePattern.FindAllStringSubmatchIndex( + typed, + -1, + ) { + if match[0] > 0 && typed[match[0]-1] == '$' { + continue + } + name := typed[match[2]:match[3]] + environment[name] = typed[match[0]:match[1]] + } + case map[string]any: + for _, nested := range typed { + collectEnvironmentTemplates(nested, environment) + } + case []any: + for _, nested := range typed { + collectEnvironmentTemplates(nested, environment) + } + } +} + +func setServiceEnvironment( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, + environment map[string]string, +) error { + if len(environment) == 0 { + return nil + } + + sectionValues := make(map[string]any, len(environment)) + for key, value := range environment { + sectionValues[key] = value + } + section, err := structpb.NewStruct(sectionValues) + if err != nil { + return fmt.Errorf( + "encoding env for service %q: %w", + serviceName, + err, + ) + } + + // ServiceConfig.Environment only carries expanded values. + // The config RPC preserves raw ${VAR} templates. + _, err = azdClient.Project().SetServiceConfigSection( + ctx, + &azdext.SetServiceConfigSectionRequest{ + ServiceName: serviceName, + Path: "env", + Section: section, + }, + ) + if err != nil { + return fmt.Errorf( + "setting env for service %q: %w", + serviceName, + err, + ) + } + return nil +} + // setServiceUses sets the uses: list on an existing service. uses is a real // core ServiceConfig field, so it is written via SetServiceConfigValue (a raw // map path) rather than AddService's inlined config map, which cannot carry it. 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..a0dcacc1213 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 @@ -88,6 +88,53 @@ func TestReserveServiceName(t *testing.T) { assert.Contains(t, err.Error(), "agent service") } +func TestServiceEnvironmentTemplates(t *testing.T) { + t.Parallel() + + cfg, err := project.MarshalStruct(&project.Connection{ + Credentials: map[string]any{ + "key": "${SEARCH_KEY}", + }, + Metadata: map[string]string{ + "server": "${SERVER_NAME}", + "token": "${{connections.search.credentials.key}}", + "literal": "$${LITERAL}", + }, + }) + require.NoError(t, err) + + assert.Equal(t, map[string]string{ + "SEARCH_KEY": "${SEARCH_KEY}", + "SERVER_NAME": "${SERVER_NAME}", + }, serviceEnvironmentTemplates(cfg)) +} + +func TestAddResourceServiceWritesEnvironment(t *testing.T) { + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + cfg, err := project.MarshalStruct(&project.Connection{ + Credentials: map[string]any{"key": "${SEARCH_KEY}"}, + }) + require.NoError(t, err) + + require.NoError(t, addResourceService( + t.Context(), + client, + "search", + AiConnectionHost, + cfg, + nil, + )) + + server.mu.Lock() + defer server.mu.Unlock() + require.Len(t, server.added, 1) + assert.Empty(t, server.added[0].GetEnvironment()) + assert.Equal(t, map[string]any{ + "SEARCH_KEY": "${SEARCH_KEY}", + }, server.env["search"]) +} + // TestCollectProjectDeployments verifies deployments are sourced only from // azure.ai.project services and ignore sibling hosts. func TestCollectProjectDeployments(t *testing.T) { @@ -251,6 +298,7 @@ type recordingProjectServer struct { mu sync.Mutex added []*azdext.ServiceConfig uses map[string][]string + env map[string]map[string]any // configValues records non-"uses" SetServiceConfigValue calls keyed by path. configValues map[string]configValueRecord // existing is returned by Get to simulate services already present in the @@ -315,6 +363,21 @@ func (s *recordingProjectServer) SetServiceConfigValue( return &azdext.EmptyResponse{}, nil } +func (s *recordingProjectServer) SetServiceConfigSection( + _ context.Context, + req *azdext.SetServiceConfigSectionRequest, +) (*azdext.EmptyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.env == nil { + s.env = map[string]map[string]any{} + } + if req.Path == "env" && req.Section != nil { + s.env[req.ServiceName] = req.Section.AsMap() + } + return &azdext.EmptyResponse{}, nil +} + // newProjectRecorderClient spins up an in-process gRPC server backed by the // supplied project server stub and returns a client wired to its address. func newProjectRecorderClient(t *testing.T, server azdext.ProjectServiceServer) *azdext.AzdClient { 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..dad7a2c3d9a 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 @@ -8,6 +8,7 @@ import ( "log" "maps" "os" + "slices" "sync" "azureaiagent/internal/exterrors" @@ -83,11 +84,12 @@ func WarnLegacyAgentShape(source AgentDefinitionSource) { type AgentDefinitionInline struct { agent_yaml.AgentDefinition `json:",inline"` Protocols []agent_yaml.ProtocolVersionRecord `json:"protocols,omitempty"` - EnvironmentVariables *[]agent_yaml.EnvironmentVariable `json:"environmentVariables,omitempty"` - AgentEndpoint *agent_yaml.AgentEndpoint `json:"agentEndpoint,omitempty"` - AgentCard *agent_yaml.AgentCard `json:"agentCard,omitempty"` - CodeConfiguration *agent_yaml.CodeConfiguration `json:"codeConfiguration,omitempty"` - Policies []agent_yaml.Policy `json:"policies,omitempty"` + // EnvironmentVariables reads the deprecated inline shape. + EnvironmentVariables *[]agent_yaml.EnvironmentVariable `json:"environmentVariables,omitempty"` + AgentEndpoint *agent_yaml.AgentEndpoint `json:"agentEndpoint,omitempty"` + AgentCard *agent_yaml.AgentCard `json:"agentCard,omitempty"` + CodeConfiguration *agent_yaml.CodeConfiguration `json:"codeConfiguration,omitempty"` + Policies []agent_yaml.Policy `json:"policies,omitempty"` } // agentDefinitionToInline splits a ContainerAgent into the inline definition, @@ -96,13 +98,12 @@ type AgentDefinitionInline struct { // returned separately so the caller can place them on their respective homes. func agentDefinitionToInline(ca agent_yaml.ContainerAgent) (AgentDefinitionInline, *ContainerSettings, string) { inline := AgentDefinitionInline{ - AgentDefinition: ca.AgentDefinition, - Protocols: ca.Protocols, - EnvironmentVariables: ca.EnvironmentVariables, - AgentEndpoint: ca.AgentEndpoint, - AgentCard: ca.AgentCard, - CodeConfiguration: ca.CodeConfiguration, - Policies: ca.Policies, + AgentDefinition: ca.AgentDefinition, + Protocols: ca.Protocols, + AgentEndpoint: ca.AgentEndpoint, + AgentCard: ca.AgentCard, + CodeConfiguration: ca.CodeConfiguration, + Policies: ca.Policies, } var container *ContainerSettings @@ -118,12 +119,28 @@ func agentDefinitionToInline(ca agent_yaml.ContainerAgent) (AgentDefinitionInlin // toContainerAgent rebuilds the agent_yaml.ContainerAgent from the inline // definition, the CPU/memory carried in the `container` config, and the image // carried on the core service field. -func (d AgentDefinitionInline) toContainerAgent(container *ContainerSettings, image string) agent_yaml.ContainerAgent { +func (d AgentDefinitionInline) toContainerAgent( + container *ContainerSettings, + image string, + environment map[string]string, +) agent_yaml.ContainerAgent { + environmentVariables := d.EnvironmentVariables + if len(environment) > 0 { + legacyEnvironment := AgentEnvironment(agent_yaml.ContainerAgent{ + EnvironmentVariables: d.EnvironmentVariables, + }) + if legacyEnvironment == nil { + legacyEnvironment = map[string]string{} + } + maps.Copy(legacyEnvironment, environment) + environmentVariables = environmentVariablesFromMap(legacyEnvironment) + } + ca := agent_yaml.ContainerAgent{ AgentDefinition: d.AgentDefinition, Image: image, Protocols: d.Protocols, - EnvironmentVariables: d.EnvironmentVariables, + EnvironmentVariables: environmentVariables, AgentEndpoint: d.AgentEndpoint, AgentCard: d.AgentCard, CodeConfiguration: d.CodeConfiguration, @@ -140,6 +157,40 @@ func (d AgentDefinitionInline) toContainerAgent(container *ContainerSettings, im return ca } +// AgentEnvironment converts an agent environment list to a map. +func AgentEnvironment(ca agent_yaml.ContainerAgent) map[string]string { + if ca.EnvironmentVariables == nil || len(*ca.EnvironmentVariables) == 0 { + return nil + } + + environment := make(map[string]string, len(*ca.EnvironmentVariables)) + for _, variable := range *ca.EnvironmentVariables { + environment[variable.Name] = variable.Value + } + return environment +} + +func environmentVariablesFromMap( + environment map[string]string, +) *[]agent_yaml.EnvironmentVariable { + if len(environment) == 0 { + return nil + } + + variables := make( + []agent_yaml.EnvironmentVariable, + 0, + len(environment), + ) + for _, name := range slices.Sorted(maps.Keys(environment)) { + variables = append(variables, agent_yaml.EnvironmentVariable{ + Name: name, + Value: environment[name], + }) + } + return &variables +} + // structHasKind reports whether the struct carries a non-empty string `kind`, // the marker that an agent definition is present in a service entry's inline or // config properties. @@ -197,7 +248,11 @@ func AgentDefinitionFromService( } } - ca, isHosted, err := agentDefinitionFromStruct(inlineStruct, svc.GetImage()) + ca, isHosted, err := agentDefinitionFromStruct( + inlineStruct, + svc.GetImage(), + svc.GetEnvironment(), + ) return ca, isHosted, true, source, err } @@ -228,11 +283,7 @@ func ServiceConfigProps(svc *azdext.ServiceConfig) *structpb.Struct { return svc.GetConfig() } -// 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`). -// Returns an error when the service carries no inline definition; callers fall -// back to mutating a legacy on-disk agent.yaml in that case. +// UpsertAgentEnvVars updates the service-level environment map. func UpsertAgentEnvVars(svc *azdext.ServiceConfig, kv map[string]string) error { ca, _, found, source, err := AgentDefinitionFromService(svc) if err != nil { @@ -242,40 +293,19 @@ func UpsertAgentEnvVars(svc *azdext.ServiceConfig, kv map[string]string) error { return fmt.Errorf("service %q does not carry an inline agent definition", svc.GetName()) } - envVars := []agent_yaml.EnvironmentVariable{} - if ca.EnvironmentVariables != nil { - envVars = *ca.EnvironmentVariables - } - for key, value := range kv { - idx := -1 - for i := range envVars { - if envVars[i].Name == key { - idx = i - break - } - } - if idx >= 0 { - envVars[idx].Value = value - } else { - envVars = append(envVars, agent_yaml.EnvironmentVariable{Name: key, Value: value}) - } - } - ca.EnvironmentVariables = &envVars - - cfg, err := LoadServiceTargetAgentConfig(svc) - if err != nil { - return err - } - - props, err := AgentDefinitionToServiceProperties(ca, cfg) - if err != nil { - return err + environment := AgentEnvironment(ca) + if environment == nil { + environment = map[string]string{} } + maps.Copy(environment, kv) + svc.Environment = environment + props := svc.GetAdditionalProperties() if source == AgentDefinitionSourceLegacyConfig { - svc.Config = props - } else { - svc.AdditionalProperties = props + props = svc.GetConfig() + } + if props != nil { + delete(props.Fields, "environmentVariables") } return nil } @@ -318,7 +348,11 @@ func SetAgentContainerSettings(svc *azdext.ServiceConfig, container *ContainerSe // struct that carries the agent definition as service-level properties. coreImage // is the value of the service's `image` field, which is carried on the core // [azdext.ServiceConfig] rather than in the inline property bag. -func agentDefinitionFromStruct(s *structpb.Struct, coreImage string) (agent_yaml.ContainerAgent, bool, error) { +func agentDefinitionFromStruct( + s *structpb.Struct, + coreImage string, + environment map[string]string, +) (agent_yaml.ContainerAgent, bool, error) { var inline AgentDefinitionInline if err := UnmarshalStruct(s, &inline); err != nil { return agent_yaml.ContainerAgent{}, false, exterrors.Validation( @@ -341,7 +375,7 @@ func agentDefinitionFromStruct(s *structpb.Struct, coreImage string) (agent_yaml ) } - ca := inline.toContainerAgent(cfg.Container, coreImage) + ca := inline.toContainerAgent(cfg.Container, coreImage, environment) // Validate the inline definition with the same rules the on-disk agent.yaml // path uses (kind, name format, policies), so an inline definition cannot 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..27c2f145ed0 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 @@ -51,11 +52,14 @@ func TestAgentDefinitionRoundTrip(t *testing.T) { props, err := AgentDefinitionToServiceProperties(ca, extra) require.NoError(t, err) + _, hasInlineEnvironment := props.GetFields()["environmentVariables"] + require.False(t, hasInlineEnvironment) svc := &azdext.ServiceConfig{ Name: "basic-agent", Host: "azure.ai.agent", AdditionalProperties: props, + Environment: AgentEnvironment(ca), } got, isHosted, found, source, err := AgentDefinitionFromService(svc) @@ -110,6 +114,45 @@ func TestAgentDefinitionFromService_LegacyConfigShape(t *testing.T) { require.Equal(t, "basic-agent", got.Name) } +func TestAgentDefinitionFromService_LegacyEnvironment(t *testing.T) { + props, err := AgentDefinitionToServiceProperties( + sampleContainerAgent(), + nil, + ) + require.NoError(t, err) + legacyEnvironment, err := structpb.NewValue([]any{ + map[string]any{ + "name": "LEGACY_KEY", + "value": "${LEGACY_KEY}", + }, + map[string]any{ + "name": "SHARED_KEY", + "value": "legacy", + }, + }) + require.NoError(t, err) + props.Fields["environmentVariables"] = legacyEnvironment + + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + Config: props, + Environment: map[string]string{ + "NEW_KEY": "new", + "SHARED_KEY": "service", + }, + } + got, _, found, source, err := AgentDefinitionFromService(svc) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, AgentDefinitionSourceLegacyConfig, source) + require.Equal(t, map[string]string{ + "LEGACY_KEY": "${LEGACY_KEY}", + "NEW_KEY": "new", + "SHARED_KEY": "service", + }, AgentEnvironment(got)) +} + // 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) { @@ -211,9 +254,15 @@ func TestLoadAgentDefinition_DiskFallback(t *testing.T) { // TestUpsertAgentEnvVars verifies that env vars are added/updated on the inline // definition while preserving the other definition keys. func TestUpsertAgentEnvVars(t *testing.T) { - props, err := AgentDefinitionToServiceProperties(sampleContainerAgent(), nil) + ca := sampleContainerAgent() + props, err := AgentDefinitionToServiceProperties(ca, nil) require.NoError(t, err) - svc := &azdext.ServiceConfig{Name: "basic-agent", Host: "azure.ai.agent", AdditionalProperties: props} + svc := &azdext.ServiceConfig{ + Name: "basic-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + Environment: AgentEnvironment(ca), + } require.NoError(t, UpsertAgentEnvVars(svc, map[string]string{ "FOUNDRY_MODEL_DEPLOYMENT_NAME": "gpt-4o", // update existing @@ -225,11 +274,10 @@ func TestUpsertAgentEnvVars(t *testing.T) { require.True(t, found) require.Equal(t, "basic-agent", got.Name) // other keys preserved require.NotNil(t, got.EnvironmentVariables) + _, hasInlineEnvironment := props.GetFields()["environmentVariables"] + require.False(t, hasInlineEnvironment) - values := map[string]string{} - for _, ev := range *got.EnvironmentVariables { - values[ev.Name] = ev.Value - } + values := AgentEnvironment(got) require.Equal(t, "gpt-4o", values["FOUNDRY_MODEL_DEPLOYMENT_NAME"]) require.Equal(t, "cand-1", values["OPTIMIZATION_CANDIDATE_ID"]) } 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..ce5a633a684 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -45,7 +45,6 @@ type ServiceTargetAgentConfig struct { // Foundry project. Its presence is the brownfield signal that makes provision // connect to that project instead of creating a new one. Endpoint string `json:"endpoint,omitempty"` - Environment map[string]string `json:"env,omitempty"` Container *ContainerSettings `json:"container,omitempty"` Deployments []Deployment `json:"deployments,omitempty"` Resources []Resource `json:"resources,omitempty"` diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/config_test.go index 33c8819ff18..ac4fa16b397 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config_test.go @@ -147,7 +147,6 @@ func TestServiceTargetAgentConfig_MultipleToolboxes(t *testing.T) { // alongside other ServiceTargetAgentConfig fields. func TestServiceTargetAgentConfig_WithOtherFields(t *testing.T) { original := ServiceTargetAgentConfig{ - Environment: map[string]string{"KEY": "VALUE"}, Deployments: []Deployment{ { Name: "test-deployment", @@ -187,10 +186,6 @@ func TestServiceTargetAgentConfig_WithOtherFields(t *testing.T) { t.Fatalf("UnmarshalStruct failed: %v", err) } - if roundTripped.Environment["KEY"] != "VALUE" { - t.Errorf("Expected env KEY=VALUE, got '%s'", roundTripped.Environment["KEY"]) - } - if len(roundTripped.Deployments) != 1 { t.Fatalf("Expected 1 deployment, got %d", len(roundTripped.Deployments)) } 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..1e3acc4b03a 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 @@ -4,13 +4,6 @@ "description": "Custom configuration for the Azure AI Agent Service target", "type": "object", "properties": { - "env": { - "type": "object", - "description": "Environment variables as key-value pairs", - "additionalProperties": { - "type": "string" - } - }, "container": { "$ref": "#/definitions/ContainerSettings" }, diff --git a/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml b/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml index 1f299dd05bb..4889c2f7b03 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml +++ b/cli/azd/extensions/azure.ai.agents/schemas/examples/complex.azure.yaml @@ -46,6 +46,8 @@ services: host: azure.ai.connection uses: - ai-project + env: + SEARCH_API_KEY: ${SEARCH_API_KEY} category: CognitiveSearch target: https://my-search.search.windows.net authType: ApiKey @@ -108,7 +110,7 @@ services: - research-tools env: LOG_LEVEL: info - MODEL_ENDPOINT: ${{project.endpoint}} + MODEL_ENDPOINT: $${{project.endpoint}} protocols: - protocol: a2a version: "0.2" @@ -143,6 +145,8 @@ services: host: azure.ai.routine uses: - researcher + env: + DIGEST_TOPIC: ${DIGEST_TOPIC} description: Summarize the day's documents every night. triggers: default: diff --git a/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml b/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml index 38197ca2346..6c51d4ab962 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml +++ b/cli/azd/extensions/azure.ai.agents/schemas/examples/simple.azure.yaml @@ -25,3 +25,5 @@ services: kind: hosted name: assistant description: A simple assistant. + env: + LOG_LEVEL: info diff --git a/cli/azd/extensions/azure.ai.connections/CHANGELOG.md b/cli/azd/extensions/azure.ai.connections/CHANGELOG.md index 5ecd9a65ff1..c617849f196 100644 --- a/cli/azd/extensions/azure.ai.connections/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.connections/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0-beta.3 (Unreleased) + +### Bugs Fixed + +- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Resolve `${VAR}` references from the service-level `env` object forwarded by azd core. Existing services without `env` keep falling back to the active azd environment. + ## 1.0.0-beta.2 (2026-07-09) ### Bugs Fixed diff --git a/cli/azd/extensions/azure.ai.connections/extension.yaml b/cli/azd/extensions/azure.ai.connections/extension.yaml index a1d9d0ee90d..39a83f9571f 100644 --- a/cli/azd/extensions/azure.ai.connections/extension.yaml +++ b/cli/azd/extensions/azure.ai.connections/extension.yaml @@ -17,4 +17,4 @@ tags: - connection usage: azd ai connection [options] version: 1.0.0-beta.2 -requiredAzdVersion: ">=1.27.0" +requiredAzdVersion: ">=1.27.1" diff --git a/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json b/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json index f51ea03067e..c4f76754581 100644 --- a/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json +++ b/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json @@ -12,7 +12,7 @@ }, "target": { "type": "string", - "description": "Target endpoint URL or ARM resource ID. May contain ${VAR} (azd env, resolved client-side)." + "description": "Target endpoint URL or ARM resource ID. May contain ${VAR}; declare each referenced variable in the service-level env object." }, "authType": { "type": "string", @@ -38,7 +38,7 @@ }, "credentials": { "type": "object", - "description": "Credentials. Values may contain ${VAR} (azd env, resolved client-side) or ${{...}} (Foundry server-side resolution, passed through untouched).", + "description": "Credentials. Values may contain ${VAR} declared in the service-level env object or ${{...}} for Foundry server-side resolution.", "additionalProperties": true }, "metadata": { diff --git a/cli/azd/extensions/azure.ai.routines/CHANGELOG.md b/cli/azd/extensions/azure.ai.routines/CHANGELOG.md index bcfd3a84bf3..2cb28a4f83f 100644 --- a/cli/azd/extensions/azure.ai.routines/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.routines/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0-beta.3 (Unreleased) + +### Bugs Fixed + +- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Resolve routine input `${VAR}` references from the service-level `env` object forwarded by azd core. Existing services without `env` keep falling back to the active azd environment. + ## 1.0.0-beta.2 (2026-07-09) ### Bugs Fixed diff --git a/cli/azd/extensions/azure.ai.routines/extension.yaml b/cli/azd/extensions/azure.ai.routines/extension.yaml index f34400f68da..c23c76f948c 100644 --- a/cli/azd/extensions/azure.ai.routines/extension.yaml +++ b/cli/azd/extensions/azure.ai.routines/extension.yaml @@ -17,4 +17,4 @@ tags: - routine usage: azd ai routine [options] version: 1.0.0-beta.2 -requiredAzdVersion: ">=1.27.0" +requiredAzdVersion: ">=1.27.1" diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go index 9203d8a3f34..8dc8d49092d 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target.go @@ -29,18 +29,21 @@ var _ azdext.ServiceTargetProvider = (*routineServiceTarget)(nil) // model (triggers, action, ...); the routine name is the service key. Package // and Publish are no-ops because a routine has no build artifact. type routineServiceTarget struct { - azdClient *azdext.AzdClient - serviceConfig *azdext.ServiceConfig + azdClient *azdext.AzdClient } // newRoutineServiceTarget creates the azure.ai.routine service-target provider. -func newRoutineServiceTarget(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { +func newRoutineServiceTarget( + azdClient *azdext.AzdClient, +) azdext.ServiceTargetProvider { return &routineServiceTarget{azdClient: azdClient} } -// Initialize stores the service configuration; no other setup is required. -func (p *routineServiceTarget) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { - p.serviceConfig = serviceConfig +// Initialize requires no setup. +func (p *routineServiceTarget) Initialize( + _ context.Context, + _ *azdext.ServiceConfig, +) error { return nil } @@ -111,14 +114,16 @@ func (p *routineServiceTarget) Deploy( // The service key is the routine identity; ignore any name in the body. body.Name = serviceConfig.GetName() - // Resolve ${VAR} references in the routine's action input against the azd - // environment, leaving Foundry server-side ${{...}} expressions untouched. + // Resolve ${VAR} against the service environment forwarded by azd. if body.Action != nil { - env, err := p.currentEnvValues(ctx) + environment, err := p.environmentValues(ctx, serviceConfig) if err != nil { return nil, err } - body.Action.Input = expandRoutineValue(body.Action.Input, env) + body.Action.Input = expandRoutineValue( + body.Action.Input, + environment, + ) } if progress != nil { @@ -185,16 +190,27 @@ func newRoutineServiceClient(ctx context.Context) (*routines.Client, error) { ), nil } -// currentEnvValues loads all key-value pairs from the active azd environment, used to -// resolve ${VAR} references in routine fields at deploy time. -func (p *routineServiceTarget) currentEnvValues(ctx context.Context) (map[string]string, error) { - current, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) +func (p *routineServiceTarget) environmentValues( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, +) (map[string]string, error) { + if environment := serviceConfig.GetEnvironment(); len(environment) > 0 { + return environment, nil + } + + current, err := p.azdClient.Environment().GetCurrent( + ctx, + &azdext.EmptyRequest{}, + ) if err != nil { return nil, fmt.Errorf("resolving current azd environment: %w", err) } - resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ - Name: current.GetEnvironment().GetName(), - }) + resp, err := p.azdClient.Environment().GetValues( + ctx, + &azdext.GetEnvironmentRequest{ + Name: current.GetEnvironment().GetName(), + }, + ) if err != nil { return nil, fmt.Errorf("loading azd environment values: %w", err) } @@ -205,9 +221,7 @@ func (p *routineServiceTarget) currentEnvValues(ctx context.Context) (map[string return values, nil } -// expandRoutineValue recursively expands ${VAR} references in every string within a -// routine value (maps, slices, scalars) against the azd environment, preserving Foundry -// server-side ${{...}} expressions. +// expandRoutineValue expands ${VAR} in nested routine values. func expandRoutineValue(value any, env map[string]string) any { switch typed := value.(type) { case string: diff --git a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go index 4df169c2cd9..074fa000190 100644 --- a/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go +++ b/cli/azd/extensions/azure.ai.routines/internal/cmd/service_target_test.go @@ -57,3 +57,25 @@ func TestParseRoutineServiceConfig_ConfigFallback(t *testing.T) { require.NoError(t, err) assert.Equal(t, "legacy", body.Description) } + +func TestExpandRoutineValue(t *testing.T) { + t.Parallel() + + serviceConfig := &azdext.ServiceConfig{ + Environment: map[string]string{"DIGEST_TOPIC": "weekly changes"}, + } + environment, err := (&routineServiceTarget{}).environmentValues( + t.Context(), + serviceConfig, + ) + require.NoError(t, err) + input := map[string]any{ + "topic": "${DIGEST_TOPIC}", + "secret": "${{connections.search.credentials.key}}", + } + + assert.Equal(t, map[string]any{ + "topic": "weekly changes", + "secret": "${{connections.search.credentials.key}}", + }, expandRoutineValue(input, environment)) +} diff --git a/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json b/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json index 40058c5dfaa..d4f72ec59c7 100644 --- a/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json +++ b/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json @@ -36,7 +36,7 @@ "properties": { "type": { "type": "string", "description": "Action variant (e.g. invoke_agent_responses_api, invoke_agent_invocations_api)." }, "agent_name": { "type": "string", "description": "Name of the azure.ai.agent service the routine invokes." }, - "input": { "description": "Static JSON input sent to the agent when the routine fires. Values may use ${VAR} or ${{...}}." } + "input": { "description": "Static JSON input sent to the agent when the routine fires. Values may use ${VAR} declared in the service-level env object or ${{...}} for Foundry server-side resolution." } } } } diff --git a/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md b/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md index f6134911adc..70136f9c987 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.toolboxes/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0-beta.3 (Unreleased) + +### Bugs Fixed + +- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Resolve toolbox `${VAR}` references from the service-level `env` object forwarded by azd core. Existing services without `env` keep falling back to the active azd environment. + ## 1.0.0-beta.2 (2026-07-09) ### Other Changes diff --git a/cli/azd/extensions/azure.ai.toolboxes/extension.yaml b/cli/azd/extensions/azure.ai.toolboxes/extension.yaml index 494cbc823d8..84e47842a00 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/extension.yaml +++ b/cli/azd/extensions/azure.ai.toolboxes/extension.yaml @@ -17,4 +17,4 @@ tags: - toolbox usage: azd ai toolbox [options] version: 1.0.0-beta.2 -requiredAzdVersion: ">=1.27.0" +requiredAzdVersion: ">=1.27.1" diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go index d97321acaeb..122c58612bf 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target.go @@ -46,19 +46,25 @@ type toolboxServiceConfig struct { // name is the service key. Package and Publish are no-ops because a toolbox has no build // artifact. type toolboxServiceTarget struct { - azdClient *azdext.AzdClient - serviceConfig *azdext.ServiceConfig - resolver connectionResolver + azdClient *azdext.AzdClient + resolver connectionResolver } // newToolboxServiceTarget creates the azure.ai.toolbox service-target provider. -func newToolboxServiceTarget(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { - return &toolboxServiceTarget{azdClient: azdClient, resolver: defaultConnectionResolver{}} +func newToolboxServiceTarget( + azdClient *azdext.AzdClient, +) azdext.ServiceTargetProvider { + return &toolboxServiceTarget{ + azdClient: azdClient, + resolver: defaultConnectionResolver{}, + } } -// Initialize stores the service configuration; no other setup is required. -func (p *toolboxServiceTarget) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { - p.serviceConfig = serviceConfig +// Initialize requires no setup. +func (p *toolboxServiceTarget) Initialize( + _ context.Context, + _ *azdext.ServiceConfig, +) error { return nil } @@ -113,8 +119,8 @@ func (p *toolboxServiceTarget) Publish( // Deploy upserts the toolbox by creating a new version from the entry's tools. Tool // entries that name a `connection` are resolved to their project_connection_id (the -// `uses:` edge guarantees the connection is reconciled first). ${VAR} references resolve -// against the azd environment; Foundry ${{...}} expressions pass through untouched. +// `uses:` edge guarantees the connection is reconciled first). ${VAR} +// references resolve from the forwarded service environment. // Removing the service from azure.yaml stops azd managing the toolbox but does not delete // it (use `azd ai toolbox delete`). // When the entry sets `endpoint` instead, azd reuses that existing @@ -144,12 +150,16 @@ func (p *toolboxServiceTarget) Deploy( } endpoint := resolved.Endpoint - env, err := p.currentEnvValues(ctx) + environment, err := p.environmentValues(ctx, serviceConfig) if err != nil { return nil, err } - - tools, err := p.buildToolEntries(ctx, endpoint, cfg.Tools, env) + tools, err := p.buildToolEntries( + ctx, + endpoint, + cfg.Tools, + environment, + ) if err != nil { return nil, err } @@ -323,16 +333,27 @@ func parseToolboxServiceConfig(svc *azdext.ServiceConfig) (*toolboxServiceConfig return cfg, nil } -// currentEnvValues loads all key-value pairs from the active azd environment, used to -// resolve ${VAR} references in tool fields at deploy time. -func (p *toolboxServiceTarget) currentEnvValues(ctx context.Context) (map[string]string, error) { - current, err := p.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) +func (p *toolboxServiceTarget) environmentValues( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, +) (map[string]string, error) { + if environment := serviceConfig.GetEnvironment(); len(environment) > 0 { + return environment, nil + } + + current, err := p.azdClient.Environment().GetCurrent( + ctx, + &azdext.EmptyRequest{}, + ) if err != nil { return nil, fmt.Errorf("resolving current azd environment: %w", err) } - resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ - Name: current.GetEnvironment().GetName(), - }) + resp, err := p.azdClient.Environment().GetValues( + ctx, + &azdext.GetEnvironmentRequest{ + Name: current.GetEnvironment().GetName(), + }, + ) if err != nil { return nil, fmt.Errorf("loading azd environment values: %w", err) } @@ -343,9 +364,7 @@ func (p *toolboxServiceTarget) currentEnvValues(ctx context.Context) (map[string return values, nil } -// expandToolboxValue recursively expands ${VAR} references in every string within a tool -// value (maps, slices, scalars) against the azd environment, preserving Foundry -// server-side ${{...}} expressions. +// expandToolboxValue expands ${VAR} in nested toolbox values. func expandToolboxValue(value any, env map[string]string) any { switch typed := value.(type) { case string: diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go index 8fece33c91a..72dc06eaa8e 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/service_target_test.go @@ -174,14 +174,26 @@ func TestBuildToolEntries_ResolvesConnectionRef(t *testing.T) { func TestExpandToolboxValue(t *testing.T) { t.Parallel() - env := map[string]string{"MCP_URL": "https://resolved.example.com"} + serviceConfig := &azdext.ServiceConfig{ + Environment: map[string]string{ + "MCP_URL": "https://resolved.example.com", + }, + } + environment, err := (&toolboxServiceTarget{}).environmentValues( + t.Context(), + serviceConfig, + ) + require.NoError(t, err) in := map[string]any{ "type": "mcp", "server_url": "${MCP_URL}", "headers": []any{"x-secret: ${{secrets.token}}"}, } - out, ok := expandToolboxValue(in, env).(map[string]any) + out, ok := expandToolboxValue( + in, + environment, + ).(map[string]any) require.True(t, ok) assert.Equal(t, "https://resolved.example.com", out["server_url"]) // Foundry ${{...}} passes through untouched. diff --git a/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json b/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json index 54ae8ef04e3..8f48945a852 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json +++ b/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json @@ -16,7 +16,7 @@ }, "tools": { "type": "array", - "description": "List of tools in the toolbox.", + "description": "List of tools in the toolbox. Values may use ${VAR} declared in the service-level env object.", "items": { "type": "object", "required": ["type"], diff --git a/cli/azd/extensions/microsoft.foundry/CHANGELOG.md b/cli/azd/extensions/microsoft.foundry/CHANGELOG.md index 8c4fe543015..0bea9898bb5 100644 --- a/cli/azd/extensions/microsoft.foundry/CHANGELOG.md +++ b/cli/azd/extensions/microsoft.foundry/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0-beta.2 (Unreleased) + +### Other Changes + +- [[#9079]](https://github.com/Azure/azure-dev/pull/9079) Require azd 1.27.1 so bundled Foundry service targets receive service-level environment values. + ## 1.0.0-beta.1 (2026-06-30) ### Features Added diff --git a/cli/azd/extensions/microsoft.foundry/extension.yaml b/cli/azd/extensions/microsoft.foundry/extension.yaml index ae8597bfe99..4eb57a9bef7 100644 --- a/cli/azd/extensions/microsoft.foundry/extension.yaml +++ b/cli/azd/extensions/microsoft.foundry/extension.yaml @@ -6,7 +6,7 @@ tags: - ai - foundry version: 1.0.0-beta.1 -requiredAzdVersion: ">=1.27.0" +requiredAzdVersion: ">=1.27.1" dependencies: - id: azure.ai.agents version: "~1.0.0-beta.1"