diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index f63bf0bda84..2bc4d5c517e 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## Unreleased + +### Features Added + +- `azd ai agent init` now writes each Foundry resource as its own `azure.yaml` service entry instead of bundling everything into the agent service. Model deployments become a single `azure.ai.project` service, each connection becomes an `azure.ai.connection` service, and each toolbox becomes an `azure.ai.toolbox` service, all wired to the agent through `uses:`. The agents extension registers the `azure.ai.project`, `azure.ai.connection`, and `azure.ai.toolbox` service-target hosts itself as no-ops (the resources are created by Bicep at provision time), so only this extension needs to be installed for `azd up`/`azd deploy` to walk the new service entries. Provisioning behavior is unchanged: the agent extension re-sources deployments, connections, and toolboxes from the sibling services when setting provisioning environment variables and creating toolsets, falling back to a pre-split `azure.yaml` that still bundles them on the agent service so existing projects keep provisioning without re-running `init`. + ## 0.1.41-preview (2026-06-19) - [[#8731]](https://github.com/Azure/azure-dev/pull/8731) Improve the post-deploy `Next:` guidance with a stacked layout that puts each command on its own line above its description, adds a blank line between suggestions, and highlights `azd` commands. The new layout applies across deploy, `azd ai agent show`, `init`, and `doctor`. Thanks @therealjohn for the contribution! diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index 51b6a1b2cc9..37089e2c078 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -5,7 +5,7 @@ displayName: Foundry agents (Preview) description: Ship agents with Microsoft Foundry from your terminal. (Preview) usage: azd ai agent [options] # NOTE: Make sure version.txt is in sync with this version. -version: 0.1.41-preview +version: 0.1.42-preview requiredAzdVersion: ">1.25.2" dependencies: - id: azure.ai.inspector @@ -22,6 +22,15 @@ providers: - name: azure.ai.agent type: service-target description: Deploys agents to the Foundry Agent Service + - name: azure.ai.project + type: service-target + description: Registers Foundry project service entries so azd up/deploy succeed + - name: azure.ai.connection + type: service-target + description: Registers Foundry connection service entries so azd up/deploy succeed + - name: azure.ai.toolbox + type: service-target + description: Registers Foundry toolbox service entries so azd up/deploy succeed - name: microsoft.foundry type: provisioning-provider description: Provisions a Microsoft Foundry project from azure.yaml without an on-disk infra/ directory 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 3aa02ed3fd4..8e18b21a14c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2933,6 +2933,19 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa agentConfig.StartupCommand = startupCmd } + // Each Foundry resource is written as its own azure.yaml service entry, so + // the deployments, connections, and toolboxes move out of the agent config + // into sibling azure.ai.project/connection/toolbox services emitted below. + // The agent keeps its container, resources, tool connections, and startup + // command. The provisioning handlers re-source the moved data from the + // sibling services. + resourceDeployments := agentConfig.Deployments + resourceConnections := agentConfig.Connections + resourceToolboxes := agentConfig.Toolboxes + agentConfig.Deployments = nil + agentConfig.Connections = nil + agentConfig.Toolboxes = nil + var agentConfigStruct *structpb.Struct if agentConfigStruct, err = project.MarshalStruct(&agentConfig); err != nil { return fmt.Errorf("failed to marshal agent config: %w", err) @@ -2970,6 +2983,15 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa return fmt.Errorf("adding agent service to project: %w", err) } + // Emit the sibling Foundry resource services (project + deployments, + // connections, toolboxes) and wire the agent's uses: to them. + if err := emitResourceServices( + ctx, a.azdClient, a.serviceNameOverride, + resourceDeployments, resourceConnections, resourceToolboxes, + ); err != nil { + return err + } + fmt.Printf( "\nAdded your agent as a service entry named '%s' under the file azure.yaml.\n", a.serviceNameOverride, 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 f6189075992..4c6b64b76f6 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,11 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, agentConfig.StartupCommand = startupCmd } + // Move the model deployments out of the agent config into a sibling + // azure.ai.project service, emitted after the agent service below. + resourceDeployments := agentConfig.Deployments + agentConfig.Deployments = nil + var agentConfigStruct *structpb.Struct var err error if agentConfigStruct, err = project.MarshalStruct(&agentConfig); err != nil { @@ -888,6 +893,15 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, return fmt.Errorf("adding agent service to project: %w", err) } + // Emit the sibling azure.ai.project service carrying the model deployments + // and wire the agent's uses: to it. + agentServiceName := strings.ReplaceAll(agentName, " ", "") + if err := emitResourceServices( + ctx, a.azdClient, agentServiceName, resourceDeployments, nil, nil, + ); err != nil { + return err + } + fmt.Printf("\nAdded your agent as a service entry named '%s' under the file azure.yaml.\n", agentName) return nil } 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 1d72a4d6b8f..83f2372a949 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -41,6 +41,19 @@ func configureExtensionHost(host *azdext.ExtensionHost) { WithServiceTarget(AiAgentHost, func() azdext.ServiceTargetProvider { return project.NewAgentServiceTargetProvider(azdClient) }). + // The Foundry resource hosts written by `azd ai agent init` are owned by + // this extension too, so `azd up`/`azd deploy` can walk them without a + // separate extension per host. They are no-ops today; the resources are + // created by Bicep at provision time. + WithServiceTarget(AiProjectHost, func() azdext.ServiceTargetProvider { + return project.NewResourceServiceTargetProvider(azdClient) + }). + WithServiceTarget(AiConnectionHost, func() azdext.ServiceTargetProvider { + return project.NewResourceServiceTargetProvider(azdClient) + }). + WithServiceTarget(AiToolboxHost, func() azdext.ServiceTargetProvider { + return project.NewResourceServiceTargetProvider(azdClient) + }). WithProvisioningProvider(project.FoundryProviderName, func() azdext.ProvisioningProvider { return project.NewFoundryProvisioningProvider(azdClient) }). @@ -62,13 +75,22 @@ func configureExtensionHost(host *azdext.ExtensionHost) { } func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { + deployments, err := collectProjectDeployments(args.Project.Services) + if err != nil { + return err + } + connections, err := collectConnections(args.Project.Services) + if err != nil { + return err + } + for _, svc := range args.Project.Services { switch svc.Host { case AiAgentHost: if err := populateContainerSettings(ctx, azdClient, svc); 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); err != nil { + if err := envUpdate(ctx, azdClient, args.Project, svc, deployments, connections); err != nil { return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err) } } @@ -82,18 +104,34 @@ func postprovisionHandler( azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs, ) error { - hasAgent := false - for _, svc := range args.Project.Services { - if svc.Host != AiAgentHost { - continue + // Toolboxes live in sibling azure.ai.toolbox services; their connection + // enrichment still needs the project connections (azure.ai.connection + // services) and the agent tool connections. + toolboxes, err := collectToolboxes(args.Project.Services) + if err != nil { + return fmt.Errorf("failed to collect toolboxes: %w", err) + } + + if len(toolboxes) > 0 { + connections, err := collectConnections(args.Project.Services) + if err != nil { + return fmt.Errorf("failed to collect connections: %w", err) + } + toolConnections, err := collectAgentToolConnections(args.Project.Services) + if err != nil { + return fmt.Errorf("failed to collect tool connections: %w", err) } - hasAgent = true - if err := provisionToolboxes(ctx, azdClient, svc); err != nil { - return fmt.Errorf( - "failed to provision toolboxes for service %q: %w", - svc.Name, err, - ) + if err := provisionToolboxes(ctx, azdClient, toolboxes, connections, toolConnections); err != nil { + return fmt.Errorf("failed to provision toolboxes: %w", err) + } + } + + hasAgent := false + for _, svc := range args.Project.Services { + if svc.Host == AiAgentHost { + hasAgent = true + break } } @@ -156,6 +194,15 @@ func currentEnvName(ctx context.Context, azdClient *azdext.AzdClient) (string, e } func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { + deployments, err := collectProjectDeployments(args.Project.Services) + if err != nil { + return err + } + connections, err := collectConnections(args.Project.Services) + if err != nil { + return err + } + hasHostedAgentService := false for _, svc := range args.Project.Services { if svc.Host != AiAgentHost { @@ -165,7 +212,7 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az if err := populateContainerSettings(ctx, azdClient, svc); 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); err != nil { + if err := envUpdate(ctx, azdClient, args.Project, svc, deployments, connections); err != nil { return fmt.Errorf("failed to update environment for service %q: %w", svc.Name, err) } @@ -376,7 +423,14 @@ func cleanupAgentSessionState(ctx context.Context, azdClient *azdext.AzdClient, return !failed } -func envUpdate(ctx context.Context, azdClient *azdext.AzdClient, azdProject *azdext.ProjectConfig, svc *azdext.ServiceConfig) error { +func envUpdate( + ctx context.Context, + azdClient *azdext.AzdClient, + azdProject *azdext.ProjectConfig, + svc *azdext.ServiceConfig, + deployments []project.Deployment, + connections []project.Connection, +) error { var foundryAgentConfig *project.ServiceTargetAgentConfig @@ -393,28 +447,31 @@ func envUpdate(ctx context.Context, azdClient *azdext.AzdClient, azdProject *azd return err } - if len(foundryAgentConfig.Deployments) > 0 { - if err := deploymentEnvUpdate(ctx, foundryAgentConfig.Deployments, azdClient, currentEnvResponse.Environment.Name); err != nil { + // Deployments and connections are sourced from the sibling + // azure.ai.project and azure.ai.connection services. Resources and tool + // connections stay on the agent service. + if len(deployments) > 0 { + if err := deploymentEnvUpdate(ctx, deployments, azdClient, currentEnvResponse.Environment.Name); err != nil { return err } } - if len(foundryAgentConfig.Resources) > 0 { + if foundryAgentConfig != nil && len(foundryAgentConfig.Resources) > 0 { if err := resourcesEnvUpdate(ctx, foundryAgentConfig.Resources, azdClient, currentEnvResponse.Environment.Name); err != nil { return err } } - if len(foundryAgentConfig.Connections) > 0 { + if len(connections) > 0 { if err := connectionsEnvUpdate( - ctx, foundryAgentConfig.Connections, + ctx, connections, azdClient, currentEnvResponse.Environment.Name, ); err != nil { return err } } - if len(foundryAgentConfig.ToolConnections) > 0 { + if foundryAgentConfig != nil && len(foundryAgentConfig.ToolConnections) > 0 { if err := toolConnectionsEnvUpdate( ctx, foundryAgentConfig.ToolConnections, azdClient, currentEnvResponse.Environment.Name, @@ -672,22 +729,27 @@ func populateContainerSettings(ctx context.Context, azdClient *azdext.AzdClient, } // provisionToolboxes creates or updates Foundry Toolsets for each toolbox -// in the service config. Called during post-provision after the project -// endpoint has been created by Bicep. +// sourced from the sibling azure.ai.toolbox services. Called during +// post-provision after the project endpoint has been created by Bicep. The +// connections and toolConnections are used to resolve connection references +// declared on the toolboxes. func provisionToolboxes( ctx context.Context, azdClient *azdext.AzdClient, - svc *azdext.ServiceConfig, + toolboxes []project.Toolbox, + connections []project.Connection, + toolConnections []project.ToolConnection, ) error { - var config *project.ServiceTargetAgentConfig - if err := project.UnmarshalStruct(svc.Config, &config); err != nil { - return fmt.Errorf("failed to parse service config: %w", err) - } - - if config == nil || len(config.Toolboxes) == 0 { + if len(toolboxes) == 0 { return nil } + // Build connection lookup for enriching tool entries with server_url/server_label + connByName := toolboxConnectionsByName(&project.ServiceTargetAgentConfig{ + Connections: connections, + ToolConnections: toolConnections, + }) + currentEnv, err := azdClient.Environment().GetCurrent( ctx, &azdext.EmptyRequest{}, ) @@ -751,10 +813,7 @@ func provisionToolboxes( return fmt.Errorf("loading connection IDs: %w", err) } - // Build connection lookup for enriching tool entries with server_url/server_label - connByName := toolboxConnectionsByName(config) - - for _, toolbox := range config.Toolboxes { + for _, toolbox := range toolboxes { fmt.Fprintf( os.Stderr, "Provisioning toolbox: %s\n", toolbox.Name, ) 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 new file mode 100644 index 00000000000..ecc5016a227 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -0,0 +1,361 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "sort" + "strings" + + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/protobuf/types/known/structpb" +) + +// Foundry resource service hosts. Each Foundry resource is written to azure.yaml +// as its own service entry keyed by the resource name, carrying a singular +// host: azure.ai.. The owning extension registers a service-target +// provider for the host so `azd up`/`provision`/`deploy` can walk the service. +const ( + // AiProjectHost owns the Foundry project and its model deployments. + AiProjectHost = "azure.ai.project" + // AiConnectionHost owns a single Foundry project connection. + AiConnectionHost = "azure.ai.connection" + // AiToolboxHost owns a single Foundry toolbox (toolset). + AiToolboxHost = "azure.ai.toolbox" + + // aiProjectServiceName is the stable azure.yaml service key used for the + // single azure.ai.project service. A stable name keeps repeated inits + // idempotent (AddService overwrites by name) so there is one project + // service per project, matching the unified Foundry config design. + aiProjectServiceName = "ai-project" +) + +// 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 +// wires the agent service's uses: list to them for ordering. Each resource is +// its own azure.yaml service entry so a different extension can own each host. +func emitResourceServices( + ctx context.Context, + azdClient *azdext.AzdClient, + agentServiceName string, + deployments []project.Deployment, + connections []project.Connection, + toolboxes []project.Toolbox, +) error { + var agentUses []string + + // Track every azure.yaml service key we emit so two resource names that + // sanitize to the same key (e.g. "my conn" and "myconn") fail fast instead + // of silently overwriting each other -- AddService overwrites by name. + // Seed it with the agent service name, which the caller adds before this + // runs, so a resource colliding with the agent is caught too. + usedNames := map[string]string{} + if agentServiceName != "" { + usedNames[agentServiceName] = "agent service" + } + + // One project service owns the model deployments and represents the single + // Foundry project the agent targets. It is always emitted -- even with no + // deployments (e.g. "Skip model configuration") -- so every agent has one + // stable ai-project sibling that connections and toolboxes can depend on to + // enforce provisioning order. + projectCfg, err := project.MarshalStruct(&project.ServiceTargetAgentConfig{Deployments: deployments}) + if err != nil { + return fmt.Errorf("marshaling project service config: %w", err) + } + projectServiceName := aiProjectServiceName + if err := reserveServiceName(usedNames, projectServiceName, "project service"); err != nil { + return err + } + if err := addResourceService(ctx, azdClient, projectServiceName, AiProjectHost, projectCfg, nil); err != nil { + return err + } + agentUses = append(agentUses, projectServiceName) + + // Connection and toolbox services depend on the project service so the + // project is provisioned first. + siblingUses := []string{projectServiceName} + + for i := range connections { + conn := connections[i] + connName := sanitizeServiceName(conn.Name) + if connName == "" { + continue + } + if err := reserveServiceName(usedNames, connName, fmt.Sprintf("connection %q", conn.Name)); err != nil { + return err + } + connCfg, err := project.MarshalStruct(&conn) + if err != nil { + return fmt.Errorf("marshaling connection service %q config: %w", connName, err) + } + if err := addResourceService(ctx, azdClient, connName, AiConnectionHost, connCfg, siblingUses); err != nil { + return err + } + agentUses = append(agentUses, connName) + } + + for i := range toolboxes { + toolbox := toolboxes[i] + toolboxName := sanitizeServiceName(toolbox.Name) + if toolboxName == "" { + continue + } + if err := reserveServiceName(usedNames, toolboxName, fmt.Sprintf("toolbox %q", toolbox.Name)); err != nil { + return err + } + toolboxCfg, err := project.MarshalStruct(&toolbox) + if err != nil { + return fmt.Errorf("marshaling toolbox service %q config: %w", toolboxName, err) + } + if err := addResourceService(ctx, azdClient, toolboxName, AiToolboxHost, toolboxCfg, siblingUses); err != nil { + return err + } + agentUses = append(agentUses, toolboxName) + } + + // Wire the agent service to its resource siblings so azd walks them first. + if len(agentUses) > 0 && agentServiceName != "" { + if err := setServiceUses(ctx, azdClient, agentServiceName, agentUses); err != nil { + return err + } + } + + return nil +} + +// addResourceService adds a single Foundry resource service to azure.yaml with +// its schema under config: and optionally wires its uses: list. The service is +// added with an empty language so azd resolves a no-op framework; the owning +// extension's service-target provider handles its (currently no-op) lifecycle. +func addResourceService( + ctx context.Context, + azdClient *azdext.AzdClient, + name string, + host string, + cfg *structpb.Struct, + uses []string, +) error { + svc := &azdext.ServiceConfig{ + Name: name, + Host: host, + Config: cfg, + } + + if _, err := azdClient.Project().AddService(ctx, &azdext.AddServiceRequest{Service: svc}); err != nil { + return fmt.Errorf("adding %s service %q: %w", host, name, err) + } + + if len(uses) > 0 { + if err := setServiceUses(ctx, azdClient, name, uses); err != nil { + return 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. +func setServiceUses(ctx context.Context, azdClient *azdext.AzdClient, serviceName string, uses []string) error { + usesItems := make([]any, len(uses)) + for i, u := range uses { + usesItems[i] = u + } + + usesValue, err := structpb.NewValue(usesItems) + if err != nil { + return fmt.Errorf("encoding uses for service %q: %w", serviceName, err) + } + + if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "uses", + Value: usesValue, + }); err != nil { + return fmt.Errorf("setting uses for service %q: %w", serviceName, err) + } + + return nil +} + +// sanitizeServiceName converts a resource name into a valid azure.yaml service +// key by trimming and removing spaces, matching how the agent service name is +// derived from the agent name. +func sanitizeServiceName(name string) string { + return strings.ReplaceAll(strings.TrimSpace(name), " ", "") +} + +// reserveServiceName records an azure.yaml service key derived from a Foundry +// resource name, returning an error when two resources sanitize to the same +// key. AddService overwrites by name, so without this a collision would +// silently drop a resource and corrupt the uses: graph; failing fast lets the +// user rename the offending resource. +func reserveServiceName(used map[string]string, name, source string) error { + if existing, ok := used[name]; ok { + return fmt.Errorf( + "resource service name collision: %s and %s both map to azure.yaml service %q; "+ + "rename one so they produce distinct service names", + existing, source, name, + ) + } + used[name] = source + return nil +} + +// collectProjectDeployments gathers the model deployments declared across all +// azure.ai.project services so provisioning handlers can source them from the +// sibling project service instead of the agent service config. Services are +// visited in sorted name order so serialized env-var output stays stable. +// +// Falls back to the deployments bundled on the agent service when no project +// 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) { + var out []project.Deployment + for _, svc := range sortedServices(services) { + if svc.Host != AiProjectHost || svc.Config == nil { + continue + } + var cfg *project.ServiceTargetAgentConfig + if err := project.UnmarshalStruct(svc.Config, &cfg); err != nil { + return nil, fmt.Errorf("parsing project service %q config: %w", svc.Name, err) + } + if cfg != nil { + out = append(out, cfg.Deployments...) + } + } + if len(out) > 0 { + return out, nil + } + legacy, err := collectLegacyAgentConfigs(services) + if err != nil { + return nil, err + } + for _, cfg := range legacy { + out = append(out, cfg.Deployments...) + } + return out, nil +} + +// collectConnections gathers the connections declared across all +// azure.ai.connection services. Falls back to the connections bundled on the +// 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) { + var out []project.Connection + for _, svc := range sortedServices(services) { + if svc.Host != AiConnectionHost || svc.Config == nil { + continue + } + var conn *project.Connection + if err := project.UnmarshalStruct(svc.Config, &conn); err != nil { + return nil, fmt.Errorf("parsing connection service %q config: %w", svc.Name, err) + } + if conn != nil { + out = append(out, *conn) + } + } + if len(out) > 0 { + return out, nil + } + legacy, err := collectLegacyAgentConfigs(services) + if err != nil { + return nil, err + } + for _, cfg := range legacy { + out = append(out, cfg.Connections...) + } + return out, nil +} + +// collectToolboxes gathers the toolboxes declared across all azure.ai.toolbox +// services. Falls back to the toolboxes bundled on the agent service when no +// 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) { + var out []project.Toolbox + for _, svc := range sortedServices(services) { + if svc.Host != AiToolboxHost || svc.Config == nil { + continue + } + var toolbox *project.Toolbox + if err := project.UnmarshalStruct(svc.Config, &toolbox); err != nil { + return nil, fmt.Errorf("parsing toolbox service %q config: %w", svc.Name, err) + } + if toolbox != nil { + out = append(out, *toolbox) + } + } + if len(out) > 0 { + return out, nil + } + legacy, err := collectLegacyAgentConfigs(services) + if err != nil { + return nil, err + } + for _, cfg := range legacy { + out = append(out, cfg.Toolboxes...) + } + return out, nil +} + +// collectAgentToolConnections gathers the tool connections declared on agent +// services. Tool connections stay on the agent service (they are agent tool +// configuration), so toolbox enrichment still needs them alongside the +// connections sourced from azure.ai.connection services. +func collectAgentToolConnections(services map[string]*azdext.ServiceConfig) ([]project.ToolConnection, error) { + configs, err := collectLegacyAgentConfigs(services) + if err != nil { + return nil, err + } + var out []project.ToolConnection + for _, cfg := range configs { + out = append(out, cfg.ToolConnections...) + } + return out, nil +} + +// collectLegacyAgentConfigs parses the bundled ServiceTargetAgentConfig from +// every agent service, in sorted name order. Tool connections always live here; +// projects created before the per-resource split also carry their deployments, +// 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) { + var out []*project.ServiceTargetAgentConfig + for _, svc := range sortedServices(services) { + if svc.Host != AiAgentHost || svc.Config == nil { + continue + } + var cfg *project.ServiceTargetAgentConfig + if err := project.UnmarshalStruct(svc.Config, &cfg); err != nil { + return nil, fmt.Errorf("parsing agent service %q config: %w", svc.Name, err) + } + if cfg != nil { + out = append(out, cfg) + } + } + 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 { + keys := make([]string, 0, len(services)) + for k := range services { + keys = append(keys, k) + } + sort.Strings(keys) + + out := make([]*azdext.ServiceConfig, 0, len(services)) + for _, k := range keys { + out = append(out, services[k]) + } + return out +} 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 new file mode 100644 index 00000000000..3459f6af91a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -0,0 +1,369 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "net" + "sync" + "testing" + + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +func mustMarshalConfig[T any](t *testing.T, in *T) *azdext.ServiceConfig { + t.Helper() + cfg, err := project.MarshalStruct(in) + require.NoError(t, err) + return &azdext.ServiceConfig{Config: cfg} +} + +func projectService(t *testing.T, name string, deployments ...project.Deployment) *azdext.ServiceConfig { + t.Helper() + svc := mustMarshalConfig(t, &project.ServiceTargetAgentConfig{Deployments: deployments}) + svc.Name = name + svc.Host = AiProjectHost + return svc +} + +func connectionService(t *testing.T, name string, conn project.Connection) *azdext.ServiceConfig { + t.Helper() + svc := mustMarshalConfig(t, &conn) + svc.Name = name + svc.Host = AiConnectionHost + return svc +} + +func toolboxService(t *testing.T, name string, toolbox project.Toolbox) *azdext.ServiceConfig { + t.Helper() + svc := mustMarshalConfig(t, &toolbox) + svc.Name = name + svc.Host = AiToolboxHost + return svc +} + +func agentService(t *testing.T, name string, toolConnections ...project.ToolConnection) *azdext.ServiceConfig { + t.Helper() + svc := mustMarshalConfig(t, &project.ServiceTargetAgentConfig{ToolConnections: toolConnections}) + svc.Name = name + svc.Host = AiAgentHost + return svc +} + +// TestSanitizeServiceName verifies resource names are normalized into valid +// azure.yaml service keys (spaces removed, surrounding whitespace trimmed). +func TestSanitizeServiceName(t *testing.T) { + t.Parallel() + + assert.Equal(t, "MyAgent", sanitizeServiceName(" My Agent ")) + assert.Equal(t, "gpt4o", sanitizeServiceName("gpt 4 o")) + assert.Equal(t, "", sanitizeServiceName(" ")) +} + +// TestReserveServiceName verifies distinct service keys are accepted and that +// two resources collapsing to the same azure.yaml key (e.g. "my conn" and +// "myconn") fail fast with an actionable collision error instead of silently +// overwriting each other. +func TestReserveServiceName(t *testing.T) { + t.Parallel() + + used := map[string]string{"weatheragent": "agent service"} + require.NoError(t, reserveServiceName(used, "myconn", `connection "my conn"`)) + require.NoError(t, reserveServiceName(used, "toolbox", `toolbox "toolbox"`)) + + err := reserveServiceName(used, "myconn", `connection "myconn"`) + require.Error(t, err) + assert.Contains(t, err.Error(), "collision") + assert.Contains(t, err.Error(), "myconn") + + // A resource colliding with the seeded agent service is also caught. + err = reserveServiceName(used, "weatheragent", `connection "weather agent"`) + require.Error(t, err) + assert.Contains(t, err.Error(), "agent service") +} + +// TestCollectProjectDeployments verifies deployments are sourced only from +// azure.ai.project services and ignore sibling hosts. +func TestCollectProjectDeployments(t *testing.T) { + t.Parallel() + + dep := project.Deployment{Name: "gpt-4o", Model: project.DeploymentModel{Name: "gpt-4o"}} + services := map[string]*azdext.ServiceConfig{ + "ai-project": projectService(t, "ai-project", dep), + "agent": agentService(t, "agent"), + "conn": connectionService(t, "conn", project.Connection{Name: "conn"}), + } + + deployments, err := collectProjectDeployments(services) + require.NoError(t, err) + require.Len(t, deployments, 1) + assert.Equal(t, "gpt-4o", deployments[0].Name) +} + +// TestCollectConnections verifies connections are sourced from +// azure.ai.connection services in deterministic (sorted) order. +func TestCollectConnections(t *testing.T) { + t.Parallel() + + services := map[string]*azdext.ServiceConfig{ + "zeta": connectionService(t, "zeta", project.Connection{Name: "zeta", Category: "ApiKey"}), + "alpha": connectionService(t, "alpha", project.Connection{Name: "alpha", Category: "ApiKey"}), + "ai-project": projectService(t, "ai-project"), + "agent": agentService(t, "agent"), + } + + connections, err := collectConnections(services) + require.NoError(t, err) + require.Len(t, connections, 2) + // Sorted by service key (alpha before zeta) for stable env-var output. + assert.Equal(t, "alpha", connections[0].Name) + assert.Equal(t, "zeta", connections[1].Name) +} + +// TestCollectToolboxes verifies toolboxes are sourced from azure.ai.toolbox +// services only. +func TestCollectToolboxes(t *testing.T) { + t.Parallel() + + services := map[string]*azdext.ServiceConfig{ + "tb": toolboxService(t, "tb", project.Toolbox{Name: "tb", Tools: []map[string]any{{"type": "mcp"}}}), + "agent": agentService(t, "agent"), + } + + toolboxes, err := collectToolboxes(services) + require.NoError(t, err) + require.Len(t, toolboxes, 1) + assert.Equal(t, "tb", toolboxes[0].Name) + require.Len(t, toolboxes[0].Tools, 1) +} + +// TestCollectAgentToolConnections verifies tool connections stay on the agent +// service and are sourced from there for toolbox enrichment. +func TestCollectAgentToolConnections(t *testing.T) { + t.Parallel() + + tc := project.ToolConnection{Name: "mcp-conn", Category: "CustomKeys", Target: "https://example.com"} + services := map[string]*azdext.ServiceConfig{ + "agent": agentService(t, "agent", tc), + "ai-project": projectService(t, "ai-project"), + } + + toolConnections, err := collectAgentToolConnections(services) + require.NoError(t, err) + require.Len(t, toolConnections, 1) + assert.Equal(t, "mcp-conn", toolConnections[0].Name) +} + +// TestCollectHelpers_EmptyAndNilConfigs verifies the collectors tolerate +// services with nil config and unrelated hosts without error. +func TestCollectHelpers_EmptyAndNilConfigs(t *testing.T) { + t.Parallel() + + services := map[string]*azdext.ServiceConfig{ + "web": {Name: "web", Host: "containerapp"}, + "nilcfg": {Name: "nilcfg", Host: AiProjectHost}, + } + + deployments, err := collectProjectDeployments(services) + require.NoError(t, err) + assert.Empty(t, deployments) + + connections, err := collectConnections(services) + require.NoError(t, err) + assert.Empty(t, connections) + + toolboxes, err := collectToolboxes(services) + require.NoError(t, err) + assert.Empty(t, toolboxes) +} + +// TestCollect_FallbackToBundledAgentConfig verifies that a pre-split azure.yaml +// -- deployments, connections, and toolboxes bundled on the agent service with +// no sibling azure.ai. services -- still yields those resources, so +// existing projects provision without re-running init. +func TestCollect_FallbackToBundledAgentConfig(t *testing.T) { + t.Parallel() + + bundled := &project.ServiceTargetAgentConfig{ + Deployments: []project.Deployment{{Name: "gpt-4o", Model: project.DeploymentModel{Name: "gpt-4o"}}}, + Connections: []project.Connection{{Name: "conn", Category: "ApiKey"}}, + Toolboxes: []project.Toolbox{{Name: "tb", Tools: []map[string]any{{"type": "mcp"}}}}, + } + svc := mustMarshalConfig(t, bundled) + svc.Name = "my-agent" + svc.Host = AiAgentHost + services := map[string]*azdext.ServiceConfig{"my-agent": svc} + + deployments, err := collectProjectDeployments(services) + require.NoError(t, err) + require.Len(t, deployments, 1) + assert.Equal(t, "gpt-4o", deployments[0].Name) + + connections, err := collectConnections(services) + require.NoError(t, err) + require.Len(t, connections, 1) + assert.Equal(t, "conn", connections[0].Name) + + toolboxes, err := collectToolboxes(services) + require.NoError(t, err) + require.Len(t, toolboxes, 1) + assert.Equal(t, "tb", toolboxes[0].Name) +} + +// TestCollectProjectDeployments_SiblingWinsOverBundled verifies the sibling +// azure.ai.project service takes precedence: the fallback to bundled agent +// deployments only applies when no project service carries any. +func TestCollectProjectDeployments_SiblingWinsOverBundled(t *testing.T) { + t.Parallel() + + bundled := &project.ServiceTargetAgentConfig{ + Deployments: []project.Deployment{{Name: "legacy", Model: project.DeploymentModel{Name: "legacy"}}}, + } + agentSvc := mustMarshalConfig(t, bundled) + agentSvc.Name = "my-agent" + agentSvc.Host = AiAgentHost + + services := map[string]*azdext.ServiceConfig{ + "my-agent": agentSvc, + "ai-project": projectService( + t, "ai-project", project.Deployment{Name: "gpt-4o", Model: project.DeploymentModel{Name: "gpt-4o"}}, + ), + } + + deployments, err := collectProjectDeployments(services) + require.NoError(t, err) + require.Len(t, deployments, 1) + assert.Equal(t, "gpt-4o", deployments[0].Name) +} + +// recordingProjectServer captures the AddService and SetServiceConfigValue +// calls emitResourceServices makes, so tests can assert on the emitted +// azure.yaml service graph without a real azd host. +type recordingProjectServer struct { + azdext.UnimplementedProjectServiceServer + + mu sync.Mutex + added []*azdext.ServiceConfig + uses map[string][]string +} + +func (s *recordingProjectServer) AddService( + _ context.Context, req *azdext.AddServiceRequest, +) (*azdext.EmptyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.added = append(s.added, req.Service) + return &azdext.EmptyResponse{}, nil +} + +func (s *recordingProjectServer) SetServiceConfigValue( + _ context.Context, req *azdext.SetServiceConfigValueRequest, +) (*azdext.EmptyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.uses == nil { + s.uses = map[string][]string{} + } + if req.Path == "uses" && req.Value != nil { + if list, ok := req.Value.AsInterface().([]any); ok { + vals := make([]string, 0, len(list)) + for _, v := range list { + if str, ok := v.(string); ok { + vals = append(vals, str) + } + } + s.uses[req.ServiceName] = vals + } + } + 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 { + t.Helper() + + grpcServer := grpc.NewServer() + azdext.RegisterProjectServiceServer(grpcServer, server) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + serveErr := make(chan error, 1) + go func() { + if err := grpcServer.Serve(listener); err != nil { + serveErr <- err + } + }() + + t.Cleanup(func() { + grpcServer.Stop() + _ = listener.Close() + select { + case err := <-serveErr: + require.ErrorIs(t, err, grpc.ErrServerStopped) + default: + } + }) + + client, err := azdext.NewAzdClient(azdext.WithAddress(listener.Addr().String())) + require.NoError(t, err) + t.Cleanup(func() { client.Close() }) + + return client +} + +// TestEmitResourceServices_AlwaysEmitsProjectService verifies the ai-project +// service is written even when the agent has no deployments, connections, or +// toolboxes, and that the agent's uses: is wired to it. The project service is +// emitted unconditionally as the stable provisioning-order anchor every agent +// references rather than being gated on a Foundry resource being present. +func TestEmitResourceServices_AlwaysEmitsProjectService(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + + err := emitResourceServices(t.Context(), client, "myagent", nil, nil, nil) + require.NoError(t, err) + + server.mu.Lock() + defer server.mu.Unlock() + + require.Len(t, server.added, 1) + assert.Equal(t, aiProjectServiceName, server.added[0].Name) + assert.Equal(t, AiProjectHost, server.added[0].Host) + assert.Equal(t, []string{aiProjectServiceName}, server.uses["myagent"]) +} + +// TestEmitResourceServices_WiresSiblingsToProject verifies a connection service +// is emitted alongside the project service, depends on it via uses: so the +// project provisions first, and that the agent is wired to both siblings. +func TestEmitResourceServices_WiresSiblingsToProject(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + + conns := []project.Connection{{Name: "myconn", Category: "ApiKey"}} + err := emitResourceServices(t.Context(), client, "myagent", nil, conns, nil) + require.NoError(t, err) + + server.mu.Lock() + defer server.mu.Unlock() + + require.Len(t, server.added, 2) + assert.Equal(t, aiProjectServiceName, server.added[0].Name) + assert.Equal(t, AiProjectHost, server.added[0].Host) + assert.Equal(t, "myconn", server.added[1].Name) + assert.Equal(t, AiConnectionHost, server.added[1].Host) + + assert.Equal(t, []string{aiProjectServiceName}, server.uses["myconn"]) + assert.Equal(t, []string{aiProjectServiceName, "myconn"}, server.uses["myagent"]) +} 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 bc6a6423c88..bd7fb81865b 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 @@ -1496,6 +1496,13 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(ctx context.Context, serv } } + return zipSourceDir(ctx, srcDir) +} + +// zipSourceDir creates a ZIP archive of srcDir honoring .agentignore, writes it to a +// temp file, and computes its SHA-256. It returns the temp file path and SHA-256 hex +// string. +func zipSourceDir(ctx context.Context, srcDir string) (string, string, error) { // Load .agentignore (or use defaults if no file exists) ignoreMatcher, err := newAgentIgnoreMatcher(ctx, srcDir) if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_resource.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_resource.go new file mode 100644 index 00000000000..523984acf9d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_resource.go @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +var _ azdext.ServiceTargetProvider = (*ResourceServiceTargetProvider)(nil) + +// ResourceServiceTargetProvider is a no-op service target shared by the Foundry +// resource hosts that `azd ai agent init` writes as sibling service entries: +// azure.ai.project, azure.ai.connection, and azure.ai.toolbox. The agents +// extension registers all three so `azd up`/`azd deploy` can walk the service +// entries the agent references via uses:, without requiring a separate +// extension per host. The resources themselves are created by Bicep during +// `azd provision` (orchestrated by this extension), so every deploy-time hook +// here intentionally does nothing. +// +// These hosts share one provider type because none of them has deploy-time +// behavior yet. When a host gains real backend functionality it can move to its +// own dedicated extension, at which point that extension registers the host +// instead of this one. +type ResourceServiceTargetProvider struct { + azdClient *azdext.AzdClient + serviceConfig *azdext.ServiceConfig +} + +// NewResourceServiceTargetProvider creates a no-op service target for a Foundry +// resource host. +func NewResourceServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { + return &ResourceServiceTargetProvider{azdClient: azdClient} +} + +// Initialize stores the service configuration; no other setup is required. +func (p *ResourceServiceTargetProvider) Initialize( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, +) error { + p.serviceConfig = serviceConfig + return nil +} + +// Endpoints returns no endpoints; Foundry resource services do not expose any. +func (p *ResourceServiceTargetProvider) Endpoints( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + targetResource *azdext.TargetResource, +) ([]string, error) { + return nil, nil +} + +// GetTargetResource resolves the target resource. It delegates to azd's default +// resolver and falls back to a minimal target so the deploy pipeline can proceed. +func (p *ResourceServiceTargetProvider) GetTargetResource( + ctx context.Context, + subscriptionId string, + serviceConfig *azdext.ServiceConfig, + defaultResolver func() (*azdext.TargetResource, error), +) (*azdext.TargetResource, error) { + if defaultResolver != nil { + if target, err := defaultResolver(); err == nil && target != nil { + return target, nil + } + } + + // Deploy is a no-op and does not use the target; azd only requires a + // non-nil target to continue the deploy pipeline. + return &azdext.TargetResource{SubscriptionId: subscriptionId}, nil +} + +// Package is a no-op; there is nothing to build or stage for a resource service. +func (p *ResourceServiceTargetProvider) Package( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + progress azdext.ProgressReporter, +) (*azdext.ServicePackageResult, error) { + return &azdext.ServicePackageResult{}, nil +} + +// Publish is a no-op; resource services have no artifacts to publish. +func (p *ResourceServiceTargetProvider) Publish( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + publishOptions *azdext.PublishOptions, + progress azdext.ProgressReporter, +) (*azdext.ServicePublishResult, error) { + return &azdext.ServicePublishResult{}, nil +} + +// Deploy is a no-op; the resources are created at provision time by Bicep. +func (p *ResourceServiceTargetProvider) Deploy( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + progress azdext.ProgressReporter, +) (*azdext.ServiceDeployResult, error) { + return &azdext.ServiceDeployResult{}, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/version.txt b/cli/azd/extensions/azure.ai.agents/version.txt index de4db352fba..d76fe6c36e1 100644 --- a/cli/azd/extensions/azure.ai.agents/version.txt +++ b/cli/azd/extensions/azure.ai.agents/version.txt @@ -1 +1 @@ -0.1.41-preview +0.1.42-preview