mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into flaky-test-report
This commit is contained in:
@@ -163,10 +163,10 @@
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Evaluation/">
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/Evaluation_ExpectedOutputs.csproj" />
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_Multimodal/Evaluation_Multimodal.csproj" />
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentWithMemory/">
|
||||
<File Path="samples/02-agents/AgentWithMemory/README.md" />
|
||||
@@ -226,6 +226,7 @@
|
||||
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj" />
|
||||
@@ -347,17 +348,17 @@
|
||||
<File Path="samples/02-agents/A2A/README.md" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/">
|
||||
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
|
||||
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/Evaluation/">
|
||||
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
|
||||
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/Evaluation_FoundryQuality.csproj" />
|
||||
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/Evaluation_MixedProviders.csproj" />
|
||||
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/A2AClientServer/">
|
||||
<File Path="samples/05-end-to-end/A2AClientServer/README.md" />
|
||||
@@ -543,8 +544,8 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Declarative/Microsoft.Agents.AI.Declarative.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
|
||||
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
|
||||
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="InvokeHttpRequest.yaml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,76 @@
|
||||
#
|
||||
# This workflow demonstrates using HttpRequestAction to call a REST API directly
|
||||
# from the workflow without going through an AI agent first.
|
||||
#
|
||||
# HttpRequestAction allows workflows to:
|
||||
# - Fetch data from external HTTP endpoints
|
||||
# - Store the parsed response in workflow variables for later use
|
||||
# - Add the response body to the conversation so a downstream agent can
|
||||
# answer questions based on it
|
||||
#
|
||||
# This sample fetches public metadata for the dotnet/runtime repository from
|
||||
# the GitHub REST API (no authentication required) and uses an agent to
|
||||
# answer follow-up questions about it.
|
||||
#
|
||||
# Example input:
|
||||
# How many subscribers does the repository have?
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_invoke_http_request_demo
|
||||
actions:
|
||||
|
||||
# Capture the original user message for input to the follow-up agent.
|
||||
- kind: SetVariable
|
||||
id: set_user_message
|
||||
variable: Local.InputMessage
|
||||
value: =System.LastMessage
|
||||
|
||||
# Set the repository org/name used to form the request URL.
|
||||
- kind: SetVariable
|
||||
id: set_repo_name
|
||||
variable: Local.RepoName
|
||||
value: microsoft/agent-framework
|
||||
|
||||
# Invoke the GitHub repo API. The response body is parsed into Local.RepoInfo
|
||||
# and also added to the conversation (via conversationId) so the agent below
|
||||
# can answer questions based on it.
|
||||
- kind: HttpRequestAction
|
||||
id: fetch_repo_info
|
||||
conversationId: =System.ConversationId
|
||||
method: GET
|
||||
url: =Concatenate("https://api.github.com/repos/", Local.RepoName)
|
||||
headers:
|
||||
Accept: application/vnd.github+json
|
||||
User-Agent: agent-framework-sample
|
||||
response: Local.RepoInfo
|
||||
|
||||
# Display a confirmation message showing key fields from the parsed response.
|
||||
- kind: SendMessage
|
||||
id: show_repo_summary
|
||||
message: "Fetched repo: visibility={Local.RepoInfo.visibility}, description={Local.RepoInfo.description}"
|
||||
|
||||
# Use the agent to summarize the repo using the conversation context.
|
||||
- kind: InvokeAzureAgent
|
||||
id: summarize_repo
|
||||
conversationId: =System.ConversationId
|
||||
agent:
|
||||
name: GitHubRepoInfoAgent
|
||||
input:
|
||||
messages: =UserMessage("Please provide a brief summary of this GitHub repository based on the data already in the conversation.")
|
||||
output:
|
||||
autoSend: true
|
||||
messages: Local.AgentResponse
|
||||
|
||||
# Allow the user to ask follow-up questions about the repo in a loop.
|
||||
- kind: InvokeAzureAgent
|
||||
id: invoke_followup
|
||||
conversationId: =System.ConversationId
|
||||
agent:
|
||||
name: GitHubRepoInfoAgent
|
||||
input:
|
||||
messages: =Local.InputMessage
|
||||
externalLoop:
|
||||
when: =Upper(System.LastMessage.Text) <> "EXIT"
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shared.Foundry;
|
||||
using Shared.Workflows;
|
||||
|
||||
namespace Demo.Workflows.Declarative.InvokeHttpRequest;
|
||||
|
||||
/// <summary>
|
||||
/// Demonstrates a workflow that uses HttpRequestAction to call a REST API
|
||||
/// directly from the workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The HttpRequestAction allows workflows to issue HTTP requests and:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Fetch data from external REST endpoints</item>
|
||||
/// <item>Store the parsed response in workflow variables</item>
|
||||
/// <item>Add the response body to the conversation so an agent can answer
|
||||
/// questions based on it</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// This sample fetches public metadata for the dotnet/runtime repository from
|
||||
/// the GitHub REST API (no authentication required) and uses a Foundry agent
|
||||
/// to answer follow-up questions about it. Type "EXIT" to end the conversation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the README.md file in the parent folder (../README.md) for detailed
|
||||
/// information about the configuration required to run this sample.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class Program
|
||||
{
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
// Initialize configuration
|
||||
IConfiguration configuration = Application.InitializeConfig();
|
||||
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
|
||||
|
||||
// Ensure sample agent exists in Foundry. The agent has no tools - it answers
|
||||
// questions about the GitHub repository using only the JSON data that the
|
||||
// HttpRequestAction adds to the conversation.
|
||||
await CreateAgentAsync(foundryEndpoint, configuration);
|
||||
|
||||
// Get input from command line or console
|
||||
string workflowInput = Application.GetInput(args);
|
||||
|
||||
// The default HttpRequestHandler is sufficient for this sample because the
|
||||
// GitHub REST endpoint used here does not require authentication. For
|
||||
// authenticated endpoints, supply a custom Func<HttpRequestInfo, ..., HttpClient?>
|
||||
// to DefaultHttpRequestHandler so each request can be routed through a
|
||||
// pre-configured (cached) HttpClient with the appropriate credentials.
|
||||
await using DefaultHttpRequestHandler httpRequestHandler = new();
|
||||
|
||||
// Create the workflow factory with the HTTP request handler
|
||||
WorkflowFactory workflowFactory = new("InvokeHttpRequest.yaml", foundryEndpoint)
|
||||
{
|
||||
HttpRequestHandler = httpRequestHandler
|
||||
};
|
||||
|
||||
// Execute the workflow
|
||||
WorkflowRunner runner = new() { UseJsonCheckpoints = true };
|
||||
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
|
||||
}
|
||||
|
||||
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration)
|
||||
{
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential());
|
||||
|
||||
await aiProjectClient.CreateAgentAsync(
|
||||
agentName: "GitHubRepoInfoAgent",
|
||||
agentDefinition: DefineAgent(configuration),
|
||||
agentDescription: "Answers questions about a GitHub repository using HTTP response data in the conversation");
|
||||
}
|
||||
|
||||
private static DeclarativeAgentDefinition DefineAgent(IConfiguration configuration)
|
||||
{
|
||||
return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel))
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
Answer the user's questions about the GitHub repository using only the
|
||||
JSON data already present in the conversation history.
|
||||
If the answer is not contained in the conversation, say so plainly
|
||||
rather than guessing. Be concise and helpful.
|
||||
"""
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
var agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
|
||||
if (agent is not null)
|
||||
{
|
||||
FoundryHostingExtensions.TryApplyUserAgent(agent);
|
||||
return FoundryHostingExtensions.ApplyOpenTelemetry(agent);
|
||||
}
|
||||
|
||||
@@ -310,12 +311,13 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
var defaultAgent = this._serviceProvider.GetService<AIAgent>();
|
||||
if (defaultAgent is not null)
|
||||
{
|
||||
FoundryHostingExtensions.TryApplyUserAgent(defaultAgent);
|
||||
return FoundryHostingExtensions.ApplyOpenTelemetry(defaultAgent);
|
||||
}
|
||||
|
||||
var errorMessage = string.IsNullOrEmpty(agentName)
|
||||
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered."
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AIAgent.";
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent) or services.AddKeyedSingleton<AIAgent>(\"{agentName}\", ...).";
|
||||
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
@@ -352,7 +354,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
|
||||
var errorMessage = string.IsNullOrEmpty(agentName)
|
||||
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered."
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AgentSessionStore.";
|
||||
: $"AgentSessionStore for agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent, agentSessionStore) or services.AddKeyedSingleton<AgentSessionStore>(\"{agentName}\", ...).";
|
||||
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ResponsesClient"/> subclass that delegates every protocol-level request to a
|
||||
/// wrapped <see cref="ResponsesClient"/>. Before each call, a
|
||||
/// <see cref="HostedAgentUserAgentPolicy"/> is added to the per-call
|
||||
/// <see cref="RequestOptions"/> so the wrapped client's pipeline appends the hosted-agent
|
||||
/// <c>User-Agent</c> segment on the wire.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The streaming overloads MEAI binds via reflection (<c>internal CreateResponseStreamingAsync(CreateResponseOptions, RequestOptions)</c>
|
||||
/// and <c>internal GetResponseStreamingAsync(GetResponseOptions, RequestOptions)</c>) bottom out
|
||||
/// in calls to the public-virtual non-streaming protocol overloads on <see langword="this"/>. Overriding those
|
||||
/// non-streaming overloads is therefore sufficient to intercept both streaming and non-streaming traffic.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The base pipeline supplied to <see cref="ResponsesClient(ClientPipeline, OpenAIClientOptions)"/>
|
||||
/// is a dummy pipeline whose terminal transport throws if invoked. Every override on this class
|
||||
/// delegates to the inner client BEFORE any code path reaches <see cref="ResponsesClient.Pipeline"/>, so the dummy is
|
||||
/// never expected to run; the throwing transport surfaces any unexpected escape route loudly.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class DelegatingResponsesClient : ResponsesClient
|
||||
{
|
||||
private readonly ResponsesClient _inner;
|
||||
|
||||
public DelegatingResponsesClient(ResponsesClient inner)
|
||||
: base(BuildDummyPipeline(), new OpenAIClientOptions { Endpoint = inner?.Endpoint })
|
||||
{
|
||||
this._inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
public override async Task<ClientResult> CreateResponseAsync(BinaryContent content, RequestOptions? options = null)
|
||||
=> await this._inner.CreateResponseAsync(content, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult CreateResponse(BinaryContent content, RequestOptions? options = null)
|
||||
=> this._inner.CreateResponse(content, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> GetResponseAsync(string responseId, IEnumerable<IncludedResponseProperty>? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options)
|
||||
=> await this._inner.GetResponseAsync(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult GetResponse(string responseId, IEnumerable<IncludedResponseProperty>? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options)
|
||||
=> this._inner.GetResponse(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> DeleteResponseAsync(string responseId, RequestOptions options)
|
||||
=> await this._inner.DeleteResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult DeleteResponse(string responseId, RequestOptions options)
|
||||
=> this._inner.DeleteResponse(responseId, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> CancelResponseAsync(string responseId, RequestOptions options)
|
||||
=> await this._inner.CancelResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult CancelResponse(string responseId, RequestOptions options)
|
||||
=> this._inner.CancelResponse(responseId, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> GetInputTokenCountAsync(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> await this._inner.GetInputTokenCountAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult GetInputTokenCount(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> this._inner.GetInputTokenCount(contentType, content, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> CompactResponseAsync(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> await this._inner.CompactResponseAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult CompactResponse(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> this._inner.CompactResponse(contentType, content, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> GetResponseInputItemCollectionPageAsync(string responseId, int? limit, string order, string after, string before, RequestOptions options)
|
||||
=> await this._inner.GetResponseInputItemCollectionPageAsync(responseId, limit, order, after, before, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult GetResponseInputItemCollectionPage(string responseId, int? limit, string order, string after, string before, RequestOptions options)
|
||||
=> this._inner.GetResponseInputItemCollectionPage(responseId, limit, order, after, before, AddUserAgentPolicy(options));
|
||||
|
||||
private static RequestOptions AddUserAgentPolicy(RequestOptions? options)
|
||||
{
|
||||
options ??= new RequestOptions();
|
||||
options.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall);
|
||||
return options;
|
||||
}
|
||||
|
||||
private static ClientPipeline BuildDummyPipeline()
|
||||
{
|
||||
var options = new ClientPipelineOptions
|
||||
{
|
||||
Transport = new ThrowingTransport(),
|
||||
};
|
||||
return ClientPipeline.Create(options, default, default, default);
|
||||
}
|
||||
|
||||
private sealed class ThrowingTransport : PipelineTransport
|
||||
{
|
||||
private const string Message =
|
||||
"DelegatingResponsesClient transport invoked bypassed the override-and-delegate design. This exception should be unreachable and should never be thrown following the correct usage of DelegatingResponsesClient.";
|
||||
|
||||
protected override PipelineMessage CreateMessageCore() => throw new InvalidOperationException(Message);
|
||||
protected override void ProcessCore(PipelineMessage message) => throw new InvalidOperationException(Message);
|
||||
protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new InvalidOperationException(Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline policy that appends the hosted-agent <c>User-Agent</c> segment
|
||||
/// (e.g. <c>"foundry-hosting/agent-framework-dotnet/{version}"</c>) to outgoing requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The supplement value is computed once from the Microsoft.Agents.AI.Foundry.Hosting
|
||||
/// assembly's informational version. The policy is idempotent on retries: if the segment
|
||||
/// is already present in the <c>User-Agent</c> header, the policy does not append it again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This policy is added at request time (per-call <see cref="PipelinePosition"/>)
|
||||
/// by <see cref="DelegatingResponsesClient"/> when invoking the wrapped
|
||||
/// <see cref="OpenAI.Responses.ResponsesClient"/>. It is only registered when an agent is
|
||||
/// resolved by the Foundry hosting layer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
public static HostedAgentUserAgentPolicy Instance { get; } = new HostedAgentUserAgentPolicy();
|
||||
|
||||
private static readonly string s_supplementValue = CreateSupplementValue();
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void AppendHeader(PipelineMessage message)
|
||||
{
|
||||
if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing))
|
||||
{
|
||||
// Guard against double-append on retries or when the policy
|
||||
// is registered on multiple pipeline positions.
|
||||
if (existing.Contains(s_supplementValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
message.Request.Headers.Set("User-Agent", $"{existing} {s_supplementValue}");
|
||||
}
|
||||
else
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", s_supplementValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateSupplementValue()
|
||||
{
|
||||
const string Name = "foundry-hosting/agent-framework-dotnet";
|
||||
|
||||
if (typeof(HostedAgentUserAgentPolicy).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,15 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -36,7 +35,7 @@ public static class FoundryHostingExtensions
|
||||
/// <para>
|
||||
/// Example:
|
||||
/// <code>
|
||||
/// builder.AddAIAgent("my-agent", ...);
|
||||
/// builder.Services.AddKeyedSingleton<AIAgent>("my-agent", myAgent);
|
||||
/// builder.Services.AddFoundryResponses();
|
||||
///
|
||||
/// var app = builder.Build();
|
||||
@@ -181,13 +180,6 @@ public static class FoundryHostingExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
endpoints.MapResponsesServer(prefix);
|
||||
|
||||
if (endpoints is IApplicationBuilder app)
|
||||
{
|
||||
// Ensure the middleware is added to the pipeline
|
||||
app.UseMiddleware<AgentFrameworkUserAgentMiddleware>();
|
||||
}
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
@@ -216,46 +208,85 @@ public static class FoundryHostingExtensions
|
||||
.Build();
|
||||
}
|
||||
|
||||
private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next)
|
||||
/// <summary>
|
||||
/// Attempts to wrap the agent's underlying <see cref="ResponsesClient"/>
|
||||
/// with a <see cref="DelegatingResponsesClient"/> so every outgoing Responses-API request
|
||||
/// carries the hosted-agent <c>User-Agent</c> segment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Best-effort and idempotent. The method is a no-op when:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><paramref name="agent"/> exposes no <see cref="IChatClient"/>;</description></item>
|
||||
/// <item><description>the chat client is not backed by MEAI's internal <c>OpenAIResponsesChatClient</c> (e.g., a non-OpenAI provider or a custom impl);</description></item>
|
||||
/// <item><description>the inner <see cref="ResponsesClient"/> is already a <see cref="DelegatingResponsesClient"/>.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Works for any <see cref="ResponsesClient"/>-derived inner client — both the Foundry-specific
|
||||
/// <see cref="Azure.AI.Extensions.OpenAI.ProjectResponsesClient"/> and the native OpenAI
|
||||
/// <see cref="ResponsesClient"/> obtained from <see cref="OpenAI.OpenAIClient"/>. The wrapper preserves
|
||||
/// the inner client's pipeline (Transport, RetryPolicy, NetworkTimeout, OrganizationId / ProjectId /
|
||||
/// UserAgentApplicationId, custom policies) because every override delegates to the inner instance.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Returns the same <paramref name="agent"/> instance unchanged. Mutation happens via
|
||||
/// reflection on MEAI's private <c>_responseClient</c> field; the agent itself is not wrapped.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static AIAgent TryApplyUserAgent(AIAgent agent)
|
||||
{
|
||||
private static readonly string s_userAgentValue = CreateUserAgentValue();
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
if (chatClient is null)
|
||||
{
|
||||
var headers = context.Request.Headers;
|
||||
var userAgent = headers.UserAgent.ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(userAgent))
|
||||
{
|
||||
headers.UserAgent = s_userAgentValue;
|
||||
}
|
||||
else if (!userAgent.Contains(s_userAgentValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
headers.UserAgent = $"{userAgent} {s_userAgentValue}";
|
||||
}
|
||||
|
||||
await next(context).ConfigureAwait(false);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private static string CreateUserAgentValue()
|
||||
var meaiType = s_meaiResponsesChatClientType;
|
||||
if (meaiType is null)
|
||||
{
|
||||
const string Name = "agent-framework-dotnet";
|
||||
|
||||
if (typeof(AgentFrameworkUserAgentMiddleware).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
return agent;
|
||||
}
|
||||
|
||||
var meaiInstance = chatClient.GetService(meaiType);
|
||||
if (meaiInstance is null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
var field = s_meaiResponseClientField;
|
||||
if (field is null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
var current = field.GetValue(meaiInstance) as ResponsesClient;
|
||||
if (current is null or DelegatingResponsesClient)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
field.SetValue(meaiInstance, new DelegatingResponsesClient(current));
|
||||
return agent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MEAI's internal <c>OpenAIResponsesChatClient</c> type, resolved once via reflection.
|
||||
/// <see langword="null"/> if the type cannot be found (e.g., MEAI version drift).
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
|
||||
Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2073:RequiresUnreferencedCode",
|
||||
Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")]
|
||||
private static readonly Type? s_meaiResponsesChatClientType =
|
||||
typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
|
||||
|
||||
/// <summary>
|
||||
/// MEAI's internal <c>_responseClient</c> field on <c>OpenAIResponsesChatClient</c>,
|
||||
/// resolved once via reflection. <see langword="null"/> if the field cannot be found.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2080:RequiresDynamicallyAccessedMembers",
|
||||
Justification = "OpenAIResponsesChatClient and its private fields are preserved by the polyfill design; MEAI does the same reflection internally.")]
|
||||
private static readonly FieldInfo? s_meaiResponseClientField =
|
||||
s_meaiResponsesChatClientType?.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -13,20 +12,6 @@ internal static class RequestOptionsExtensions
|
||||
/// <summary>Gets the singleton <see cref="PipelinePolicy"/> that adds a MEAI user-agent header.</summary>
|
||||
internal static PipelinePolicy UserAgentPolicy => MeaiUserAgentPolicy.Instance;
|
||||
|
||||
/// <summary>Creates a <see cref="RequestOptions"/> configured for use with Foundry Agents.</summary>
|
||||
public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming)
|
||||
{
|
||||
RequestOptions requestOptions = new()
|
||||
{
|
||||
CancellationToken = cancellationToken,
|
||||
BufferResponse = !streaming
|
||||
};
|
||||
|
||||
requestOptions.AddPolicy(MeaiUserAgentPolicy.Instance, PipelinePosition.PerCall);
|
||||
|
||||
return requestOptions;
|
||||
}
|
||||
|
||||
/// <summary>Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header.</summary>
|
||||
private sealed class MeaiUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
|
||||
+54
@@ -16,6 +16,60 @@ internal static class ChatMessageExtensions
|
||||
public static RecordValue ToRecord(this ChatMessage message) =>
|
||||
FormulaValue.NewRecordFromFields(message.GetMessageFields());
|
||||
|
||||
/// <summary>
|
||||
/// Merges the user-authored <paramref name="input"/> with the round-tripped
|
||||
/// <paramref name="inputMessage"/> returned by <c>AgentProvider.CreateMessageAsync</c>
|
||||
/// to produce the value stored in <c>System.LastMessage</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The agent service often strips or alters <see cref="TextContent"/> on round-trip,
|
||||
/// while replacing inline media (<see cref="DataContent"/>, <see cref="UriContent"/>)
|
||||
/// with server-side references (typically <see cref="HostedFileContent"/>).
|
||||
/// We want both: the original text (so <c>=System.LastMessage.Text</c> works) and
|
||||
/// the server's media references (so subsequent actions don't re-upload large blobs).
|
||||
/// <para>
|
||||
/// Strategy: keep <paramref name="inputMessage"/> as the base — it has the server-generated
|
||||
/// <see cref="ChatMessage.MessageId"/> and any provider-augmented metadata, and is forward-
|
||||
/// compatible with new properties added on <see cref="ChatMessage"/> in the abstractions
|
||||
/// layer. Only the <see cref="ChatMessage.Contents"/> list is mutated to substitute
|
||||
/// original <see cref="TextContent"/> items in place (and append any extras the round-trip
|
||||
/// dropped). Non-text content items returned by the service are left untouched so
|
||||
/// server-side references survive.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static ChatMessage MergeForLastMessage(this ChatMessage input, ChatMessage? inputMessage)
|
||||
{
|
||||
if (inputMessage is null)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
// Build a queue of the original text items, in order. Fall back to ChatMessage.Text
|
||||
// if the input has no explicit TextContent entries.
|
||||
Queue<TextContent> originalTexts = new(input.Contents.OfType<TextContent>());
|
||||
if (originalTexts.Count == 0 && !string.IsNullOrEmpty(input.Text))
|
||||
{
|
||||
originalTexts.Enqueue(new TextContent(input.Text));
|
||||
}
|
||||
|
||||
// Replace TextContent items in inputMessage.Contents with the originals, in order.
|
||||
for (int i = 0; i < inputMessage.Contents.Count && originalTexts.Count > 0; i++)
|
||||
{
|
||||
if (inputMessage.Contents[i] is TextContent)
|
||||
{
|
||||
inputMessage.Contents[i] = originalTexts.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
// Append any remaining original text items that the round-trip dropped entirely.
|
||||
while (originalTexts.Count > 0)
|
||||
{
|
||||
inputMessage.Contents.Add(originalTexts.Dequeue());
|
||||
}
|
||||
|
||||
return inputMessage;
|
||||
}
|
||||
|
||||
public static TableValue ToTable(this IEnumerable<ChatMessage> messages) =>
|
||||
FormulaValue.NewTable(TypeSchema.Message.RecordType, messages.Select(message => message.ToRecord()));
|
||||
|
||||
|
||||
+5
-1
@@ -43,7 +43,11 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
|
||||
await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken).ConfigureAwait(false);
|
||||
await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
|
||||
|
||||
// Use the original input for System.LastMessage to ensure Text is preserved (the
|
||||
// service may strip text on round-trip), but substitute server-side media references
|
||||
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
|
||||
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
|
||||
|
||||
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ public abstract class RootExecutor<TInput> : Executor<TInput>, IResettableExecut
|
||||
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
DeclarativeWorkflowContext declarativeContext = new(context, this._state);
|
||||
await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage input = (this._inputTransform ?? DefaultInputTransform).Invoke(message);
|
||||
|
||||
@@ -69,7 +68,13 @@ public abstract class RootExecutor<TInput> : Executor<TInput>, IResettableExecut
|
||||
await declarativeContext.QueueConversationUpdateAsync(this._conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage inputMessage = await this._agentProvider.CreateMessageAsync(this._conversationId, input, cancellationToken).ConfigureAwait(false);
|
||||
await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
|
||||
|
||||
// Use the original input for System.LastMessage to ensure Text is preserved (the
|
||||
// service may strip text on round-trip), but substitute server-side media references
|
||||
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
|
||||
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
|
||||
|
||||
await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await declarativeContext.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
|
||||
// Assign to provide MCP tool capabilities
|
||||
public IMcpToolHandler? McpToolHandler { get; init; }
|
||||
|
||||
// Assign to enable HttpRequestAction support
|
||||
public IHttpRequestHandler? HttpRequestHandler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Create the workflow from the declarative YAML. Includes definition of the
|
||||
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="ResponseAgentProvider"/>.
|
||||
@@ -46,6 +49,7 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
|
||||
ConversationId = this.ConversationId,
|
||||
LoggerFactory = this.LoggerFactory,
|
||||
McpToolHandler = this.McpToolHandler,
|
||||
HttpRequestHandler = this.HttpRequestHandler,
|
||||
};
|
||||
|
||||
string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile);
|
||||
|
||||
@@ -162,7 +162,10 @@ internal sealed class WorkflowRunner
|
||||
|
||||
case RequestInfoEvent requestInfo:
|
||||
Debug.WriteLine($"REQUEST #{requestInfo.Request.RequestId}");
|
||||
externalResponse = requestInfo.Request;
|
||||
if (response is null || !string.Equals(requestInfo.Request.RequestId, response.RequestId, StringComparison.Ordinal))
|
||||
{
|
||||
externalResponse = requestInfo.Request;
|
||||
}
|
||||
break;
|
||||
|
||||
case ConversationUpdateEvent invokeEvent:
|
||||
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="DelegatingResponsesClient"/> preserves user-supplied client options
|
||||
/// (Transport, RetryPolicy, UserAgentApplicationId, OrganizationId, ProjectId) and adds the
|
||||
/// hosted-agent User-Agent supplement on every outgoing request, including streaming.
|
||||
/// Covers both the Azure-flavored <see cref="ProjectResponsesClient"/> and the native OpenAI
|
||||
/// <see cref="ResponsesClient"/>.
|
||||
/// </summary>
|
||||
public sealed partial class DelegatingResponsesClientTests
|
||||
{
|
||||
private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj";
|
||||
private const string OpenAIEndpoint = "https://fake-openai.example.com/v1";
|
||||
private const string Deployment = "fake-deployment";
|
||||
|
||||
[System.Text.RegularExpressions.GeneratedRegex("foundry-hosting/agent-framework-dotnet")]
|
||||
private static partial System.Text.RegularExpressions.Regex SupplementRegex();
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_NonStreaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
Assert.StartsWith(TestEndpoint, req.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_Streaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalSseResponse());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
await foreach (var _ in chat.GetStreamingResponseAsync("hello"))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
Assert.StartsWith(TestEndpoint, req.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_PreservesOrganizationAndProjectHeadersAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient,
|
||||
userAgentApplicationId: "MY_APP_ID",
|
||||
organizationId: "org_xyz",
|
||||
projectId: "proj_abc");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_HonorsUserSuppliedRetryPolicy_ByCountingRetriesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var retryPolicy = new CountingRetryPolicy(extraAttempts: 2);
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy);
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert: retry policy ran (1 + 2 extras = 3 attempts).
|
||||
Assert.Equal(3, handler.Requests.Count);
|
||||
Assert.Equal(3, retryPolicy.InvocationCount);
|
||||
foreach (var req in handler.Requests)
|
||||
{
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Baseline_NonStreaming_DoesNotInjectSupplementAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = inner.AsIChatClient(Deployment);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_NativeOpenAIResponsesClient_NonStreaming_AddsSupplementAsync()
|
||||
{
|
||||
// Arrange: use the NATIVE OpenAI SDK ResponsesClient (no Foundry / Azure project involved).
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
Assert.StartsWith(OpenAIEndpoint, req.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_NativeOpenAIResponsesClient_Streaming_AddsSupplementAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalSseResponse());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
await foreach (var _ in chat.GetStreamingResponseAsync("hello"))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
Assert.StartsWith(OpenAIEndpoint, req.Uri);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DeleteResponseAsync")]
|
||||
[InlineData("CancelResponseAsync")]
|
||||
[InlineData("GetInputTokenCountAsync")]
|
||||
[InlineData("CompactResponseAsync")]
|
||||
[InlineData("GetResponseInputItemCollectionPageAsync")]
|
||||
public async Task Polyfill_AncillaryProtocolMethod_AddsSupplementAsync(string method)
|
||||
{
|
||||
// Arrange: hit the wrapper DIRECTLY (no MEAI in the chain) to simulate user code that
|
||||
// grabs the underlying ResponsesClient via chat.GetService<ResponsesClient>() and invokes
|
||||
// a non-Create/Get protocol method. This is the regression path: without overriding these,
|
||||
// the wrapper's dummy throwing pipeline would fire.
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var wrapper = new DelegatingResponsesClient(inner);
|
||||
|
||||
// Act
|
||||
switch (method)
|
||||
{
|
||||
case "DeleteResponseAsync":
|
||||
_ = await wrapper.DeleteResponseAsync("resp_1", options: null!);
|
||||
break;
|
||||
case "CancelResponseAsync":
|
||||
_ = await wrapper.CancelResponseAsync("resp_1", options: null!);
|
||||
break;
|
||||
case "GetInputTokenCountAsync":
|
||||
_ = await wrapper.GetInputTokenCountAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}")));
|
||||
break;
|
||||
case "CompactResponseAsync":
|
||||
_ = await wrapper.CompactResponseAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}")));
|
||||
break;
|
||||
case "GetResponseInputItemCollectionPageAsync":
|
||||
_ = await wrapper.GetResponseInputItemCollectionPageAsync("resp_1", limit: null, order: "asc", after: "a", before: "b", options: null!);
|
||||
break;
|
||||
default:
|
||||
Assert.Fail($"Unhandled method: {method}");
|
||||
break;
|
||||
}
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_RetryWithinCall_DoesNotDuplicateSupplementInUserAgentAsync()
|
||||
{
|
||||
// Arrange: a custom retry policy that re-runs the inner pipeline on the SAME message,
|
||||
// so the per-call HostedAgentUserAgentPolicy fires multiple times against the same headers.
|
||||
// The policy's Contains-guard must prevent the supplement from appearing twice.
|
||||
var retryPolicy = new CountingRetryPolicy(extraAttempts: 2);
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy);
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert: each retry attempt must have exactly ONE foundry-hosting segment, never two.
|
||||
Assert.Equal(3, handler.Requests.Count);
|
||||
foreach (var req in handler.Requests)
|
||||
{
|
||||
int matches = SupplementRegex().Matches(req.UserAgent).Count;
|
||||
Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment per retry attempt, got {matches}. UA: {req.UserAgent}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryApplyUserAgent_CalledTwiceOnSameAgent_DoesNotDoubleWrapAsync()
|
||||
{
|
||||
// Arrange: build a real ChatClientAgent whose IChatClient resolves to MEAI's
|
||||
// OpenAIResponsesChatClient → ProjectResponsesClient (with a fake transport).
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
IChatClient chatClient = inner.AsIChatClient(Deployment);
|
||||
AIAgent agent = new ChatClientAgent(chatClient);
|
||||
|
||||
// Act: apply twice.
|
||||
FoundryHostingExtensions.TryApplyUserAgent(agent);
|
||||
FoundryHostingExtensions.TryApplyUserAgent(agent);
|
||||
|
||||
// Assert: invoking the agent produces exactly ONE outbound request whose UA contains
|
||||
// the supplement EXACTLY ONCE (would be twice if the wrapper were nested).
|
||||
_ = await chatClient.GetResponseAsync("hello");
|
||||
var req = Assert.Single(handler.Requests);
|
||||
int matches = SupplementRegex().Matches(req.UserAgent).Count;
|
||||
Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment, got {matches}. UA: {req.UserAgent}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenAIResponsesChatClient_ResponseClientField_ReflectionGuard()
|
||||
{
|
||||
// Guards the polyfill's reflection target. Failure here means MEAI internals
|
||||
// changed and the polyfill needs updating.
|
||||
var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly
|
||||
.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
|
||||
Assert.NotNull(meaiType);
|
||||
|
||||
var field = meaiType!.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(field);
|
||||
Assert.True(typeof(ResponsesClient).IsAssignableFrom(field!.FieldType),
|
||||
$"Expected _responseClient to be assignable to ResponsesClient but was {field.FieldType}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponsesClient_PipelineProperty_ReflectionGuard()
|
||||
{
|
||||
// The polyfill design assumes ResponsesClient.Pipeline remains accessible.
|
||||
var pipelineProp = typeof(ResponsesClient).GetProperty("Pipeline", BindingFlags.Public | BindingFlags.Instance);
|
||||
Assert.NotNull(pipelineProp);
|
||||
Assert.Equal(typeof(ClientPipeline), pipelineProp!.PropertyType);
|
||||
}
|
||||
|
||||
private static IChatClient MakeWithDelegating(ResponsesClient inner)
|
||||
{
|
||||
IChatClient meai = inner.AsIChatClient(Deployment);
|
||||
var meaiType = meai.GetType();
|
||||
var field = meaiType.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
field.SetValue(meai, new DelegatingResponsesClient(inner));
|
||||
return meai;
|
||||
}
|
||||
|
||||
private static ProjectResponsesClient BuildInner(
|
||||
HttpClient httpClient,
|
||||
string? userAgentApplicationId = null,
|
||||
string? organizationId = null,
|
||||
string? projectId = null,
|
||||
PipelinePolicy? retryPolicy = null)
|
||||
{
|
||||
var options = new ProjectResponsesClientOptions
|
||||
{
|
||||
Transport = new HttpClientPipelineTransport(httpClient),
|
||||
};
|
||||
if (userAgentApplicationId is not null)
|
||||
{
|
||||
options.UserAgentApplicationId = userAgentApplicationId;
|
||||
}
|
||||
if (organizationId is not null)
|
||||
{
|
||||
options.OrganizationId = organizationId;
|
||||
}
|
||||
if (projectId is not null)
|
||||
{
|
||||
options.ProjectId = projectId;
|
||||
}
|
||||
if (retryPolicy is not null)
|
||||
{
|
||||
options.RetryPolicy = retryPolicy;
|
||||
}
|
||||
|
||||
return new ProjectResponsesClient(new Uri(TestEndpoint), new FakeAuthenticationTokenProvider(), options);
|
||||
}
|
||||
|
||||
private static ResponsesClient BuildOpenAIInner(
|
||||
HttpClient httpClient,
|
||||
string? userAgentApplicationId = null)
|
||||
{
|
||||
var options = new OpenAIClientOptions
|
||||
{
|
||||
Transport = new HttpClientPipelineTransport(httpClient),
|
||||
Endpoint = new Uri(OpenAIEndpoint),
|
||||
};
|
||||
if (userAgentApplicationId is not null)
|
||||
{
|
||||
options.UserAgentApplicationId = userAgentApplicationId;
|
||||
}
|
||||
|
||||
return new ResponsesClient(new ApiKeyCredential("test-key"), options);
|
||||
}
|
||||
|
||||
private static string MinimalResponseJson() => """
|
||||
{
|
||||
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
|
||||
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
|
||||
}
|
||||
""";
|
||||
|
||||
private static string MinimalSseResponse()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("event: response.completed\n");
|
||||
sb.Append("data: ").Append("""{"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":1700000000,"status":"completed","model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}""").Append("\n\n");
|
||||
sb.Append("data: [DONE]\n\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
private readonly string _body;
|
||||
public List<RecordedRequest> Requests { get; } = [];
|
||||
|
||||
public RecordingHandler(string body)
|
||||
{
|
||||
this._body = body;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
string ua = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: "(none)";
|
||||
this.Requests.Add(new RecordedRequest(request.Method.Method, request.RequestUri?.ToString() ?? "?", ua));
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct RecordedRequest(string Method, string Uri, string UserAgent);
|
||||
|
||||
private sealed class CountingRetryPolicy : PipelinePolicy
|
||||
{
|
||||
private readonly int _extraAttempts;
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
public CountingRetryPolicy(int extraAttempts)
|
||||
{
|
||||
this._extraAttempts = extraAttempts;
|
||||
}
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
for (int i = 0; i <= this._extraAttempts; i++)
|
||||
{
|
||||
this.InvocationCount++;
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
for (int i = 0; i <= this._extraAttempts; i++)
|
||||
{
|
||||
this.InvocationCount++;
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests that exercise the FULL hosted ASP.NET Core pipeline:
|
||||
/// inbound HTTP → MapFoundryResponses → AgentFrameworkResponseHandler → TryApplyUserAgent →
|
||||
/// agent invocation → outbound HTTP from inside the hosted environment.
|
||||
/// Verifies that the hosted-agent <c>User-Agent</c> supplement reaches the outbound wire,
|
||||
/// not just the inbound request.
|
||||
/// </summary>
|
||||
public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
|
||||
{
|
||||
private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj";
|
||||
private const string Deployment = "fake-deployment";
|
||||
|
||||
private WebApplication? _app;
|
||||
private HttpClient? _inboundClient;
|
||||
private RecordingHandler? _outboundHandler;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
this._inboundClient?.Dispose();
|
||||
this._outboundHandler?.Dispose();
|
||||
if (this._app is not null)
|
||||
{
|
||||
await this._app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hosted_InboundResponsesRequest_TriggersOutboundCall_WithFoundryHostingSupplementAsync()
|
||||
{
|
||||
// Arrange: spin up a real ASP.NET Core TestServer that hosts an AIAgent backed by MEAI's
|
||||
// OpenAIResponsesChatClient → ProjectResponsesClient → fake HTTP transport. This is the
|
||||
// exact production stack minus the network: the only thing not real is the wire transport.
|
||||
await this.StartHostedServerAsync();
|
||||
|
||||
// Act: send an inbound /openai/v1/responses request as the Foundry runtime would.
|
||||
using var inboundRequest = new HttpRequestMessage(HttpMethod.Post, "/responses")
|
||||
{
|
||||
Content = new StringContent(InboundResponsesRequestJson(), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
using var inboundResponse = await this._inboundClient!.SendAsync(inboundRequest);
|
||||
var inboundBody = await inboundResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert: at least one OUTBOUND request reached the fake transport, AND it carries the
|
||||
// foundry-hosting/agent-framework-dotnet/{version} supplement on its User-Agent.
|
||||
// (We don't care about the inbound response shape — only that the agent's call to MEAI
|
||||
// triggered an outbound request whose UA reaches the sandbox boundary correctly.)
|
||||
Assert.True(this._outboundHandler!.Requests.Count > 0,
|
||||
$"Expected at least one outbound request. Inbound status: {(int)inboundResponse.StatusCode}, body: {inboundBody}");
|
||||
var outbound = this._outboundHandler.Requests[0];
|
||||
Assert.StartsWith(TestEndpoint, outbound.Uri);
|
||||
Assert.Contains("MEAI/", outbound.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", outbound.UserAgent);
|
||||
}
|
||||
|
||||
private async Task StartHostedServerAsync()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
// Build a real ChatClientAgent whose IChatClient is MEAI's OpenAIResponsesChatClient
|
||||
// wrapping a ProjectResponsesClient backed by a fake HTTP handler. After AgentFrameworkResponseHandler
|
||||
// resolves this agent, TryApplyUserAgent will swap the inner _responseClient with our wrapper.
|
||||
this._outboundHandler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
var outboundHttpClient = new HttpClient(this._outboundHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var projectOptions = new ProjectResponsesClientOptions
|
||||
{
|
||||
Transport = new HttpClientPipelineTransport(outboundHttpClient),
|
||||
};
|
||||
var projectResponsesClient = new ProjectResponsesClient(
|
||||
new Uri(TestEndpoint),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
projectOptions);
|
||||
|
||||
IChatClient chatClient = projectResponsesClient.AsIChatClient(Deployment);
|
||||
AIAgent agent = new ChatClientAgent(chatClient);
|
||||
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddLogging();
|
||||
|
||||
this._app = builder.Build();
|
||||
this._app.MapFoundryResponses();
|
||||
|
||||
await this._app.StartAsync();
|
||||
|
||||
var testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found");
|
||||
|
||||
this._inboundClient = testServer.CreateClient();
|
||||
}
|
||||
|
||||
private static string InboundResponsesRequestJson() => """
|
||||
{
|
||||
"model": "fake-deployment",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "user",
|
||||
"content": [{ "type": "input_text", "text": "Hello" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private static string MinimalResponseJson() => """
|
||||
{
|
||||
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
|
||||
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
|
||||
}
|
||||
""";
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
private readonly string _body;
|
||||
public List<RecordedRequest> Requests { get; } = [];
|
||||
|
||||
public RecordingHandler(string body)
|
||||
{
|
||||
this._body = body;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
string ua = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: "(none)";
|
||||
this.Requests.Add(new RecordedRequest(request.RequestUri?.ToString() ?? "?", ua));
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct RecordedRequest(string Uri, string UserAgent);
|
||||
}
|
||||
+43
@@ -4,8 +4,10 @@ using System;
|
||||
using System.Linq;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
|
||||
@@ -93,4 +95,45 @@ public class ServiceCollectionExtensionsTests
|
||||
|
||||
Assert.Same(instrumented, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyUserAgent_AgentWithoutChatClient_NoOp()
|
||||
{
|
||||
// Arrange: agent.GetService<IChatClient>() returns null.
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
|
||||
// Act
|
||||
var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockAgent.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyUserAgent_AgentWithNonMeaiChatClient_NoOp()
|
||||
{
|
||||
// Arrange: chat client that does not return MEAI's OpenAIResponsesChatClient via GetService.
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
mockChatClient.Setup(c => c.GetService(It.IsAny<Type>(), It.IsAny<object?>())).Returns(null!);
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.Setup(a => a.GetService(typeof(IChatClient), It.IsAny<object?>())).Returns(mockChatClient.Object);
|
||||
|
||||
// Act
|
||||
var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockAgent.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeaiOpenAIResponsesChatClient_TypeFullName_ReflectionGuard()
|
||||
{
|
||||
// Guards the polyfill's reflection target type-name.
|
||||
var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly
|
||||
.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
|
||||
Assert.NotNull(meaiType);
|
||||
Assert.True(typeof(IChatClient).IsAssignableFrom(meaiType!),
|
||||
$"Expected MEAI {meaiType!.FullName} to implement IChatClient.");
|
||||
}
|
||||
}
|
||||
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>AgentFrameworkUserAgentMiddleware</c> registered by
|
||||
/// <see cref="FoundryHostingExtensions.MapFoundryResponses"/>.
|
||||
/// </summary>
|
||||
public sealed partial class UserAgentMiddlewareTests : IAsyncDisposable
|
||||
{
|
||||
private const string VersionedUserAgentPattern = @"agent-framework-dotnet/\d+\.\d+\.\d+(-[\w.]+)?";
|
||||
|
||||
private WebApplication? _app;
|
||||
private HttpClient? _httpClient;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
this._httpClient?.Dispose();
|
||||
if (this._app != null)
|
||||
{
|
||||
await this._app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_NoUserAgentHeader_SetsAgentFrameworkUserAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
await this.CreateTestServerAsync();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
|
||||
// Act
|
||||
var response = await this._httpClient!.SendAsync(request);
|
||||
var userAgent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Matches(VersionedUserAgentPattern, userAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_WithExistingUserAgent_AppendsAgentFrameworkUserAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
await this.CreateTestServerAsync();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "MyApp/1.0");
|
||||
|
||||
// Act
|
||||
var response = await this._httpClient!.SendAsync(request);
|
||||
var userAgent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
Assert.StartsWith("MyApp/1.0", userAgent);
|
||||
Assert.Matches(VersionedUserAgentPattern, userAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_AlreadyContainsUserAgent_DoesNotDuplicateAsync()
|
||||
{
|
||||
// Arrange
|
||||
await this.CreateTestServerAsync();
|
||||
|
||||
// First request to capture the actual middleware-generated value
|
||||
using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
var firstResponse = await this._httpClient!.SendAsync(firstRequest);
|
||||
var middlewareValue = await firstResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Act: send a second request that already contains the middleware value
|
||||
using var secondRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
secondRequest.Headers.TryAddWithoutValidation("User-Agent", $"MyApp/2.0 {middlewareValue}");
|
||||
var secondResponse = await this._httpClient!.SendAsync(secondRequest);
|
||||
var userAgent = await secondResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert: should remain unchanged (no duplication)
|
||||
Assert.Equal($"MyApp/2.0 {middlewareValue}", userAgent);
|
||||
Assert.Single(VersionedUserAgentRegex().Matches(userAgent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_UserAgentValue_ContainsVersionAsync()
|
||||
{
|
||||
// Arrange
|
||||
await this.CreateTestServerAsync();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
|
||||
// Act
|
||||
var response = await this._httpClient!.SendAsync(request);
|
||||
var userAgent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert: should match "agent-framework-dotnet/x.y.z" pattern
|
||||
Assert.Matches(VersionedUserAgentPattern, userAgent);
|
||||
}
|
||||
|
||||
private async Task CreateTestServerAsync()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
builder.Services.AddFoundryResponses(mockAgent.Object);
|
||||
|
||||
this._app = builder.Build();
|
||||
this._app.MapFoundryResponses();
|
||||
|
||||
// Test endpoint that echoes the User-Agent header after middleware processing
|
||||
this._app.MapGet("/test-ua", (HttpContext ctx) =>
|
||||
Results.Text(ctx.Request.Headers.UserAgent.ToString()));
|
||||
|
||||
await this._app.StartAsync();
|
||||
|
||||
var testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found");
|
||||
|
||||
this._httpClient = testServer.CreateClient();
|
||||
}
|
||||
|
||||
[GeneratedRegex(VersionedUserAgentPattern)]
|
||||
private static partial Regex VersionedUserAgentRegex();
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the per-call <c>MeaiUserAgentPolicy</c> exposed via
|
||||
/// <see cref="RequestOptionsExtensions.UserAgentPolicy"/>. The policy is reachable through the
|
||||
/// public <see cref="FoundryAgent"/> constructors (which add it to the internally-built
|
||||
/// <see cref="Azure.AI.Projects.AIProjectClient"/>'s pipeline), so its behavior is part of the
|
||||
/// public API surface.
|
||||
/// </summary>
|
||||
public sealed class RequestOptionsExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MeaiUserAgentPolicy_AddsMeaiSegment_ToOutgoingRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new System.Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, handler.Count);
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("MEAI/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MeaiUserAgentPolicy_DoesNotAddFoundryHostingSegmentAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new System.Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: the policy is MEAI-only; the foundry-hosting supplement is added elsewhere
|
||||
// (by the polyfill DelegatingResponsesClient → HostedAgentUserAgentPolicy).
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserAgentPolicy_ExposesSingletonInstance()
|
||||
{
|
||||
// Two reads of the static property must return the same instance — the policy is stateless and shared.
|
||||
var first = RequestOptionsExtensions.UserAgentPolicy;
|
||||
var second = RequestOptionsExtensions.UserAgentPolicy;
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeaiUserAgentPolicy_ValueIncludesAFFoundryAssemblyVersion_ReflectionGuard()
|
||||
{
|
||||
// The policy emits "MEAI/{Microsoft.Agents.AI.Foundry assembly InformationalVersion}".
|
||||
// If the assembly metadata stops being readable, the policy falls back to "MEAI" without a version,
|
||||
// which is a measurable telemetry regression.
|
||||
var attr = typeof(RequestOptionsExtensions).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
Assert.NotNull(attr);
|
||||
Assert.False(string.IsNullOrEmpty(attr!.InformationalVersion));
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
public int Count { get; private set; }
|
||||
public string? LastUserAgent { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.Count++;
|
||||
this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: null;
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
@@ -769,4 +769,165 @@ public sealed class ChatMessageExtensionsTests
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageReturnsInputWhenInputMessageIsNull()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage input = new(ChatRole.User, "hello") { MessageId = "local" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(null);
|
||||
|
||||
// Assert
|
||||
Assert.Same(input, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageReturnsSameInstanceAsRoundTripped()
|
||||
{
|
||||
// Arrange: returning the round-tripped instance keeps the merge forward-compatible
|
||||
// with future ChatMessage properties (e.g., new metadata fields) without explicit copies.
|
||||
ChatMessage input = new(ChatRole.User, "original");
|
||||
ChatMessage roundTripped = new(ChatRole.User, "stripped") { MessageId = "server" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert
|
||||
Assert.Same(roundTripped, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessagePrefersOriginalTextOverRoundTrippedText()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage input = new(ChatRole.User, "original text");
|
||||
ChatMessage roundTripped = new(ChatRole.User, "stripped") { MessageId = "server-id" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("server-id", result.MessageId);
|
||||
Assert.Equal("original text", result.Text);
|
||||
TextContent text = Assert.IsType<TextContent>(Assert.Single(result.Contents));
|
||||
Assert.Equal("original text", text.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageReplacesTextInPlaceAndKeepsServerMedia()
|
||||
{
|
||||
// Arrange
|
||||
HostedFileContent serverRef = new("file-abc");
|
||||
ChatMessage input = new(ChatRole.User, [new TextContent("look at this:"), new DataContent("data:image/jpeg;base64,QUJD", "image/jpeg")]);
|
||||
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped"), serverRef]) { MessageId = "server-id" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert: server's text slot is replaced with original text; server's media reference is preserved.
|
||||
Assert.Equal("server-id", result.MessageId);
|
||||
Assert.Collection(result.Contents,
|
||||
c => Assert.Equal("look at this:", Assert.IsType<TextContent>(c).Text),
|
||||
c => Assert.Same(serverRef, c));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageAppendsOriginalTextWhenRoundTripHasNoTextSlot()
|
||||
{
|
||||
// Arrange: round-tripped message has only media (no text slot to replace).
|
||||
HostedFileContent serverRef = new("file-1");
|
||||
ChatMessage input = new(ChatRole.User, [new TextContent("middle"), new DataContent("data:image/jpeg;base64,QUE=", "image/jpeg")]);
|
||||
ChatMessage roundTripped = new(ChatRole.User, [serverRef]) { MessageId = "id" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert: media kept; original text appended at end.
|
||||
Assert.Collection(result.Contents,
|
||||
c => Assert.Same(serverRef, c),
|
||||
c => Assert.Equal("middle", Assert.IsType<TextContent>(c).Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageReplacesMultipleTextSlotsInOrder()
|
||||
{
|
||||
// Arrange: input has two text items; round-tripped has two text slots interleaved with media.
|
||||
HostedFileContent firstRef = new("file-1");
|
||||
HostedFileContent secondRef = new("file-2");
|
||||
ChatMessage input = new(ChatRole.User, [new TextContent("first"), new TextContent("second")]);
|
||||
ChatMessage roundTripped = new(ChatRole.User, [firstRef, new TextContent("a"), secondRef, new TextContent("b")]) { MessageId = "id" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert
|
||||
Assert.Collection(result.Contents,
|
||||
c => Assert.Same(firstRef, c),
|
||||
c => Assert.Equal("first", Assert.IsType<TextContent>(c).Text),
|
||||
c => Assert.Same(secondRef, c),
|
||||
c => Assert.Equal("second", Assert.IsType<TextContent>(c).Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageFallsBackToInputTextWhenInputHasNoTextContent()
|
||||
{
|
||||
// Arrange: ChatMessage(role, "string") populates Text but no explicit TextContent
|
||||
// when Contents is initially empty in some construction paths. Verify we still
|
||||
// recover the original Text via input.Text.
|
||||
ChatMessage input = new(ChatRole.User, "fallback text");
|
||||
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped")]) { MessageId = "id" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("fallback text", Assert.IsType<TextContent>(Assert.Single(result.Contents)).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessagePreservesServerAuthoredProperties()
|
||||
{
|
||||
// Arrange: server (round-trip) is authoritative for metadata. Returning the
|
||||
// round-tripped instance means any future ChatMessage property is automatically
|
||||
// preserved without code changes here.
|
||||
ChatMessage input = new(ChatRole.User, "hi")
|
||||
{
|
||||
AuthorName = "client-side",
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary { ["client"] = "value" },
|
||||
};
|
||||
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped")])
|
||||
{
|
||||
MessageId = "server",
|
||||
AuthorName = "server-side",
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary { ["server"] = "value" },
|
||||
};
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("server", result.MessageId);
|
||||
Assert.Equal("server-side", result.AuthorName);
|
||||
Assert.NotNull(result.AdditionalProperties);
|
||||
Assert.True(result.AdditionalProperties.ContainsKey("server"));
|
||||
Assert.False(result.AdditionalProperties.ContainsKey("client"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeForLastMessageHandlesEmptyInputContents()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage input = new(ChatRole.User, new List<AIContent>());
|
||||
HostedFileContent serverRef = new("file-only");
|
||||
ChatMessage roundTripped = new(ChatRole.User, [serverRef]) { MessageId = "id" };
|
||||
|
||||
// Act
|
||||
ChatMessage result = input.MergeForLastMessage(roundTripped);
|
||||
|
||||
// Assert: nothing to splice; round-tripped returned unchanged.
|
||||
Assert.Same(roundTripped, result);
|
||||
Assert.Equal("file-only", Assert.IsType<HostedFileContent>(Assert.Single(result.Contents)).FileId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,6 +872,8 @@ class RawAnthropicClient(
|
||||
tool_mode = validate_tool_mode(options.get("tool_choice"))
|
||||
if tool_mode is None:
|
||||
return result or None
|
||||
if "allowed_tools" in tool_mode:
|
||||
logger.warning("allowed_tools is not supported by Anthropic; the setting will be ignored")
|
||||
allow_multiple = options.get("allow_multiple_tool_calls")
|
||||
match tool_mode.get("mode"):
|
||||
case "auto":
|
||||
|
||||
@@ -405,6 +405,8 @@ class BedrockChatClient(
|
||||
|
||||
tool_config = self._prepare_tools(options.get("tools"))
|
||||
if tool_mode := validate_tool_mode(options.get("tool_choice")):
|
||||
if "allowed_tools" in tool_mode:
|
||||
logger.warning("allowed_tools is not supported by Bedrock; the setting will be ignored")
|
||||
match tool_mode.get("mode"):
|
||||
case "none":
|
||||
# Bedrock doesn't support toolChoice "none".
|
||||
|
||||
@@ -3246,10 +3246,12 @@ class ToolMode(TypedDict, total=False):
|
||||
Fields:
|
||||
mode: One of "auto", "required", or "none".
|
||||
required_function_name: Optional function name when `mode == "required"`.
|
||||
allowed_tools: Optional list of tool names when `mode` is `"auto"` or `"required"`.
|
||||
"""
|
||||
|
||||
mode: Literal["auto", "required", "none"]
|
||||
required_function_name: str
|
||||
allowed_tools: list[str]
|
||||
|
||||
|
||||
# region TypedDict-based Chat Options
|
||||
@@ -3482,7 +3484,7 @@ def validate_tool_mode(
|
||||
|
||||
Returns:
|
||||
A ToolMode dict (contains keys: "mode", and optionally
|
||||
"required_function_name"), or ``None`` when not provided.
|
||||
"required_function_name" or "allowed_tools"), or ``None`` when not provided.
|
||||
|
||||
Raises:
|
||||
ContentError: If the tool_choice string is invalid.
|
||||
@@ -3499,6 +3501,17 @@ def validate_tool_mode(
|
||||
raise ContentError(f"Invalid tool choice: {tool_choice['mode']}")
|
||||
if tool_choice["mode"] != "required" and "required_function_name" in tool_choice:
|
||||
raise ContentError("tool_choice with mode other than 'required' cannot have 'required_function_name'")
|
||||
if tool_choice["mode"] not in ("auto", "required") and "allowed_tools" in tool_choice:
|
||||
raise ContentError("tool_choice 'allowed_tools' is only valid when mode is 'auto' or 'required'")
|
||||
if "allowed_tools" in tool_choice:
|
||||
allowed_tools = tool_choice["allowed_tools"]
|
||||
if isinstance(allowed_tools, str) or not isinstance(allowed_tools, Sequence):
|
||||
raise ContentError("tool_choice 'allowed_tools' must be a non-string sequence of strings")
|
||||
if not all(isinstance(tool_name, str) for tool_name in allowed_tools):
|
||||
raise ContentError("tool_choice 'allowed_tools' must contain only strings")
|
||||
normalized_tool_choice = dict(tool_choice)
|
||||
normalized_tool_choice["allowed_tools"] = list(allowed_tools)
|
||||
return cast(ToolMode, normalized_tool_choice)
|
||||
return tool_choice
|
||||
|
||||
|
||||
|
||||
@@ -1087,16 +1087,20 @@ def test_chat_tool_mode():
|
||||
required_any: ToolMode = {"mode": "required"}
|
||||
required_mode: ToolMode = {"mode": "required", "required_function_name": "example_function"}
|
||||
none_mode: ToolMode = {"mode": "none"}
|
||||
allowed_mode: ToolMode = {"mode": "auto", "allowed_tools": ["get_weather", "search_docs"]}
|
||||
|
||||
# Check the type and content
|
||||
assert auto_mode["mode"] == "auto"
|
||||
assert "required_function_name" not in auto_mode
|
||||
assert "allowed_tools" not in auto_mode
|
||||
assert required_any["mode"] == "required"
|
||||
assert "required_function_name" not in required_any
|
||||
assert required_mode["mode"] == "required"
|
||||
assert required_mode["required_function_name"] == "example_function"
|
||||
assert none_mode["mode"] == "none"
|
||||
assert "required_function_name" not in none_mode
|
||||
assert allowed_mode["mode"] == "auto"
|
||||
assert allowed_mode["allowed_tools"] == ["get_weather", "search_docs"]
|
||||
|
||||
# equality of dicts
|
||||
assert {"mode": "required", "required_function_name": "example_function"} == {
|
||||
@@ -1154,6 +1158,45 @@ def test_chat_options_tool_choice_validation():
|
||||
with raises(ContentError):
|
||||
validate_tool_mode({"mode": "auto", "required_function_name": "should_not_be_here"})
|
||||
|
||||
# Valid allowed_tools
|
||||
assert validate_tool_mode({"mode": "auto", "allowed_tools": ["get_weather"]}) == {
|
||||
"mode": "auto",
|
||||
"allowed_tools": ["get_weather"],
|
||||
}
|
||||
assert validate_tool_mode({"mode": "auto", "allowed_tools": ["get_weather", "search_docs"]}) == {
|
||||
"mode": "auto",
|
||||
"allowed_tools": ["get_weather", "search_docs"],
|
||||
}
|
||||
|
||||
# allowed_tools valid with required mode
|
||||
assert validate_tool_mode({"mode": "required", "allowed_tools": ["get_weather"]}) == {
|
||||
"mode": "required",
|
||||
"allowed_tools": ["get_weather"],
|
||||
}
|
||||
|
||||
# allowed_tools invalid with none mode
|
||||
with raises(ContentError):
|
||||
validate_tool_mode({"mode": "none", "allowed_tools": ["get_weather"]})
|
||||
|
||||
# allowed_tools must be a non-string sequence of strings
|
||||
with raises(ContentError):
|
||||
validate_tool_mode({"mode": "auto", "allowed_tools": "get_weather"})
|
||||
with raises(ContentError):
|
||||
validate_tool_mode({"mode": "auto", "allowed_tools": 123})
|
||||
with raises(ContentError):
|
||||
validate_tool_mode({"mode": "auto", "allowed_tools": ["get_weather", 123]})
|
||||
|
||||
# Empty list is valid (caller explicitly allows no tools)
|
||||
assert validate_tool_mode({"mode": "auto", "allowed_tools": []}) == {
|
||||
"mode": "auto",
|
||||
"allowed_tools": [],
|
||||
}
|
||||
|
||||
# Tuple is normalized to list
|
||||
result = validate_tool_mode({"mode": "auto", "allowed_tools": ("get_weather",)})
|
||||
assert result is not None
|
||||
assert result["allowed_tools"] == ["get_weather"]
|
||||
|
||||
|
||||
def test_chat_options_merge(tool_tool, ai_tool) -> None:
|
||||
"""Test merge_chat_options utility function."""
|
||||
|
||||
@@ -823,19 +823,28 @@ class RawGeminiChatClient(
|
||||
|
||||
match tool_mode.get("mode"):
|
||||
case "auto":
|
||||
function_calling_mode, allowed_names = types.FunctionCallingConfigMode.AUTO, None
|
||||
if "allowed_tools" in tool_mode:
|
||||
function_calling_mode = types.FunctionCallingConfigMode.VALIDATED
|
||||
allowed_names = list(tool_mode["allowed_tools"])
|
||||
else:
|
||||
function_calling_mode, allowed_names = types.FunctionCallingConfigMode.AUTO, None
|
||||
case "none":
|
||||
function_calling_mode, allowed_names = types.FunctionCallingConfigMode.NONE, None
|
||||
case "required":
|
||||
function_calling_mode = types.FunctionCallingConfigMode.ANY
|
||||
name = tool_mode.get("required_function_name")
|
||||
allowed_names = [name] if name else None
|
||||
if name:
|
||||
allowed_names = [name]
|
||||
elif "allowed_tools" in tool_mode:
|
||||
allowed_names = list(tool_mode["allowed_tools"])
|
||||
else:
|
||||
allowed_names = None
|
||||
case unknown_mode:
|
||||
logger.warning("Unsupported tool_choice mode for Gemini: %s", unknown_mode)
|
||||
return None
|
||||
|
||||
function_calling_kwargs: dict[str, Any] = {"mode": function_calling_mode}
|
||||
if allowed_names:
|
||||
if allowed_names is not None:
|
||||
function_calling_kwargs["allowed_function_names"] = allowed_names
|
||||
|
||||
return types.ToolConfig(function_calling_config=types.FunctionCallingConfig(**function_calling_kwargs))
|
||||
|
||||
@@ -1157,6 +1157,86 @@ async def test_unknown_tool_choice_mode_is_ignored() -> None:
|
||||
assert not hasattr(config, "tool_config") or config.tool_config is None
|
||||
|
||||
|
||||
async def test_tool_choice_auto_with_allowed_tools_uses_VALIDATED() -> None:
|
||||
"""Maps auto + allowed_tools to FunctionCallingConfigMode.VALIDATED with allowed_function_names."""
|
||||
tool = _make_dummy_tool()
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")]))
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={
|
||||
"tools": [tool],
|
||||
"tool_choice": {"mode": "auto", "allowed_tools": ["dummy", "other"]},
|
||||
},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
function_calling_config = config.tool_config.function_calling_config
|
||||
assert function_calling_config.mode == "VALIDATED"
|
||||
assert function_calling_config.allowed_function_names == ["dummy", "other"]
|
||||
|
||||
|
||||
async def test_tool_choice_auto_with_empty_allowed_tools_uses_VALIDATED() -> None:
|
||||
"""Maps auto + empty allowed_tools to VALIDATED with empty allowed_function_names."""
|
||||
tool = _make_dummy_tool()
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")]))
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={
|
||||
"tools": [tool],
|
||||
"tool_choice": {"mode": "auto", "allowed_tools": []},
|
||||
},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
function_calling_config = config.tool_config.function_calling_config
|
||||
assert function_calling_config.mode == "VALIDATED"
|
||||
assert function_calling_config.allowed_function_names == []
|
||||
|
||||
|
||||
async def test_tool_choice_required_with_allowed_tools_uses_ANY() -> None:
|
||||
"""Maps required + allowed_tools to ANY with allowed_function_names."""
|
||||
tool = _make_dummy_tool()
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")]))
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={
|
||||
"tools": [tool],
|
||||
"tool_choice": {"mode": "required", "allowed_tools": ["dummy"]},
|
||||
},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
function_calling_config = config.tool_config.function_calling_config
|
||||
assert function_calling_config.mode == "ANY"
|
||||
assert function_calling_config.allowed_function_names == ["dummy"]
|
||||
|
||||
|
||||
async def test_tool_choice_required_function_name_takes_precedence_over_allowed_tools() -> None:
|
||||
"""When both required_function_name and allowed_tools are present, required_function_name wins."""
|
||||
tool = _make_dummy_tool()
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")]))
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={
|
||||
"tools": [tool],
|
||||
"tool_choice": {"mode": "required", "required_function_name": "dummy", "allowed_tools": ["other"]},
|
||||
},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
function_calling_config = config.tool_config.function_calling_config
|
||||
assert function_calling_config.mode == "ANY"
|
||||
assert function_calling_config.allowed_function_names == ["dummy"]
|
||||
|
||||
|
||||
# built-in tool factories
|
||||
|
||||
|
||||
|
||||
@@ -1296,6 +1296,12 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
"type": "function",
|
||||
"name": func_name,
|
||||
}
|
||||
elif mode == "auto" and (allowed := tool_mode.get("allowed_tools")) is not None:
|
||||
run_options["tool_choice"] = {
|
||||
"type": "allowed_tools",
|
||||
"mode": "auto",
|
||||
"tools": [{"type": "function", "name": name} for name in allowed],
|
||||
}
|
||||
else:
|
||||
run_options["tool_choice"] = mode
|
||||
else:
|
||||
|
||||
@@ -662,6 +662,12 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc]
|
||||
"type": "function",
|
||||
"function": {"name": func_name},
|
||||
}
|
||||
elif mode in ("auto", "required") and tool_mode.get("allowed_tools") is not None:
|
||||
logger.warning(
|
||||
"allowed_tools is not supported by the Chat Completions API; "
|
||||
"the setting will be ignored. Use OpenAIChatClient (Responses API) instead."
|
||||
)
|
||||
run_options["tool_choice"] = mode
|
||||
else:
|
||||
run_options["tool_choice"] = mode
|
||||
|
||||
|
||||
@@ -4272,6 +4272,12 @@ def test_with_callable_api_key() -> None:
|
||||
True,
|
||||
id="tool_choice_required",
|
||||
),
|
||||
param(
|
||||
"tool_choice",
|
||||
{"mode": "auto", "allowed_tools": ["get_weather"]},
|
||||
True,
|
||||
id="tool_choice_allowed_tools",
|
||||
),
|
||||
param("response_format", OutputStruct, True, id="response_format_pydantic"),
|
||||
param(
|
||||
"response_format",
|
||||
@@ -4822,6 +4828,90 @@ async def test_prepare_options_excludes_continuation_token() -> None:
|
||||
assert run_options["background"] is True
|
||||
|
||||
|
||||
async def test_prepare_options_allowed_tools() -> None:
|
||||
"""Test that _prepare_options converts allowed_tools to OpenAI API format."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
@tool
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get the weather for a city."""
|
||||
return f"Sunny in {city}"
|
||||
|
||||
@tool
|
||||
def search_docs(query: str) -> str:
|
||||
"""Search documentation."""
|
||||
return f"Results for {query}"
|
||||
|
||||
messages = [Message(role="user", contents=[Content.from_text(text="Hello")])]
|
||||
options: dict[str, Any] = {
|
||||
"model": "test-model",
|
||||
"tools": [get_weather, search_docs],
|
||||
"tool_choice": {"mode": "auto", "allowed_tools": ["get_weather"]},
|
||||
}
|
||||
|
||||
run_options = await client._prepare_options(messages, options)
|
||||
|
||||
assert run_options["tool_choice"] == {
|
||||
"type": "allowed_tools",
|
||||
"mode": "auto",
|
||||
"tools": [{"type": "function", "name": "get_weather"}],
|
||||
}
|
||||
|
||||
|
||||
async def test_prepare_options_allowed_tools_multiple() -> None:
|
||||
"""Test that _prepare_options converts multiple allowed_tools correctly."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
@tool
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get the weather for a city."""
|
||||
return f"Sunny in {city}"
|
||||
|
||||
@tool
|
||||
def search_docs(query: str) -> str:
|
||||
"""Search documentation."""
|
||||
return f"Results for {query}"
|
||||
|
||||
messages = [Message(role="user", contents=[Content.from_text(text="Hello")])]
|
||||
options: dict[str, Any] = {
|
||||
"model": "test-model",
|
||||
"tools": [get_weather, search_docs],
|
||||
"tool_choice": {"mode": "auto", "allowed_tools": ["get_weather", "search_docs"]},
|
||||
}
|
||||
|
||||
run_options = await client._prepare_options(messages, options)
|
||||
|
||||
assert run_options["tool_choice"] == {
|
||||
"type": "allowed_tools",
|
||||
"mode": "auto",
|
||||
"tools": [
|
||||
{"type": "function", "name": "get_weather"},
|
||||
{"type": "function", "name": "search_docs"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_prepare_options_auto_without_allowed_tools() -> None:
|
||||
"""Test that auto mode without allowed_tools still returns plain 'auto' string."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
@tool
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get the weather for a city."""
|
||||
return f"Sunny in {city}"
|
||||
|
||||
messages = [Message(role="user", contents=[Content.from_text(text="Hello")])]
|
||||
options: dict[str, Any] = {
|
||||
"model": "test-model",
|
||||
"tools": [get_weather],
|
||||
"tool_choice": {"mode": "auto"},
|
||||
}
|
||||
|
||||
run_options = await client._prepare_options(messages, options)
|
||||
|
||||
assert run_options["tool_choice"] == "auto"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
|
||||
@@ -1430,6 +1430,57 @@ def test_tool_choice_required_with_function_name(
|
||||
assert prepared_options["tool_choice"]["function"]["name"] == "get_weather"
|
||||
|
||||
|
||||
def test_tool_choice_allowed_tools_falls_back_to_mode(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that tool_choice with allowed_tools falls back to plain mode (Chat Completions API unsupported)."""
|
||||
client = OpenAIChatCompletionClient()
|
||||
|
||||
messages = [Message(role="user", contents=["test"])]
|
||||
options = {
|
||||
"tools": [get_weather],
|
||||
"tool_choice": {"mode": "auto", "allowed_tools": ["get_weather"]},
|
||||
}
|
||||
|
||||
prepared_options = client._prepare_options(messages, options)
|
||||
|
||||
assert prepared_options["tool_choice"] == "auto"
|
||||
|
||||
|
||||
def test_tool_choice_allowed_tools_required_mode_falls_back(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that tool_choice with allowed_tools and required mode falls back to 'required'."""
|
||||
client = OpenAIChatCompletionClient()
|
||||
|
||||
messages = [Message(role="user", contents=["test"])]
|
||||
options = {
|
||||
"tools": [get_weather],
|
||||
"tool_choice": {"mode": "required", "allowed_tools": ["get_weather"]},
|
||||
}
|
||||
|
||||
prepared_options = client._prepare_options(messages, options)
|
||||
|
||||
assert prepared_options["tool_choice"] == "required"
|
||||
|
||||
|
||||
def test_tool_choice_auto_dict_without_allowed_tools(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that tool_choice dict with mode auto and no allowed_tools falls through to plain 'auto'."""
|
||||
client = OpenAIChatCompletionClient()
|
||||
|
||||
messages = [Message(role="user", contents=["test"])]
|
||||
options = {
|
||||
"tools": [get_weather],
|
||||
"tool_choice": {"mode": "auto"},
|
||||
}
|
||||
|
||||
prepared_options = client._prepare_options(messages, options)
|
||||
|
||||
assert prepared_options["tool_choice"] == "auto"
|
||||
|
||||
|
||||
def test_response_format_dict_passthrough(openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test that response_format as dict is passed through directly."""
|
||||
client = OpenAIChatCompletionClient()
|
||||
@@ -1590,6 +1641,12 @@ class OutputStruct(BaseModel):
|
||||
False,
|
||||
id="tool_choice_required",
|
||||
),
|
||||
param(
|
||||
"tool_choice",
|
||||
{"mode": "auto", "allowed_tools": ["get_weather"]},
|
||||
False,
|
||||
id="tool_choice_allowed_tools",
|
||||
),
|
||||
param("response_format", OutputStruct, True, id="response_format_pydantic"),
|
||||
param(
|
||||
"response_format",
|
||||
|
||||
Generated
+588
-589
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user