Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cli/azd/extensions/azure.ai.agents/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +5 to +7

### 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://<account>.services.ai.azure.com/api/projects/<project>/agents/<agent>/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.
Expand Down
9 changes: 9 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,7 @@ func (a *InitFromCodeAction) addToProject(
if err != nil {
return err
}
agentEnvironment := project.AgentEnvironment(*definition)

language := "python"
if !isCodeDeploy {
Expand All @@ -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,
Expand All @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
},
}

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"fmt"
"os"
"regexp"
"slices"
"strings"

Expand Down Expand Up @@ -35,6 +36,10 @@ const (
aiProjectServiceName = "ai-project"
)

var envReferencePattern = regexp.MustCompile(
`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`,
)
Comment on lines +39 to +41

// 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
Expand Down Expand Up @@ -275,6 +280,7 @@ func addResourceService(
cfg *structpb.Struct,
uses []string,
) error {
environment := serviceEnvironmentTemplates(cfg)
svc := &azdext.ServiceConfig{
Name: name,
Host: host,
Expand All @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading