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/OpenAIResponseRequestInfo.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs index 0d5741984e7..abd78fe4d91 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponseRequestInfo.cs @@ -74,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/OpenAIResponsesMapOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs index 63a550f2c1e..81b18c42a62 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIResponsesMapOptions.cs @@ -2,6 +2,11 @@ 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; namespace Microsoft.Agents.AI.Hosting.OpenAI; @@ -18,26 +23,80 @@ 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 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 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 + /// 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 + /// and its default behavior. The request's + /// tool_choice is not enabled by this setting and remains controlled by + /// . + /// + /// + /// 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. + /// + /// + /// 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 bool DangerouslyAllowClientFunctionTools { get; set; } /// /// The default implementation. Throws a @@ -55,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); @@ -78,12 +161,12 @@ public sealed class OpenAIResponsesMapOptions LocalAdd("instructions"); } - if (request.Tools is { Count: > 0 }) + if (tools is { Count: > 0 }) { LocalAdd("tools"); } - if (request.ToolChoice is not null) + if (request.HasToolChoice || request.ToolChoice is not null) { LocalAdd("tool_choice"); } @@ -94,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 959209a0aa4..5b01cc1394a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs @@ -17,13 +17,15 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; internal sealed class AIAgentResponseExecutor : IResponseExecutor { private readonly AIAgent _agent; - private readonly Func _runOptionsFactory; + private readonly OpenAIResponsesMapOptions _mapOptions; - public AIAgentResponseExecutor(AIAgent agent, OpenAIResponsesMapOptions? mapOptions = null) + public AIAgentResponseExecutor( + AIAgent agent, + OpenAIResponsesMapOptions? mapOptions = null) { ArgumentNullException.ThrowIfNull(agent); this._agent = agent; - this._runOptionsFactory = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory; + this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions(); } public ValueTask ValidateRequestAsync( @@ -35,9 +37,9 @@ public AIAgentResponseExecutor(AIAgent agent, OpenAIResponsesMapOptions? mapOpti { 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. - _ = this._runOptionsFactory(request.ToRequestInfo()); + _ = this._mapOptions.RunOptionsFactory(request.ToRequestInfo()); return null; } catch (NotSupportedException ex) @@ -58,7 +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 = this._runOptionsFactory(request.ToRequestInfo()); + 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 bc51e74dc76..687c121be25 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()); + _ = this._mapOptions.RunOptionsFactory(request.ToRequestInfo()); } catch (NotSupportedException ex) { @@ -109,7 +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 = this._runOptionsFactory(request.ToRequestInfo()); + 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 46b4532638b..e38b604fb91 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, @@ -18,8 +20,94 @@ internal static class OpenAIResponseRequestInfoBuilder Model = request.Model, Tools = request.Tools is { Count: > 0 } tools ? new List(tools) : null, ToolChoice = request.ToolChoice?.ToChatToolMode(), + HasToolChoice = request.ToolChoice is not null, }; + internal static (List? ClientTools, List? RemainingTools) + ConvertClientFunctionTools(this IReadOnlyList tools) + { + List? clientTools = null; + List? remainingTools = null; + + foreach (JsonElement tool in tools) + { + if (tool.ToFunctionTool() is { } functionTool) + { + (clientTools ??= []).Add(functionTool); + } + else + { + (remainingTools ??= []).Add(tool); + } + } + + return (clientTools, remainingTools); + } + + private static ClientAIFunctionDeclaration? 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); + bool? strict = tool.TryGetProperty("strict", out JsonElement requestStrict) && + requestStrict.ValueKind is JsonValueKind.True or JsonValueKind.False + ? requestStrict.GetBoolean() + : null; + + return new ClientAIFunctionDeclaration(function, strict); + } + + internal sealed class ClientAIFunctionDeclaration : AIFunctionDeclaration + { + private readonly AIFunctionDeclaration _innerFunction; + + public ClientAIFunctionDeclaration(AIFunctionDeclaration innerFunction, bool? strict) + { + this._innerFunction = innerFunction; + var additionalProperties = new Dictionary(); + foreach (KeyValuePair property in innerFunction.AdditionalProperties) + { + additionalProperties.Add(property.Key, property.Value); + } + + if (strict is not null) + { + 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.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs new file mode 100644 index 00000000000..f948a7d85c6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesClientFunctionToolsLiveTests.cs @@ -0,0 +1,78 @@ +// 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 AllowedClientFunction_ReturnsFunctionCallAsync() + { + // Arrange + Assert.SkipWhen( + string.IsNullOrEmpty(ApiKey), + "OPENAI_API_KEY is not configured; skipping live client function tool test."); + + 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 + OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(requestBody, mapOptions); + AgentResponse response = await agent.RunAsync(run.Messages, options: run.Options); + + // Assert + FunctionCallContent functionCall = Assert.Single( + response.Messages.SelectMany(message => message.Contents).OfType()); + Assert.Equal("get_weather", functionCall.Name); + } + + 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/OpenAIMapOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIMapOptionsTests.cs index b0ce53eaa08..01bb755df2f 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,114 @@ 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); + using HttpClient client = GetClient(app); + using var content = new StringContent( + """{"input":"hello","tools":[{"type":"function","name":"client_function"}]}""", + Encoding.UTF8, + "application/json"); + + // Act + using HttpResponseMessage response = await client.PostAsync( + new Uri("/reject-tools-agent/v1/responses", UriKind.Relative), + content); + + // 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 = true + }; +#pragma warning restore MAAI001 + using var app = await CreateResponsesServerAsync("reject-tool-choice-agent", mapOptions); + 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 + using HttpResponseMessage response = await client.PostAsync( + new Uri("/reject-tool-choice-agent/v1/responses", UriKind.Relative), + content); + + // 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 = true + }; +#pragma warning restore MAAI001 + using var app = await CreateResponsesServerAsync("reject-hosted-tool-agent", mapOptions); + using HttpClient client = GetClient(app); + using var content = new StringContent( + """{"input":"hello","tools":[{"type":"web_search"}]}""", + Encoding.UTF8, + "application/json"); + + // Act + using HttpResponseMessage response = await client.PostAsync( + new Uri("/reject-hosted-tool-agent/v1/responses", UriKind.Relative), + content); + + // 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); + using HttpClient client = GetClient(app); + using var content = new StringContent( + """{"input":"hello","tool_choice":"unsupported"}""", + Encoding.UTF8, + "application/json"); + + // Act + using HttpResponseMessage response = await client.PostAsync( + new Uri("/reject-unknown-tool-choice-agent/v1/responses", UriKind.Relative), + content); + + // 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 bef3325dd7d..7e02fcff475 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,88 @@ public void ToRequestInfo_NoToolChoice_MapsToNull() Assert.Null(info.ToolChoice); } + [Fact] + public void ToRequestInfo_PreservesFunctionToolAsRawTool() + { + // 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 + 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()); + } + + [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); @@ -101,4 +183,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/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 4612ec35844..c1f622fba22 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs @@ -940,6 +940,245 @@ public async Task CreateResponse_FunctionCall_ReturnsCorrectlyAsync() Assert.NotNull(response.Id); } + [Fact] + 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 = true + }; +#pragma warning restore MAAI001 + + this._httpClient = await this.CreateTestServerWithCustomClientAsync( + agentName: AgentName, + instructions: "You are a helpful assistant.", + chatClient, + mapOptions); + + 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 + } + ] + } + """, + 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.Null(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); + 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()); + } + + [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 = true + }; +#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_WithConflictingClientFunction_ForwardsBothToolsToChatClientAsync() + { + // Arrange + const string AgentName = "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 = true + }; +#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); + Assert.Equal(2, chatClient.LastChatOptions!.Tools!.Count); + Assert.Contains(hostedFunction, chatClient.LastChatOptions.Tools); + AIFunctionDeclaration forwardedFunction = + Assert.IsAssignableFrom(Assert.Single( + chatClient.LastChatOptions.Tools, tool => tool is not AIFunction)); + 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. /// @@ -1409,7 +1648,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(); @@ -1420,8 +1663,32 @@ 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(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + return testServer.CreateClient(); + } + + private async Task CreateTestServerWithHostedToolAsync( + string agentName, + IChatClient chatClient, + AITool tool, + OpenAIResponsesMapOptions? mapOptions = null) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + 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 @@ -1430,6 +1697,32 @@ private async Task CreateTestServerWithCustomClientAsync(string agen 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) { 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 2ed5b735acb..14a3b9b1941 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,61 @@ public void ToAgentRunRequest_StringInput_ProducesUserMessage() Assert.Null(request.Options); } + [Fact] + public void ToAgentRunRequest_DangerousClientFunctionOptInWithoutTools_ReturnsNullOptions() + { + // Arrange + using var doc = JsonDocument.Parse("""{ "input": "Hello there" }"""); +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions + { + DangerouslyAllowClientFunctionTools = true + }; +#pragma warning restore MAAI001 + + // Act + var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement, mapOptions); + + // Assert + Assert.Null(request.Options); + } + + [Fact] + public void ToAgentRunRequest_DangerousClientFunctionOptIn_ReturnsRunOptions() + { + // Arrange + using var doc = JsonDocument.Parse( + """ + { + "input": "Hello there", + "tools": [ + { + "type": "function", + "name": "client_function", + "parameters": { "type": "object" } + } + ] + } + """); +#pragma warning disable MAAI001 + var mapOptions = new OpenAIResponsesMapOptions + { + DangerouslyAllowClientFunctionTools = true + }; +#pragma warning restore MAAI001 + + // Act + OpenAIResponsesRunRequest request = + OpenAIResponses.ToAgentRunRequest(doc.RootElement, 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/TestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/TestHelpers.cs index 198e65629e4..85c68b284dc 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 @@ -427,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. ///