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
29 changes: 23 additions & 6 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args
switch svc.Host {
case AiAgentHost:
if err := prepareContainerSettings(
ctx,
azdClient,
svc,
args.Project.Path,
); err != nil {
Expand Down Expand Up @@ -245,6 +247,8 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az
}

if err := prepareContainerSettings(
ctx,
azdClient,
svc,
args.Project.Path,
); err != nil {
Expand Down Expand Up @@ -757,6 +761,8 @@ func setEnvVar(ctx context.Context, azdClient *azdext.AzdClient, envName string,
}

func prepareContainerSettings(
ctx context.Context,
azdClient *azdext.AzdClient,
svc *azdext.ServiceConfig,
projectRoot string,
) error {
Expand Down Expand Up @@ -805,15 +811,26 @@ func prepareContainerSettings(
result.Cpu = project.DefaultCpu
}

if err := project.SetAgentContainerSettings(
// Persist the resolved container settings back onto the service's inline
// properties, preserving the agent definition and other config keys.
containerPath, containerValue, err := project.SetAgentContainerSettings(
svc,
&project.ContainerSettings{Resources: result},
); err != nil {
return fmt.Errorf(
"failed to update agent container settings: %w",
err,
)
)
if err != nil {
return fmt.Errorf("failed to update agent container settings: %w", err)
}

if !hasRootFileRef {
if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{
ServiceName: svc.GetName(),
Path: containerPath,
Value: containerValue,
}); err != nil {
return fmt.Errorf("persisting agent container settings: %w", err)
}
}

return nil
}

Expand Down
118 changes: 115 additions & 3 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package cmd

import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
Expand All @@ -20,6 +21,109 @@ import (
"google.golang.org/protobuf/types/known/structpb"
)

type containerSettingsProjectServer struct {
azdext.UnimplementedProjectServiceServer

mu sync.Mutex
addServiceCalls int
setServiceRequests []*azdext.SetServiceConfigValueRequest
}

func (s *containerSettingsProjectServer) AddService(
_ context.Context,
_ *azdext.AddServiceRequest,
) (*azdext.EmptyResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.addServiceCalls++
return &azdext.EmptyResponse{}, nil
}

func (s *containerSettingsProjectServer) SetServiceConfigValue(
_ context.Context,
req *azdext.SetServiceConfigValueRequest,
) (*azdext.EmptyResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.setServiceRequests = append(s.setServiceRequests, req)
return &azdext.EmptyResponse{}, nil
}

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

tests := []struct {
name string
legacy bool
wantPath string
}{
{
name: "inline service properties",
wantPath: "container",
},
{
name: "legacy config properties",
legacy: true,
wantPath: "config.container",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

props, err := structpb.NewStruct(map[string]any{
"kind": "hosted",
"name": "my-chat-agent",
"customField": "preserved",
"container": map[string]any{
"resources": map[string]any{
"cpu": "1",
},
},
})
require.NoError(t, err)

svc := &azdext.ServiceConfig{
Name: "agent",
Host: AiAgentHost,
Image: "myregistry.azurecr.io/my-agent:${MY_TAG}",
}
if tt.legacy {
svc.Config = props
} else {
svc.AdditionalProperties = props
}

server := &containerSettingsProjectServer{}
client := newProjectRecorderClient(t, server)

require.NoError(t, prepareContainerSettings(t.Context(), client, svc, t.TempDir()))

server.mu.Lock()
defer server.mu.Unlock()

require.Zero(t, server.addServiceCalls,
"full service replacement would drop fields that are not modeled by the extension")
require.Len(t, server.setServiceRequests, 1)

req := server.setServiceRequests[0]
require.Equal(t, "agent", req.ServiceName)
require.Equal(t, tt.wantPath, req.Path)
require.Equal(t, map[string]any{
"resources": map[string]any{
"cpu": "1",
"memory": project.DefaultMemory,
},
}, req.Value.AsInterface())

require.Equal(t, "myregistry.azurecr.io/my-agent:${MY_TAG}", svc.Image)
require.Equal(t, "preserved",
project.ServiceConfigProps(svc).GetFields()["customField"].GetStringValue())
})
}
}

// TestPostdeployHandler_NonHostedAgent_NoOp verifies postdeployHandler returns nil
// without any RPC calls when the service is an agent but not a hosted agent (no agent.yaml
// with kind: hostedAgent). With service-level event handlers, the core filters by host type,
Expand Down Expand Up @@ -159,8 +263,10 @@ func TestPrepareContainerSettings_DoesNotPersistResolvedFileRef(
RelativePath: "src/echo",
AdditionalProperties: props,
}
server := &containerSettingsProjectServer{}
client := newProjectRecorderClient(t, server)

err = prepareContainerSettings(svc, root)
err = prepareContainerSettings(t.Context(), client, svc, root)

require.NoError(t, err)
require.Equal(t, "src/echo", svc.GetRelativePath())
Expand All @@ -170,6 +276,7 @@ func TestPrepareContainerSettings_DoesNotPersistResolvedFileRef(
require.NotNil(t, cfg.Container.Resources)
require.Equal(t, "2", cfg.Container.Resources.Cpu)
require.Equal(t, "4Gi", cfg.Container.Resources.Memory)
require.Empty(t, server.setServiceRequests)
}

func TestPrepareContainerSettings_PreservesNestedFileRef(t *testing.T) {
Expand All @@ -188,8 +295,9 @@ func TestPrepareContainerSettings_PreservesNestedFileRef(t *testing.T) {
Host: AiAgentHost,
AdditionalProperties: props,
}
client := newProjectRecorderClient(t, &containerSettingsProjectServer{})

err = prepareContainerSettings(svc, t.TempDir())
err = prepareContainerSettings(t.Context(), client, svc, t.TempDir())

require.NoError(t, err)
deployments, ok := svc.GetAdditionalProperties().
Expand Down Expand Up @@ -227,8 +335,9 @@ func TestPrepareContainerSettings_NormalizesInlineEnvironment(t *testing.T) {
Host: AiAgentHost,
AdditionalProperties: props,
}
client := newProjectRecorderClient(t, &containerSettingsProjectServer{})

err = prepareContainerSettings(svc, t.TempDir())
err = prepareContainerSettings(t.Context(), client, svc, t.TempDir())

require.NoError(t, err)
require.Equal(
Expand All @@ -243,7 +352,10 @@ func TestPrepareContainerSettings_NormalizesInlineEnvironment(t *testing.T) {
func TestPrepareContainerSettings_WithoutProperties(t *testing.T) {
t.Parallel()

client := newProjectRecorderClient(t, &containerSettingsProjectServer{})
err := prepareContainerSettings(
t.Context(),
client,
&azdext.ServiceConfig{Name: "echo", Host: AiAgentHost},
t.TempDir(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -549,34 +549,38 @@ func UpsertAgentEnvVars(svc *azdext.ServiceConfig, kv map[string]string) error {
// agent service's inline properties, preserving every other key (the agent
// definition and the rest of the deploy/provision config). It mutates whichever
// shape the service uses (the unified AdditionalProperties, or — for older
// projects — the config-nested struct).
func SetAgentContainerSettings(svc *azdext.ServiceConfig, container *ContainerSettings) error {
legacy := false
props := svc.GetAdditionalProperties()
if props == nil || len(props.GetFields()) == 0 {
if cfg := svc.GetConfig(); cfg != nil && len(cfg.GetFields()) > 0 {
props = cfg
legacy = true
} else {
props = &structpb.Struct{}
}
// projects — the config-nested struct). The returned path and value identify
// the exact mutation for callers that need to persist it through the azd host.
func SetAgentContainerSettings(
svc *azdext.ServiceConfig,
container *ContainerSettings,
) (string, *structpb.Value, error) {
props := ServiceConfigProps(svc)
legacy := props != nil && props == svc.GetConfig()
containerPath := "container"
if legacy {
containerPath = "config.container"
}
if props == nil {
props = &structpb.Struct{}
}
if props.Fields == nil {
props.Fields = map[string]*structpb.Value{}
}

containerStruct, err := MarshalStruct(container)
if err != nil {
return fmt.Errorf("marshaling container settings: %w", err)
return "", nil, fmt.Errorf("marshaling container settings: %w", err)
}
props.Fields["container"] = structpb.NewStructValue(containerStruct)
containerValue := structpb.NewStructValue(containerStruct)
props.Fields["container"] = containerValue

if legacy {
svc.Config = props
} else {
svc.AdditionalProperties = props
}
return nil
return containerPath, containerValue, nil
}

// agentDefinitionFromStruct builds the ContainerAgent from an inline/config
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,74 @@ func TestAgentDefinitionFromService_NoDefinition(t *testing.T) {
require.False(t, found)
}

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

tests := []struct {
name string
legacy bool
unrelatedInline bool
wantPath string
}{
{
name: "inline service properties",
wantPath: "container",
},
{
name: "legacy config properties",
legacy: true,
wantPath: "config.container",
},
{
name: "legacy config properties with unrelated inline properties",
legacy: true,
unrelatedInline: true,
wantPath: "config.container",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

props, err := structpb.NewStruct(map[string]any{
"kind": "hosted",
"customField": "preserved",
})
require.NoError(t, err)

svc := &azdext.ServiceConfig{Host: "azure.ai.agent"}
if tt.legacy {
svc.Config = props
} else {
svc.AdditionalProperties = props
}
if tt.unrelatedInline {
svc.AdditionalProperties, err = structpb.NewStruct(map[string]any{
"resumeSessionOnDeploy": true,
})
require.NoError(t, err)
}

path, value, err := SetAgentContainerSettings(svc, &ContainerSettings{
Resources: &ResourceSettings{Cpu: "1", Memory: "2Gi"},
})
require.NoError(t, err)
require.Equal(t, tt.wantPath, path)
require.Equal(t, map[string]any{
"resources": map[string]any{
"cpu": "1",
"memory": "2Gi",
},
}, value.AsInterface())

storedProps := ServiceConfigProps(svc)
require.Equal(t, "preserved", storedProps.GetFields()["customField"].GetStringValue())
require.Same(t, value, storedProps.GetFields()["container"])
})
}
}

// TestAgentDefinition_ImageRidesOnCoreServiceField verifies the prebuilt image
// maps onto the core ServiceConfig.Image field (which core binds and round-trips)
// rather than the inline property bag, where core would strip it on reload.
Expand Down
Loading
Loading