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 7156cbd985e..7767332ae7d 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 @@ -30,6 +30,23 @@ import ( // Reference implementation +// agentAPIVersion is the API version used for agent endpoint invocation URLs. +const agentAPIVersion = "2025-11-15-preview" + +// displayableProtocolEntry defines a protocol that produces user-visible invocation endpoints. +type displayableProtocolEntry struct { + Protocol agent_api.AgentProtocol + URLPath string // path suffix in the invocation URL + EnvSuffix string // suffix used in AGENT_{KEY}_{SUFFIX}_ENDPOINT env vars +} + +// displayableProtocols is the single source of truth for protocols that produce +// user-facing invocation endpoints and env vars. +var displayableProtocols = []displayableProtocolEntry{ + {Protocol: agent_api.AgentProtocolResponses, URLPath: "openai/responses", EnvSuffix: "RESPONSES"}, + {Protocol: agent_api.AgentProtocolInvocations, URLPath: "invocations", EnvSuffix: "INVOCATIONS"}, +} + // Ensure AgentServiceTargetProvider implements ServiceTargetProvider interface var _ azdext.ServiceTargetProvider = &AgentServiceTargetProvider{} @@ -231,9 +248,24 @@ func (p *AgentServiceTargetProvider) Endpoints( ) } - endpoint := p.agentEndpoint(azdEnv["AZURE_AI_PROJECT_ENDPOINT"], azdEnv[agentNameKey], azdEnv[agentVersionKey]) + // Collect per-protocol endpoint env vars + var endpoints []string + for _, dp := range displayableProtocols { + key := fmt.Sprintf("AGENT_%s_%s_ENDPOINT", serviceKey, dp.EnvSuffix) + if val := azdEnv[key]; val != "" { + endpoints = append(endpoints, val) + } + } + + if len(endpoints) == 0 { + return nil, exterrors.Dependency( + exterrors.CodeMissingAgentEnvVars, + fmt.Sprintf("no agent endpoint variables found for service %s", serviceKey), + "run 'azd deploy' to deploy the agent and set these variables", + ) + } - return []string{endpoint}, nil + return endpoints, nil } // GetTargetResource returns a custom target resource for the agent service @@ -566,8 +598,11 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( return nil, err } - // Register agent info in environment - err = p.registerAgentEnvironmentVariables(ctx, azdEnv, serviceConfig, agentVersionResponse) + // Register agent info in environment (prompt agents use the responses protocol) + promptProtocols := []agent_yaml.ProtocolVersionRecord{ + {Protocol: string(agent_api.AgentProtocolResponses), Version: "1.0.0"}, + } + err = p.registerAgentEnvironmentVariables(ctx, azdEnv, serviceConfig, agentVersionResponse, promptProtocols) if err != nil { return nil, err } @@ -579,6 +614,7 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( agentVersionResponse.Version, azdEnv["AZURE_AI_PROJECT_ID"], azdEnv["AZURE_AI_PROJECT_ENDPOINT"], + promptProtocols, ) return &azdext.ServiceDeployResult{ @@ -586,7 +622,7 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( }, nil } -// deployHostedAgent handles deployment of hosted container agents +// deployHostedAgent deploys a container-based hosted agent to the Foundry service. func (p *AgentServiceTargetProvider) deployHostedAgent( ctx context.Context, serviceConfig *azdext.ServiceConfig, @@ -691,7 +727,16 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( // Register agent info in environment progress("Registering agent environment variables") - err = p.registerAgentEnvironmentVariables(ctx, azdEnv, serviceConfig, agentVersionResponse) + + // Default to "responses" protocol when none specified in agent.yaml. + protocols := agentDef.Protocols + if len(protocols) == 0 { + protocols = []agent_yaml.ProtocolVersionRecord{ + {Protocol: string(agent_api.AgentProtocolResponses), Version: "1.0.0"}, + } + } + + err = p.registerAgentEnvironmentVariables(ctx, azdEnv, serviceConfig, agentVersionResponse, protocols) if err != nil { return nil, err } @@ -701,6 +746,7 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( agentVersionResponse.Version, azdEnv["AZURE_AI_PROJECT_ID"], azdEnv["AZURE_AI_PROJECT_ENDPOINT"], + protocols, ) return &azdext.ServiceDeployResult{ @@ -708,12 +754,14 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( }, nil } -// deployArtifacts constructs the artifacts list for deployment results +// deployArtifacts constructs the artifacts list for deployment results. +// It produces one endpoint artifact per displayable protocol. func (p *AgentServiceTargetProvider) deployArtifacts( agentName string, agentVersion string, projectResourceID string, projectEndpoint string, + protocols []agent_yaml.ProtocolVersionRecord, ) []*azdext.Artifact { artifacts := []*azdext.Artifact{} @@ -734,30 +782,72 @@ func (p *AgentServiceTargetProvider) deployArtifacts( } } - // Add agent endpoint + // Add agent endpoint(s) — one per displayable protocol if projectEndpoint != "" { - agentEndpoint := p.agentEndpoint(projectEndpoint, agentName, agentVersion) - artifacts = append(artifacts, &azdext.Artifact{ - Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, - Location: agentEndpoint, - LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, - Metadata: map[string]string{ - "agentName": agentName, - "agentVersion": agentVersion, - "label": "Agent endpoint", - "clickable": "false", - "note": "For information on invoking the agent, see " + output.WithLinkFormat( - "https://aka.ms/azd-agents-invoke"), - }, - }) + endpoints := agentInvocationEndpoints(projectEndpoint, agentName, protocols) + for _, ep := range endpoints { + artifacts = append(artifacts, &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Location: ep.URL, + LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, + Metadata: map[string]string{ + "agentName": agentName, + "agentVersion": agentVersion, + "label": fmt.Sprintf("Agent endpoint (%s)", ep.Protocol), + "clickable": "false", + }, + }) + } + + // Attach the informational note to the last endpoint only, to avoid repetition. + if len(endpoints) > 0 { + last := artifacts[len(artifacts)-1] + last.Metadata["note"] = "For information on invoking the agent, see " + output.WithLinkFormat( + "https://aka.ms/azd-agents-invoke") + } } return artifacts } -// agentEndpoint constructs the agent endpoint URL from the provided parameters -func (p *AgentServiceTargetProvider) agentEndpoint(projectEndpoint, agentName, agentVersion string) string { - return fmt.Sprintf("%s/agents/%s/versions/%s", projectEndpoint, agentName, agentVersion) +// protocolEndpointInfo holds a displayable protocol label and its invocation URL. +type protocolEndpointInfo struct { + Protocol string + URL string +} + +// protocolPath maps an agent protocol to its URL path suffix. +// Returns empty string for protocols that should not be displayed. +func protocolPath(protocol string) string { + for _, dp := range displayableProtocols { + if agent_api.AgentProtocol(protocol) == dp.Protocol { + return dp.URLPath + } + } + return "" +} + +// agentInvocationEndpoints builds the list of displayable invocation endpoints +// from the agent's protocols. +func agentInvocationEndpoints( + projectEndpoint string, + agentName string, + protocols []agent_yaml.ProtocolVersionRecord, +) []protocolEndpointInfo { + var endpoints []protocolEndpointInfo + for _, p := range protocols { + path := protocolPath(p.Protocol) + if path == "" { + continue + } + endpoints = append(endpoints, protocolEndpointInfo{ + Protocol: p.Protocol, + URL: fmt.Sprintf( + "%s/agents/%s/endpoint/protocols/%s?api-version=%s", + projectEndpoint, agentName, path, agentAPIVersion), + }) + } + return endpoints } // agentPlaygroundUrl constructs a URL to the agent playground in the Foundry portal @@ -800,9 +890,6 @@ func (p *AgentServiceTargetProvider) createAgent( p.credential, ) - // Use constant API version - const apiVersion = "2025-11-15-preview" - // Extract CreateAgentVersionRequest from CreateAgentRequest versionRequest := &agent_api.CreateAgentVersionRequest{ Description: request.Description, @@ -811,7 +898,7 @@ func (p *AgentServiceTargetProvider) createAgent( } // Create agent version - agentVersionResponse, err := agentClient.CreateAgentVersion(ctx, request.Name, versionRequest, apiVersion) + agentVersionResponse, err := agentClient.CreateAgentVersion(ctx, request.Name, versionRequest, agentAPIVersion) if err != nil { return nil, exterrors.ServiceFromAzure(err, exterrors.OpCreateAgent) } @@ -855,25 +942,35 @@ func (p *AgentServiceTargetProvider) displayAgentInfo(request *agent_api.CreateA fmt.Fprintln(os.Stderr) } -// registerAgentEnvironmentVariables registers agent information as azd environment variables +// registerAgentEnvironmentVariables registers agent information as azd environment variables. +// Per-protocol endpoint vars are set (e.g. AGENT_{KEY}_RESPONSES_ENDPOINT). +// The legacy single-endpoint var (AGENT_{KEY}_ENDPOINT) is cleared to avoid stale data. func (p *AgentServiceTargetProvider) registerAgentEnvironmentVariables( ctx context.Context, azdEnv map[string]string, serviceConfig *azdext.ServiceConfig, agentVersionResponse *agent_api.AgentVersionObject, + protocols []agent_yaml.ProtocolVersionRecord, ) error { + serviceKey := p.getServiceKey(serviceConfig.Name) + envVars := map[string]string{ + fmt.Sprintf("AGENT_%s_NAME", serviceKey): agentVersionResponse.Name, + fmt.Sprintf("AGENT_%s_VERSION", serviceKey): agentVersionResponse.Version, + } + + // Clear legacy single-endpoint var so upgraded environments don't retain stale URLs. + legacyKey := fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey) + envVars[legacyKey] = "" - endpoint := p.agentEndpoint( + endpoints := agentInvocationEndpoints( azdEnv["AZURE_AI_PROJECT_ENDPOINT"], agentVersionResponse.Name, - agentVersionResponse.Version, + protocols, ) - - serviceKey := p.getServiceKey(serviceConfig.Name) - envVars := map[string]string{ - fmt.Sprintf("AGENT_%s_NAME", serviceKey): agentVersionResponse.Name, - fmt.Sprintf("AGENT_%s_VERSION", serviceKey): agentVersionResponse.Version, - fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey): endpoint, + for _, ep := range endpoints { + suffix := strings.ToUpper(ep.Protocol) + key := fmt.Sprintf("AGENT_%s_%s_ENDPOINT", serviceKey, suffix) + envVars[key] = ep.URL } for key, value := range envVars { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index 95ba91aedd3..9e2d06e6782 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -11,6 +11,7 @@ import ( "testing" "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/require" @@ -148,6 +149,277 @@ func newContainerTestClient(t *testing.T, containerSrv azdext.ContainerServiceSe return client } +// stubEnvServer records SetValue calls for testing registerAgentEnvironmentVariables. +type stubEnvServer struct { + azdext.UnimplementedEnvironmentServiceServer + values map[string]string +} + +func (s *stubEnvServer) SetValue( + _ context.Context, req *azdext.SetEnvRequest, +) (*azdext.EmptyResponse, error) { + if s.values == nil { + s.values = make(map[string]string) + } + s.values[req.Key] = req.Value + return &azdext.EmptyResponse{}, nil +} + +// newEnvTestClient spins up a gRPC server with the given environment +// service stub and returns an AzdClient connected to it. +func newEnvTestClient( + t *testing.T, envSrv azdext.EnvironmentServiceServer, +) *azdext.AzdClient { + t.Helper() + + srv := grpc.NewServer() + azdext.RegisterEnvironmentServiceServer(srv, envSrv) + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + go func() { _ = srv.Serve(lis) }() + t.Cleanup(func() { + srv.Stop() + _ = lis.Close() + }) + + client, err := azdext.NewAzdClient( + azdext.WithAddress(lis.Addr().String()), + ) + require.NoError(t, err) + t.Cleanup(func() { client.Close() }) + + return client +} + +func TestRegisterAgentEnvironmentVariables(t *testing.T) { + t.Parallel() + + envStub := &stubEnvServer{} + client := newEnvTestClient(t, envStub) + + provider := &AgentServiceTargetProvider{ + azdClient: client, + env: &azdext.Environment{Name: "test-env"}, + } + + azdEnv := map[string]string{ + "AZURE_AI_PROJECT_ENDPOINT": "https://proj.azure.com", + } + protocols := []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "1.0.0"}, + {Protocol: "invocations", Version: "1.0.0"}, + } + agentVersion := &agent_api.AgentVersionObject{ + Name: "my-agent", + Version: "1.0.0", + } + + err := provider.registerAgentEnvironmentVariables( + t.Context(), azdEnv, + &azdext.ServiceConfig{Name: "my-svc"}, + agentVersion, + protocols, + ) + require.NoError(t, err) + + // Verify per-protocol env vars + require.Contains(t, envStub.values, "AGENT_MY_SVC_NAME") + require.Equal(t, "my-agent", envStub.values["AGENT_MY_SVC_NAME"]) + require.Contains(t, envStub.values, "AGENT_MY_SVC_VERSION") + require.Equal(t, "1.0.0", envStub.values["AGENT_MY_SVC_VERSION"]) + + // Per-protocol endpoints + require.Contains(t, envStub.values, "AGENT_MY_SVC_RESPONSES_ENDPOINT") + require.Contains(t, + envStub.values["AGENT_MY_SVC_RESPONSES_ENDPOINT"], + "/agents/my-agent/endpoint/protocols/openai/responses") + require.Contains(t, envStub.values, "AGENT_MY_SVC_INVOCATIONS_ENDPOINT") + require.Contains(t, + envStub.values["AGENT_MY_SVC_INVOCATIONS_ENDPOINT"], + "/agents/my-agent/endpoint/protocols/invocations") + + // Legacy env var cleared + require.Contains(t, envStub.values, "AGENT_MY_SVC_ENDPOINT") + require.Empty(t, envStub.values["AGENT_MY_SVC_ENDPOINT"]) +} + +func TestProtocolPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + protocol string + expected string + }{ + {"responses", "responses", "openai/responses"}, + {"invocations", "invocations", "invocations"}, + {"activity_protocol excluded", "activity_protocol", ""}, + {"unknown excluded", "unknown_proto", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := protocolPath(tt.protocol) + require.Equal(t, tt.expected, got) + }) + } +} + +func TestAgentInvocationEndpoints(t *testing.T) { + t.Parallel() + + const endpoint = "https://myproject.services.ai.azure.com" + const agentName = "my-agent" + baseURL := endpoint + "/agents/" + agentName + "/endpoint/protocols/" + + tests := []struct { + name string + protocols []agent_yaml.ProtocolVersionRecord + expected []protocolEndpointInfo + }{ + { + name: "single responses protocol", + protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "1.0.0"}, + }, + expected: []protocolEndpointInfo{ + { + Protocol: "responses", + URL: baseURL + "openai/responses?api-version=" + agentAPIVersion, + }, + }, + }, + { + name: "single invocations protocol", + protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "invocations", Version: "1.0.0"}, + }, + expected: []protocolEndpointInfo{ + { + Protocol: "invocations", + URL: baseURL + "invocations?api-version=" + agentAPIVersion, + }, + }, + }, + { + name: "multiple protocols with activity_protocol excluded", + protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "1.0.0"}, + {Protocol: "activity_protocol", Version: "1.0.0"}, + {Protocol: "invocations", Version: "1.0.0"}, + }, + expected: []protocolEndpointInfo{ + { + Protocol: "responses", + URL: baseURL + "openai/responses?api-version=" + agentAPIVersion, + }, + { + Protocol: "invocations", + URL: baseURL + "invocations?api-version=" + agentAPIVersion, + }, + }, + }, + { + name: "only activity_protocol yields empty", + protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "activity_protocol", Version: "1.0.0"}, + }, + expected: nil, + }, + { + name: "nil protocols yields empty", + protocols: nil, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := agentInvocationEndpoints(endpoint, agentName, tt.protocols) + require.Equal(t, tt.expected, got) + }) + } +} + +func TestDeployArtifacts_HostedAgent_ProtocolEndpoints(t *testing.T) { + t.Parallel() + + p := &AgentServiceTargetProvider{} + const ep = "https://myproject.services.ai.azure.com" + + protocols := []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "1.0.0"}, + {Protocol: "invocations", Version: "1.0.0"}, + } + + artifacts := p.deployArtifacts( + "test-agent", "1.0.0", + "", // no project resource ID — skip playground + ep, + protocols, + ) + + // Should have 2 endpoint artifacts (one per displayable protocol) + require.Len(t, artifacts, 2) + + wantResponses := ep + + "/agents/test-agent/endpoint/protocols/openai/responses" + + "?api-version=" + agentAPIVersion + require.Equal(t, wantResponses, artifacts[0].Location) + require.Equal(t, "Agent endpoint (responses)", artifacts[0].Metadata["label"]) + require.Empty(t, artifacts[0].Metadata["note"], + "note should only appear on the last endpoint") + + wantInvocations := ep + + "/agents/test-agent/endpoint/protocols/invocations" + + "?api-version=" + agentAPIVersion + require.Equal(t, wantInvocations, artifacts[1].Location) + require.Equal(t, "Agent endpoint (invocations)", artifacts[1].Metadata["label"]) + require.Contains(t, artifacts[1].Metadata["note"], "invoking the agent") +} + +func TestDeployArtifacts_PromptAgent_ResponsesProtocol(t *testing.T) { + t.Parallel() + + p := &AgentServiceTargetProvider{} + const ep = "https://myproject.services.ai.azure.com" + + protocols := []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "1.0.0"}, + } + + artifacts := p.deployArtifacts( + "prompt-agent", "2.0.0", + "", // no project resource ID — skip playground + ep, + protocols, + ) + + require.Len(t, artifacts, 1) + wantURL := ep + + "/agents/prompt-agent/endpoint/protocols/openai/responses" + + "?api-version=" + agentAPIVersion + require.Equal(t, wantURL, artifacts[0].Location) + require.Equal(t, "Agent endpoint (responses)", artifacts[0].Metadata["label"]) + require.Contains(t, artifacts[0].Metadata["note"], "invoking the agent") +} + +func TestDeployArtifacts_EmptyProtocols_NoEndpoints(t *testing.T) { + t.Parallel() + + p := &AgentServiceTargetProvider{} + + // When protocols is empty, no endpoint artifacts are produced. + artifacts := p.deployArtifacts( + "agent", "1.0.0", + "", "https://ep.azure.com", + nil, + ) + require.Empty(t, artifacts) +} + // TestPackage_NoEarlyFailureWithoutACR is a regression test ensuring that // Package for a hosted agent does not fail early when // AZURE_CONTAINER_REGISTRY_ENDPOINT is unset. The ACR endpoint is resolved