From 817af2d50f557bbf516a45397d2a8cde8be76e7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:22:53 +0000 Subject: [PATCH 1/7] Initial plan From 50aa418499484d5e9fd98c8f66606cc42f30f218 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:56:08 +0000 Subject: [PATCH 2/7] Forward opted-in Responses function tools Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- .../OpenAIResponseRequestInfo.cs | 11 +++ .../OpenAIResponseRequestInfoBuilder.cs | 80 +++++++++++++++++++ .../OpenAIResponseRequestInfoBuilderTests.cs | 46 +++++++++++ .../OpenAIResponsesIntegrationTests.cs | 72 ++++++++++++++++- .../PermissiveMapOptions.cs | 1 + .../TestHelpers.cs | 6 ++ 6 files changed, 214 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs index 0d5741984e7..3fa0f07d920 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs @@ -64,6 +64,17 @@ public sealed class OpenAIResponseRequestInfo /// public IReadOnlyList? Tools { get; set; } + /// + /// Gets or sets the request-supplied function tools represented as declaration-only + /// instances, if any. + /// + /// + /// These declarations contain the client-provided function metadata and JSON schema but cannot + /// execute code on the server. Responses tool types that have no Microsoft.Extensions.AI + /// equivalent remain available through only. + /// + public IReadOnlyList? FunctionTools { get; set; } + /// /// Gets or sets the tool selection mode (tool_choice) supplied on the request, if any. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs index 46b4532638b..32c6255f99c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs @@ -9,6 +9,8 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; internal static class OpenAIResponseRequestInfoBuilder { + private static readonly JsonElement s_emptyJson = JsonElement.Parse("{}"); + public static OpenAIResponseRequestInfo ToRequestInfo(this CreateResponse request) => new() { Temperature = request.Temperature, @@ -17,9 +19,87 @@ internal static class OpenAIResponseRequestInfoBuilder Instructions = request.Instructions, Model = request.Model, Tools = request.Tools is { Count: > 0 } tools ? new List(tools) : null, + FunctionTools = request.Tools?.ToFunctionTools(), ToolChoice = request.ToolChoice?.ToChatToolMode(), }; + private static List? ToFunctionTools(this IReadOnlyList tools) + { + List? functionTools = null; + + foreach (JsonElement tool in tools) + { + if (tool.ToFunctionTool() is { } functionTool) + { + (functionTools ??= []).Add(functionTool); + } + } + + return functionTools; + } + + private static AIFunctionDeclaration? ToFunctionTool(this JsonElement tool) + { + if (tool.ValueKind != JsonValueKind.Object || + !tool.TryGetProperty("type", out JsonElement type) || + type.ValueKind != JsonValueKind.String || + type.GetString() != "function" || + !tool.TryGetProperty("name", out JsonElement name) || + name.ValueKind != JsonValueKind.String || + name.GetString() is not { Length: > 0 } functionName) + { + return null; + } + + JsonElement parameters = tool.TryGetProperty("parameters", out JsonElement requestParameters) && + requestParameters.ValueKind == JsonValueKind.Object + ? requestParameters + : s_emptyJson; + + string? description = tool.TryGetProperty("description", out JsonElement requestDescription) && + requestDescription.ValueKind == JsonValueKind.String + ? requestDescription.GetString() + : null; + + AIFunctionDeclaration function = AIFunctionFactory.CreateDeclaration(functionName, description, parameters); + + if (tool.TryGetProperty("strict", out JsonElement strict) && + strict.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + function = new ResponseAIFunctionDeclaration(function, strict.GetBoolean()); + } + + return function; + } + + private sealed class ResponseAIFunctionDeclaration : AIFunctionDeclaration + { + private readonly AIFunctionDeclaration _innerFunction; + + public ResponseAIFunctionDeclaration(AIFunctionDeclaration innerFunction, bool strict) + { + this._innerFunction = innerFunction; + var additionalProperties = new Dictionary(); + foreach (KeyValuePair property in innerFunction.AdditionalProperties) + { + additionalProperties.Add(property.Key, property.Value); + } + + additionalProperties["strict"] = strict; + this.AdditionalProperties = additionalProperties; + } + + public override string Name => this._innerFunction.Name; + + public override string Description => this._innerFunction.Description; + + public override JsonElement JsonSchema => this._innerFunction.JsonSchema; + + public override JsonElement? ReturnJsonSchema => this._innerFunction.ReturnJsonSchema; + + public override IReadOnlyDictionary AdditionalProperties { get; } + } + /// /// Maps an OpenAI Responses tool_choice value onto its equivalent. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs index bef3325dd7d..f4d613e6d6a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs @@ -92,6 +92,46 @@ public void ToRequestInfo_NoToolChoice_MapsToNull() Assert.Null(info.ToolChoice); } + [Fact] + public void ToRequestInfo_MapsFunctionToolToDeclaration() + { + // Arrange + CreateResponse request = new() + { + Input = "hello", + Tools = + [ + ParseElement( + """ + { + "type": "function", + "name": "get_weather", + "description": "Retrieves current weather.", + "parameters": { + "type": "object", + "properties": { + "location": { "type": "string" } + }, + "required": [ "location" ] + }, + "strict": true + } + """) + ] + }; + + // Act + OpenAIResponseRequestInfo info = request.ToRequestInfo(); + + // Assert + Assert.Single(info.Tools!); + AIFunctionDeclaration function = Assert.IsAssignableFrom(Assert.Single(info.FunctionTools!)); + Assert.Equal("get_weather", function.Name); + Assert.Equal("Retrieves current weather.", function.Description); + Assert.True(function.JsonSchema.GetProperty("properties").TryGetProperty("location", out _)); + Assert.True(Assert.IsType(function.AdditionalProperties["strict"])); + } + private static CreateResponse CreateRequestWithToolChoice(string toolChoiceJson) { using JsonDocument document = JsonDocument.Parse(toolChoiceJson); @@ -101,4 +141,10 @@ private static CreateResponse CreateRequestWithToolChoice(string toolChoiceJson) ToolChoice = document.RootElement.Clone(), }; } + + private static JsonElement ParseElement(string json) + { + using JsonDocument document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs index 9a89bee4838..6653d63b2e0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs @@ -941,6 +941,70 @@ public async Task CreateResponse_FunctionCall_ReturnsCorrectlyAsync() Assert.NotNull(response.Id); } + [Fact] + public async Task CreateResponse_WithRequestFunctionTool_ForwardsToolAndReturnsFunctionCallAsync() + { + // Arrange + const string AgentName = "request-function-tool-agent"; + var chatClient = new TestHelpers.FunctionCallMockChatClient( + "get_weather", + """{"location":"Valencia, Spain","units":"celsius"}"""); + + this._httpClient = await this.CreateTestServerWithCustomClientAsync( + agentName: AgentName, + instructions: "You are a helpful assistant.", + chatClient, + PermissiveMapOptions.Responses()); + + using var content = new StringContent( + """ + { + "input": "What's the current weather in Valencia?", + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "Retrieves current weather for the given location.", + "parameters": { + "type": "object", + "properties": { + "location": { "type": "string" }, + "units": { "type": "string", "enum": [ "celsius", "fahrenheit" ] } + }, + "required": [ "location", "units" ], + "additionalProperties": false + }, + "strict": true + } + ], + "tool_choice": "required" + } + """, + Encoding.UTF8, + "application/json"); + + // Act + using HttpResponseMessage httpResponse = await this._httpClient.PostAsync( + new Uri($"/{AgentName}/v1/responses", UriKind.Relative), + content); + + // Assert + Assert.True(httpResponse.IsSuccessStatusCode, $"Response status: {httpResponse.StatusCode}"); + Assert.NotNull(chatClient.LastChatOptions); + Assert.Equal(ChatToolMode.RequireAny, chatClient.LastChatOptions.ToolMode); + AIFunctionDeclaration tool = Assert.IsAssignableFrom(Assert.Single(chatClient.LastChatOptions.Tools!)); + Assert.Equal("get_weather", tool.Name); + Assert.Equal("Retrieves current weather for the given location.", tool.Description); + Assert.True(tool.JsonSchema.GetProperty("properties").TryGetProperty("units", out _)); + Assert.True(Assert.IsType(tool.AdditionalProperties["strict"])); + + using System.Text.Json.JsonDocument document = + System.Text.Json.JsonDocument.Parse(await httpResponse.Content.ReadAsStringAsync()); + System.Text.Json.JsonElement output = Assert.Single(document.RootElement.GetProperty("output").EnumerateArray()); + Assert.Equal("function_call", output.GetProperty("type").GetString()); + Assert.Equal("get_weather", output.GetProperty("name").GetString()); + } + /// /// Verifies that responses with function calls stream correctly. /// @@ -1410,7 +1474,11 @@ private async Task CreateTestServerWithCustomClientAndConversationsA return testServer.CreateClient(); } - private async Task CreateTestServerWithCustomClientAsync(string agentName, string instructions, IChatClient chatClient) + private async Task CreateTestServerWithCustomClientAsync( + string agentName, + string instructions, + IChatClient chatClient, + OpenAIResponsesMapOptions? mapOptions = null) { WebApplicationBuilder builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); @@ -1421,7 +1489,7 @@ private async Task CreateTestServerWithCustomClientAsync(string agen this._app = builder.Build(); AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); - this._app.MapOpenAIResponses(agent); + this._app.MapOpenAIResponses(agent, responsesPath: null, mapOptions); await this._app.StartAsync(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs index 80a9af2895c..cecf8a1b755 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs @@ -28,6 +28,7 @@ internal static class PermissiveMapOptions Instructions = request.Instructions, ModelId = request.Model, ToolMode = request.ToolChoice, + Tools = request.FunctionTools is { Count: > 0 } tools ? tools.ToList() : null, }; return new ChatClientAgentRunOptions(chatOptions); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs index 198e65629e4..eeb318619e4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs @@ -361,6 +361,8 @@ internal sealed class FunctionCallMockChatClient : IChatClient private readonly string _functionName; private readonly Dictionary _arguments; + public ChatOptions? LastChatOptions { get; private set; } + public FunctionCallMockChatClient(string functionName = "test_function", string arguments = "{\"param\":\"value\"}") { this._functionName = functionName; @@ -374,6 +376,8 @@ public Task GetResponseAsync( ChatOptions? options = null, CancellationToken cancellationToken = default) { + this.LastChatOptions = options; + ChatMessage message = new(ChatRole.Assistant, [ new FunctionCallContent("call_123", this._functionName) { @@ -399,6 +403,8 @@ public async IAsyncEnumerable GetStreamingResponseAsync( ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + this.LastChatOptions = options; + await Task.Delay(1, cancellationToken); yield return new ChatResponseUpdate From 41c68b25e1d3aafb8e424a662c3dc80afc47ee5d Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:52:12 +0100 Subject: [PATCH 3/7] Add guarded client function tools --- ...ndpointRouteBuilderExtensions.Responses.cs | 6 +- .../Microsoft.Agents.AI.Hosting.OpenAI.csproj | 1 + ...IClientFunctionToolNameConflictBehavior.cs | 65 ++++ .../OpenAIClientFunctionToolsOptions.cs | 30 ++ .../OpenAIResponseRequestInfo.cs | 13 +- .../OpenAIResponses.cs | 47 ++- .../OpenAIResponsesMapOptions.cs | 36 +- .../Responses/AIAgentResponseExecutor.cs | 20 +- .../Responses/HostedAgentResponseExecutor.cs | 12 +- .../OpenAIResponseRequestInfoBuilder.cs | 35 +- .../OpenAIResponseRunOptionsBuilder.cs | 252 +++++++++++++ ...AIResponsesClientFunctionToolsLiveTests.cs | 122 ++++++ ...ntFunctionToolNameConflictBehaviorTests.cs | 48 +++ .../OpenAIClientFunctionToolsOptionsTests.cs | 21 ++ .../OpenAIMapOptionsTests.cs | 106 ++++++ .../OpenAIResponseRequestInfoBuilderTests.cs | 14 +- .../OpenAIResponsesIntegrationTests.cs | 355 +++++++++++++++++- .../OpenAIResponsesTests.cs | 57 +++ .../PermissiveMapOptions.cs | 4 +- .../TestHelpers.cs | 74 ++++ 20 files changed, 1269 insertions(+), 49 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolNameConflictBehavior.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolsOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolNameConflictBehaviorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolsOptionsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs index 598725c9b0b..4a2ffcd03b3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Builder; @@ -71,7 +72,10 @@ public static IEndpointConventionBuilder MapOpenAIResponses( responsesPath ??= $"/{agent.Name}/v1/responses"; // Create an executor for this agent - var executor = new AIAgentResponseExecutor(agent, mapOptions); + var executor = new AIAgentResponseExecutor( + agent, + mapOptions, + endpoints.ServiceProvider.GetService>()); var storageOptions = endpoints.ServiceProvider.GetService() ?? new InMemoryStorageOptions(); var conversationStorage = endpoints.ServiceProvider.GetService(); var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj index 78364f4a30b..baf2152635c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj @@ -12,6 +12,7 @@ + true true diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolNameConflictBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolNameConflictBehavior.cs new file mode 100644 index 00000000000..af85d18052a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolNameConflictBehavior.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +/// +/// Defines how a client-provided function declaration is handled when its name conflicts with a +/// function configured by the hosted agent developer. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class OpenAIClientFunctionToolNameConflictBehavior +{ + private protected OpenAIClientFunctionToolNameConflictBehavior() + { + } + + /// + /// Creates a behavior that rejects the request. + /// + /// The conflict behavior. + public static OpenAIClientFunctionToolNameConflictBehavior Reject() => new RejectBehavior(); + + /// + /// Creates a behavior that keeps the hosted agent function, ignores the client declaration, + /// and writes a server warning. + /// + /// The conflict behavior. + public static OpenAIClientFunctionToolNameConflictBehavior Ignore() => new IgnoreBehavior(); + + /// + /// Creates a behavior that uses the client declaration instead of the hosted agent function for + /// that request. + /// + /// The conflict behavior. + public static OpenAIClientFunctionToolNameConflictBehavior AllowOverride() => new AllowOverrideBehavior(); + + internal abstract OpenAIClientFunctionToolNameConflictBehaviorKind Kind { get; } + + private sealed class RejectBehavior : OpenAIClientFunctionToolNameConflictBehavior + { + internal override OpenAIClientFunctionToolNameConflictBehaviorKind Kind => + OpenAIClientFunctionToolNameConflictBehaviorKind.Reject; + } + + private sealed class IgnoreBehavior : OpenAIClientFunctionToolNameConflictBehavior + { + internal override OpenAIClientFunctionToolNameConflictBehaviorKind Kind => + OpenAIClientFunctionToolNameConflictBehaviorKind.Ignore; + } + + private sealed class AllowOverrideBehavior : OpenAIClientFunctionToolNameConflictBehavior + { + internal override OpenAIClientFunctionToolNameConflictBehaviorKind Kind => + OpenAIClientFunctionToolNameConflictBehaviorKind.AllowOverride; + } +} + +internal enum OpenAIClientFunctionToolNameConflictBehaviorKind +{ + Reject, + Ignore, + AllowOverride, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolsOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolsOptions.cs new file mode 100644 index 00000000000..427e1349d10 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolsOptions.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +/// +/// Configures the dangerous forwarding of client-provided function declarations. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class OpenAIClientFunctionToolsOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The required behavior when a client function uses the same name as a hosted agent tool. + /// + public OpenAIClientFunctionToolsOptions(OpenAIClientFunctionToolNameConflictBehavior nameConflictBehavior) + { + this.NameConflictBehavior = Throw.IfNull(nameConflictBehavior); + } + + /// + /// Gets the behavior used when a client function uses the same name as a hosted agent tool. + /// + public OpenAIClientFunctionToolNameConflictBehavior NameConflictBehavior { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs index 3fa0f07d920..abd78fe4d91 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs @@ -64,17 +64,6 @@ public sealed class OpenAIResponseRequestInfo /// public IReadOnlyList? Tools { get; set; } - /// - /// Gets or sets the request-supplied function tools represented as declaration-only - /// instances, if any. - /// - /// - /// These declarations contain the client-provided function metadata and JSON schema but cannot - /// execute code on the server. Responses tool types that have no Microsoft.Extensions.AI - /// equivalent remain available through only. - /// - public IReadOnlyList? FunctionTools { get; set; } - /// /// Gets or sets the tool selection mode (tool_choice) supplied on the request, if any. /// @@ -85,4 +74,6 @@ public sealed class OpenAIResponseRequestInfo /// . /// public ChatToolMode? ToolChoice { get; set; } + + internal bool HasToolChoice { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs index d8786f602ad..6ef15c79210 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs @@ -9,6 +9,7 @@ using Microsoft.Agents.AI.Hosting.OpenAI.Responses; using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.Hosting.OpenAI; @@ -43,6 +44,39 @@ public static class OpenAIResponses /// The body could not be parsed as an OpenAI Responses request. /// A request setting is not supported by the configured mapping. public static OpenAIResponsesRunRequest ToAgentRunRequest(JsonElement body, OpenAIResponsesMapOptions? mapOptions = null) + => ToAgentRunRequestCore(body, mapOptions ?? new OpenAIResponsesMapOptions(), agent: null, logger: null); + + /// + /// Converts an OpenAI Responses request body into Agent Framework run values using the target + /// agent to resolve client function tool name conflicts. + /// + /// The OpenAI Responses-shaped request body. + /// The target agent whose configured functions participate in conflict resolution. + /// Options controlling how request settings are mapped onto the run. + /// Optional logger used for client function conflict warnings. + /// The parsed messages and mapped run options. + /// + /// or is . + /// + /// The body could not be parsed as an OpenAI Responses request. + /// A request setting is not supported by the configured mapping. + public static OpenAIResponsesRunRequest ToAgentRunRequest( + JsonElement body, + AIAgent agent, + OpenAIResponsesMapOptions mapOptions, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(agent); + ArgumentNullException.ThrowIfNull(mapOptions); + + return ToAgentRunRequestCore(body, mapOptions, agent, logger); + } + + private static OpenAIResponsesRunRequest ToAgentRunRequestCore( + JsonElement body, + OpenAIResponsesMapOptions mapOptions, + AIAgent? agent, + ILogger? logger) { CreateResponse request; try @@ -60,7 +94,18 @@ public static OpenAIResponsesRunRequest ToAgentRunRequest(JsonElement body, Open throw new ArgumentException("The request body is missing the required 'input' field.", nameof(body)); } - AgentRunOptions? options = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory(request.ToRequestInfo()); +#pragma warning disable MAAI001 + if (agent is null && mapOptions.DangerouslyAllowClientFunctionTools is not null) + { + throw new NotSupportedException( + $"{nameof(OpenAIResponsesMapOptions.DangerouslyAllowClientFunctionTools)} requires the " + + $"{nameof(ToAgentRunRequest)} overload that accepts an {nameof(AIAgent)}."); + } +#pragma warning restore MAAI001 + + AgentRunOptions? options = agent is null + ? mapOptions.RunOptionsFactory(request.ToRequestInfo()) + : request.ToRunOptions(mapOptions, agent, logger, logConflicts: true); var messages = new List(); foreach (InputMessage inputMessage in request.Input.GetInputMessages()) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs index 63a550f2c1e..ef5b9638555 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting.OpenAI; @@ -39,6 +41,38 @@ public sealed class OpenAIResponsesMapOptions } } = RejectRequestSettings; + /// + /// Gets or sets the explicit opt-in that allows function declarations supplied by the client to + /// be forwarded to the hosted agent's inference client. + /// + /// + /// + /// This setting is dangerous because client-provided function names, descriptions, and schemas + /// can change which tools the model chooses. The declarations cannot execute code in the hosted + /// server, but matching function calls are returned to the client for execution. + /// + /// + /// The default is , which leaves client-provided tools subject to + /// . Enabling this setting requires an explicit + /// . The request's + /// tool_choice is not enabled by this setting and remains controlled by + /// . + /// + /// + /// This setting applies only to MapOpenAIResponses endpoints because the target agent is + /// required to enforce name conflicts. Accepted function declarations are handled by the endpoint + /// and removed from before + /// is called. Other tool types remain in that collection. + /// + /// + /// Client functions are forwarded with parallel tool calls disabled. This prevents a response from + /// combining a client function call, which must be returned to the client, with a hosted function + /// call that must execute inside the hosted agent. + /// + /// + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] + public OpenAIClientFunctionToolsOptions? DangerouslyAllowClientFunctionTools { get; set; } + /// /// The default implementation. Throws a /// when the request specifies any setting that would otherwise be mapped onto the agent, and otherwise @@ -83,7 +117,7 @@ public sealed class OpenAIResponsesMapOptions LocalAdd("tools"); } - if (request.ToolChoice is not null) + if (request.HasToolChoice || request.ToolChoice is not null) { LocalAdd("tool_choice"); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs index 959209a0aa4..bf78e96fb38 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; @@ -17,13 +18,18 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; internal sealed class AIAgentResponseExecutor : IResponseExecutor { private readonly AIAgent _agent; - private readonly Func _runOptionsFactory; + private readonly OpenAIResponsesMapOptions _mapOptions; + private readonly ILogger? _logger; - public AIAgentResponseExecutor(AIAgent agent, OpenAIResponsesMapOptions? mapOptions = null) + public AIAgentResponseExecutor( + AIAgent agent, + OpenAIResponsesMapOptions? mapOptions = null, + ILogger? logger = null) { ArgumentNullException.ThrowIfNull(agent); this._agent = agent; - this._runOptionsFactory = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory; + this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions(); + this._logger = logger; } public ValueTask ValidateRequestAsync( @@ -37,7 +43,7 @@ public AIAgentResponseExecutor(AIAgent agent, OpenAIResponsesMapOptions? mapOpti { // Invoke the factory during validation so that unsupported request settings are surfaced // as a clean request error rather than an unhandled exception during execution. - _ = this._runOptionsFactory(request.ToRequestInfo()); + _ = request.ToRunOptions(this._mapOptions, this._agent, this._logger); return null; } catch (NotSupportedException ex) @@ -58,7 +64,11 @@ public async IAsyncEnumerable ExecuteAsync( { // The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any) // request settings are mapped onto the agent run. By default no request setting is mapped. - AgentRunOptions? options = this._runOptionsFactory(request.ToRequestInfo()); + AgentRunOptions? options = request.ToRunOptions( + this._mapOptions, + this._agent, + this._logger, + logConflicts: true); // Convert input to chat messages, prepending conversation history if available var messages = new List(); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs index bc51e74dc76..2bedf42f69b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs @@ -21,7 +21,7 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor { private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; - private readonly Func _runOptionsFactory; + private readonly OpenAIResponsesMapOptions _mapOptions; /// /// Initializes a new instance of the class. @@ -39,7 +39,7 @@ public HostedAgentResponseExecutor( this._serviceProvider = serviceProvider; this._logger = logger; - this._runOptionsFactory = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory; + this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions(); } /// @@ -83,7 +83,7 @@ Ensure the agent is registered with '{agentName}' name in the dependency injecti // exception during execution. try { - _ = this._runOptionsFactory(request.ToRequestInfo()); + _ = request.ToRunOptions(this._mapOptions, agent, this._logger); } catch (NotSupportedException ex) { @@ -109,7 +109,11 @@ public async IAsyncEnumerable ExecuteAsync( // The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any) // request settings are mapped onto the agent run. By default no request setting is mapped. - AgentRunOptions? options = this._runOptionsFactory(request.ToRequestInfo()); + AgentRunOptions? options = request.ToRunOptions( + this._mapOptions, + agent, + this._logger, + logConflicts: true); var messages = new List(); if (conversationHistory is not null) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs index 32c6255f99c..f8647da4a4d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs @@ -19,13 +19,16 @@ internal static class OpenAIResponseRequestInfoBuilder Instructions = request.Instructions, Model = request.Model, Tools = request.Tools is { Count: > 0 } tools ? new List(tools) : null, - FunctionTools = request.Tools?.ToFunctionTools(), ToolChoice = request.ToolChoice?.ToChatToolMode(), + HasToolChoice = request.ToolChoice is not null, }; - private static List? ToFunctionTools(this IReadOnlyList tools) + internal static List? ExtractClientFunctionTools( + this IReadOnlyList tools, + out List? unsupportedTools) { List? functionTools = null; + unsupportedTools = null; foreach (JsonElement tool in tools) { @@ -33,12 +36,16 @@ internal static class OpenAIResponseRequestInfoBuilder { (functionTools ??= []).Add(functionTool); } + else + { + (unsupportedTools ??= []).Add(tool); + } } return functionTools; } - private static AIFunctionDeclaration? ToFunctionTool(this JsonElement tool) + private static ClientAIFunctionDeclaration? ToFunctionTool(this JsonElement tool) { if (tool.ValueKind != JsonValueKind.Object || !tool.TryGetProperty("type", out JsonElement type) || @@ -62,21 +69,19 @@ internal static class OpenAIResponseRequestInfoBuilder : null; AIFunctionDeclaration function = AIFunctionFactory.CreateDeclaration(functionName, description, parameters); + bool? strict = tool.TryGetProperty("strict", out JsonElement requestStrict) && + requestStrict.ValueKind is JsonValueKind.True or JsonValueKind.False + ? requestStrict.GetBoolean() + : null; - if (tool.TryGetProperty("strict", out JsonElement strict) && - strict.ValueKind is JsonValueKind.True or JsonValueKind.False) - { - function = new ResponseAIFunctionDeclaration(function, strict.GetBoolean()); - } - - return function; + return new ClientAIFunctionDeclaration(function, strict); } - private sealed class ResponseAIFunctionDeclaration : AIFunctionDeclaration + internal sealed class ClientAIFunctionDeclaration : AIFunctionDeclaration { private readonly AIFunctionDeclaration _innerFunction; - public ResponseAIFunctionDeclaration(AIFunctionDeclaration innerFunction, bool strict) + public ClientAIFunctionDeclaration(AIFunctionDeclaration innerFunction, bool? strict) { this._innerFunction = innerFunction; var additionalProperties = new Dictionary(); @@ -85,7 +90,11 @@ public ResponseAIFunctionDeclaration(AIFunctionDeclaration innerFunction, bool s additionalProperties.Add(property.Key, property.Value); } - additionalProperties["strict"] = strict; + if (strict is not null) + { + additionalProperties["strict"] = strict; + } + this.AdditionalProperties = additionalProperties; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs new file mode 100644 index 00000000000..a17d521d57f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; + +#pragma warning disable MAAI001 + +internal static class OpenAIResponseRunOptionsBuilder +{ + public static AgentRunOptions? ToRunOptions( + this CreateResponse request, + OpenAIResponsesMapOptions mapOptions, + AIAgent? agent = null, + ILogger? logger = null, + bool logConflicts = false) + { + OpenAIResponseRequestInfo requestInfo = request.ToRequestInfo(); + OpenAIClientFunctionToolsOptions? clientFunctionOptions = mapOptions.DangerouslyAllowClientFunctionTools; + + if (clientFunctionOptions is null || requestInfo.Tools is not { Count: > 0 } requestTools) + { + return mapOptions.RunOptionsFactory(requestInfo); + } + + List? clientFunctions = requestTools.ExtractClientFunctionTools(out List? unsupportedTools); + if (clientFunctions is not { Count: > 0 }) + { + return mapOptions.RunOptionsFactory(requestInfo); + } + + ThrowIfDuplicateClientFunctionNames(clientFunctions); + + // Accepted function declarations are handled here. Any other tool type remains visible to the + // configured factory and is rejected by the default factory. + requestInfo.Tools = unsupportedTools; + AgentRunOptions? runOptions = mapOptions.RunOptionsFactory(requestInfo); + OpenAIClientFunctionToolNameConflictBehaviorKind nameConflictBehavior = + clientFunctionOptions.NameConflictBehavior.Kind; + + HashSet hostedToolNames = GetHostedToolNames(agent, runOptions); + List conflictingNames = clientFunctions + .Select(tool => tool.Name) + .Where(hostedToolNames.Contains) + .Distinct(StringComparer.Ordinal) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + + if (conflictingNames.Count > 0) + { + switch (nameConflictBehavior) + { + case OpenAIClientFunctionToolNameConflictBehaviorKind.Reject: + throw CreateNameConflictException(conflictingNames); + + case OpenAIClientFunctionToolNameConflictBehaviorKind.Ignore: + clientFunctions.RemoveAll(tool => hostedToolNames.Contains(tool.Name)); + if (logConflicts) + { + logger?.LogWarning( + "Ignoring client function tool declarations that conflict with hosted agent tools: {ToolNames}", + string.Join(", ", conflictingNames)); + } + break; + + case OpenAIClientFunctionToolNameConflictBehaviorKind.AllowOverride: + break; + } + } + + return clientFunctions.Count > 0 + ? AddClientFunctions(runOptions, clientFunctions, nameConflictBehavior, logger) + : runOptions; + } + + private static ChatClientAgentRunOptions AddClientFunctions( + AgentRunOptions? runOptions, + List clientFunctions, + OpenAIClientFunctionToolNameConflictBehaviorKind nameConflictBehavior, + ILogger? logger) + { + ChatClientAgentRunOptions chatRunOptions = runOptions switch + { + null => new ChatClientAgentRunOptions(), + ChatClientAgentRunOptions existingRunOptions => (ChatClientAgentRunOptions)existingRunOptions.Clone(), + _ => throw new NotSupportedException( + $"{nameof(OpenAIResponsesMapOptions.DangerouslyAllowClientFunctionTools)} requires " + + $"{nameof(OpenAIResponsesMapOptions.RunOptionsFactory)} to return null or {nameof(ChatClientAgentRunOptions)}.") + }; + + ChatOptions chatOptions = chatRunOptions.ChatOptions?.Clone() ?? new ChatOptions(); + chatOptions.AllowMultipleToolCalls = false; + chatOptions.Tools = chatOptions.Tools is { Count: > 0 } existingTools + ? [.. clientFunctions, .. existingTools] + : [.. clientFunctions]; + chatRunOptions.ChatOptions = chatOptions; + + Func? innerFactory = chatRunOptions.ChatClientFactory; + chatRunOptions.ChatClientFactory = chatClient => + { + IChatClient innerClient = innerFactory is null + ? chatClient + : innerFactory(chatClient) ?? throw new InvalidOperationException( + $"{nameof(ChatClientAgentRunOptions.ChatClientFactory)} returned null."); + return new ClientFunctionToolConflictResolvingChatClient(innerClient, nameConflictBehavior, logger); + }; + + return chatRunOptions; + } + + private static HashSet GetHostedToolNames(AIAgent? agent, AgentRunOptions? runOptions) + { + var names = new HashSet(StringComparer.Ordinal); + + AddToolNames(agent?.GetService()?.AdditionalTools, names); + AddToolNames(agent?.GetService()?.Tools, names); + AddToolNames((runOptions as ChatClientAgentRunOptions)?.ChatOptions?.Tools, names); + + return names; + } + + private static void AddToolNames(IEnumerable? tools, HashSet names) + { + if (tools is null) + { + return; + } + + foreach (AITool tool in tools) + { + if (tool is AIFunctionDeclaration && !string.IsNullOrEmpty(tool.Name)) + { + names.Add(tool.Name); + } + } + } + + private static void ThrowIfDuplicateClientFunctionNames(List clientFunctions) + { + string? duplicateName = clientFunctions + .GroupBy(tool => tool.Name, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1) + ?.Key; + + if (duplicateName is not null) + { + throw new NotSupportedException( + $"The request contains more than one client function tool named '{duplicateName}'."); + } + } + + private static NotSupportedException CreateNameConflictException(IReadOnlyList conflictingNames) => + new( + "Client function tool declarations conflict with tools configured by the hosted agent: " + + $"{string.Join(", ", conflictingNames)}."); + + private sealed class ClientFunctionToolConflictResolvingChatClient : DelegatingChatClient + { + private readonly OpenAIClientFunctionToolNameConflictBehaviorKind _nameConflictBehavior; + private readonly ILogger? _logger; + private bool _loggedIgnoredConflicts; + + public ClientFunctionToolConflictResolvingChatClient( + IChatClient innerClient, + OpenAIClientFunctionToolNameConflictBehaviorKind nameConflictBehavior, + ILogger? logger) + : base(innerClient) + { + this._nameConflictBehavior = nameConflictBehavior; + this._logger = logger; + } + + public override Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) => + base.GetResponseAsync(messages, this.ResolveNameConflicts(options), cancellationToken); + + public override IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) => + base.GetStreamingResponseAsync(messages, this.ResolveNameConflicts(options), cancellationToken); + + private ChatOptions? ResolveNameConflicts(ChatOptions? options) + { + if (options?.Tools is not { Count: > 0 } tools) + { + return options; + } + + var clientNames = tools + .OfType() + .Select(tool => tool.Name) + .ToHashSet(StringComparer.Ordinal); + if (clientNames.Count == 0) + { + return options; + } + + List conflictingNames = tools + .Where(tool => + tool is AIFunctionDeclaration and + not OpenAIResponseRequestInfoBuilder.ClientAIFunctionDeclaration) + .Select(tool => tool.Name) + .Where(clientNames.Contains) + .Distinct(StringComparer.Ordinal) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + if (conflictingNames.Count == 0) + { + return options; + } + + if (this._nameConflictBehavior == OpenAIClientFunctionToolNameConflictBehaviorKind.Reject) + { + throw CreateNameConflictException(conflictingNames); + } + + var conflictSet = conflictingNames.ToHashSet(StringComparer.Ordinal); + ChatOptions resolvedOptions = options.Clone(); + resolvedOptions.Tools = this._nameConflictBehavior == OpenAIClientFunctionToolNameConflictBehaviorKind.Ignore + ? tools.Where(tool => + tool is not OpenAIResponseRequestInfoBuilder.ClientAIFunctionDeclaration || + !conflictSet.Contains(tool.Name)).ToList() + : tools.Where(tool => + tool is OpenAIResponseRequestInfoBuilder.ClientAIFunctionDeclaration || + tool is not AIFunctionDeclaration || + !conflictSet.Contains(tool.Name)).ToList(); + + if (this._nameConflictBehavior == OpenAIClientFunctionToolNameConflictBehaviorKind.Ignore && + !this._loggedIgnoredConflicts) + { + this._loggedIgnoredConflicts = true; + this._logger?.LogWarning( + "Ignoring client function tool declarations that conflict with hosted agent tools: {ToolNames}", + string.Join(", ", conflictingNames)); + } + + return resolvedOptions; + } + } +} + +#pragma warning restore MAAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs new file mode 100644 index 00000000000..714c9d2684d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using OpenAI; +using Shared.IntegrationTests; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests; + +/// +/// Live integration tests for client-provided function tools passed through OpenAI Responses hosting. +/// +public sealed class OpenAIResponsesClientFunctionToolsLiveTests +{ + private const string ClientRequestJson = """ + { + "input": "Call get_weather for Valencia. Do not answer without calling the function.", + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "Return weather from the client application.", + "parameters": { + "type": "object", + "properties": { + "location": { "type": "string" } + }, + "required": [ "location" ], + "additionalProperties": false + }, + "strict": true + } + ] + } + """; + + private static string? ApiKey => Environment.GetEnvironmentVariable(TestSettings.OpenAIApiKey); + + private static string ModelName => + Environment.GetEnvironmentVariable(TestSettings.OpenAIChatModelName) ?? "gpt-4o-mini"; + + [Fact] + public async Task ClientFunctionNameConflictPolicies_WorkEndToEndAsync() + { + // Arrange + Assert.SkipWhen( + string.IsNullOrEmpty(ApiKey), + "OPENAI_API_KEY is not configured; skipping live client function tool test."); + + (ChatClientAgent rejectAgent, _) = CreateAgent(); + (ChatClientAgent ignoreAgent, Func getIgnoreInvocationCount) = CreateAgent(); + (ChatClientAgent overrideAgent, Func getOverrideInvocationCount) = CreateAgent(); + JsonElement requestBody = ParseBody(ClientRequestJson); + + // Act & Assert: Reject blocks the conflicting client declaration before inference. + Assert.Throws(() => + OpenAIResponses.ToAgentRunRequest( + requestBody, + rejectAgent, + CreateMapOptions(OpenAIClientFunctionToolNameConflictBehavior.Reject()))); + + // Act & Assert: Ignore keeps and executes the hosted function. + OpenAIResponsesRunRequest ignoreRun = OpenAIResponses.ToAgentRunRequest( + requestBody, + ignoreAgent, + CreateMapOptions(OpenAIClientFunctionToolNameConflictBehavior.Ignore())); + AgentResponse ignoreResponse = await ignoreAgent.RunAsync(ignoreRun.Messages, options: ignoreRun.Options); + Assert.True(getIgnoreInvocationCount() > 0); + Assert.Contains("HOSTED_FUNCTION_RESULT", ignoreResponse.Text, StringComparison.Ordinal); + + // Act & Assert: AllowOverride returns the client function call without executing the hosted function. + OpenAIResponsesRunRequest overrideRun = OpenAIResponses.ToAgentRunRequest( + requestBody, + overrideAgent, + CreateMapOptions(OpenAIClientFunctionToolNameConflictBehavior.AllowOverride())); + AgentResponse overrideResponse = await overrideAgent.RunAsync( + overrideRun.Messages, + options: overrideRun.Options); + Assert.Equal(0, getOverrideInvocationCount()); + FunctionCallContent functionCall = Assert.Single( + overrideResponse.Messages.SelectMany(message => message.Contents).OfType()); + Assert.Equal("get_weather", functionCall.Name); + } + + private static (ChatClientAgent Agent, Func GetInvocationCount) CreateAgent() + { + int invocationCount = 0; + var agent = new ChatClientAgent( + new OpenAIClient(ApiKey).GetResponsesClient().AsIChatClient(ModelName), + instructions: """ + For every weather request, call get_weather before answering. + After receiving a function result, include it verbatim in the answer. + """, + name: "weather-agent", + tools: [AIFunctionFactory.Create(GetHostedWeather, name: "get_weather")]); + return (agent, () => invocationCount); + + string GetHostedWeather(string location) + { + invocationCount++; + return $"HOSTED_FUNCTION_RESULT: {location}=18C"; + } + } + +#pragma warning disable MAAI001 + private static OpenAIResponsesMapOptions CreateMapOptions( + OpenAIClientFunctionToolNameConflictBehavior behavior) => + new() + { + DangerouslyAllowClientFunctionTools = new(behavior) + }; +#pragma warning restore MAAI001 + + private static JsonElement ParseBody(string json) + { + using JsonDocument document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolNameConflictBehaviorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolNameConflictBehaviorTests.cs new file mode 100644 index 00000000000..7c7a3a675be --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolNameConflictBehaviorTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Tests for . +/// +public sealed class OpenAIClientFunctionToolNameConflictBehaviorTests +{ + [Fact] + public void Reject_ReturnsRejectBehavior() + { + // Act +#pragma warning disable MAAI001 + OpenAIClientFunctionToolNameConflictBehavior behavior = + OpenAIClientFunctionToolNameConflictBehavior.Reject(); +#pragma warning restore MAAI001 + + // Assert + Assert.Equal(OpenAIClientFunctionToolNameConflictBehaviorKind.Reject, behavior.Kind); + } + + [Fact] + public void Ignore_ReturnsIgnoreBehavior() + { + // Act +#pragma warning disable MAAI001 + OpenAIClientFunctionToolNameConflictBehavior behavior = + OpenAIClientFunctionToolNameConflictBehavior.Ignore(); +#pragma warning restore MAAI001 + + // Assert + Assert.Equal(OpenAIClientFunctionToolNameConflictBehaviorKind.Ignore, behavior.Kind); + } + + [Fact] + public void AllowOverride_ReturnsAllowOverrideBehavior() + { + // Act +#pragma warning disable MAAI001 + OpenAIClientFunctionToolNameConflictBehavior behavior = + OpenAIClientFunctionToolNameConflictBehavior.AllowOverride(); +#pragma warning restore MAAI001 + + // Assert + Assert.Equal(OpenAIClientFunctionToolNameConflictBehaviorKind.AllowOverride, behavior.Kind); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolsOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolsOptionsTests.cs new file mode 100644 index 00000000000..1c8d32afdbe --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolsOptionsTests.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Tests for . +/// +public sealed class OpenAIClientFunctionToolsOptionsTests +{ + [Fact] + public void Constructor_NullConflictBehavior_ThrowsArgumentNullException() + { + // Act & Assert +#pragma warning disable MAAI001 + Assert.Throws(() => + new OpenAIClientFunctionToolsOptions(nameConflictBehavior: null!)); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs index b0ce53eaa08..0794da15ab3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs @@ -146,6 +146,112 @@ public async Task Responses_DefaultEndpoint_RejectsRequestWithSettingsAsync() Assert.Contains("temperature", body, StringComparison.Ordinal); } + [Fact] + public async Task Responses_DefaultEndpoint_RejectsClientFunctionToolsAsync() + { + // Arrange + using var app = await CreateResponsesServerAsync("reject-tools-agent", mapOptions: null); + HttpClient client = GetClient(app); + + // Act + HttpResponseMessage response = await client.PostAsync( + new Uri("/reject-tools-agent/v1/responses", UriKind.Relative), + new StringContent( + """{"input":"hello","tools":[{"type":"function","name":"client_function"}]}""", + Encoding.UTF8, + "application/json")); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + string body = await response.Content.ReadAsStringAsync(); + Assert.Contains("tools", body, StringComparison.Ordinal); + } + + [Fact] + public async Task Responses_DangerousClientFunctionOptIn_DoesNotAllowToolChoiceAsync() + { + // Arrange +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions + { + DangerouslyAllowClientFunctionTools = + new(OpenAIClientFunctionToolNameConflictBehavior.Reject()) + }; +#pragma warning restore MAAI001 + using var app = await CreateResponsesServerAsync("reject-tool-choice-agent", mapOptions); + HttpClient client = GetClient(app); + + // Act + HttpResponseMessage response = await client.PostAsync( + new Uri("/reject-tool-choice-agent/v1/responses", UriKind.Relative), + new StringContent( + """ + { + "input": "hello", + "tools": [ + { "type": "function", "name": "client_function" } + ], + "tool_choice": "required" + } + """, + Encoding.UTF8, + "application/json")); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + string body = await response.Content.ReadAsStringAsync(); + Assert.Contains("tool_choice", body, StringComparison.Ordinal); + } + + [Fact] + public async Task Responses_DangerousClientFunctionOptIn_DoesNotAllowOtherToolTypesAsync() + { + // Arrange +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions + { + DangerouslyAllowClientFunctionTools = + new(OpenAIClientFunctionToolNameConflictBehavior.Reject()) + }; +#pragma warning restore MAAI001 + using var app = await CreateResponsesServerAsync("reject-hosted-tool-agent", mapOptions); + HttpClient client = GetClient(app); + + // Act + HttpResponseMessage response = await client.PostAsync( + new Uri("/reject-hosted-tool-agent/v1/responses", UriKind.Relative), + new StringContent( + """{"input":"hello","tools":[{"type":"web_search"}]}""", + Encoding.UTF8, + "application/json")); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + string body = await response.Content.ReadAsStringAsync(); + Assert.Contains("tools", body, StringComparison.Ordinal); + } + + [Fact] + public async Task Responses_DefaultEndpoint_RejectsUnrecognizedToolChoiceAsync() + { + // Arrange + using var app = await CreateResponsesServerAsync("reject-unknown-tool-choice-agent", mapOptions: null); + HttpClient client = GetClient(app); + + // Act + HttpResponseMessage response = await client.PostAsync( + new Uri("/reject-unknown-tool-choice-agent/v1/responses", UriKind.Relative), + new StringContent( + """{"input":"hello","tool_choice":"unsupported"}""", + Encoding.UTF8, + "application/json")); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + string body = await response.Content.ReadAsStringAsync(); + Assert.Contains("tool_choice", body, StringComparison.Ordinal); + } + [Fact] public async Task Responses_ConfiguredEndpoint_HonorsRequestSettingsAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs index f4d613e6d6a..394bbb7d4f6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs @@ -93,7 +93,7 @@ public void ToRequestInfo_NoToolChoice_MapsToNull() } [Fact] - public void ToRequestInfo_MapsFunctionToolToDeclaration() + public void ToRequestInfo_PreservesFunctionToolAsRawTool() { // Arrange CreateResponse request = new() @@ -124,12 +124,12 @@ public void ToRequestInfo_MapsFunctionToolToDeclaration() OpenAIResponseRequestInfo info = request.ToRequestInfo(); // Assert - Assert.Single(info.Tools!); - AIFunctionDeclaration function = Assert.IsAssignableFrom(Assert.Single(info.FunctionTools!)); - Assert.Equal("get_weather", function.Name); - Assert.Equal("Retrieves current weather.", function.Description); - Assert.True(function.JsonSchema.GetProperty("properties").TryGetProperty("location", out _)); - Assert.True(Assert.IsType(function.AdditionalProperties["strict"])); + JsonElement function = Assert.Single(info.Tools!); + Assert.Equal("function", function.GetProperty("type").GetString()); + Assert.Equal("get_weather", function.GetProperty("name").GetString()); + Assert.Equal("Retrieves current weather.", function.GetProperty("description").GetString()); + Assert.True(function.GetProperty("parameters").GetProperty("properties").TryGetProperty("location", out _)); + Assert.True(function.GetProperty("strict").GetBoolean()); } private static CreateResponse CreateRequestWithToolChoice(string toolChoiceJson) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs index 6653d63b2e0..78f2c98ee2a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs @@ -15,6 +15,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using OpenAI; using OpenAI.Responses; @@ -942,19 +943,26 @@ public async Task CreateResponse_FunctionCall_ReturnsCorrectlyAsync() } [Fact] - public async Task CreateResponse_WithRequestFunctionTool_ForwardsToolAndReturnsFunctionCallAsync() + public async Task CreateResponse_WithAllowedClientFunctionTool_ForwardsToolAndReturnsFunctionCallAsync() { // Arrange const string AgentName = "request-function-tool-agent"; var chatClient = new TestHelpers.FunctionCallMockChatClient( "get_weather", """{"location":"Valencia, Spain","units":"celsius"}"""); +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions + { + DangerouslyAllowClientFunctionTools = + new(OpenAIClientFunctionToolNameConflictBehavior.AllowOverride()) + }; +#pragma warning restore MAAI001 this._httpClient = await this.CreateTestServerWithCustomClientAsync( agentName: AgentName, instructions: "You are a helpful assistant.", chatClient, - PermissiveMapOptions.Responses()); + mapOptions); using var content = new StringContent( """ @@ -976,8 +984,7 @@ public async Task CreateResponse_WithRequestFunctionTool_ForwardsToolAndReturnsF }, "strict": true } - ], - "tool_choice": "required" + ] } """, Encoding.UTF8, @@ -991,7 +998,8 @@ public async Task CreateResponse_WithRequestFunctionTool_ForwardsToolAndReturnsF // Assert Assert.True(httpResponse.IsSuccessStatusCode, $"Response status: {httpResponse.StatusCode}"); Assert.NotNull(chatClient.LastChatOptions); - Assert.Equal(ChatToolMode.RequireAny, chatClient.LastChatOptions.ToolMode); + Assert.False(chatClient.LastChatOptions.AllowMultipleToolCalls); + Assert.Null(chatClient.LastChatOptions.ToolMode); AIFunctionDeclaration tool = Assert.IsAssignableFrom(Assert.Single(chatClient.LastChatOptions.Tools!)); Assert.Equal("get_weather", tool.Name); Assert.Equal("Retrieves current weather for the given location.", tool.Description); @@ -1005,6 +1013,256 @@ public async Task CreateResponse_WithRequestFunctionTool_ForwardsToolAndReturnsF Assert.Equal("get_weather", output.GetProperty("name").GetString()); } + [Fact] + public async Task CreateResponse_WithHostedAIFunction_ForwardsAndExecutesToolAsync() + { + // Arrange + const string AgentName = "hosted-function-tool-agent"; + const string FunctionName = "get_weather"; + int invocationCount = 0; + var chatClient = new TestHelpers.FunctionToolExecutingMockChatClient(FunctionName); + AIFunction function = AIFunctionFactory.Create(GetWeather, FunctionName); + + this._httpClient = await this.CreateTestServerWithHostedToolAsync( + AgentName, + chatClient, + function); + + using var content = new StringContent( + """{"input":"What's the weather in Valencia?"}""", + Encoding.UTF8, + "application/json"); + + // Act + using HttpResponseMessage response = await this._httpClient.PostAsync( + new Uri($"/{AgentName}/v1/responses", UriKind.Relative), + content); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1, invocationCount); + Assert.Equal("Sunny in Valencia", chatClient.FunctionResult); + Assert.Contains( + chatClient.FirstRequestOptions!.Tools!, + tool => tool is AIFunction candidate && candidate.Name == FunctionName); + + string GetWeather(string location) + { + invocationCount++; + return $"Sunny in {location}"; + } + } + + [Fact] + public async Task CreateResponse_WithHostedMcpTool_ForwardsToolToResponsesProviderAsync() + { + // Arrange + var chatClient = new TestHelpers.SimpleMockChatClient("Documentation found."); +#pragma warning disable MEAI001 + var hostedMcpTool = new HostedMcpServerTool( + serverName: "test_mcp", + serverAddress: "https://example.test/mcp") + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire + }; +#pragma warning restore MEAI001 + const string AgentName = "hosted-mcp-tool-agent"; + + this._httpClient = await this.CreateTestServerWithHostedToolAsync( + AgentName, + chatClient, + hostedMcpTool); + + using var content = new StringContent( + """{"input":"Search the documentation."}""", + Encoding.UTF8, + "application/json"); + + // Act + using HttpResponseMessage response = await this._httpClient.PostAsync( + new Uri($"/{AgentName}/v1/responses", UriKind.Relative), + content); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + HostedMcpServerTool forwardedTool = + Assert.IsType(Assert.Single(chatClient.LastChatOptions!.Tools!)); + Assert.Equal("test_mcp", forwardedTool.ServerName); + Assert.Equal("https://example.test/mcp", forwardedTool.ServerAddress); + } + + [Fact] + public async Task CreateResponse_WithClientFunctionNamedMcp_DoesNotReplaceHostedMcpToolAsync() + { + // Arrange + const string AgentName = "client-function-and-hosted-mcp-agent"; + var chatClient = new TestHelpers.SimpleMockChatClient("Documentation found."); +#pragma warning disable MEAI001 + var hostedMcpTool = new HostedMcpServerTool( + serverName: "test_mcp", + serverAddress: "https://example.test/mcp") + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire + }; +#pragma warning restore MEAI001 +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions + { + DangerouslyAllowClientFunctionTools = + new(OpenAIClientFunctionToolNameConflictBehavior.AllowOverride()) + }; +#pragma warning restore MAAI001 + + this._httpClient = await this.CreateTestServerWithHostedToolAsync( + AgentName, + chatClient, + hostedMcpTool, + mapOptions); + using StringContent content = CreateClientFunctionRequest("mcp"); + + // Act + using HttpResponseMessage response = await this._httpClient.PostAsync( + new Uri($"/{AgentName}/v1/responses", UriKind.Relative), + content); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains(chatClient.LastChatOptions!.Tools!, tool => tool is HostedMcpServerTool); + Assert.Contains(chatClient.LastChatOptions.Tools!, tool => + tool is AIFunctionDeclaration function && function.Name == "mcp"); + } + + [Fact] + public async Task CreateResponse_WithConflictingClientFunctionAndRejectPolicy_ReturnsBadRequestAsync() + { + // Arrange + const string AgentName = "reject-client-function-conflict-agent"; + const string FunctionName = "get_weather"; + AIFunction hostedFunction = AIFunctionFactory.Create( + (string location) => $"Sunny in {location}", + FunctionName); +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions + { + DangerouslyAllowClientFunctionTools = + new(OpenAIClientFunctionToolNameConflictBehavior.Reject()) + }; +#pragma warning restore MAAI001 + + this._httpClient = await this.CreateTestServerWithHostedToolAsync( + AgentName, + new TestHelpers.SimpleMockChatClient(), + hostedFunction, + mapOptions); + using StringContent content = CreateClientFunctionRequest(FunctionName); + + // Act + using HttpResponseMessage response = await this._httpClient.PostAsync( + new Uri($"/{AgentName}/v1/responses", UriKind.Relative), + content); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains( + FunctionName, + await response.Content.ReadAsStringAsync(), + StringComparison.Ordinal); + } + + [Fact] + public async Task CreateResponse_WithConflictingClientFunctionAndIgnorePolicy_UsesHostedFunctionAndLogsWarningAsync() + { + // Arrange + const string AgentName = "ignore-client-function-conflict-agent"; + const string FunctionName = "get_weather"; + int invocationCount = 0; + var chatClient = new TestHelpers.FunctionToolExecutingMockChatClient(FunctionName); + var loggerProvider = new WarningRecordingLoggerProvider(); + AIFunction hostedFunction = AIFunctionFactory.Create(GetWeather, FunctionName); +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions + { + DangerouslyAllowClientFunctionTools = + new(OpenAIClientFunctionToolNameConflictBehavior.Ignore()) + }; +#pragma warning restore MAAI001 + + this._httpClient = await this.CreateTestServerWithHostedToolAsync( + AgentName, + chatClient, + hostedFunction, + mapOptions, + loggerProvider); + using StringContent content = CreateClientFunctionRequest(FunctionName); + + // Act + using HttpResponseMessage response = await this._httpClient.PostAsync( + new Uri($"/{AgentName}/v1/responses", UriKind.Relative), + content); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1, invocationCount); + Assert.Equal("Sunny in Valencia", chatClient.FunctionResult); + Assert.Contains(loggerProvider.Messages, message => + message.Contains(FunctionName, StringComparison.Ordinal)); + + string GetWeather(string location) + { + invocationCount++; + return $"Sunny in {location}"; + } + } + + [Fact] + public async Task CreateResponse_WithConflictingClientFunctionAndAllowOverridePolicy_UsesClientDeclarationAsync() + { + // Arrange + const string AgentName = "override-client-function-conflict-agent"; + const string FunctionName = "get_weather"; + int invocationCount = 0; + var chatClient = new TestHelpers.FunctionCallMockChatClient( + FunctionName, + """{"location":"Valencia"}"""); + AIFunction hostedFunction = AIFunctionFactory.Create(GetWeather, FunctionName); +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions + { + DangerouslyAllowClientFunctionTools = + new(OpenAIClientFunctionToolNameConflictBehavior.AllowOverride()) + }; +#pragma warning restore MAAI001 + + this._httpClient = await this.CreateTestServerWithHostedToolAsync( + AgentName, + chatClient, + hostedFunction, + mapOptions); + using StringContent content = CreateClientFunctionRequest(FunctionName); + + // Act + using HttpResponseMessage response = await this._httpClient.PostAsync( + new Uri($"/{AgentName}/v1/responses", UriKind.Relative), + content); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(0, invocationCount); + AIFunctionDeclaration forwardedFunction = + Assert.IsAssignableFrom(Assert.Single(chatClient.LastChatOptions!.Tools!)); + Assert.Equal("Client-provided function.", forwardedFunction.Description); + Assert.Contains( + "\"type\":\"function_call\"", + await response.Content.ReadAsStringAsync(), + StringComparison.Ordinal); + + string GetWeather(string location) + { + invocationCount++; + return $"Sunny in {location}"; + } + } + /// /// Verifies that responses with function calls stream correctly. /// @@ -1499,6 +1757,61 @@ private async Task CreateTestServerWithCustomClientAsync( return testServer.CreateClient(); } + private async Task CreateTestServerWithHostedToolAsync( + string agentName, + IChatClient chatClient, + AITool tool, + OpenAIResponsesMapOptions? mapOptions = null, + ILoggerProvider? loggerProvider = null) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + if (loggerProvider is not null) + { + builder.Logging.AddProvider(loggerProvider); + } + + IHostedAgentBuilder agentBuilder = builder + .AddAIAgent(agentName, "You are a helpful assistant.", chatClient) + .WithAITool(tool); + builder.AddOpenAIResponses(); + + this._app = builder.Build(); + this._app.MapOpenAIResponses(agentBuilder, path: null, mapOptions); + await this._app.StartAsync(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + return testServer.CreateClient(); + } + + private static StringContent CreateClientFunctionRequest(string functionName) => + new( + $$""" + { + "input": "What's the weather in Valencia?", + "tools": [ + { + "type": "function", + "name": "{{functionName}}", + "description": "Client-provided function.", + "parameters": { + "type": "object", + "properties": { + "location": { "type": "string" } + }, + "required": [ "location" ], + "additionalProperties": false + }, + "strict": true + } + ] + } + """, + Encoding.UTF8, + "application/json"); + private async Task CreateTestServerWithMultipleAgentsAsync( params (string Name, string Instructions, string ResponseText)[] agents) { @@ -1529,4 +1842,36 @@ private async Task CreateTestServerWithMultipleAgentsAsync( return testServer.CreateClient(); } + + private sealed class WarningRecordingLoggerProvider : ILoggerProvider + { + public List Messages { get; } = []; + + public ILogger CreateLogger(string categoryName) => new WarningRecordingLogger(this.Messages); + + public void Dispose() + { + } + + private sealed class WarningRecordingLogger(List messages) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (this.IsEnabled(logLevel)) + { + messages.Add(formatter(state, exception)); + } + } + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs index 2ed5b735acb..cbb912246d5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs @@ -27,6 +27,63 @@ public void ToAgentRunRequest_StringInput_ProducesUserMessage() Assert.Null(request.Options); } + [Fact] + public void ToAgentRunRequest_DangerousClientFunctionOptInWithoutAgent_ThrowsNotSupportedException() + { + // Arrange + using var doc = JsonDocument.Parse("""{ "input": "Hello there" }"""); +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions + { + DangerouslyAllowClientFunctionTools = + new(OpenAIClientFunctionToolNameConflictBehavior.Reject()) + }; +#pragma warning restore MAAI001 + + // Act & Assert + Assert.Throws(() => + OpenAIResponses.ToAgentRunRequest(doc.RootElement, mapOptions)); + } + + [Fact] + public void ToAgentRunRequest_DangerousClientFunctionOptInWithAgent_ReturnsRunOptions() + { + // Arrange + using var doc = JsonDocument.Parse( + """ + { + "input": "Hello there", + "tools": [ + { + "type": "function", + "name": "client_function", + "parameters": { "type": "object" } + } + ] + } + """); + using var chatClient = new TestHelpers.SimpleMockChatClient(); + AIAgent agent = chatClient.AsAIAgent(name: "test-agent"); +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions + { + DangerouslyAllowClientFunctionTools = + new(OpenAIClientFunctionToolNameConflictBehavior.Reject()) + }; +#pragma warning restore MAAI001 + + // Act + OpenAIResponsesRunRequest request = + OpenAIResponses.ToAgentRunRequest(doc.RootElement, agent, mapOptions); + + // Assert + ChatClientAgentRunOptions runOptions = + Assert.IsType(request.Options); + AIFunctionDeclaration function = + Assert.IsAssignableFrom(Assert.Single(runOptions.ChatOptions!.Tools!)); + Assert.Equal("client_function", function.Name); + } + [Fact] public void GetSessionStoreId_PreviousResponseId_IsReturned() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs index cecf8a1b755..7d7c70f4f21 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs @@ -18,6 +18,9 @@ internal static class PermissiveMapOptions { public static OpenAIResponsesMapOptions Responses() => new() { +#pragma warning disable MAAI001 + DangerouslyAllowClientFunctionTools = new(OpenAIClientFunctionToolNameConflictBehavior.AllowOverride()), +#pragma warning restore MAAI001 RunOptionsFactory = static request => { var chatOptions = new ChatOptions @@ -28,7 +31,6 @@ internal static class PermissiveMapOptions Instructions = request.Instructions, ModelId = request.Model, ToolMode = request.ToolChoice, - Tools = request.FunctionTools is { Count: > 0 } tools ? tools.ToList() : null, }; return new ChatClientAgentRunOptions(chatOptions); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs index eeb318619e4..85c68b284dc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs @@ -433,6 +433,80 @@ public void Dispose() } } + /// + /// Mock implementation of IChatClient that requests a function and then returns a final response + /// after receiving the function result. + /// + internal sealed class FunctionToolExecutingMockChatClient : IChatClient + { + private readonly string _functionName; + private bool _functionRequested; + + public FunctionToolExecutingMockChatClient(string functionName) + { + this._functionName = functionName; + } + + public ChatOptions? FirstRequestOptions { get; private set; } + + public string? FunctionResult { get; private set; } + + public ChatClientMetadata Metadata { get; } = new( + "Test", + new Uri("https://test.example.com"), + "test-model"); + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Delay(1, cancellationToken); + + if (!this._functionRequested) + { + this._functionRequested = true; + this.FirstRequestOptions = options; + yield return new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = + [ + new FunctionCallContent( + "call_1", + this._functionName, + new Dictionary { ["location"] = "Valencia" }) + ] + }; + yield break; + } + + FunctionResultContent result = messages + .SelectMany(message => message.Contents) + .OfType() + .Single(); + this.FunctionResult = result.Result?.ToString(); + yield return new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new TextContent("The weather tool completed.")] + }; + } + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType.IsInstanceOfType(this) ? this : null; + + public void Dispose() + { + } + } + /// /// Mock implementation of IChatClient that returns mixed content types. /// From bc86a5305cf88ff7bf51dc81b200fac63cec3395 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:15:00 +0100 Subject: [PATCH 4/7] Simplify client function tool options --- ...IClientFunctionToolNameConflictBehavior.cs | 23 +++++++------- .../OpenAIClientFunctionToolsOptions.cs | 30 ------------------- .../OpenAIResponsesMapOptions.cs | 2 +- .../OpenAIResponseRequestInfoBuilder.cs | 9 +++--- .../OpenAIResponseRunOptionsBuilder.cs | 30 ++++++++++--------- ...AIResponsesClientFunctionToolsLiveTests.cs | 2 +- ...ntFunctionToolNameConflictBehaviorTests.cs | 6 ++-- .../OpenAIClientFunctionToolsOptionsTests.cs | 21 ------------- .../OpenAIMapOptionsTests.cs | 4 +-- .../OpenAIResponsesIntegrationTests.cs | 10 +++---- .../OpenAIResponsesTests.cs | 4 +-- .../PermissiveMapOptions.cs | 2 +- 12 files changed, 45 insertions(+), 98 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolsOptions.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolsOptionsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolNameConflictBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolNameConflictBehavior.cs index af85d18052a..c2c10b84252 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolNameConflictBehavior.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolNameConflictBehavior.cs @@ -36,30 +36,27 @@ private protected OpenAIClientFunctionToolNameConflictBehavior() /// The conflict behavior. public static OpenAIClientFunctionToolNameConflictBehavior AllowOverride() => new AllowOverrideBehavior(); - internal abstract OpenAIClientFunctionToolNameConflictBehaviorKind Kind { get; } + internal abstract BehaviorKind Kind { get; } private sealed class RejectBehavior : OpenAIClientFunctionToolNameConflictBehavior { - internal override OpenAIClientFunctionToolNameConflictBehaviorKind Kind => - OpenAIClientFunctionToolNameConflictBehaviorKind.Reject; + internal override BehaviorKind Kind => BehaviorKind.Reject; } private sealed class IgnoreBehavior : OpenAIClientFunctionToolNameConflictBehavior { - internal override OpenAIClientFunctionToolNameConflictBehaviorKind Kind => - OpenAIClientFunctionToolNameConflictBehaviorKind.Ignore; + internal override BehaviorKind Kind => BehaviorKind.Ignore; } private sealed class AllowOverrideBehavior : OpenAIClientFunctionToolNameConflictBehavior { - internal override OpenAIClientFunctionToolNameConflictBehaviorKind Kind => - OpenAIClientFunctionToolNameConflictBehaviorKind.AllowOverride; + internal override BehaviorKind Kind => BehaviorKind.AllowOverride; } -} -internal enum OpenAIClientFunctionToolNameConflictBehaviorKind -{ - Reject, - Ignore, - AllowOverride, + internal enum BehaviorKind + { + Reject, + Ignore, + AllowOverride, + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolsOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolsOptions.cs deleted file mode 100644 index 427e1349d10..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolsOptions.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.OpenAI; - -/// -/// Configures the dangerous forwarding of client-provided function declarations. -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class OpenAIClientFunctionToolsOptions -{ - /// - /// Initializes a new instance of the class. - /// - /// - /// The required behavior when a client function uses the same name as a hosted agent tool. - /// - public OpenAIClientFunctionToolsOptions(OpenAIClientFunctionToolNameConflictBehavior nameConflictBehavior) - { - this.NameConflictBehavior = Throw.IfNull(nameConflictBehavior); - } - - /// - /// Gets the behavior used when a client function uses the same name as a hosted agent tool. - /// - public OpenAIClientFunctionToolNameConflictBehavior NameConflictBehavior { get; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs index ef5b9638555..53f3599ea30 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs @@ -71,7 +71,7 @@ public sealed class OpenAIResponsesMapOptions /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] - public OpenAIClientFunctionToolsOptions? DangerouslyAllowClientFunctionTools { get; set; } + public OpenAIClientFunctionToolNameConflictBehavior? DangerouslyAllowClientFunctionTools { get; set; } /// /// The default implementation. Throws a diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs index f8647da4a4d..ac2b2c0289a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs @@ -23,12 +23,11 @@ internal static class OpenAIResponseRequestInfoBuilder HasToolChoice = request.ToolChoice is not null, }; - internal static List? ExtractClientFunctionTools( - this IReadOnlyList tools, - out List? unsupportedTools) + internal static (List? FunctionTools, List? UnsupportedTools) + ExtractClientFunctionTools(this IReadOnlyList tools) { List? functionTools = null; - unsupportedTools = null; + List? unsupportedTools = null; foreach (JsonElement tool in tools) { @@ -42,7 +41,7 @@ internal static class OpenAIResponseRequestInfoBuilder } } - return functionTools; + return (functionTools, unsupportedTools); } private static ClientAIFunctionDeclaration? ToFunctionTool(this JsonElement tool) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs index a17d521d57f..b011008aacd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs @@ -23,14 +23,16 @@ internal static class OpenAIResponseRunOptionsBuilder bool logConflicts = false) { OpenAIResponseRequestInfo requestInfo = request.ToRequestInfo(); - OpenAIClientFunctionToolsOptions? clientFunctionOptions = mapOptions.DangerouslyAllowClientFunctionTools; + OpenAIClientFunctionToolNameConflictBehavior? clientFunctionBehavior = + mapOptions.DangerouslyAllowClientFunctionTools; - if (clientFunctionOptions is null || requestInfo.Tools is not { Count: > 0 } requestTools) + if (clientFunctionBehavior is null || requestInfo.Tools is not { Count: > 0 } requestTools) { return mapOptions.RunOptionsFactory(requestInfo); } - List? clientFunctions = requestTools.ExtractClientFunctionTools(out List? unsupportedTools); + (List? clientFunctions, List? unsupportedTools) = + requestTools.ExtractClientFunctionTools(); if (clientFunctions is not { Count: > 0 }) { return mapOptions.RunOptionsFactory(requestInfo); @@ -42,8 +44,8 @@ internal static class OpenAIResponseRunOptionsBuilder // configured factory and is rejected by the default factory. requestInfo.Tools = unsupportedTools; AgentRunOptions? runOptions = mapOptions.RunOptionsFactory(requestInfo); - OpenAIClientFunctionToolNameConflictBehaviorKind nameConflictBehavior = - clientFunctionOptions.NameConflictBehavior.Kind; + OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind nameConflictBehavior = + clientFunctionBehavior.Kind; HashSet hostedToolNames = GetHostedToolNames(agent, runOptions); List conflictingNames = clientFunctions @@ -57,10 +59,10 @@ internal static class OpenAIResponseRunOptionsBuilder { switch (nameConflictBehavior) { - case OpenAIClientFunctionToolNameConflictBehaviorKind.Reject: + case OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.Reject: throw CreateNameConflictException(conflictingNames); - case OpenAIClientFunctionToolNameConflictBehaviorKind.Ignore: + case OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.Ignore: clientFunctions.RemoveAll(tool => hostedToolNames.Contains(tool.Name)); if (logConflicts) { @@ -70,7 +72,7 @@ internal static class OpenAIResponseRunOptionsBuilder } break; - case OpenAIClientFunctionToolNameConflictBehaviorKind.AllowOverride: + case OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.AllowOverride: break; } } @@ -83,7 +85,7 @@ internal static class OpenAIResponseRunOptionsBuilder private static ChatClientAgentRunOptions AddClientFunctions( AgentRunOptions? runOptions, List clientFunctions, - OpenAIClientFunctionToolNameConflictBehaviorKind nameConflictBehavior, + OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind nameConflictBehavior, ILogger? logger) { ChatClientAgentRunOptions chatRunOptions = runOptions switch @@ -163,13 +165,13 @@ private static NotSupportedException CreateNameConflictException(IReadOnlyList tool is not OpenAIResponseRequestInfoBuilder.ClientAIFunctionDeclaration || !conflictSet.Contains(tool.Name)).ToList() @@ -235,7 +237,7 @@ tool is OpenAIResponseRequestInfoBuilder.ClientAIFunctionDeclaration || tool is not AIFunctionDeclaration || !conflictSet.Contains(tool.Name)).ToList(); - if (this._nameConflictBehavior == OpenAIClientFunctionToolNameConflictBehaviorKind.Ignore && + if (this._nameConflictBehavior == OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.Ignore && !this._loggedIgnoredConflicts) { this._loggedIgnoredConflicts = true; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs index 714c9d2684d..2ba57ea48ed 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs @@ -110,7 +110,7 @@ private static OpenAIResponsesMapOptions CreateMapOptions( OpenAIClientFunctionToolNameConflictBehavior behavior) => new() { - DangerouslyAllowClientFunctionTools = new(behavior) + DangerouslyAllowClientFunctionTools = behavior }; #pragma warning restore MAAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolNameConflictBehaviorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolNameConflictBehaviorTests.cs index 7c7a3a675be..9c1ba0b5379 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolNameConflictBehaviorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolNameConflictBehaviorTests.cs @@ -17,7 +17,7 @@ public void Reject_ReturnsRejectBehavior() #pragma warning restore MAAI001 // Assert - Assert.Equal(OpenAIClientFunctionToolNameConflictBehaviorKind.Reject, behavior.Kind); + Assert.Equal(OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.Reject, behavior.Kind); } [Fact] @@ -30,7 +30,7 @@ public void Ignore_ReturnsIgnoreBehavior() #pragma warning restore MAAI001 // Assert - Assert.Equal(OpenAIClientFunctionToolNameConflictBehaviorKind.Ignore, behavior.Kind); + Assert.Equal(OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.Ignore, behavior.Kind); } [Fact] @@ -43,6 +43,6 @@ public void AllowOverride_ReturnsAllowOverrideBehavior() #pragma warning restore MAAI001 // Assert - Assert.Equal(OpenAIClientFunctionToolNameConflictBehaviorKind.AllowOverride, behavior.Kind); + Assert.Equal(OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.AllowOverride, behavior.Kind); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolsOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolsOptionsTests.cs deleted file mode 100644 index 1c8d32afdbe..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolsOptionsTests.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; - -/// -/// Tests for . -/// -public sealed class OpenAIClientFunctionToolsOptionsTests -{ - [Fact] - public void Constructor_NullConflictBehavior_ThrowsArgumentNullException() - { - // Act & Assert -#pragma warning disable MAAI001 - Assert.Throws(() => - new OpenAIClientFunctionToolsOptions(nameConflictBehavior: null!)); -#pragma warning restore MAAI001 - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs index 0794da15ab3..73058d06056 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs @@ -175,7 +175,7 @@ public async Task Responses_DangerousClientFunctionOptIn_DoesNotAllowToolChoiceA var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = - new(OpenAIClientFunctionToolNameConflictBehavior.Reject()) + OpenAIClientFunctionToolNameConflictBehavior.Reject() }; #pragma warning restore MAAI001 using var app = await CreateResponsesServerAsync("reject-tool-choice-agent", mapOptions); @@ -211,7 +211,7 @@ public async Task Responses_DangerousClientFunctionOptIn_DoesNotAllowOtherToolTy var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = - new(OpenAIClientFunctionToolNameConflictBehavior.Reject()) + OpenAIClientFunctionToolNameConflictBehavior.Reject() }; #pragma warning restore MAAI001 using var app = await CreateResponsesServerAsync("reject-hosted-tool-agent", mapOptions); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs index 78f2c98ee2a..0e9852d2508 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs @@ -954,7 +954,7 @@ public async Task CreateResponse_WithAllowedClientFunctionTool_ForwardsToolAndRe var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = - new(OpenAIClientFunctionToolNameConflictBehavior.AllowOverride()) + OpenAIClientFunctionToolNameConflictBehavior.AllowOverride() }; #pragma warning restore MAAI001 @@ -1109,7 +1109,7 @@ public async Task CreateResponse_WithClientFunctionNamedMcp_DoesNotReplaceHosted var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = - new(OpenAIClientFunctionToolNameConflictBehavior.AllowOverride()) + OpenAIClientFunctionToolNameConflictBehavior.AllowOverride() }; #pragma warning restore MAAI001 @@ -1145,7 +1145,7 @@ public async Task CreateResponse_WithConflictingClientFunctionAndRejectPolicy_Re var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = - new(OpenAIClientFunctionToolNameConflictBehavior.Reject()) + OpenAIClientFunctionToolNameConflictBehavior.Reject() }; #pragma warning restore MAAI001 @@ -1183,7 +1183,7 @@ public async Task CreateResponse_WithConflictingClientFunctionAndIgnorePolicy_Us var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = - new(OpenAIClientFunctionToolNameConflictBehavior.Ignore()) + OpenAIClientFunctionToolNameConflictBehavior.Ignore() }; #pragma warning restore MAAI001 @@ -1229,7 +1229,7 @@ public async Task CreateResponse_WithConflictingClientFunctionAndAllowOverridePo var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = - new(OpenAIClientFunctionToolNameConflictBehavior.AllowOverride()) + OpenAIClientFunctionToolNameConflictBehavior.AllowOverride() }; #pragma warning restore MAAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs index cbb912246d5..cb2db3790ac 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs @@ -36,7 +36,7 @@ public void ToAgentRunRequest_DangerousClientFunctionOptInWithoutAgent_ThrowsNot var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = - new(OpenAIClientFunctionToolNameConflictBehavior.Reject()) + OpenAIClientFunctionToolNameConflictBehavior.Reject() }; #pragma warning restore MAAI001 @@ -68,7 +68,7 @@ public void ToAgentRunRequest_DangerousClientFunctionOptInWithAgent_ReturnsRunOp var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = - new(OpenAIClientFunctionToolNameConflictBehavior.Reject()) + OpenAIClientFunctionToolNameConflictBehavior.Reject() }; #pragma warning restore MAAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs index 7d7c70f4f21..10597052765 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs @@ -19,7 +19,7 @@ internal static class PermissiveMapOptions public static OpenAIResponsesMapOptions Responses() => new() { #pragma warning disable MAAI001 - DangerouslyAllowClientFunctionTools = new(OpenAIClientFunctionToolNameConflictBehavior.AllowOverride()), + DangerouslyAllowClientFunctionTools = OpenAIClientFunctionToolNameConflictBehavior.AllowOverride(), #pragma warning restore MAAI001 RunOptionsFactory = static request => { From 2790966322f742ddd832b0e4ef9d0a410a9d7fda Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:31:55 +0100 Subject: [PATCH 5/7] Clarify client function tool risk --- .../OpenAIResponsesMapOptions.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs index 53f3599ea30..9e70355167b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs @@ -52,6 +52,11 @@ public sealed class OpenAIResponsesMapOptions /// server, but matching function calls are returned to the client for execution. /// /// + /// A client function may cause the model to choose it instead of a function configured by the + /// hosted agent developer, even when their names do not conflict. Function arguments and any data + /// included in those arguments are then returned to the client. + /// + /// /// The default is , which leaves client-provided tools subject to /// . Enabling this setting requires an explicit /// . The request's From 87f357a78cb15ea035843a09580cdebdf3def81a Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:56:04 +0100 Subject: [PATCH 6/7] Dispose HTTP test resources and simplify tool filtering Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d6ca13a-3274-4167-a074-bfe4df9423ed --- .../OpenAIResponseRunOptionsBuilder.cs | 7 +- .../OpenAIMapOptionsTests.cs | 68 ++++++++++--------- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs index b011008aacd..08745ae4bea 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs @@ -135,12 +135,9 @@ private static void AddToolNames(IEnumerable? tools, HashSet nam return; } - foreach (AITool tool in tools) + foreach (AITool tool in tools.Where(tool => tool is AIFunctionDeclaration && !string.IsNullOrEmpty(tool.Name))) { - if (tool is AIFunctionDeclaration && !string.IsNullOrEmpty(tool.Name)) - { - names.Add(tool.Name); - } + names.Add(tool.Name); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs index 73058d06056..088e6730321 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs @@ -151,15 +151,16 @@ public async Task Responses_DefaultEndpoint_RejectsClientFunctionToolsAsync() { // Arrange using var app = await CreateResponsesServerAsync("reject-tools-agent", mapOptions: null); - HttpClient client = GetClient(app); + using HttpClient client = GetClient(app); + using var content = new StringContent( + """{"input":"hello","tools":[{"type":"function","name":"client_function"}]}""", + Encoding.UTF8, + "application/json"); // Act - HttpResponseMessage response = await client.PostAsync( + using HttpResponseMessage response = await client.PostAsync( new Uri("/reject-tools-agent/v1/responses", UriKind.Relative), - new StringContent( - """{"input":"hello","tools":[{"type":"function","name":"client_function"}]}""", - Encoding.UTF8, - "application/json")); + content); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); @@ -179,23 +180,24 @@ public async Task Responses_DangerousClientFunctionOptIn_DoesNotAllowToolChoiceA }; #pragma warning restore MAAI001 using var app = await CreateResponsesServerAsync("reject-tool-choice-agent", mapOptions); - HttpClient client = GetClient(app); + using HttpClient client = GetClient(app); + using var content = new StringContent( + """ + { + "input": "hello", + "tools": [ + { "type": "function", "name": "client_function" } + ], + "tool_choice": "required" + } + """, + Encoding.UTF8, + "application/json"); // Act - HttpResponseMessage response = await client.PostAsync( + using HttpResponseMessage response = await client.PostAsync( new Uri("/reject-tool-choice-agent/v1/responses", UriKind.Relative), - new StringContent( - """ - { - "input": "hello", - "tools": [ - { "type": "function", "name": "client_function" } - ], - "tool_choice": "required" - } - """, - Encoding.UTF8, - "application/json")); + content); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); @@ -215,15 +217,16 @@ public async Task Responses_DangerousClientFunctionOptIn_DoesNotAllowOtherToolTy }; #pragma warning restore MAAI001 using var app = await CreateResponsesServerAsync("reject-hosted-tool-agent", mapOptions); - HttpClient client = GetClient(app); + using HttpClient client = GetClient(app); + using var content = new StringContent( + """{"input":"hello","tools":[{"type":"web_search"}]}""", + Encoding.UTF8, + "application/json"); // Act - HttpResponseMessage response = await client.PostAsync( + using HttpResponseMessage response = await client.PostAsync( new Uri("/reject-hosted-tool-agent/v1/responses", UriKind.Relative), - new StringContent( - """{"input":"hello","tools":[{"type":"web_search"}]}""", - Encoding.UTF8, - "application/json")); + content); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); @@ -236,15 +239,16 @@ public async Task Responses_DefaultEndpoint_RejectsUnrecognizedToolChoiceAsync() { // Arrange using var app = await CreateResponsesServerAsync("reject-unknown-tool-choice-agent", mapOptions: null); - HttpClient client = GetClient(app); + using HttpClient client = GetClient(app); + using var content = new StringContent( + """{"input":"hello","tool_choice":"unsupported"}""", + Encoding.UTF8, + "application/json"); // Act - HttpResponseMessage response = await client.PostAsync( + using HttpResponseMessage response = await client.PostAsync( new Uri("/reject-unknown-tool-choice-agent/v1/responses", UriKind.Relative), - new StringContent( - """{"input":"hello","tool_choice":"unsupported"}""", - Encoding.UTF8, - "application/json")); + content); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); From 196d3b798ead0f6504a7f1b4bfe100c3ff0baba6 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:47:19 +0100 Subject: [PATCH 7/7] Simplify Responses client function forwarding Centralize mapping in RunOptionsFactory and make dangerous opt-in a boolean. Leave conflict handling and tool execution to downstream clients. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d6ca13a-3274-4167-a074-bfe4df9423ed --- ...ndpointRouteBuilderExtensions.Responses.cs | 6 +- ...IClientFunctionToolNameConflictBehavior.cs | 62 ----- .../OpenAIResponses.cs | 47 +--- .../OpenAIResponsesMapOptions.cs | 84 ++++-- .../Responses/AIAgentResponseExecutor.cs | 16 +- .../Responses/HostedAgentResponseExecutor.cs | 8 +- .../OpenAIResponseRequestInfoBuilder.cs | 14 +- .../OpenAIResponseRunOptionsBuilder.cs | 251 ------------------ ...AIResponsesClientFunctionToolsLiveTests.cs | 72 +---- ...ntFunctionToolNameConflictBehaviorTests.cs | 48 ---- .../OpenAIMapOptionsTests.cs | 6 +- .../OpenAIResponseRequestInfoBuilderTests.cs | 42 +++ .../OpenAIResponsesAgentCompatibilityTests.cs | 194 ++++++++++++++ .../OpenAIResponsesIntegrationTests.cs | 142 +--------- .../OpenAIResponsesMapOptionsTests.cs | 166 ++++++++++++ .../OpenAIResponsesTests.cs | 22 +- .../PermissiveMapOptions.cs | 3 - 17 files changed, 517 insertions(+), 666 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolNameConflictBehavior.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolNameConflictBehaviorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesAgentCompatibilityTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesMapOptionsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs index 4a2ffcd03b3..598725c9b0b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Builder; @@ -72,10 +71,7 @@ public static IEndpointConventionBuilder MapOpenAIResponses( responsesPath ??= $"/{agent.Name}/v1/responses"; // Create an executor for this agent - var executor = new AIAgentResponseExecutor( - agent, - mapOptions, - endpoints.ServiceProvider.GetService>()); + var executor = new AIAgentResponseExecutor(agent, mapOptions); var storageOptions = endpoints.ServiceProvider.GetService() ?? new InMemoryStorageOptions(); var conversationStorage = endpoints.ServiceProvider.GetService(); var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolNameConflictBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolNameConflictBehavior.cs deleted file mode 100644 index c2c10b84252..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIClientFunctionToolNameConflictBehavior.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI.Hosting.OpenAI; - -/// -/// Defines how a client-provided function declaration is handled when its name conflicts with a -/// function configured by the hosted agent developer. -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public abstract class OpenAIClientFunctionToolNameConflictBehavior -{ - private protected OpenAIClientFunctionToolNameConflictBehavior() - { - } - - /// - /// Creates a behavior that rejects the request. - /// - /// The conflict behavior. - public static OpenAIClientFunctionToolNameConflictBehavior Reject() => new RejectBehavior(); - - /// - /// Creates a behavior that keeps the hosted agent function, ignores the client declaration, - /// and writes a server warning. - /// - /// The conflict behavior. - public static OpenAIClientFunctionToolNameConflictBehavior Ignore() => new IgnoreBehavior(); - - /// - /// Creates a behavior that uses the client declaration instead of the hosted agent function for - /// that request. - /// - /// The conflict behavior. - public static OpenAIClientFunctionToolNameConflictBehavior AllowOverride() => new AllowOverrideBehavior(); - - internal abstract BehaviorKind Kind { get; } - - private sealed class RejectBehavior : OpenAIClientFunctionToolNameConflictBehavior - { - internal override BehaviorKind Kind => BehaviorKind.Reject; - } - - private sealed class IgnoreBehavior : OpenAIClientFunctionToolNameConflictBehavior - { - internal override BehaviorKind Kind => BehaviorKind.Ignore; - } - - private sealed class AllowOverrideBehavior : OpenAIClientFunctionToolNameConflictBehavior - { - internal override BehaviorKind Kind => BehaviorKind.AllowOverride; - } - - internal enum BehaviorKind - { - Reject, - Ignore, - AllowOverride, - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs index 6ef15c79210..d8786f602ad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponses.cs @@ -9,7 +9,6 @@ using Microsoft.Agents.AI.Hosting.OpenAI.Responses; using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.Hosting.OpenAI; @@ -44,39 +43,6 @@ public static class OpenAIResponses /// The body could not be parsed as an OpenAI Responses request. /// A request setting is not supported by the configured mapping. public static OpenAIResponsesRunRequest ToAgentRunRequest(JsonElement body, OpenAIResponsesMapOptions? mapOptions = null) - => ToAgentRunRequestCore(body, mapOptions ?? new OpenAIResponsesMapOptions(), agent: null, logger: null); - - /// - /// Converts an OpenAI Responses request body into Agent Framework run values using the target - /// agent to resolve client function tool name conflicts. - /// - /// The OpenAI Responses-shaped request body. - /// The target agent whose configured functions participate in conflict resolution. - /// Options controlling how request settings are mapped onto the run. - /// Optional logger used for client function conflict warnings. - /// The parsed messages and mapped run options. - /// - /// or is . - /// - /// The body could not be parsed as an OpenAI Responses request. - /// A request setting is not supported by the configured mapping. - public static OpenAIResponsesRunRequest ToAgentRunRequest( - JsonElement body, - AIAgent agent, - OpenAIResponsesMapOptions mapOptions, - ILogger? logger = null) - { - ArgumentNullException.ThrowIfNull(agent); - ArgumentNullException.ThrowIfNull(mapOptions); - - return ToAgentRunRequestCore(body, mapOptions, agent, logger); - } - - private static OpenAIResponsesRunRequest ToAgentRunRequestCore( - JsonElement body, - OpenAIResponsesMapOptions mapOptions, - AIAgent? agent, - ILogger? logger) { CreateResponse request; try @@ -94,18 +60,7 @@ private static OpenAIResponsesRunRequest ToAgentRunRequestCore( throw new ArgumentException("The request body is missing the required 'input' field.", nameof(body)); } -#pragma warning disable MAAI001 - if (agent is null && mapOptions.DangerouslyAllowClientFunctionTools is not null) - { - throw new NotSupportedException( - $"{nameof(OpenAIResponsesMapOptions.DangerouslyAllowClientFunctionTools)} requires the " + - $"{nameof(ToAgentRunRequest)} overload that accepts an {nameof(AIAgent)}."); - } -#pragma warning restore MAAI001 - - AgentRunOptions? options = agent is null - ? mapOptions.RunOptionsFactory(request.ToRequestInfo()) - : request.ToRunOptions(mapOptions, agent, logger, logConflicts: true); + AgentRunOptions? options = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory(request.ToRequestInfo()); var messages = new List(); foreach (InputMessage inputMessage in request.Input.GetInputMessages()) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs index 9e70355167b..81b18c42a62 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs @@ -3,6 +3,9 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses; +using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -20,36 +23,48 @@ public sealed class OpenAIResponsesMapOptions /// /// /// - /// By default this is set to , which throws when the request + /// By default this uses , which throws when the request /// carries any setting that would otherwise be mapped onto the agent (for example /// temperature, instructions, tools or tool_choice). This prevents a /// caller from silently overriding the configuration of a self-contained agent. + /// Enabling selects a default mapping that + /// forwards function declarations while continuing to reject other unsupported settings. /// /// /// Hosting developers that want to honor specific request settings can supply their own callback /// that maps the desired fields onto an (or a subclass such as /// ), and may choose to throw, map, or ignore any field. + /// A custom callback receives all request settings, including the complete + /// collection, and replaces the default mapping. + /// Its result is used unchanged, regardless of . /// Returning runs the agent with its own configuration only. /// /// public Func RunOptionsFactory { - get; + get + { +#pragma warning disable MAAI001 + return field ?? (this.DangerouslyAllowClientFunctionTools + ? MapClientFunctionTools + : RejectRequestSettings); +#pragma warning restore MAAI001 + } set { field = Throw.IfNull(value); } - } = RejectRequestSettings; + } /// - /// Gets or sets the explicit opt-in that allows function declarations supplied by the client to - /// be forwarded to the hosted agent's inference client. + /// Gets or sets whether the default mapping forwards client-provided function declarations + /// in the agent's run options. /// /// /// /// This setting is dangerous because client-provided function names, descriptions, and schemas - /// can change which tools the model chooses. The declarations cannot execute code in the hosted - /// server, but matching function calls are returned to the client for execution. + /// can change which tools the model chooses. The declarations do not contain executable code. + /// The downstream chat client and provider determine how function calls are handled. /// /// /// A client function may cause the model to choose it instead of a function configured by the @@ -57,26 +72,31 @@ public sealed class OpenAIResponsesMapOptions /// included in those arguments are then returned to the client. /// /// - /// The default is , which leaves client-provided tools subject to - /// . Enabling this setting requires an explicit - /// . The request's + /// The default is , which leaves client-provided tools subject to + /// and its default behavior. The request's /// tool_choice is not enabled by this setting and remains controlled by /// . /// /// - /// This setting applies only to MapOpenAIResponses endpoints because the target agent is - /// required to enforce name conflicts. Accepted function declarations are handled by the endpoint - /// and removed from before - /// is called. Other tool types remain in that collection. + /// With the default mapping, accepted function declarations are converted to + /// ChatClientAgentRunOptions.ChatOptions.Tools. Other tool types and unsupported request + /// settings are rejected. This setting has no effect when a custom + /// is supplied; that callback owns the entire mapping. + /// + /// + /// The default mapping produces . The hosting layer + /// does not require a particular agent implementation. Agents that do not consume these options + /// may ignore the mapped functions; enabling this setting does not add function support to them. /// /// - /// Client functions are forwarded with parallel tool calls disabled. This prevents a response from - /// combining a client function call, which must be returned to the client, with a hosted function - /// call that must execute inside the hosted agent. + /// Function names are not checked for conflicts or deduplicated. The downstream chat client and + /// provider determine whether duplicate names are accepted and which function is selected. + /// The hosting layer does not guarantee that a hosted function takes precedence over a client + /// declaration. The agent's parallel tool calling configuration is not changed. /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] - public OpenAIClientFunctionToolNameConflictBehavior? DangerouslyAllowClientFunctionTools { get; set; } + public bool DangerouslyAllowClientFunctionTools { get; set; } /// /// The default implementation. Throws a @@ -94,6 +114,30 @@ public sealed class OpenAIResponsesMapOptions { ArgumentNullException.ThrowIfNull(request); + ThrowIfUnsupportedRequestSettings(request, request.Tools); + return null; + } + + private static AgentRunOptions? MapClientFunctionTools(OpenAIResponseRequestInfo request) + { + ArgumentNullException.ThrowIfNull(request); + + if (request.Tools is not { Count: > 0 } tools) + { + return RejectRequestSettings(request); + } + + (List? clientTools, List? remainingTools) = tools.ConvertClientFunctionTools(); + ThrowIfUnsupportedRequestSettings(request, remainingTools); + return clientTools is { Count: > 0 } + ? new ChatClientAgentRunOptions(new ChatOptions { Tools = clientTools }) + : null; + } + + private static void ThrowIfUnsupportedRequestSettings( + OpenAIResponseRequestInfo request, + IReadOnlyList? tools) + { List? unsupported = null; void LocalAdd(string name) => (unsupported ??= []).Add(name); @@ -117,7 +161,7 @@ public sealed class OpenAIResponsesMapOptions LocalAdd("instructions"); } - if (request.Tools is { Count: > 0 }) + if (tools is { Count: > 0 }) { LocalAdd("tools"); } @@ -133,7 +177,5 @@ public sealed class OpenAIResponsesMapOptions $"The following request setting(s) are not supported by this agent endpoint: {string.Join(", ", unsupported)}. " + "Configure an OpenAIResponsesMapOptions.RunOptionsFactory to map these settings onto the agent if they should be honored."); } - - return null; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs index bf78e96fb38..5b01cc1394a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs @@ -7,7 +7,6 @@ using System.Threading.Tasks; using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; @@ -19,17 +18,14 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor { private readonly AIAgent _agent; private readonly OpenAIResponsesMapOptions _mapOptions; - private readonly ILogger? _logger; public AIAgentResponseExecutor( AIAgent agent, - OpenAIResponsesMapOptions? mapOptions = null, - ILogger? logger = null) + OpenAIResponsesMapOptions? mapOptions = null) { ArgumentNullException.ThrowIfNull(agent); this._agent = agent; this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions(); - this._logger = logger; } public ValueTask ValidateRequestAsync( @@ -41,9 +37,9 @@ public AIAgentResponseExecutor( { try { - // Invoke the factory during validation so that unsupported request settings are surfaced + // Map options during validation so that unsupported request settings are surfaced // as a clean request error rather than an unhandled exception during execution. - _ = request.ToRunOptions(this._mapOptions, this._agent, this._logger); + _ = this._mapOptions.RunOptionsFactory(request.ToRequestInfo()); return null; } catch (NotSupportedException ex) @@ -64,11 +60,7 @@ public async IAsyncEnumerable ExecuteAsync( { // The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any) // request settings are mapped onto the agent run. By default no request setting is mapped. - AgentRunOptions? options = request.ToRunOptions( - this._mapOptions, - this._agent, - this._logger, - logConflicts: true); + AgentRunOptions? options = this._mapOptions.RunOptionsFactory(request.ToRequestInfo()); // Convert input to chat messages, prepending conversation history if available var messages = new List(); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs index 2bedf42f69b..687c121be25 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs @@ -83,7 +83,7 @@ Ensure the agent is registered with '{agentName}' name in the dependency injecti // exception during execution. try { - _ = request.ToRunOptions(this._mapOptions, agent, this._logger); + _ = this._mapOptions.RunOptionsFactory(request.ToRequestInfo()); } catch (NotSupportedException ex) { @@ -109,11 +109,7 @@ public async IAsyncEnumerable ExecuteAsync( // The hosting developer controls, via OpenAIResponsesMapOptions.RunOptionsFactory, which (if any) // request settings are mapped onto the agent run. By default no request setting is mapped. - AgentRunOptions? options = request.ToRunOptions( - this._mapOptions, - agent, - this._logger, - logConflicts: true); + AgentRunOptions? options = this._mapOptions.RunOptionsFactory(request.ToRequestInfo()); var messages = new List(); if (conversationHistory is not null) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs index ac2b2c0289a..e38b604fb91 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRequestInfoBuilder.cs @@ -23,25 +23,25 @@ internal static class OpenAIResponseRequestInfoBuilder HasToolChoice = request.ToolChoice is not null, }; - internal static (List? FunctionTools, List? UnsupportedTools) - ExtractClientFunctionTools(this IReadOnlyList tools) + internal static (List? ClientTools, List? RemainingTools) + ConvertClientFunctionTools(this IReadOnlyList tools) { - List? functionTools = null; - List? unsupportedTools = null; + List? clientTools = null; + List? remainingTools = null; foreach (JsonElement tool in tools) { if (tool.ToFunctionTool() is { } functionTool) { - (functionTools ??= []).Add(functionTool); + (clientTools ??= []).Add(functionTool); } else { - (unsupportedTools ??= []).Add(tool); + (remainingTools ??= []).Add(tool); } } - return (functionTools, unsupportedTools); + return (clientTools, remainingTools); } private static ClientAIFunctionDeclaration? ToFunctionTool(this JsonElement tool) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs deleted file mode 100644 index 08745ae4bea..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponseRunOptionsBuilder.cs +++ /dev/null @@ -1,251 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; - -namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; - -#pragma warning disable MAAI001 - -internal static class OpenAIResponseRunOptionsBuilder -{ - public static AgentRunOptions? ToRunOptions( - this CreateResponse request, - OpenAIResponsesMapOptions mapOptions, - AIAgent? agent = null, - ILogger? logger = null, - bool logConflicts = false) - { - OpenAIResponseRequestInfo requestInfo = request.ToRequestInfo(); - OpenAIClientFunctionToolNameConflictBehavior? clientFunctionBehavior = - mapOptions.DangerouslyAllowClientFunctionTools; - - if (clientFunctionBehavior is null || requestInfo.Tools is not { Count: > 0 } requestTools) - { - return mapOptions.RunOptionsFactory(requestInfo); - } - - (List? clientFunctions, List? unsupportedTools) = - requestTools.ExtractClientFunctionTools(); - if (clientFunctions is not { Count: > 0 }) - { - return mapOptions.RunOptionsFactory(requestInfo); - } - - ThrowIfDuplicateClientFunctionNames(clientFunctions); - - // Accepted function declarations are handled here. Any other tool type remains visible to the - // configured factory and is rejected by the default factory. - requestInfo.Tools = unsupportedTools; - AgentRunOptions? runOptions = mapOptions.RunOptionsFactory(requestInfo); - OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind nameConflictBehavior = - clientFunctionBehavior.Kind; - - HashSet hostedToolNames = GetHostedToolNames(agent, runOptions); - List conflictingNames = clientFunctions - .Select(tool => tool.Name) - .Where(hostedToolNames.Contains) - .Distinct(StringComparer.Ordinal) - .OrderBy(name => name, StringComparer.Ordinal) - .ToList(); - - if (conflictingNames.Count > 0) - { - switch (nameConflictBehavior) - { - case OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.Reject: - throw CreateNameConflictException(conflictingNames); - - case OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.Ignore: - clientFunctions.RemoveAll(tool => hostedToolNames.Contains(tool.Name)); - if (logConflicts) - { - logger?.LogWarning( - "Ignoring client function tool declarations that conflict with hosted agent tools: {ToolNames}", - string.Join(", ", conflictingNames)); - } - break; - - case OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.AllowOverride: - break; - } - } - - return clientFunctions.Count > 0 - ? AddClientFunctions(runOptions, clientFunctions, nameConflictBehavior, logger) - : runOptions; - } - - private static ChatClientAgentRunOptions AddClientFunctions( - AgentRunOptions? runOptions, - List clientFunctions, - OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind nameConflictBehavior, - ILogger? logger) - { - ChatClientAgentRunOptions chatRunOptions = runOptions switch - { - null => new ChatClientAgentRunOptions(), - ChatClientAgentRunOptions existingRunOptions => (ChatClientAgentRunOptions)existingRunOptions.Clone(), - _ => throw new NotSupportedException( - $"{nameof(OpenAIResponsesMapOptions.DangerouslyAllowClientFunctionTools)} requires " + - $"{nameof(OpenAIResponsesMapOptions.RunOptionsFactory)} to return null or {nameof(ChatClientAgentRunOptions)}.") - }; - - ChatOptions chatOptions = chatRunOptions.ChatOptions?.Clone() ?? new ChatOptions(); - chatOptions.AllowMultipleToolCalls = false; - chatOptions.Tools = chatOptions.Tools is { Count: > 0 } existingTools - ? [.. clientFunctions, .. existingTools] - : [.. clientFunctions]; - chatRunOptions.ChatOptions = chatOptions; - - Func? innerFactory = chatRunOptions.ChatClientFactory; - chatRunOptions.ChatClientFactory = chatClient => - { - IChatClient innerClient = innerFactory is null - ? chatClient - : innerFactory(chatClient) ?? throw new InvalidOperationException( - $"{nameof(ChatClientAgentRunOptions.ChatClientFactory)} returned null."); - return new ClientFunctionToolConflictResolvingChatClient(innerClient, nameConflictBehavior, logger); - }; - - return chatRunOptions; - } - - private static HashSet GetHostedToolNames(AIAgent? agent, AgentRunOptions? runOptions) - { - var names = new HashSet(StringComparer.Ordinal); - - AddToolNames(agent?.GetService()?.AdditionalTools, names); - AddToolNames(agent?.GetService()?.Tools, names); - AddToolNames((runOptions as ChatClientAgentRunOptions)?.ChatOptions?.Tools, names); - - return names; - } - - private static void AddToolNames(IEnumerable? tools, HashSet names) - { - if (tools is null) - { - return; - } - - foreach (AITool tool in tools.Where(tool => tool is AIFunctionDeclaration && !string.IsNullOrEmpty(tool.Name))) - { - names.Add(tool.Name); - } - } - - private static void ThrowIfDuplicateClientFunctionNames(List clientFunctions) - { - string? duplicateName = clientFunctions - .GroupBy(tool => tool.Name, StringComparer.Ordinal) - .FirstOrDefault(group => group.Count() > 1) - ?.Key; - - if (duplicateName is not null) - { - throw new NotSupportedException( - $"The request contains more than one client function tool named '{duplicateName}'."); - } - } - - private static NotSupportedException CreateNameConflictException(IReadOnlyList conflictingNames) => - new( - "Client function tool declarations conflict with tools configured by the hosted agent: " + - $"{string.Join(", ", conflictingNames)}."); - - private sealed class ClientFunctionToolConflictResolvingChatClient : DelegatingChatClient - { - private readonly OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind _nameConflictBehavior; - private readonly ILogger? _logger; - private bool _loggedIgnoredConflicts; - - public ClientFunctionToolConflictResolvingChatClient( - IChatClient innerClient, - OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind nameConflictBehavior, - ILogger? logger) - : base(innerClient) - { - this._nameConflictBehavior = nameConflictBehavior; - this._logger = logger; - } - - public override Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) => - base.GetResponseAsync(messages, this.ResolveNameConflicts(options), cancellationToken); - - public override IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) => - base.GetStreamingResponseAsync(messages, this.ResolveNameConflicts(options), cancellationToken); - - private ChatOptions? ResolveNameConflicts(ChatOptions? options) - { - if (options?.Tools is not { Count: > 0 } tools) - { - return options; - } - - var clientNames = tools - .OfType() - .Select(tool => tool.Name) - .ToHashSet(StringComparer.Ordinal); - if (clientNames.Count == 0) - { - return options; - } - - List conflictingNames = tools - .Where(tool => - tool is AIFunctionDeclaration and - not OpenAIResponseRequestInfoBuilder.ClientAIFunctionDeclaration) - .Select(tool => tool.Name) - .Where(clientNames.Contains) - .Distinct(StringComparer.Ordinal) - .OrderBy(name => name, StringComparer.Ordinal) - .ToList(); - if (conflictingNames.Count == 0) - { - return options; - } - - if (this._nameConflictBehavior == OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.Reject) - { - throw CreateNameConflictException(conflictingNames); - } - - var conflictSet = conflictingNames.ToHashSet(StringComparer.Ordinal); - ChatOptions resolvedOptions = options.Clone(); - resolvedOptions.Tools = this._nameConflictBehavior == OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.Ignore - ? tools.Where(tool => - tool is not OpenAIResponseRequestInfoBuilder.ClientAIFunctionDeclaration || - !conflictSet.Contains(tool.Name)).ToList() - : tools.Where(tool => - tool is OpenAIResponseRequestInfoBuilder.ClientAIFunctionDeclaration || - tool is not AIFunctionDeclaration || - !conflictSet.Contains(tool.Name)).ToList(); - - if (this._nameConflictBehavior == OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.Ignore && - !this._loggedIgnoredConflicts) - { - this._loggedIgnoredConflicts = true; - this._logger?.LogWarning( - "Ignoring client function tool declarations that conflict with hosted agent tools: {ToolNames}", - string.Join(", ", conflictingNames)); - } - - return resolvedOptions; - } - } -} - -#pragma warning restore MAAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs index 2ba57ea48ed..f948a7d85c6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs @@ -43,77 +43,33 @@ public sealed class OpenAIResponsesClientFunctionToolsLiveTests Environment.GetEnvironmentVariable(TestSettings.OpenAIChatModelName) ?? "gpt-4o-mini"; [Fact] - public async Task ClientFunctionNameConflictPolicies_WorkEndToEndAsync() + public async Task AllowedClientFunction_ReturnsFunctionCallAsync() { // Arrange Assert.SkipWhen( string.IsNullOrEmpty(ApiKey), "OPENAI_API_KEY is not configured; skipping live client function tool test."); - (ChatClientAgent rejectAgent, _) = CreateAgent(); - (ChatClientAgent ignoreAgent, Func getIgnoreInvocationCount) = CreateAgent(); - (ChatClientAgent overrideAgent, Func getOverrideInvocationCount) = CreateAgent(); + using IChatClient chatClient = new OpenAIClient(ApiKey).GetResponsesClient().AsIChatClient(ModelName); + var agent = new ChatClientAgent( + chatClient, + instructions: "For every weather request, call get_weather before answering.", + name: "weather-agent"); JsonElement requestBody = ParseBody(ClientRequestJson); +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = true }; +#pragma warning restore MAAI001 - // Act & Assert: Reject blocks the conflicting client declaration before inference. - Assert.Throws(() => - OpenAIResponses.ToAgentRunRequest( - requestBody, - rejectAgent, - CreateMapOptions(OpenAIClientFunctionToolNameConflictBehavior.Reject()))); - - // Act & Assert: Ignore keeps and executes the hosted function. - OpenAIResponsesRunRequest ignoreRun = OpenAIResponses.ToAgentRunRequest( - requestBody, - ignoreAgent, - CreateMapOptions(OpenAIClientFunctionToolNameConflictBehavior.Ignore())); - AgentResponse ignoreResponse = await ignoreAgent.RunAsync(ignoreRun.Messages, options: ignoreRun.Options); - Assert.True(getIgnoreInvocationCount() > 0); - Assert.Contains("HOSTED_FUNCTION_RESULT", ignoreResponse.Text, StringComparison.Ordinal); + // Act + OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(requestBody, mapOptions); + AgentResponse response = await agent.RunAsync(run.Messages, options: run.Options); - // Act & Assert: AllowOverride returns the client function call without executing the hosted function. - OpenAIResponsesRunRequest overrideRun = OpenAIResponses.ToAgentRunRequest( - requestBody, - overrideAgent, - CreateMapOptions(OpenAIClientFunctionToolNameConflictBehavior.AllowOverride())); - AgentResponse overrideResponse = await overrideAgent.RunAsync( - overrideRun.Messages, - options: overrideRun.Options); - Assert.Equal(0, getOverrideInvocationCount()); + // Assert FunctionCallContent functionCall = Assert.Single( - overrideResponse.Messages.SelectMany(message => message.Contents).OfType()); + response.Messages.SelectMany(message => message.Contents).OfType()); Assert.Equal("get_weather", functionCall.Name); } - private static (ChatClientAgent Agent, Func GetInvocationCount) CreateAgent() - { - int invocationCount = 0; - var agent = new ChatClientAgent( - new OpenAIClient(ApiKey).GetResponsesClient().AsIChatClient(ModelName), - instructions: """ - For every weather request, call get_weather before answering. - After receiving a function result, include it verbatim in the answer. - """, - name: "weather-agent", - tools: [AIFunctionFactory.Create(GetHostedWeather, name: "get_weather")]); - return (agent, () => invocationCount); - - string GetHostedWeather(string location) - { - invocationCount++; - return $"HOSTED_FUNCTION_RESULT: {location}=18C"; - } - } - -#pragma warning disable MAAI001 - private static OpenAIResponsesMapOptions CreateMapOptions( - OpenAIClientFunctionToolNameConflictBehavior behavior) => - new() - { - DangerouslyAllowClientFunctionTools = behavior - }; -#pragma warning restore MAAI001 - private static JsonElement ParseBody(string json) { using JsonDocument document = JsonDocument.Parse(json); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolNameConflictBehaviorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolNameConflictBehaviorTests.cs deleted file mode 100644 index 9c1ba0b5379..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIClientFunctionToolNameConflictBehaviorTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; - -/// -/// Tests for . -/// -public sealed class OpenAIClientFunctionToolNameConflictBehaviorTests -{ - [Fact] - public void Reject_ReturnsRejectBehavior() - { - // Act -#pragma warning disable MAAI001 - OpenAIClientFunctionToolNameConflictBehavior behavior = - OpenAIClientFunctionToolNameConflictBehavior.Reject(); -#pragma warning restore MAAI001 - - // Assert - Assert.Equal(OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.Reject, behavior.Kind); - } - - [Fact] - public void Ignore_ReturnsIgnoreBehavior() - { - // Act -#pragma warning disable MAAI001 - OpenAIClientFunctionToolNameConflictBehavior behavior = - OpenAIClientFunctionToolNameConflictBehavior.Ignore(); -#pragma warning restore MAAI001 - - // Assert - Assert.Equal(OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.Ignore, behavior.Kind); - } - - [Fact] - public void AllowOverride_ReturnsAllowOverrideBehavior() - { - // Act -#pragma warning disable MAAI001 - OpenAIClientFunctionToolNameConflictBehavior behavior = - OpenAIClientFunctionToolNameConflictBehavior.AllowOverride(); -#pragma warning restore MAAI001 - - // Assert - Assert.Equal(OpenAIClientFunctionToolNameConflictBehavior.BehaviorKind.AllowOverride, behavior.Kind); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs index 088e6730321..01bb755df2f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs @@ -175,8 +175,7 @@ public async Task Responses_DangerousClientFunctionOptIn_DoesNotAllowToolChoiceA #pragma warning disable MAAI001 var mapOptions = new OpenAIResponsesMapOptions { - DangerouslyAllowClientFunctionTools = - OpenAIClientFunctionToolNameConflictBehavior.Reject() + DangerouslyAllowClientFunctionTools = true }; #pragma warning restore MAAI001 using var app = await CreateResponsesServerAsync("reject-tool-choice-agent", mapOptions); @@ -212,8 +211,7 @@ public async Task Responses_DangerousClientFunctionOptIn_DoesNotAllowOtherToolTy #pragma warning disable MAAI001 var mapOptions = new OpenAIResponsesMapOptions { - DangerouslyAllowClientFunctionTools = - OpenAIClientFunctionToolNameConflictBehavior.Reject() + DangerouslyAllowClientFunctionTools = true }; #pragma warning restore MAAI001 using var app = await CreateResponsesServerAsync("reject-hosted-tool-agent", mapOptions); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs index 394bbb7d4f6..7e02fcff475 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponseRequestInfoBuilderTests.cs @@ -132,6 +132,48 @@ public void ToRequestInfo_PreservesFunctionToolAsRawTool() Assert.True(function.GetProperty("strict").GetBoolean()); } + [Theory] + [InlineData(null)] + [InlineData(false)] + [InlineData(true)] + public void ConvertClientFunctionTools_PreservesMetadataAndStrict(bool? strict) + { + // Arrange + string strictProperty = strict.HasValue ? $",\"strict\":{(strict.Value ? "true" : "false")}" : string.Empty; + JsonElement[] tools = + [ + ParseElement( + $$$$""" + { + "type":"function", + "name":"get_weather", + "description":"Retrieves current weather.", + "parameters":{"type":"object","properties":{"location":{"type":"string"}}} + {{{{strictProperty}}}} + } + """) + ]; + + // Act + var (clientTools, remainingTools) = tools.ConvertClientFunctionTools(); + + // Assert + var function = Assert.IsAssignableFrom(Assert.Single(clientTools!)); + Assert.Null(remainingTools); + Assert.Equal("get_weather", function.Name); + Assert.Equal("Retrieves current weather.", function.Description); + Assert.Equal("string", function.JsonSchema.GetProperty("properties").GetProperty("location").GetProperty("type").GetString()); + Assert.Null(function.ReturnJsonSchema); + if (strict.HasValue) + { + Assert.Equal(strict.Value, Assert.IsType(function.AdditionalProperties["strict"])); + } + else + { + Assert.False(function.AdditionalProperties.ContainsKey("strict")); + } + } + private static CreateResponse CreateRequestWithToolChoice(string toolChoiceJson) { using JsonDocument document = JsonDocument.Parse(toolChoiceJson); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesAgentCompatibilityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesAgentCompatibilityTests.cs new file mode 100644 index 00000000000..104bf86d74b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesAgentCompatibilityTests.cs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Tests permissive client function mapping across agent implementations. +/// +public sealed class OpenAIResponsesAgentCompatibilityTests +{ + [Theory] + [InlineData(false, "direct")] + [InlineData(true, "direct")] + [InlineData(false, "wrapped")] + [InlineData(true, "wrapped")] + [InlineData(false, "opaque")] + [InlineData(true, "opaque")] + public async Task DefaultMapping_ForwardsFunctionsWithoutRequiringDiscoverableChatClientAgentAsync(bool resolveByName, string agentKind) + { + // Arrange + using var chatClient = new TestHelpers.SimpleMockChatClient(); + ChatClientAgent inner = chatClient.AsAIAgent(name: "test-agent"); + AIAgent agent = agentKind switch + { + "direct" => inner, + "wrapped" => new TestDelegatingAgent(inner), + _ => new OpaqueAgent(inner) + }; + await using WebApplication app = await CreateServerAsync(agent, resolveByName); + using HttpClient client = app.GetTestClient(); + using var content = CreateRequest(withTools: true); + + // Act + using HttpResponseMessage response = await client.PostAsync(new Uri("/v1/responses", UriKind.Relative), content); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.IsAssignableFrom(Assert.Single(chatClient.LastChatOptions!.Tools!)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DefaultMapping_AgentIgnoringChatOptions_CanRespondAsync(bool resolveByName) + { + // Arrange + var agent = new NonChatAgent(); + await using WebApplication app = await CreateServerAsync(agent, resolveByName); + using HttpClient client = app.GetTestClient(); + using var content = CreateRequest(withTools: true); + + // Act + using HttpResponseMessage response = await client.PostAsync(new Uri("/v1/responses", UriKind.Relative), content); + + // Assert + Assert.Null(agent.GetService()); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Response without function support.", await response.Content.ReadAsStringAsync()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DefaultMapping_WithoutClientFunctions_DoesNotRequireChatClientAgentAsync(bool resolveByName) + { + // Arrange + using var chatClient = new TestHelpers.SimpleMockChatClient(); + AIAgent agent = new OpaqueAgent(chatClient.AsAIAgent(name: "test-agent")); + await using WebApplication app = await CreateServerAsync(agent, resolveByName); + using HttpClient client = app.GetTestClient(); + using var content = CreateRequest(withTools: false); + + // Act + using HttpResponseMessage response = await client.PostAsync(new Uri("/v1/responses", UriKind.Relative), content); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CustomMapping_DoesNotRequireChatClientAgentAsync(bool resolveByName) + { + // Arrange + using var chatClient = new TestHelpers.SimpleMockChatClient(); + AIAgent agent = new OpaqueAgent(chatClient.AsAIAgent(name: "test-agent")); + AIFunction tool = AIFunctionFactory.Create(() => "server", "server_function"); + await using WebApplication app = await CreateServerAsync( + agent, + resolveByName, + _ => new ChatClientAgentRunOptions(new ChatOptions { Tools = [tool] })); + using HttpClient client = app.GetTestClient(); + using var content = CreateRequest(withTools: true); + + // Act + using HttpResponseMessage response = await client.PostAsync(new Uri("/v1/responses", UriKind.Relative), content); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Same(tool, Assert.Single(chatClient.LastChatOptions!.Tools!)); + } + + private static async Task CreateServerAsync( + AIAgent agent, + bool resolveByName, + Func? factory = null) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.AddOpenAIResponses(); + builder.Services.AddKeyedSingleton("test-agent", agent); +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = true }; +#pragma warning restore MAAI001 + if (factory is not null) + { + mapOptions.RunOptionsFactory = factory; + } + + builder.Services.AddSingleton(mapOptions); + WebApplication app = builder.Build(); + if (resolveByName) + { + app.MapOpenAIResponses(); + } + else + { + app.MapOpenAIResponses(agent, "/v1/responses", mapOptions); + } + + await app.StartAsync(); + return app; + } + + private static StringContent CreateRequest(bool withTools) => new( + withTools + ? """{"agent":{"name":"test-agent"},"input":"hello","tools":[{"type":"function","name":"client_function"}]}""" + : """{"agent":{"name":"test-agent"},"input":"hello"}""", + Encoding.UTF8, + "application/json"); + + private sealed class TestDelegatingAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent); + + private sealed class OpaqueAgent(AIAgent innerAgent) : DelegatingAIAgent(innerAgent) + { + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType == typeof(ChatClientAgent) ? null : base.GetService(serviceType, serviceKey); + } + + private sealed class NonChatAgent : AIAgent + { + public override string Name => "test-agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new TestSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + protected override Task RunCoreAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Response without function support."))); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield return new AgentResponseUpdate(ChatRole.Assistant, "Response without function support."); + } + + private sealed class TestSession : AgentSession; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs index a3aceeb8d58..c1f622fba22 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs @@ -15,7 +15,6 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; using OpenAI.Responses; namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; @@ -952,8 +951,7 @@ public async Task CreateResponse_WithAllowedClientFunctionTool_ForwardsToolAndRe #pragma warning disable MAAI001 var mapOptions = new OpenAIResponsesMapOptions { - DangerouslyAllowClientFunctionTools = - OpenAIClientFunctionToolNameConflictBehavior.AllowOverride() + DangerouslyAllowClientFunctionTools = true }; #pragma warning restore MAAI001 @@ -997,7 +995,7 @@ public async Task CreateResponse_WithAllowedClientFunctionTool_ForwardsToolAndRe // Assert Assert.True(httpResponse.IsSuccessStatusCode, $"Response status: {httpResponse.StatusCode}"); Assert.NotNull(chatClient.LastChatOptions); - Assert.False(chatClient.LastChatOptions.AllowMultipleToolCalls); + Assert.Null(chatClient.LastChatOptions.AllowMultipleToolCalls); Assert.Null(chatClient.LastChatOptions.ToolMode); AIFunctionDeclaration tool = Assert.IsAssignableFrom(Assert.Single(chatClient.LastChatOptions.Tools!)); Assert.Equal("get_weather", tool.Name); @@ -1107,8 +1105,7 @@ public async Task CreateResponse_WithClientFunctionNamedMcp_DoesNotReplaceHosted #pragma warning disable MAAI001 var mapOptions = new OpenAIResponsesMapOptions { - DangerouslyAllowClientFunctionTools = - OpenAIClientFunctionToolNameConflictBehavior.AllowOverride() + DangerouslyAllowClientFunctionTools = true }; #pragma warning restore MAAI001 @@ -1132,92 +1129,10 @@ public async Task CreateResponse_WithClientFunctionNamedMcp_DoesNotReplaceHosted } [Fact] - public async Task CreateResponse_WithConflictingClientFunctionAndRejectPolicy_ReturnsBadRequestAsync() + public async Task CreateResponse_WithConflictingClientFunction_ForwardsBothToolsToChatClientAsync() { // Arrange - const string AgentName = "reject-client-function-conflict-agent"; - const string FunctionName = "get_weather"; - AIFunction hostedFunction = AIFunctionFactory.Create( - (string location) => $"Sunny in {location}", - FunctionName); -#pragma warning disable MAAI001 - var mapOptions = new OpenAIResponsesMapOptions - { - DangerouslyAllowClientFunctionTools = - OpenAIClientFunctionToolNameConflictBehavior.Reject() - }; -#pragma warning restore MAAI001 - - this._httpClient = await this.CreateTestServerWithHostedToolAsync( - AgentName, - new TestHelpers.SimpleMockChatClient(), - hostedFunction, - mapOptions); - using StringContent content = CreateClientFunctionRequest(FunctionName); - - // Act - using HttpResponseMessage response = await this._httpClient.PostAsync( - new Uri($"/{AgentName}/v1/responses", UriKind.Relative), - content); - - // Assert - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Contains( - FunctionName, - await response.Content.ReadAsStringAsync(), - StringComparison.Ordinal); - } - - [Fact] - public async Task CreateResponse_WithConflictingClientFunctionAndIgnorePolicy_UsesHostedFunctionAndLogsWarningAsync() - { - // Arrange - const string AgentName = "ignore-client-function-conflict-agent"; - const string FunctionName = "get_weather"; - int invocationCount = 0; - var chatClient = new TestHelpers.FunctionToolExecutingMockChatClient(FunctionName); - var loggerProvider = new WarningRecordingLoggerProvider(); - AIFunction hostedFunction = AIFunctionFactory.Create(GetWeather, FunctionName); -#pragma warning disable MAAI001 - var mapOptions = new OpenAIResponsesMapOptions - { - DangerouslyAllowClientFunctionTools = - OpenAIClientFunctionToolNameConflictBehavior.Ignore() - }; -#pragma warning restore MAAI001 - - this._httpClient = await this.CreateTestServerWithHostedToolAsync( - AgentName, - chatClient, - hostedFunction, - mapOptions, - loggerProvider); - using StringContent content = CreateClientFunctionRequest(FunctionName); - - // Act - using HttpResponseMessage response = await this._httpClient.PostAsync( - new Uri($"/{AgentName}/v1/responses", UriKind.Relative), - content); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(1, invocationCount); - Assert.Equal("Sunny in Valencia", chatClient.FunctionResult); - Assert.Contains(loggerProvider.Messages, message => - message.Contains(FunctionName, StringComparison.Ordinal)); - - string GetWeather(string location) - { - invocationCount++; - return $"Sunny in {location}"; - } - } - - [Fact] - public async Task CreateResponse_WithConflictingClientFunctionAndAllowOverridePolicy_UsesClientDeclarationAsync() - { - // Arrange - const string AgentName = "override-client-function-conflict-agent"; + const string AgentName = "client-function-conflict-agent"; const string FunctionName = "get_weather"; int invocationCount = 0; var chatClient = new TestHelpers.FunctionCallMockChatClient( @@ -1227,8 +1142,7 @@ public async Task CreateResponse_WithConflictingClientFunctionAndAllowOverridePo #pragma warning disable MAAI001 var mapOptions = new OpenAIResponsesMapOptions { - DangerouslyAllowClientFunctionTools = - OpenAIClientFunctionToolNameConflictBehavior.AllowOverride() + DangerouslyAllowClientFunctionTools = true }; #pragma warning restore MAAI001 @@ -1247,8 +1161,11 @@ public async Task CreateResponse_WithConflictingClientFunctionAndAllowOverridePo // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(0, invocationCount); + Assert.Equal(2, chatClient.LastChatOptions!.Tools!.Count); + Assert.Contains(hostedFunction, chatClient.LastChatOptions.Tools); AIFunctionDeclaration forwardedFunction = - Assert.IsAssignableFrom(Assert.Single(chatClient.LastChatOptions!.Tools!)); + Assert.IsAssignableFrom(Assert.Single( + chatClient.LastChatOptions.Tools, tool => tool is not AIFunction)); Assert.Equal("Client-provided function.", forwardedFunction.Description); Assert.Contains( "\"type\":\"function_call\"", @@ -1760,15 +1677,10 @@ private async Task CreateTestServerWithHostedToolAsync( string agentName, IChatClient chatClient, AITool tool, - OpenAIResponsesMapOptions? mapOptions = null, - ILoggerProvider? loggerProvider = null) + OpenAIResponsesMapOptions? mapOptions = null) { WebApplicationBuilder builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); - if (loggerProvider is not null) - { - builder.Logging.AddProvider(loggerProvider); - } IHostedAgentBuilder agentBuilder = builder .AddAIAgent(agentName, "You are a helpful assistant.", chatClient) @@ -1841,36 +1753,4 @@ private async Task CreateTestServerWithMultipleAgentsAsync( return testServer.CreateClient(); } - - private sealed class WarningRecordingLoggerProvider : ILoggerProvider - { - public List Messages { get; } = []; - - public ILogger CreateLogger(string categoryName) => new WarningRecordingLogger(this.Messages); - - public void Dispose() - { - } - - private sealed class WarningRecordingLogger(List messages) : ILogger - { - public IDisposable? BeginScope(TState state) - where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning; - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - if (this.IsEnabled(logLevel)) - { - messages.Add(formatter(state, exception)); - } - } - } - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesMapOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesMapOptionsTests.cs new file mode 100644 index 00000000000..ad82d9cebd5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesMapOptionsTests.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Tests for default and custom Responses request mapping. +/// +public sealed class OpenAIResponsesMapOptionsTests +{ + [Fact] + public void DefaultMapping_RejectsClientFunctions() + { + // Arrange + var mapOptions = new OpenAIResponsesMapOptions(); + using JsonDocument body = CreateBody(); + + // Act & Assert +#pragma warning disable MAAI001 + Assert.False(mapOptions.DangerouslyAllowClientFunctionTools); +#pragma warning restore MAAI001 + Assert.Throws(() => OpenAIResponses.ToAgentRunRequest(body.RootElement, mapOptions)); + } + + [Fact] + public void DefaultMapping_Enabled_ForwardsDuplicatesWithoutModifyingRequest() + { + // Arrange + using JsonDocument body = CreateBody(); + var request = new OpenAIResponseRequestInfo + { + Tools = [body.RootElement.GetProperty("tools")[0], body.RootElement.GetProperty("tools")[1]] + }; + var originalTools = request.Tools; +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions { DangerouslyAllowClientFunctionTools = true }; +#pragma warning restore MAAI001 + + // Act + var result = Assert.IsType(mapOptions.RunOptionsFactory(request)); + + // Assert + Assert.Same(originalTools, request.Tools); + Assert.Equal(2, result.ChatOptions!.Tools!.Count); + Assert.All(result.ChatOptions.Tools, tool => + { + Assert.Equal("get_weather", tool.Name); + Assert.IsAssignableFrom(tool); + Assert.False(tool is AIFunction); + }); + Assert.Null(result.ChatOptions.ToolMode); + Assert.Null(result.ChatOptions.AllowMultipleToolCalls); + Assert.Null(result.ChatClientFactory); + } + + [Theory] + [InlineData(false, null)] + [InlineData(true, null)] + [InlineData(true, false)] + [InlineData(true, true)] + public void CustomMapping_ReceivesAllToolsAndReturnsUnchangedOptions(bool allowClientFunctions, bool? allowMultipleToolCalls) + { + // Arrange + using JsonDocument body = CreateBody(); + AIFunction hostedFunction = AIFunctionFactory.Create(() => "hosted", "get_weather"); + Func factory = client => client; + var configuredOptions = new ChatClientAgentRunOptions(new ChatOptions + { + Tools = [hostedFunction], + ToolMode = ChatToolMode.Auto, + AllowMultipleToolCalls = allowMultipleToolCalls + }) + { + ChatClientFactory = factory + }; + OpenAIResponseRequestInfo? capturedRequest = null; + int invocationCount = 0; +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions + { + DangerouslyAllowClientFunctionTools = allowClientFunctions, + RunOptionsFactory = request => + { + capturedRequest = request; + invocationCount++; + return configuredOptions; + } + }; +#pragma warning restore MAAI001 + + // Act + var run = OpenAIResponses.ToAgentRunRequest(body.RootElement, mapOptions); + + // Assert + Assert.Equal(1, invocationCount); + Assert.NotNull(capturedRequest); + Assert.Equal(2, capturedRequest.Tools!.Count); + Assert.Equal("function", capturedRequest.Tools[0].GetProperty("type").GetString()); + Assert.Same(configuredOptions, run.Options); + Assert.Same(factory, configuredOptions.ChatClientFactory); + Assert.Equal(allowMultipleToolCalls, configuredOptions.ChatOptions!.AllowMultipleToolCalls); + Assert.Equal(ChatToolMode.Auto, configuredOptions.ChatOptions.ToolMode); + Assert.Same(hostedFunction, Assert.Single(configuredOptions.ChatOptions.Tools!)); + } + + [Fact] + public void CustomMapping_ReturningNull_DoesNotAddClientFunctions() + { + // Arrange + using JsonDocument body = CreateBody(); +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions + { + DangerouslyAllowClientFunctionTools = true, + RunOptionsFactory = _ => null + }; +#pragma warning restore MAAI001 + + // Act + var run = OpenAIResponses.ToAgentRunRequest(body.RootElement, mapOptions); + + // Assert + Assert.Null(run.Options); + } + + [Fact] + public void ExplicitRejectFactory_OverridesOptInRegardlessOfAssignmentOrder() + { + // Arrange + using JsonDocument body = CreateBody(); + var mapOptions = new OpenAIResponsesMapOptions + { + RunOptionsFactory = OpenAIResponsesMapOptions.RejectRequestSettings + }; +#pragma warning disable MAAI001 + mapOptions.DangerouslyAllowClientFunctionTools = true; +#pragma warning restore MAAI001 + + // Act & Assert + Assert.Throws(() => OpenAIResponses.ToAgentRunRequest(body.RootElement, mapOptions)); + } + + [Fact] + public void RunOptionsFactory_Null_ThrowsArgumentNullException() + { + // Arrange + var mapOptions = new OpenAIResponsesMapOptions(); + + // Act & Assert + Assert.Throws(() => mapOptions.RunOptionsFactory = null!); + } + + private static JsonDocument CreateBody() => JsonDocument.Parse( + """ + { + "input": "hello", + "tools": [ + {"type":"function","name":"get_weather","parameters":{"type":"object"}}, + {"type":"function","name":"get_weather","parameters":{"type":"object"}} + ] + } + """); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs index cb2db3790ac..14a3b9b1941 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesTests.cs @@ -28,25 +28,26 @@ public void ToAgentRunRequest_StringInput_ProducesUserMessage() } [Fact] - public void ToAgentRunRequest_DangerousClientFunctionOptInWithoutAgent_ThrowsNotSupportedException() + public void ToAgentRunRequest_DangerousClientFunctionOptInWithoutTools_ReturnsNullOptions() { // Arrange using var doc = JsonDocument.Parse("""{ "input": "Hello there" }"""); #pragma warning disable MAAI001 var mapOptions = new OpenAIResponsesMapOptions { - DangerouslyAllowClientFunctionTools = - OpenAIClientFunctionToolNameConflictBehavior.Reject() + DangerouslyAllowClientFunctionTools = true }; #pragma warning restore MAAI001 - // Act & Assert - Assert.Throws(() => - OpenAIResponses.ToAgentRunRequest(doc.RootElement, mapOptions)); + // Act + var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement, mapOptions); + + // Assert + Assert.Null(request.Options); } [Fact] - public void ToAgentRunRequest_DangerousClientFunctionOptInWithAgent_ReturnsRunOptions() + public void ToAgentRunRequest_DangerousClientFunctionOptIn_ReturnsRunOptions() { // Arrange using var doc = JsonDocument.Parse( @@ -62,19 +63,16 @@ public void ToAgentRunRequest_DangerousClientFunctionOptInWithAgent_ReturnsRunOp ] } """); - using var chatClient = new TestHelpers.SimpleMockChatClient(); - AIAgent agent = chatClient.AsAIAgent(name: "test-agent"); #pragma warning disable MAAI001 var mapOptions = new OpenAIResponsesMapOptions { - DangerouslyAllowClientFunctionTools = - OpenAIClientFunctionToolNameConflictBehavior.Reject() + DangerouslyAllowClientFunctionTools = true }; #pragma warning restore MAAI001 // Act OpenAIResponsesRunRequest request = - OpenAIResponses.ToAgentRunRequest(doc.RootElement, agent, mapOptions); + OpenAIResponses.ToAgentRunRequest(doc.RootElement, mapOptions); // Assert ChatClientAgentRunOptions runOptions = diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs index 10597052765..80a9af2895c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/PermissiveMapOptions.cs @@ -18,9 +18,6 @@ internal static class PermissiveMapOptions { public static OpenAIResponsesMapOptions Responses() => new() { -#pragma warning disable MAAI001 - DangerouslyAllowClientFunctionTools = OpenAIClientFunctionToolNameConflictBehavior.AllowOverride(), -#pragma warning restore MAAI001 RunOptionsFactory = static request => { var chatOptions = new ChatOptions