mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Bump Azure.AI.Projects to 2.1.0-beta.2 and add agent-endpoint AsAIAgent path (#5899)
* .NET: Bump Azure.AI.Projects to 2.1.0-beta.2 and add agent-endpoint AsAIAgent path
Bumps Azure.AI.Projects to 2.1.0-beta.2 with the matching transitive pins (Azure.Core 1.55.0, System.ClientModel 1.11.0).
Foundry agent endpoint plumbing:
* FoundryAgent now routes the agent-endpoint constructor through the new GetProjectResponsesClientForAgentEndpoint helper.
* Adds an internal FoundryAgent ctor that takes an existing AIProjectClient plus a parsed agent endpoint so the public extension does not need to construct a second project client.
* Adds public AIProjectClient.AsAIAgent(Uri agentEndpoint, ...) extension. This is the path consumer samples are expected to use for hosted agents because version selection happens server-side.
* Trims the dangling "If you want to construct a FoundryAgent against a project endpoint..." sentence from ParseAgentEndpoint.
Unit tests:
* Four new tests in AzureAIProjectChatClientExtensionsTests cover the AIProjectClient.AsAIAgent(Uri agentEndpoint, ...) overload. 263/263 pass.
Consumer samples (Using-Samples):
* SimpleAgent and SessionFilesClient now read AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_AGENT_NAME (both required, throw on missing), derive the agent endpoint with new Uri($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai"), then call aiProjectClient.AsAIAgent(agentEndpoint, ...).
* SessionFilesClient README updated.
Contributor samples (responses/*):
* New HostedContributorRouteExtensions.MapDevTemporaryLocalAgentEndpoint() wildcard route extension so localhost contributor servers accept the per-agent OpenAI endpoint shape the production Hosted runtime exposes.
* All 11 contributor Program.cs files call MapDevTemporaryLocalAgentEndpoint() with a contributor-only warning comment.
* Hosted-Files and Hosted-AzureSearchRag were importing Hosted_Shared_Contributor_Setup but never calling AddDevTemporaryLocalContributorSetup(). Both now call it so HostedSessionIsolationKeyProvider resolves correctly in dev.
* Hosted-AzureSearchRag, Hosted-Files, Hosted-MemoryAgent csprojs drop stale VersionOverride="2.1.0-beta.1" pins.
* Hosted-AzureSearchRag and Hosted-Files csprojs add ProjectReference to Hosted_Shared_Contributor_Setup.
* Hosted-Observability/.dockerignore removed the out/ exclusion that was blocking COPY out/ . in Dockerfile.contributor.
Verified:
* Full solution-scoped build of changed projects: green.
* Scoped CI-parity dotnet format via WSL2 + Docker (mcr.microsoft.com/dotnet/sdk:10.0) over every changed csproj: clean.
* Foundry unit tests: 263/263.
* Contributor docker smoke for 8 hosted samples (publish + docker build + docker run + curl POST to the wildcard route): HTTP 200 / 500 with route matched.
* End-to-end smoke against the real Azure Foundry project with a fresh bearer token: Hosted-Files contributor container served HTTP 200, the agent invoked ListBundledFiles, and returned the expected file name.
* Address PR review: forward pipeline settings; add UTs
- CreateProjectClientOptions also carries RetryPolicy, NetworkTimeout, ClientLoggingOptions, MessageLoggingPolicy (was Transport+UserAgentApplicationId only).
- Make CreateProjectClientOptions internal so tests can verify the copy directly.
- Add AsAIAgent(Uri) UTs covering tools forwarding to inner ChatOptions and null tools handling.
- Add CreateProjectClientOptions UTs covering null caller and full pipeline-settings copy.
This commit is contained in:
+1
-1
@@ -20,7 +20,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.Search.Documents" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
|
||||
+127
@@ -1380,6 +1380,133 @@ public sealed class AzureAIProjectChatClientExtensionsTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region AsAIAgent(AIProjectClient, Uri agentEndpoint) Tests
|
||||
|
||||
private const string TestAgentEndpointUrl = "https://test.services.ai.azure.com/api/projects/test-project/agents/it-happy-path/endpoint/protocols/openai";
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsAIAgent(Uri agentEndpoint) throws ArgumentNullException when AIProjectClient is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentEndpoint_WithNullClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient? client = null;
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
client!.AsAIAgent(new Uri(TestAgentEndpointUrl)));
|
||||
|
||||
Assert.Equal("aiProjectClient", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsAIAgent(Uri agentEndpoint) throws ArgumentNullException when agentEndpoint is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentEndpoint_WithNullEndpoint_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClient();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
client.AsAIAgent((Uri)null!));
|
||||
|
||||
Assert.Equal("agentEndpoint", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsAIAgent(Uri agentEndpoint) populates Name/Id from the parsed endpoint slug
|
||||
/// and exposes the supplied <see cref="AIProjectClient"/> via <see cref="AIAgent.GetService{TService}(object?)"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentEndpoint_PopulatesNameAndIdFromSlugAndReusesProjectClient()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClient();
|
||||
|
||||
// Act
|
||||
var agent = client.AsAIAgent(new Uri(TestAgentEndpointUrl));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<FoundryAgent>(agent);
|
||||
Assert.Equal("it-happy-path", agent.Name);
|
||||
Assert.Equal("it-happy-path", agent.Id);
|
||||
Assert.Same(client, agent.GetService<AIProjectClient>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsAIAgent(Uri agentEndpoint) applies the supplied client factory exactly once.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentEndpoint_WithClientFactory_AppliesFactoryCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClient();
|
||||
TestChatClient? testChatClient = null;
|
||||
|
||||
// Act
|
||||
var agent = client.AsAIAgent(
|
||||
new Uri(TestAgentEndpointUrl),
|
||||
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsAIAgent(Uri agentEndpoint) forwards the supplied tools to the inner
|
||||
/// <see cref="ChatClientAgent"/>'s <see cref="ChatOptions.Tools"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentEndpoint_ForwardsToolsToInnerChatOptions()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClient();
|
||||
var tool1 = AIFunctionFactory.Create(() => "result-1", "tool_1", "First test tool.");
|
||||
var tool2 = AIFunctionFactory.Create(() => "result-2", "tool_2", "Second test tool.");
|
||||
List<AITool> tools = [tool1, tool2];
|
||||
|
||||
// Act
|
||||
var agent = client.AsAIAgent(new Uri(TestAgentEndpointUrl), tools: tools);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
ChatOptions? chatOptions = GetAgentChatOptions(agent);
|
||||
Assert.NotNull(chatOptions);
|
||||
Assert.NotNull(chatOptions!.Tools);
|
||||
Assert.Equal(2, chatOptions.Tools!.Count);
|
||||
Assert.Same(tool1, chatOptions.Tools[0]);
|
||||
Assert.Same(tool2, chatOptions.Tools[1]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsAIAgent(Uri agentEndpoint) accepts a null tools argument without throwing
|
||||
/// and produces an agent whose inner <see cref="ChatOptions.Tools"/> is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentEndpoint_WithNullTools_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClient();
|
||||
|
||||
// Act
|
||||
var agent = client.AsAIAgent(new Uri(TestAgentEndpointUrl), tools: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
ChatOptions? chatOptions = GetAgentChatOptions(agent);
|
||||
Assert.NotNull(chatOptions);
|
||||
Assert.Null(chatOptions!.Tools);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
@@ -356,7 +357,7 @@ public class FoundryAgentTests
|
||||
bool userAgentFound = false;
|
||||
using HttpHandlerAssert httpHandler = new(request =>
|
||||
{
|
||||
if (request.Headers.TryGetValues("User-Agent", out System.Collections.Generic.IEnumerable<string>? values))
|
||||
if (request.Headers.TryGetValues("User-Agent", out IEnumerable<string>? values))
|
||||
{
|
||||
foreach (string value in values)
|
||||
{
|
||||
@@ -431,23 +432,23 @@ public class FoundryAgentTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
|
||||
public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull()
|
||||
{
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
|
||||
Assert.Null(agent.GetService<ProjectOpenAIClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull()
|
||||
public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNonNull()
|
||||
{
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
Assert.Null(agent.GetService<AIProjectClient>());
|
||||
Assert.NotNull(agent.GetService<AIProjectClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
|
||||
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull()
|
||||
{
|
||||
FoundryAgent agent = new(
|
||||
s_testEndpoint,
|
||||
@@ -455,7 +456,7 @@ public class FoundryAgentTests
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Test");
|
||||
|
||||
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
|
||||
Assert.Null(agent.GetService<ProjectOpenAIClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -665,21 +666,82 @@ public class FoundryAgentTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient()
|
||||
public void AgentEndpointConstructor_PreservesUserAgentApplicationId()
|
||||
{
|
||||
// The MEAI policy adds its own User-Agent header so we cannot reliably observe the OpenAI SDK's
|
||||
// application-id stamp in the outbound request. Verify the value is propagated onto the
|
||||
// project-level client's options via the public ProjectOpenAIClient surface.
|
||||
ProjectOpenAIClientOptions opts = new() { UserAgentApplicationId = "my-app-id" };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
|
||||
ProjectOpenAIClient? projectClient = agent.GetService<ProjectOpenAIClient>();
|
||||
Assert.NotNull(projectClient);
|
||||
// Caller's UserAgentApplicationId is preserved on the per-agent options bag verbatim.
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("my-app-id", opts.UserAgentApplicationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProjectClientOptions_NullCallerOptions_ReturnsNull()
|
||||
{
|
||||
Assert.Null(FoundryAgent.CreateProjectClientOptions(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProjectClientOptions_CarriesPipelineSettingsAndUserAgent()
|
||||
{
|
||||
// Arrange
|
||||
var transport = new FakePipelineTransport();
|
||||
var retryPolicy = new FakeRetryPolicy();
|
||||
var messageLoggingPolicy = new FakeMessageLoggingPolicy();
|
||||
var clientLoggingOptions = new ClientLoggingOptions { EnableLogging = false };
|
||||
var networkTimeout = TimeSpan.FromSeconds(42);
|
||||
|
||||
ProjectOpenAIClientOptions callerOptions = new()
|
||||
{
|
||||
UserAgentApplicationId = "my-app-id",
|
||||
Transport = transport,
|
||||
RetryPolicy = retryPolicy,
|
||||
MessageLoggingPolicy = messageLoggingPolicy,
|
||||
ClientLoggingOptions = clientLoggingOptions,
|
||||
NetworkTimeout = networkTimeout,
|
||||
};
|
||||
|
||||
// Act
|
||||
AIProjectClientOptions? projectOptions = FoundryAgent.CreateProjectClientOptions(callerOptions);
|
||||
|
||||
// Assert: every settable pipeline behavior the caller configured is forwarded
|
||||
// onto the project-level options bag, not silently dropped.
|
||||
Assert.NotNull(projectOptions);
|
||||
Assert.Equal("my-app-id", projectOptions!.UserAgentApplicationId);
|
||||
Assert.Same(transport, projectOptions.Transport);
|
||||
Assert.Same(retryPolicy, projectOptions.RetryPolicy);
|
||||
Assert.Same(messageLoggingPolicy, projectOptions.MessageLoggingPolicy);
|
||||
Assert.Same(clientLoggingOptions, projectOptions.ClientLoggingOptions);
|
||||
Assert.Equal(networkTimeout, projectOptions.NetworkTimeout);
|
||||
}
|
||||
|
||||
private sealed class FakeRetryPolicy : PipelinePolicy
|
||||
{
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
=> ProcessNext(message, pipeline, currentIndex);
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
=> ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
private sealed class FakeMessageLoggingPolicy : PipelinePolicy
|
||||
{
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
=> ProcessNext(message, pipeline, currentIndex);
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
=> ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
private sealed class FakePipelineTransport : PipelineTransport
|
||||
{
|
||||
protected override PipelineMessage CreateMessageCore() => throw new NotSupportedException();
|
||||
protected override void ProcessCore(PipelineMessage message) => throw new NotSupportedException();
|
||||
protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ParseAgentEndpoint tests
|
||||
@@ -762,13 +824,13 @@ public class FoundryAgentTests
|
||||
private readonly string _value;
|
||||
public HeaderStampPolicy(string name, string value) { this._name = name; this._value = value; }
|
||||
|
||||
public override void Process(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set(this._name, this._value);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set(this._name, this._value);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
|
||||
Reference in New Issue
Block a user