From f9e133237d806151cf3c23166d20a6849ee11eed Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 10 Sep 2025 20:40:58 -0400 Subject: [PATCH] Update M.E.AI and delete a bunch of dead code (#676) --- dotnet/Directory.Packages.props | 88 +- dotnet/samples/Directory.Build.props | 2 +- .../AgentOrchestration/AgentSample.cs | 5 +- .../Agent_OpenAI_Step01_Running/Program.cs | 4 +- .../Handoffs/HandoffOrchestration.cs | 2 +- .../Microsoft.Agents.Orchestration.csproj | 1 + .../MEAI.A/FunctionApprovalRequestContent.cs | 35 - .../MEAI.A/FunctionApprovalResponseContent.cs | 34 - .../MEAI.A/UserInputRequestContent.cs | 25 - .../MEAI.A/UserInputResponseContent.cs | 25 - ...t.Extensions.AI.Agents.Abstractions.csproj | 2 +- .../Extensions/AIAgentWithOpenAIExtensions.cs | 339 +--- .../Extensions/ChatOptionsExtensions.cs | 28 - .../OpenAIAssistantClientExtensions.cs | 143 +- .../OpenAIResponseClientExtensions.cs | 15 +- .../NewOpenAIAssistantChatClient.cs | 722 -------- .../NewOpenAIResponsesChatClient.cs | 918 ---------- .../ChatCompletion/ChatClientExtensions.cs | 19 +- .../MEAI/ApprovalRequiredAIFunction.cs | 40 - .../MEAI/LoggingHelpers.cs | 40 - .../MEAI/NewFunctionInvokingChatClient.cs | 1553 ----------------- .../MEAI/OpenTelemetryConsts.cs | 144 -- .../Microsoft.Extensions.AI.Agents.csproj | 1 + ...ns.AI.Agents.Abstractions.UnitTests.csproj | 1 + .../MEAI/AssertExtensions.cs | 86 - .../NewFunctionInvokingChatClientTests.cs | 931 ---------- .../MEAI/TestChatClient.cs | 51 - .../OpenAIResponseFixture.cs | 2 +- 28 files changed, 67 insertions(+), 5189 deletions(-) delete mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/FunctionApprovalRequestContent.cs delete mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/FunctionApprovalResponseContent.cs delete mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/UserInputRequestContent.cs delete mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/UserInputResponseContent.cs delete mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/ChatOptionsExtensions.cs delete mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/NewOpenAIAssistantChatClient.cs delete mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/NewOpenAIResponsesChatClient.cs delete mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/ApprovalRequiredAIFunction.cs delete mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/LoggingHelpers.cs delete mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/NewFunctionInvokingChatClient.cs delete mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/OpenTelemetryConsts.cs delete mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/MEAI/AssertExtensions.cs delete mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/MEAI/NewFunctionInvokingChatClientTests.cs delete mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/MEAI/TestChatClient.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 07fbcf93b4..f0e6d3fe0d 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -11,34 +11,34 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + - + - + - - - - + + + + @@ -48,31 +48,31 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - + + + + + diff --git a/dotnet/samples/Directory.Build.props b/dotnet/samples/Directory.Build.props index 564527ad9d..065ab34672 100644 --- a/dotnet/samples/Directory.Build.props +++ b/dotnet/samples/Directory.Build.props @@ -7,7 +7,7 @@ false net472;net9.0 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 - $(NoWarn);CA1707 + $(NoWarn);CA1707;MEAI001 diff --git a/dotnet/samples/GettingStarted/AgentOrchestration/AgentSample.cs b/dotnet/samples/GettingStarted/AgentOrchestration/AgentSample.cs index 004f1bf2e0..0f86869579 100644 --- a/dotnet/samples/GettingStarted/AgentOrchestration/AgentSample.cs +++ b/dotnet/samples/GettingStarted/AgentOrchestration/AgentSample.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Agents; using Microsoft.Shared.Diagnostics; using Microsoft.Shared.Samples; -using OpenAI; using OpenAI.Assistants; using OpenAI.Chat; using OpenAI.Responses; @@ -111,13 +110,13 @@ public class AgentSample(ITestOutputHelper output) : BaseSample(output) private IChatClient GetOpenAIResponsesClient() => new OpenAIResponseClient(TestConfiguration.OpenAI.ChatModelId, TestConfiguration.OpenAI.ApiKey) - .AsNewIChatClient(); + .AsIChatClient(); private IChatClient GetAzureAIAgentPersistentClient(ChatClientAgentOptions options) => new PersistentAgentsClient(TestConfiguration.AzureAI.Endpoint, new AzureCliCredential()).AsNewIChatClient(options.Id!); private IChatClient GetOpenAIAssistantChatClient(ChatClientAgentOptions options) - => new AssistantClient(TestConfiguration.OpenAI.ApiKey).AsNewIChatClient(options.Id!); + => new AssistantClient(TestConfiguration.OpenAI.ApiKey).AsIChatClient(options.Id!); #endregion diff --git a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs index 6625bdad2c..13760a2c89 100644 --- a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs @@ -22,11 +22,11 @@ AIAgent agent = new OpenAIClient(apiKey) UserChatMessage chatMessage = new("Tell me a joke about a pirate."); // Invoke the agent and output the text result. -ChatCompletion chatCompletion = await agent.RunAsync(chatMessage); +ChatCompletion chatCompletion = await agent.RunAsync([chatMessage]); Console.WriteLine(chatCompletion.Content.Last().Text); // Invoke the agent with streaming support. -AsyncCollectionResult completionUpdates = agent.RunStreamingAsync(chatMessage); +AsyncCollectionResult completionUpdates = agent.RunStreamingAsync([chatMessage]); await foreach (StreamingChatCompletionUpdate completionUpdate in completionUpdates) { if (completionUpdate.ContentUpdate.Count > 0) diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/HandoffOrchestration.cs b/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/HandoffOrchestration.cs index 321e71b4a2..8ef64910e7 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/HandoffOrchestration.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/Handoffs/HandoffOrchestration.cs @@ -216,7 +216,7 @@ public sealed partial class HandoffOrchestration : OrchestratingAgent static void Terminate() { - if (NewFunctionInvokingChatClient.CurrentContext is not { } ctx) + if (FunctionInvokingChatClient.CurrentContext is not { } ctx) { throw new NotSupportedException($"The agent is not configured with a {nameof(FunctionInvokingChatClient)}. Cease execution."); } diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Microsoft.Agents.Orchestration.csproj b/dotnet/src/Microsoft.Agents.Orchestration/Microsoft.Agents.Orchestration.csproj index de550fc682..9f92f7516a 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Microsoft.Agents.Orchestration.csproj +++ b/dotnet/src/Microsoft.Agents.Orchestration/Microsoft.Agents.Orchestration.csproj @@ -5,6 +5,7 @@ $(ProjectsDebugTargetFrameworks) Microsoft.Agents.Orchestration alpha + $(NoWarn);MEAI001 diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/FunctionApprovalRequestContent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/FunctionApprovalRequestContent.cs deleted file mode 100644 index b6c561411f..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/FunctionApprovalRequestContent.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// -/// Represents a request for user approval of a function call. -/// -public sealed class FunctionApprovalRequestContent : UserInputRequestContent -{ - /// - /// Initializes a new instance of the class. - /// - /// The ID to uniquely identify the function approval request/response pair. - /// The function call that requires user approval. - public FunctionApprovalRequestContent(string id, FunctionCallContent functionCall) - : base(id) - { - this.FunctionCall = Throw.IfNull(functionCall); - } - - /// - /// Gets the function call that pre-invoke approval is required for. - /// - public FunctionCallContent FunctionCall { get; } - - /// - /// Creates a to indicate whether the function call is approved or rejected based on the value of . - /// - /// if the function call is approved; otherwise, . - /// The representing the approval response. - public FunctionApprovalResponseContent CreateResponse(bool approved) - => new(this.Id, approved, this.FunctionCall); -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/FunctionApprovalResponseContent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/FunctionApprovalResponseContent.cs deleted file mode 100644 index 11bb01b5f6..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/FunctionApprovalResponseContent.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// -/// Represents a response to a function approval request. -/// -public sealed class FunctionApprovalResponseContent : UserInputResponseContent -{ - /// - /// Initializes a new instance of the class. - /// - /// The ID to uniquely identify the function approval request/response pair. - /// Indicates whether the request was approved. - /// The function call that requires user approval. - public FunctionApprovalResponseContent(string id, bool approved, FunctionCallContent functionCall) - : base(id) - { - this.Approved = approved; - this.FunctionCall = Throw.IfNull(functionCall); - } - - /// - /// Gets or sets a value indicating whether the user approved the request. - /// - public bool Approved { get; } - - /// - /// Gets the function call that pre-invoke approval is required for. - /// - public FunctionCallContent FunctionCall { get; } -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/UserInputRequestContent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/UserInputRequestContent.cs deleted file mode 100644 index ba327b6717..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/UserInputRequestContent.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// -/// Base class for user input request content. -/// -public abstract class UserInputRequestContent : AIContent -{ - /// - /// Initializes a new instance of the class. - /// - /// The ID to uniquely identify the user input request/response pair. - protected UserInputRequestContent(string id) - { - Id = Throw.IfNullOrWhitespace(id); - } - - /// - /// Gets the ID to uniquely identify the user input request/response pair. - /// - public string Id { get; } -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/UserInputResponseContent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/UserInputResponseContent.cs deleted file mode 100644 index 78b138779e..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/MEAI.A/UserInputResponseContent.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// -/// Base class for user input response content. -/// -public abstract class UserInputResponseContent : AIContent -{ - /// - /// Initializes a new instance of the class. - /// - /// The ID to uniquely identify the user input request/response pair. - protected UserInputResponseContent(string id) - { - Id = Throw.IfNullOrWhitespace(id); - } - - /// - /// Gets the ID to uniquely identify the user input request/response pair. - /// - public string Id { get; } -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/Microsoft.Extensions.AI.Agents.Abstractions.csproj b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/Microsoft.Extensions.AI.Agents.Abstractions.csproj index 37c7bc871b..0bdc6a5b49 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/Microsoft.Extensions.AI.Agents.Abstractions.csproj +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/Microsoft.Extensions.AI.Agents.Abstractions.csproj @@ -4,7 +4,7 @@ $(ProjectsTargetFrameworks) $(ProjectsDebugTargetFrameworks) Microsoft.Extensions.AI.Agents - $(NoWarn);CA1716;IDE0009; + $(NoWarn);CA1716;IDE0009;MEAI001 alpha diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/AIAgentWithOpenAIExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/AIAgentWithOpenAIExtensions.cs index c1a8373cfd..37bee66e61 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/AIAgentWithOpenAIExtensions.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/AIAgentWithOpenAIExtensions.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System.ClientModel; -using System.Text; -using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Agents; using Microsoft.Extensions.AI.Agents.OpenAI.ChatCompletion; using Microsoft.Shared.Diagnostics; @@ -22,33 +20,6 @@ namespace OpenAI; /// public static class AIAgentWithOpenAIExtensions { - /// - /// Runs the AI agent with a single OpenAI chat message and returns the response as a native OpenAI . - /// - /// The AI agent to run. - /// The OpenAI chat message to send to the agent. - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided message and agent response. - /// Optional parameters for agent invocation. - /// The to monitor for cancellation requests. The default is . - /// A representing the asynchronous operation that returns a native OpenAI response. - /// Thrown when or is . - /// Thrown when the agent's response cannot be converted to a , typically when the underlying representation is not an OpenAI response. - /// Thrown when the type is not supported by the message conversion method. - /// - /// This method converts the OpenAI chat message to the Microsoft Extensions AI format using the appropriate conversion method, - /// runs the agent, and then extracts the native OpenAI from the response using . - /// - public static async Task RunAsync(this AIAgent agent, OpenAI.Chat.ChatMessage message, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) - { - Throw.IfNull(agent); - Throw.IfNull(message); - - var response = await agent.RunAsync(message.AsChatMessage(), thread, options, cancellationToken).ConfigureAwait(false); - - var chatCompletion = response.AsChatCompletion(); - return chatCompletion; - } - /// /// Runs the AI agent with a collection of OpenAI chat messages and returns the response as a native OpenAI . /// @@ -62,7 +33,7 @@ public static class AIAgentWithOpenAIExtensions /// Thrown when the agent's response cannot be converted to a , typically when the underlying representation is not an OpenAI response. /// Thrown when any message in has a type that is not supported by the message conversion method. /// - /// This method converts each OpenAI chat message to the Microsoft Extensions AI format using , + /// This method converts the OpenAI chat messages to the Microsoft Extensions AI format using the appropriate conversion method, /// runs the agent with the converted message collection, and then extracts the native OpenAI from the response using . /// public static async Task RunAsync(this AIAgent agent, IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) @@ -76,32 +47,6 @@ public static class AIAgentWithOpenAIExtensions return chatCompletion; } - /// - /// Runs the AI agent with a single OpenAI chat message and returns the response as collection of native OpenAI . - /// - /// The AI agent to run. - /// The OpenAI chat message to send to the agent. - /// The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided message and agent response. - /// Optional parameters for agent invocation. - /// The to monitor for cancellation requests. The default is . - /// A representing the asynchronous operation that returns a native OpenAI response. - /// Thrown when or is . - /// Thrown when the agent's response cannot be converted to a , typically when the underlying representation is not an OpenAI response. - /// Thrown when the type is not supported by the message conversion method. - /// - /// This method converts the OpenAI chat message to the Microsoft Extensions AI format using the appropriate conversion method, - /// runs the agent, and then extracts the native OpenAI from the response using . - /// - public static AsyncCollectionResult RunStreamingAsync(this AIAgent agent, OpenAI.Chat.ChatMessage message, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) - { - Throw.IfNull(agent); - Throw.IfNull(message); - - IAsyncEnumerable response = agent.RunStreamingAsync(message.AsChatMessage(), thread, options, cancellationToken); - - return new AsyncStreamingUpdateCollectionResult(response); - } - /// /// Runs the AI agent with a single OpenAI chat message and returns the response as collection of native OpenAI . /// @@ -115,7 +60,7 @@ public static class AIAgentWithOpenAIExtensions /// Thrown when the agent's response cannot be converted to a , typically when the underlying representation is not an OpenAI response. /// Thrown when the type is not supported by the message conversion method. /// - /// This method converts the OpenAI chat message to the Microsoft Extensions AI format using the appropriate conversion method, + /// This method converts the OpenAI chat messages to the Microsoft Extensions AI format using the appropriate conversion method, /// runs the agent, and then extracts the native OpenAI from the response using . /// public static AsyncCollectionResult RunStreamingAsync(this AIAgent agent, IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) @@ -127,284 +72,4 @@ public static class AIAgentWithOpenAIExtensions return new AsyncStreamingUpdateCollectionResult(response); } - - /// - /// Creates a sequence of instances from the specified OpenAI input messages. - /// - /// The OpenAI input messages to convert. - /// A sequence of Microsoft Extensions AI chat messages converted from the OpenAI messages. - /// Thrown when is . - /// Thrown when a message type is encountered that cannot be converted. - /// - /// This method supports conversion of the following OpenAI message types: - /// - /// - /// - /// (obsolete) - /// - /// - /// - /// - /// - internal static IEnumerable AsChatMessages(this IEnumerable messages) - { - Throw.IfNull(messages); - - foreach (OpenAI.Chat.ChatMessage message in messages) - { - switch (message) - { - case OpenAI.Chat.AssistantChatMessage assistantMessage: - yield return assistantMessage.AsChatMessage(); - break; - case OpenAI.Chat.DeveloperChatMessage developerMessage: - yield return developerMessage.AsChatMessage(); - break; -#pragma warning disable CS0618 // Type or member is obsolete - case OpenAI.Chat.FunctionChatMessage functionMessage: - yield return functionMessage.AsChatMessage(); - break; -#pragma warning restore CS0618 // Type or member is obsolete - case OpenAI.Chat.SystemChatMessage systemMessage: - yield return systemMessage.AsChatMessage(); - break; - case OpenAI.Chat.ToolChatMessage toolMessage: - yield return toolMessage.AsChatMessage(); - break; - case OpenAI.Chat.UserChatMessage userMessage: - yield return userMessage.AsChatMessage(); - break; - } - } - } - - /// - /// Converts an OpenAI chat message to a Microsoft Extensions AI . - /// - /// The OpenAI chat message to convert. - /// A equivalent of the input OpenAI message. - /// Thrown when is . - /// Thrown when the type is not supported for conversion. - /// - /// This method provides a bridge between OpenAI SDK message types and Microsoft Extensions AI message types. - /// It handles the conversion by switching on the concrete type of the OpenAI message and calling the appropriate - /// specialized conversion method. - /// - internal static Microsoft.Extensions.AI.ChatMessage AsChatMessage(this OpenAI.Chat.ChatMessage chatMessage) - { - Throw.IfNull(chatMessage); - - return chatMessage switch - { - AssistantChatMessage assistantMessage => assistantMessage.AsChatMessage(), - DeveloperChatMessage developerMessage => developerMessage.AsChatMessage(), - SystemChatMessage systemMessage => systemMessage.AsChatMessage(), - ToolChatMessage toolMessage => toolMessage.AsChatMessage(), - UserChatMessage userMessage => userMessage.AsChatMessage(), - _ => throw new NotSupportedException($"Message type {chatMessage.GetType().Name} is not supported for conversion.") - }; - } - - /// - /// Converts OpenAI chat message content to Microsoft Extensions AI content items. - /// - /// The OpenAI chat message content to convert. - /// A sequence of items converted from the OpenAI content. - /// - /// This method supports conversion of the following OpenAI content part types: - /// - /// Text content (converted to ) - /// Refusal content (converted to ) - /// Image content (converted to or ) - /// Input audio content (converted to ) - /// File content (converted to ) - /// - /// - private static IEnumerable AsAIContent(this OpenAI.Chat.ChatMessageContent content) - { - Throw.IfNull(content); - - foreach (OpenAI.Chat.ChatMessageContentPart part in content) - { - switch (part.Kind) - { - case OpenAI.Chat.ChatMessageContentPartKind.Text: - yield return new TextContent(part.Text) - { - RawRepresentation = content - }; - break; - case OpenAI.Chat.ChatMessageContentPartKind.Refusal: - yield return new TextContent(part.Refusal) - { - RawRepresentation = content - }; - break; - case OpenAI.Chat.ChatMessageContentPartKind.Image: - if (part.ImageBytes is not null) - { - yield return new DataContent(part.ImageBytes, part.ImageBytesMediaType) - { - RawRepresentation = content - }; - } - else - { - yield return new UriContent(part.ImageUri, "image/*") - { - RawRepresentation = content - }; - } - break; - case OpenAI.Chat.ChatMessageContentPartKind.InputAudio: - yield return new DataContent(part.InputAudioBytes, "audio/*") - { - RawRepresentation = content - }; - break; - case OpenAI.Chat.ChatMessageContentPartKind.File: - yield return new DataContent(part.FileBytes, part.FileBytesMediaType) - { - RawRepresentation = content - }; - break; - default: - throw new NotSupportedException($"Content part kind '{part.Kind}' is not supported for conversion to AIContent."); - } - } - } - - /// - /// Converts OpenAI chat message content to text. - /// - /// The OpenAI chat message content to convert. - /// A string created from the text and refusal parts of the OpenAI content. - /// - /// Using when converting OpenAI For tool messages, the contents can only be of type text. - /// - private static string AsText(this OpenAI.Chat.ChatMessageContent content) - { - Throw.IfNull(content); - - StringBuilder text = new(); - foreach (OpenAI.Chat.ChatMessageContentPart part in content) - { - switch (part.Kind) - { - case OpenAI.Chat.ChatMessageContentPartKind.Text: - text.Append(part.Text); - break; - case OpenAI.Chat.ChatMessageContentPartKind.Refusal: - text.Append(part.Refusal); - break; - default: - throw new NotSupportedException($"Content part kind '{part.Kind}' is not supported for conversion to text."); - } - } - return text.ToString(); - } - - /// - /// Converts an OpenAI to a Microsoft Extensions AI . - /// - /// The OpenAI assistant message to convert. - /// A Microsoft Extensions AI chat message with assistant role. - /// - /// This method converts the assistant message content using and preserves - /// the participant name as the author name in the resulting message. - /// - private static Microsoft.Extensions.AI.ChatMessage AsChatMessage(this AssistantChatMessage assistantMessage) - { - Throw.IfNull(assistantMessage); - - return new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.Assistant, [.. assistantMessage.Content.AsAIContent()]) - { - AuthorName = assistantMessage.ParticipantName, - RawRepresentation = assistantMessage - }; - } - - /// - /// Converts an OpenAI to a Microsoft Extensions AI . - /// - /// The OpenAI developer message to convert. - /// A Microsoft Extensions AI chat message with system role. - /// - /// Developer messages are treated as system messages in the Microsoft Extensions AI framework. - /// The participant name is preserved as the author name. - /// - private static Microsoft.Extensions.AI.ChatMessage AsChatMessage(this DeveloperChatMessage developerMessage) - { - Throw.IfNull(developerMessage); - - return new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.System, [.. developerMessage.Content.AsAIContent()]) - { - AuthorName = developerMessage.ParticipantName, - RawRepresentation = developerMessage - }; - } - - /// - /// Converts an OpenAI to a Microsoft Extensions AI . - /// - /// The OpenAI system message to convert. - /// A Microsoft Extensions AI chat message with system role. - /// - /// This method converts the system message content using and preserves - /// the participant name as the author name in the resulting message. - /// - private static Microsoft.Extensions.AI.ChatMessage AsChatMessage(this SystemChatMessage systemMessage) - { - Throw.IfNull(systemMessage); - - return new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.System, [.. systemMessage.Content.AsAIContent()]) - { - AuthorName = systemMessage.ParticipantName, - RawRepresentation = systemMessage - }; - } - - /// - /// Converts an OpenAI to a Microsoft Extensions AI . - /// - /// The OpenAI tool message to convert. - /// A Microsoft Extensions AI chat message with tool role. - /// - /// This method converts tool message content using and includes the tool call ID - /// in the resulting message's additional properties for traceability. - /// - private static Microsoft.Extensions.AI.ChatMessage AsChatMessage(this ToolChatMessage toolMessage) - { - Throw.IfNull(toolMessage); - - var content = new FunctionResultContent(toolMessage.ToolCallId, toolMessage.Content.AsText()) - { - RawRepresentation = toolMessage - }; - return new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.Tool, [content]) - { - RawRepresentation = toolMessage, - AdditionalProperties = new() { ["tool_call_id"] = toolMessage.ToolCallId } - }; - } - - /// - /// Converts an OpenAI to a Microsoft Extensions AI . - /// - /// The OpenAI user message to convert. - /// A Microsoft Extensions AI chat message with user role. - /// - /// This method converts the user message content using and preserves - /// the participant name as the author name in the resulting message. - /// - private static Microsoft.Extensions.AI.ChatMessage AsChatMessage(this UserChatMessage userMessage) - { - Throw.IfNull(userMessage); - - return new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.User, [.. userMessage.Content.AsAIContent()]) - { - AuthorName = userMessage.ParticipantName, - RawRepresentation = userMessage - }; - } } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/ChatOptionsExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/ChatOptionsExtensions.cs deleted file mode 100644 index f8052f9498..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/ChatOptionsExtensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. -using Microsoft.Shared.Diagnostics; -using OpenAI.Responses; - -namespace Microsoft.Extensions.AI.Agents; - -/// -/// Extension methods for -/// -public static class ChatOptionsExtensions -{ - /// - /// Disables the storage of response output in the chat options. - /// - /// Instance of - /// - public static ChatOptions WithResponseStoredOutputDisabled(this ChatOptions options) - { - Throw.IfNull(options); - - // We can use the RawRepresentationFactory to provide Response service specific - // options. Here we can indicate that we do not want the service to store the - // conversation in a service managed thread. - options.RawRepresentationFactory = (_) => new ResponseCreationOptions() { StoredOutputEnabled = false }; - - return options; - } -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs index e048c78fae..cfba779c1e 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs @@ -1,9 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Agents; using Microsoft.Extensions.Logging; @@ -24,9 +20,6 @@ namespace OpenAI; /// public static class OpenAIAssistantClientExtensions { - /// Key into AdditionalProperties used to store a strict option. - private const string StrictKey = "strictJsonSchema"; - /// /// Creates an AI agent from an using the OpenAI Assistant API. /// @@ -209,140 +202,6 @@ public static class OpenAIAssistantClientExtensions } }; -#pragma warning disable CA2000 // Dispose objects before losing scope - var chatClient = client.AsNewIChatClient(assistantId); -#pragma warning restore CA2000 // Dispose objects before losing scope - return new ChatClientAgent(chatClient, agentOptions, loggerFactory); - } - - /// Converts an Extensions function to an OpenAI assistants function tool. - private static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction, ChatOptions? options = null) - { - bool? strict = - HasStrict(aiFunction.AdditionalProperties) ?? - HasStrict(options?.AdditionalProperties); - - return new FunctionToolDefinition(aiFunction.Name) - { - Description = aiFunction.Description, - Parameters = ToOpenAIFunctionParameters(aiFunction, strict), - StrictParameterSchemaEnabled = strict, - }; - } - - /// Extracts from an the parameters and strictness setting for use with OpenAI's APIs. - private static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict) - { - // Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting. - JsonElement jsonSchema = strict is true ? - StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) : - aiFunction.JsonSchema; - - // Roundtrip the schema through the ToolJson model type to remove extra properties - // and force missing ones into existence, then return the serialized UTF8 bytes as BinaryData. - var tool = jsonSchema.Deserialize(OpenAIJsonContext.Default.ToolJson)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.ToolJson)); - - return functionParameters; - } - - /// Gets whether the properties specify that strict schema handling is desired. - private static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => - additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && - strictObj is bool strictValue ? - strictValue : null; - - private static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new() - { - DisallowAdditionalProperties = true, - ConvertBooleanSchemas = true, - MoveDefaultKeywordToDescription = true, - RequireAllProperties = true, - TransformSchemaNode = (ctx, node) => - { - // Move content from common but unsupported properties to description. In particular, we focus on properties that - // the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation. - - if (node is JsonObject schemaObj) - { - StringBuilder? additionalDescription = null; - - ReadOnlySpan unsupportedProperties = - [ - // Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties: - "contentEncoding", "contentMediaType", "not", - - // Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models: - "minLength", "maxLength", "pattern", "format", - "minimum", "maximum", "multipleOf", - "patternProperties", - "minItems", "maxItems", - - // Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords - // as being unsupported with Azure OpenAI: - "unevaluatedProperties", "propertyNames", "minProperties", "maxProperties", - "unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems", - ]; - - foreach (string propName in unsupportedProperties) - { - if (schemaObj[propName] is { } propNode) - { - _ = schemaObj.Remove(propName); - AppendLine(ref additionalDescription, propName, propNode); - } - } - - if (additionalDescription is not null) - { - schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ? - $"{descriptionNode.GetValue()}{Environment.NewLine}{additionalDescription}" : - additionalDescription.ToString(); - } - - return node; - - static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode) - { - sb ??= new(); - - if (sb.Length > 0) - { - _ = sb.AppendLine(); - } - - _ = sb.Append(propName).Append(": ").Append(propNode); - } - } - - return node; - }, - }); - - /// Used to create the JSON payload for an OpenAI tool description. - internal sealed class ToolJson - { - [JsonPropertyName("type")] - public string Type { get; set; } = "object"; - - [JsonPropertyName("required")] - public HashSet Required { get; set; } = []; - - [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } = []; - - [JsonPropertyName("additionalProperties")] - public bool AdditionalProperties { get; set; } + return new ChatClientAgent(client.AsIChatClient(assistantId), agentOptions, loggerFactory); } } - -/// Source-generated JSON type information for use by all OpenAI implementations. -[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true)] -[JsonSerializable(typeof(OpenAIAssistantClientExtensions.ToolJson))] -[JsonSerializable(typeof(IDictionary))] -[JsonSerializable(typeof(string[]))] -[JsonSerializable(typeof(JsonElement))] -internal sealed partial class OpenAIJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/OpenAIResponseClientExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/OpenAIResponseClientExtensions.cs index c387be1e5c..fc2a6522fd 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/OpenAIResponseClientExtensions.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/Extensions/OpenAIResponseClientExtensions.cs @@ -33,6 +33,8 @@ public static class OpenAIResponseClientExtensions /// Thrown when is . public static AIAgent CreateAIAgent(this OpenAIResponseClient client, string? instructions = null, string? name = null, string? description = null, IList? tools = null, ILoggerFactory? loggerFactory = null) { + Throw.IfNull(client); + return client.CreateAIAgent( new ChatClientAgentOptions() { @@ -60,17 +62,6 @@ public static class OpenAIResponseClientExtensions Throw.IfNull(client); Throw.IfNull(options); -#pragma warning disable CA2000 // Dispose objects before losing scope - var chatClient = client.AsNewIChatClient(); -#pragma warning restore CA2000 // Dispose objects before losing scope - ChatClientAgent agent = new(chatClient, options, loggerFactory); - return agent; + return new ChatClientAgent(client.AsIChatClient(), options, loggerFactory); } - - /// Gets an for use with this . - /// The client. - /// An that can be used to converse via the . - /// is . - public static IChatClient AsNewIChatClient(this OpenAIResponseClient responseClient) => - new NewOpenAIResponsesChatClient(responseClient); } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/NewOpenAIAssistantChatClient.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/NewOpenAIAssistantChatClient.cs deleted file mode 100644 index d02646d4d0..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/NewOpenAIAssistantChatClient.cs +++ /dev/null @@ -1,722 +0,0 @@ -#pragma warning disable IDE0005 // Using directive is unnecessary. -#pragma warning disable IDE0073 // The file header does not match the required text -#pragma warning disable CS0436 // Type conflicts with imported type -#pragma warning disable CA1063 // Implement IDisposable Correctly - -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using Microsoft.Shared.Diagnostics; -using OpenAI; -using OpenAI.Assistants; - -#pragma warning disable CA1031 // Do not catch general exception types -#pragma warning disable SA1005 // Single line comments should begin with single space -#pragma warning disable SA1204 // Static elements should appear before instance elements -#pragma warning disable S125 // Sections of code should not be commented out -#pragma warning disable S907 // "goto" statement should not be used -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S1751 // Loops with at most one iteration should be refactored -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields -#pragma warning disable S4456 // Parameter validation in yielding methods should be wrapped -#pragma warning disable S4457 // Parameter validation in "async"/"await" methods should be wrapped - -namespace Microsoft.Extensions.AI; - -/// Represents an for an OpenAI . -internal sealed class NewOpenAIAssistantsChatClient : IChatClient -{ - /// The underlying . - private readonly AssistantClient _client; - - /// Metadata for the client. - private readonly ChatClientMetadata _metadata; - - /// The ID of the agent to use. - private readonly string _assistantId; - - /// The thread ID to use if none is supplied in . - private readonly string? _defaultThreadId; - - /// List of tools associated with the assistant. - private IReadOnlyList? _assistantTools; - - /// Initializes a new instance of the class for the specified . - public NewOpenAIAssistantsChatClient(AssistantClient assistantClient, string assistantId, string? defaultThreadId) - { - _client = Throw.IfNull(assistantClient); - _assistantId = Throw.IfNullOrWhitespace(assistantId); - - _defaultThreadId = defaultThreadId; - - // https://github.com/openai/openai-dotnet/issues/215 - // The endpoint isn't currently exposed, so use reflection to get at it, temporarily. Once packages - // implement the abstractions directly rather than providing adapters on top of the public APIs, - // the package can provide such implementations separate from what's exposed in the public API. - Uri providerUrl = typeof(AssistantClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(assistantClient) as Uri ?? OpenAIClientExtensions2.DefaultOpenAIEndpoint; - - _metadata = new("openai", providerUrl); - } - - /// Initializes a new instance of the class for the specified . - public NewOpenAIAssistantsChatClient(AssistantClient assistantClient, Assistant assistant, string? defaultThreadId) - : this(assistantClient, Throw.IfNull(assistant).Id, defaultThreadId) - { - _assistantTools = assistant.Tools; - } - - /// - public object? GetService(Type serviceType, object? serviceKey = null) => - serviceType is null ? throw new ArgumentNullException(nameof(serviceType)) : - serviceKey is not null ? null : - serviceType == typeof(ChatClientMetadata) ? _metadata : - serviceType == typeof(AssistantClient) ? _client : - serviceType.IsInstanceOfType(this) ? this : - null; - - /// - public async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - // Changing the original implementation to provide a RawRepresentation as a list of RawRepresentations of the updates. - // This wouldn't be needed if the API Change Proposal below is accepted: - // https://github.com/dotnet/extensions/issues/6746 - var updates = await GetStreamingResponseAsync(messages, options, cancellationToken).ToListAsync(cancellationToken).ConfigureAwait(false); - var response = updates.ToChatResponse(); - - // Expose all the raw representations of the updates. - response.RawRepresentation = updates.Select(u => u.RawRepresentation).ToArray(); - return response; - } - - /// - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - // Extract necessary state from messages and options. - (RunCreationOptions runOptions, ToolResources? toolResources, List? toolResults) = await CreateRunOptionsAsync(messages, options, cancellationToken).ConfigureAwait(false); - - // Get the thread ID. - string? threadId = options?.ConversationId ?? _defaultThreadId; - if (threadId is null && toolResults is not null) - { - Throw.ArgumentException(nameof(messages), "No thread ID was provided, but chat messages includes tool results."); - } - - // Get any active run ID for this thread. This is necessary in case a thread has been left with an - // active run, in which all attempts other than submitting tools will fail. We thus need to cancel - // any active run on the thread. - ThreadRun? threadRun = null; - if (threadId is not null) - { - await foreach (var run in _client.GetRunsAsync( - threadId, - new RunCollectionOptions { Order = RunCollectionOrder.Descending, PageSizeLimit = 1 }, - cancellationToken: cancellationToken).ConfigureAwait(false)) - { - if (run.Status != RunStatus.Completed && run.Status != RunStatus.Cancelled && run.Status != RunStatus.Failed && run.Status != RunStatus.Expired) - { - threadRun = run; - } - - break; - } - } - - // Submit the request. - IAsyncEnumerable updates; - if (threadRun is not null && - ConvertFunctionResultsToToolOutput(toolResults, out List? toolOutputs) is { } toolRunId && - toolRunId == threadRun.Id) - { - // There's an active run and we have tool results to submit, so submit the results and continue streaming. - // This is going to ignore any additional messages in the run options, as we are only submitting tool outputs, - // but there doesn't appear to be a way to submit additional messages, and having such additional messages is rare. - updates = _client.SubmitToolOutputsToRunStreamingAsync(threadRun.ThreadId, threadRun.Id, toolOutputs, cancellationToken); - } - else - { - if (threadId is null) - { - // No thread ID was provided, so create a new thread. - ThreadCreationOptions threadCreationOptions = new() - { - ToolResources = toolResources, - }; - - foreach (var message in runOptions.AdditionalMessages) - { - threadCreationOptions.InitialMessages.Add(message); - } - - runOptions.AdditionalMessages.Clear(); - - var thread = await _client.CreateThreadAsync(threadCreationOptions, cancellationToken).ConfigureAwait(false); - threadId = thread.Value.Id; - } - else if (threadRun is not null) - { - // There was an active run; we need to cancel it before starting a new run. - _ = await _client.CancelRunAsync(threadId, threadRun.Id, cancellationToken).ConfigureAwait(false); - threadRun = null; - } - - // Now create a new run and stream the results. - updates = _client.CreateRunStreamingAsync( - threadId: threadId, - _assistantId, - runOptions, - cancellationToken); - } - - // Process each update. - string? responseId = null; - await foreach (var update in updates.ConfigureAwait(false)) - { - switch (update) - { - case ThreadUpdate tu: - threadId ??= tu.Value.Id; - goto default; - - case RunUpdate ru: - threadId ??= ru.Value.ThreadId; - responseId ??= ru.Value.Id; - - ChatResponseUpdate ruUpdate = new() - { - AuthorName = _assistantId, - ConversationId = threadId, - CreatedAt = ru.Value.CreatedAt, - MessageId = responseId, - ModelId = ru.Value.Model, - RawRepresentation = ru, - ResponseId = responseId, - Role = ChatRole.Assistant, - }; - - if (ru.Value.Usage is { } usage) - { - ruUpdate.Contents.Add(new UsageContent(new() - { - InputTokenCount = usage.InputTokenCount, - OutputTokenCount = usage.OutputTokenCount, - TotalTokenCount = usage.TotalTokenCount, - })); - } - - if (ru is RequiredActionUpdate rau && rau.ToolCallId is string toolCallId && rau.FunctionName is string functionName) - { - var fcc = OpenAIClientExtensions2.ParseCallContent( - rau.FunctionArguments, - JsonSerializer.Serialize([ru.Value.Id, toolCallId], OpenAIJsonContext.Default.StringArray), - functionName); - fcc.RawRepresentation = ru; - ruUpdate.Contents.Add(fcc); - } - - yield return ruUpdate; - break; - - case MessageContentUpdate mcu: - ChatResponseUpdate textUpdate = new(mcu.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant, mcu.Text) - { - AuthorName = _assistantId, - ConversationId = threadId, - MessageId = responseId, - RawRepresentation = mcu, - ResponseId = responseId, - }; - - // Add any annotations from the text update. The OpenAI Assistants API does not support passing these back - // into the model (MessageContent.FromXx does not support providing annotations), so they end up being one way and are dropped - // on subsequent requests. - if (mcu.TextAnnotation is { } tau) - { - string? fileId = null; - string? toolName = null; - if (!string.IsNullOrWhiteSpace(tau.InputFileId)) - { - fileId = tau.InputFileId; - toolName = "file_search"; - } - else if (!string.IsNullOrWhiteSpace(tau.OutputFileId)) - { - fileId = tau.OutputFileId; - toolName = "code_interpreter"; - } - - if (fileId is not null) - { - if (textUpdate.Contents.Count == 0) - { - // In case a chunk doesn't have text content, create one with empty text to hold the annotation. - textUpdate.Contents.Add(new TextContent(string.Empty)); - } - - (((TextContent)textUpdate.Contents[0]).Annotations ??= []).Add(new CitationAnnotation - { - RawRepresentation = tau, - AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = tau.StartIndex, EndIndex = tau.EndIndex }], - FileId = fileId, - ToolName = toolName, - }); - } - } - - yield return textUpdate; - break; - - default: - yield return new() - { - AuthorName = _assistantId, - ConversationId = threadId, - MessageId = responseId, - RawRepresentation = update, - ResponseId = responseId, - Role = ChatRole.Assistant, - }; - break; - } - } - } - - /// - void IDisposable.Dispose() - { - // nop - } - - /// Converts an Extensions function to an OpenAI assistants function tool. - internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction, ChatOptions? options = null) - { - bool? strict = - OpenAIClientExtensions2.HasStrict(aiFunction.AdditionalProperties) ?? - OpenAIClientExtensions2.HasStrict(options?.AdditionalProperties); - - return new FunctionToolDefinition(aiFunction.Name) - { - Description = aiFunction.Description, - Parameters = OpenAIClientExtensions2.ToOpenAIFunctionParameters(aiFunction, strict), - StrictParameterSchemaEnabled = strict, - }; - } - - /// - /// Creates the to use for the request and extracts any function result contents - /// that need to be submitted as tool results. - /// - private async ValueTask<(RunCreationOptions RunOptions, ToolResources? Resources, List? ToolResults)> CreateRunOptionsAsync( - IEnumerable messages, ChatOptions? options, CancellationToken cancellationToken) - { - // Create the options instance to populate, either a fresh or using one the caller provides. - RunCreationOptions runOptions = - options?.RawRepresentationFactory?.Invoke(this) as RunCreationOptions ?? - new(); - - ToolResources? resources = null; - - // Populate the run options from the ChatOptions, if provided. - if (options is not null) - { - runOptions.MaxOutputTokenCount ??= options.MaxOutputTokens; - runOptions.ModelOverride ??= options.ModelId; - runOptions.NucleusSamplingFactor ??= options.TopP; - runOptions.Temperature ??= options.Temperature; - runOptions.AllowParallelToolCalls ??= options.AllowMultipleToolCalls; - - if (options.Tools is { Count: > 0 } tools) - { - // If the caller has provided any tool overrides, we'll assume they don't want to use the assistant's tools. - // But if they haven't, the only way we can provide our tools is via an override, whereas we'd really like to - // just add them. To handle that, we'll get all of the assistant's tools and add them to the override list - // along with our tools. - if (runOptions.ToolsOverride.Count == 0) - { - if (_assistantTools is null) - { - var assistant = await _client.GetAssistantAsync(_assistantId, cancellationToken).ConfigureAwait(false); - _assistantTools = assistant.Value.Tools; - } - - foreach (var tool in _assistantTools) - { - runOptions.ToolsOverride.Add(tool); - } - } - - // The caller can provide tools in the supplied ThreadAndRunOptions. Augment it with any supplied via ChatOptions.Tools. - foreach (AITool tool in tools) - { - switch (tool) - { - case AIFunction aiFunction: - runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction, options)); - break; - - case HostedCodeInterpreterTool codeInterpreterTool: - var interpreterToolDef = ToolDefinition.CreateCodeInterpreter(); - runOptions.ToolsOverride.Add(interpreterToolDef); - - if (codeInterpreterTool.Inputs?.Count is > 0) - { - ThreadInitializationMessage? threadInitializationMessage = null; - foreach (var input in codeInterpreterTool.Inputs) - { - if (input is HostedFileContent hostedFile) - { - threadInitializationMessage ??= new(MessageRole.User, [MessageContent.FromText("attachments")]); - threadInitializationMessage.Attachments.Add(new(hostedFile.FileId, [interpreterToolDef])); - } - } - - if (threadInitializationMessage is not null) - { - runOptions.AdditionalMessages.Add(threadInitializationMessage); - } - } - - break; - - case HostedFileSearchTool fileSearchTool: - runOptions.ToolsOverride.Add(ToolDefinition.CreateFileSearch(fileSearchTool.MaximumResultCount)); - if (fileSearchTool.Inputs is { Count: > 0 } fileSearchInputs) - { - foreach (var input in fileSearchInputs) - { - if (input is HostedVectorStoreContent file) - { - (resources ??= new()).FileSearch ??= new(); - resources.FileSearch.VectorStoreIds.Add(file.VectorStoreId); - } - } - } - - break; - } - } - } - - // Store the tool mode, if relevant. - if (runOptions.ToolConstraint is null) - { - switch (options.ToolMode) - { - case NoneChatToolMode: - runOptions.ToolConstraint = ToolConstraint.None; - break; - - case AutoChatToolMode: - runOptions.ToolConstraint = ToolConstraint.Auto; - break; - - case RequiredChatToolMode required when required.RequiredFunctionName is { } functionName: - runOptions.ToolConstraint = new ToolConstraint(ToolDefinition.CreateFunction(functionName)); - break; - - case RequiredChatToolMode required: - runOptions.ToolConstraint = ToolConstraint.Required; - break; - } - } - - // Store the response format, if relevant. - if (runOptions.ResponseFormat is null) - { - switch (options.ResponseFormat) - { - case ChatResponseFormatText: - runOptions.ResponseFormat = AssistantResponseFormat.CreateTextFormat(); - break; - - case ChatResponseFormatJson jsonFormat when OpenAIClientExtensions2.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema: - runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonSchemaFormat( - jsonFormat.SchemaName, - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription, - OpenAIClientExtensions2.HasStrict(options.AdditionalProperties)); - break; - - case ChatResponseFormatJson jsonFormat: - runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonObjectFormat(); - break; - } - } - } - - // Configure system instructions. - StringBuilder? instructions = null; - void AppendSystemInstructions(string? toAppend) - { - if (!string.IsNullOrEmpty(toAppend)) - { - if (instructions is null) - { - instructions = new(toAppend); - } - else - { - _ = instructions.AppendLine().AppendLine(toAppend); - } - } - } - - AppendSystemInstructions(runOptions.AdditionalInstructions); - AppendSystemInstructions(options?.Instructions); - - // Process ChatMessages. - List? functionResults = null; - foreach (var chatMessage in messages) - { - List messageContents = []; - - // Assistants doesn't support system/developer messages directly. It does support transient per-request instructions, - // so we can use the system/developer messages to build up a set of instructions that will be passed to the assistant - // as part of this request. However, in doing so, on a subsequent request that information will be lost, as there's no - // way to store per-thread instructions in the OpenAI Assistants API. We don't want to convert these to user messages, - // however, as that would then expose the system/developer messages in a way that might make the model more likely - // to include that information in its responses. System messages should ideally be instead done as instructions to - // the assistant when the assistant is created. - if (chatMessage.Role == ChatRole.System || - chatMessage.Role == OpenAIClientExtensions2.ChatRoleDeveloper) - { - foreach (var textContent in chatMessage.Contents.OfType()) - { - AppendSystemInstructions(textContent.Text); - } - - continue; - } - - foreach (AIContent content in chatMessage.Contents) - { - switch (content) - { - case AIContent when content.RawRepresentation is MessageContent rawRep: - messageContents.Add(rawRep); - break; - - case TextContent text: - messageContents.Add(MessageContent.FromText(text.Text)); - break; - - case UriContent image when image.HasTopLevelMediaType("image"): - messageContents.Add(MessageContent.FromImageUri(image.Uri)); - break; - - case FunctionResultContent result: - (functionResults ??= []).Add(result); - break; - } - } - - if (messageContents.Count > 0) - { - runOptions.AdditionalMessages.Add(new ThreadInitializationMessage( - chatMessage.Role == ChatRole.Assistant ? MessageRole.Assistant : MessageRole.User, - messageContents)); - } - } - - runOptions.AdditionalInstructions = instructions?.ToString(); - - return (runOptions, resources, functionResults); - } - - /// Convert instances to instances. - /// The tool results to process. - /// The generated list of tool outputs, if any could be created. - /// The run ID associated with the corresponding function call requests. - private static string? ConvertFunctionResultsToToolOutput(List? toolResults, out List? toolOutputs) - { - string? runId = null; - toolOutputs = null; - if (toolResults?.Count > 0) - { - foreach (var frc in toolResults) - { - // When creating the FunctionCallContext, we created it with a CallId == [runId, callId]. - // We need to extract the run ID and ensure that the ToolOutput we send back to Azure - // is only the call ID. - string[]? runAndCallIDs; - try - { - runAndCallIDs = JsonSerializer.Deserialize(frc.CallId, OpenAIJsonContext.Default.StringArray); - } - catch - { - continue; - } - - if (runAndCallIDs is null || - runAndCallIDs.Length != 2 || - string.IsNullOrWhiteSpace(runAndCallIDs[0]) || // run ID - string.IsNullOrWhiteSpace(runAndCallIDs[1]) || // call ID - (runId is not null && runId != runAndCallIDs[0])) - { - continue; - } - - runId = runAndCallIDs[0]; - (toolOutputs ??= []).Add(new(runAndCallIDs[1], frc.Result?.ToString() ?? string.Empty)); - } - } - - return runId; - } -} - -/// Provides extension methods for working with s. -internal static class OpenAIClientExtensions2 -{ - /// Key into AdditionalProperties used to store a strict option. - private const string StrictKey = "strictJsonSchema"; - - /// Gets the default OpenAI endpoint. - internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); - - /// Gets a for "developer". - internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer"); - - /// Creates a new instance of parsing arguments using a specified encoding and parser. - /// The input arguments to be parsed. - /// The function call ID. - /// The function name. - /// A new instance of containing the parse result. - /// is . - /// is . - internal static FunctionCallContent ParseCallContent(string json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(json, callId, name, - static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); - - /// Creates a new instance of parsing arguments using a specified encoding and parser. - /// The input arguments to be parsed. - /// The function call ID. - /// The function name. - /// A new instance of containing the parse result. - /// is . - /// is . - internal static FunctionCallContent ParseCallContent(BinaryData utf8json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(utf8json, callId, name, - static utf8json => JsonSerializer.Deserialize(utf8json, OpenAIJsonContext.Default.IDictionaryStringObject)!); - - /// - /// Gets the JSON schema transformer cache conforming to OpenAI strict / structured output restrictions per - /// https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas. - /// - internal static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new() - { - DisallowAdditionalProperties = true, - ConvertBooleanSchemas = true, - MoveDefaultKeywordToDescription = true, - RequireAllProperties = true, - TransformSchemaNode = (ctx, node) => - { - // Move content from common but unsupported properties to description. In particular, we focus on properties that - // the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation. - - if (node is JsonObject schemaObj) - { - StringBuilder? additionalDescription = null; - - ReadOnlySpan unsupportedProperties = - [ - // Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties: - "contentEncoding", "contentMediaType", "not", - - // Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models: - "minLength", "maxLength", "pattern", "format", - "minimum", "maximum", "multipleOf", - "patternProperties", - "minItems", "maxItems", - - // Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords - // as being unsupported with Azure OpenAI: - "unevaluatedProperties", "propertyNames", "minProperties", "maxProperties", - "unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems", - ]; - - foreach (string propName in unsupportedProperties) - { - if (schemaObj[propName] is { } propNode) - { - _ = schemaObj.Remove(propName); - AppendLine(ref additionalDescription, propName, propNode); - } - } - - if (additionalDescription is not null) - { - schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ? - $"{descriptionNode.GetValue()}{Environment.NewLine}{additionalDescription}" : - additionalDescription.ToString(); - } - - return node; - - static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode) - { - sb ??= new(); - - if (sb.Length > 0) - { - _ = sb.AppendLine(); - } - - _ = sb.Append(propName).Append(": ").Append(propNode); - } - } - - return node; - }, - }); - - // TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict. - - /// Gets whether the properties specify that strict schema handling is desired. - internal static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => - additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && - strictObj is bool strictValue ? - strictValue : null; - - /// Extracts from an the parameters and strictness setting for use with OpenAI's APIs. - internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict) - { - // Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting. - JsonElement jsonSchema = strict is true ? - StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) : - aiFunction.JsonSchema; - - // Roundtrip the schema through the ToolJson model type to remove extra properties - // and force missing ones into existence, then return the serialized UTF8 bytes as BinaryData. - var tool = jsonSchema.Deserialize(OpenAIJsonContext.Default.ToolJson)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.ToolJson)); - - return functionParameters; - } -} - -/// -/// Temporary extension methods to assist with creating proposed changed instances -/// -public static class OpenAIAssistantsExtensions -{ - /// - /// Creates a new instance of an configured for the specified assistant. - /// - /// The instance used to initialize the chat client. Cannot be . - /// The unique identifier of the assistant. Cannot be or whitespace. - /// The optional default thread identifier for the chat client. Can be . - /// A new instance configured with the specified assistant and optional default thread. - public static IChatClient AsNewIChatClient(this AssistantClient client, string assistantId, string? defaultThreadId = null) => - new NewOpenAIAssistantsChatClient(Throw.IfNull(client), Throw.IfNullOrWhitespace(assistantId), defaultThreadId); -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/NewOpenAIResponsesChatClient.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/NewOpenAIResponsesChatClient.cs deleted file mode 100644 index bd849ad320..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/NewOpenAIResponsesChatClient.cs +++ /dev/null @@ -1,918 +0,0 @@ -#pragma warning disable IDE0073 // The file header does not match the required text -#pragma warning disable CA1063 // Implement IDisposable Correctly - -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.ClientModel.Primitives; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using Microsoft.Shared.Diagnostics; -using OpenAI.Responses; - -#pragma warning disable S907 // "goto" statement should not be used -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields -#pragma warning disable S3604 // Member initializer values should not be redundant -#pragma warning disable SA1202 // Elements should be ordered by access -#pragma warning disable SA1204 // Static elements should appear before instance elements - -namespace Microsoft.Extensions.AI; - -/// Represents an for an . -internal sealed class NewOpenAIResponsesChatClient : IChatClient -{ - /// Type info for serializing and deserializing arbitrary JSON objects. - private static readonly JsonTypeInfo s_jsonTypeInfo = AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)); - - /// Metadata about the client. - private readonly ChatClientMetadata _metadata; - - /// The underlying . - private readonly OpenAIResponseClient _responseClient; - - /// Initializes a new instance of the class for the specified . - /// The underlying client. - /// is . - public NewOpenAIResponsesChatClient(OpenAIResponseClient responseClient) - { - _ = Throw.IfNull(responseClient); - - _responseClient = responseClient; - - // https://github.com/openai/openai-dotnet/issues/215 - // The endpoint and model aren't currently exposed, so use reflection to get at them, temporarily. Once packages - // implement the abstractions directly rather than providing adapters on top of the public APIs, - // the package can provide such implementations separate from what's exposed in the public API. - Uri providerUrl = typeof(OpenAIResponseClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(responseClient) as Uri ?? OpenAIClientExtensions3.DefaultOpenAIEndpoint; - string? model = typeof(OpenAIResponseClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(responseClient) as string; - - _metadata = new("openai", providerUrl, model); - } - - /// - object? IChatClient.GetService(Type serviceType, object? serviceKey) - { - _ = Throw.IfNull(serviceType); - - return - serviceKey is not null ? null : - serviceType == typeof(ChatClientMetadata) ? _metadata : - serviceType == typeof(OpenAIResponseClient) ? _responseClient : - serviceType.IsInstanceOfType(this) ? this : - null; - } - - /// - public async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - // Convert the inputs into what OpenAIResponseClient expects. - var openAIResponseItems = ToOpenAIResponseItems(messages, options); - var openAIOptions = ToOpenAIResponseCreationOptions(options); - - // Make the call to the OpenAIResponseClient. - var openAIResponse = (await _responseClient.CreateResponseAsync(openAIResponseItems, openAIOptions, cancellationToken).ConfigureAwait(false)).Value; - - // Convert the response to a ChatResponse. - return FromOpenAIResponse(openAIResponse, openAIOptions); - } - - internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, ResponseCreationOptions? openAIOptions) - { - // Convert and return the results. - ChatResponse response = new() - { - ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : openAIResponse.Id, - CreatedAt = openAIResponse.CreatedAt, - FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason), - ModelId = openAIResponse.Model, - RawRepresentation = openAIResponse, - ResponseId = openAIResponse.Id, - Usage = ToUsageDetails(openAIResponse), - }; - - if (!string.IsNullOrEmpty(openAIResponse.EndUserId)) - { - (response.AdditionalProperties ??= [])[nameof(openAIResponse.EndUserId)] = openAIResponse.EndUserId; - } - - if (openAIResponse.Error is not null) - { - (response.AdditionalProperties ??= [])[nameof(openAIResponse.Error)] = openAIResponse.Error; - } - - if (openAIResponse.OutputItems is not null) - { - response.Messages = [.. ToChatMessages(openAIResponse.OutputItems)]; - - if (response.Messages.LastOrDefault() is { } lastMessage && openAIResponse.Error is { } error) - { - lastMessage.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code.ToString() }); - } - - foreach (var message in response.Messages) - { - message.CreatedAt ??= openAIResponse.CreatedAt; - } - } - - return response; - } - - internal static IEnumerable ToChatMessages(IEnumerable items) - { - ChatMessage? message = null; - - foreach (ResponseItem outputItem in items) - { - message ??= new(ChatRole.Assistant, (string?)null); - - switch (outputItem) - { - case MessageResponseItem messageItem: - if (message.MessageId is not null && message.MessageId != messageItem.Id) - { - yield return message; - message = new ChatMessage(); - } - - message.MessageId = messageItem.Id; - message.RawRepresentation = messageItem; - message.Role = ToChatRole(messageItem.Role); - ((List)message.Contents).AddRange(ToAIContents(messageItem.Content)); - break; - - case ReasoningResponseItem reasoningItem when reasoningItem.GetSummaryText() is string summary: - message.Contents.Add(new TextReasoningContent(summary) { RawRepresentation = outputItem }); - break; - - case FunctionCallResponseItem functionCall: - var fcc = OpenAIClientExtensions3.ParseCallContent(functionCall.FunctionArguments, functionCall.CallId, functionCall.FunctionName); - fcc.RawRepresentation = outputItem; - message.Contents.Add(fcc); - break; - - case FunctionCallOutputResponseItem functionCallOutputItem: - message.Contents.Add(new FunctionResultContent(functionCallOutputItem.CallId, functionCallOutputItem.FunctionOutput) { RawRepresentation = functionCallOutputItem }); - break; - - default: - message.Contents.Add(new() { RawRepresentation = outputItem }); - break; - } - } - - if (message is not null) - { - yield return message; - } - } - - /// - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - var openAIResponseItems = ToOpenAIResponseItems(messages, options); - var openAIOptions = ToOpenAIResponseCreationOptions(options); - - var streamingUpdates = _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken); - - return FromOpenAIStreamingResponseUpdatesAsync(streamingUpdates, openAIOptions, cancellationToken); - } - - internal static async IAsyncEnumerable FromOpenAIStreamingResponseUpdatesAsync( - IAsyncEnumerable streamingResponseUpdates, ResponseCreationOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - DateTimeOffset? createdAt = null; - string? responseId = null; - string? conversationId = null; - string? modelId = null; - string? lastMessageId = null; - ChatRole? lastRole = null; - Dictionary outputIndexToMessages = []; - Dictionary? functionCallInfos = null; - - await foreach (var streamingUpdate in streamingResponseUpdates.WithCancellation(cancellationToken).ConfigureAwait(false)) - { - // Create an update populated with the current state of the response. - ChatResponseUpdate CreateUpdate(AIContent? content = null) => - new(lastRole, content is not null ? [content] : null) - { - ConversationId = conversationId, - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - RawRepresentation = streamingUpdate, - ResponseId = responseId, - }; - - switch (streamingUpdate) - { - case StreamingResponseCreatedUpdate createdUpdate: - createdAt = createdUpdate.Response.CreatedAt; - responseId = createdUpdate.Response.Id; - conversationId = options?.StoredOutputEnabled is false ? null : responseId; - modelId = createdUpdate.Response.Model; - goto default; - - case StreamingResponseCompletedUpdate completedUpdate: - { - var update = CreateUpdate(ToUsageDetails(completedUpdate.Response) is { } usage ? new UsageContent(usage) : null); - update.FinishReason = - ToFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ?? - (functionCallInfos is not null ? ChatFinishReason.ToolCalls : - ChatFinishReason.Stop); - yield return update; - break; - } - - case StreamingResponseOutputItemAddedUpdate outputItemAddedUpdate: - switch (outputItemAddedUpdate.Item) - { - case MessageResponseItem mri: - outputIndexToMessages[outputItemAddedUpdate.OutputIndex] = mri; - break; - - case FunctionCallResponseItem fcri: - (functionCallInfos ??= [])[outputItemAddedUpdate.OutputIndex] = new(fcri); - break; - } - - goto default; - - case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate: - _ = outputIndexToMessages.Remove(outputItemDoneUpdate.OutputIndex); - - if (outputItemDoneUpdate.Item is MessageResponseItem item && - item.Content is { Count: > 0 } content && - content.Any(c => c.OutputTextAnnotations is { Count: > 0 })) - { - AIContent annotatedContent = new(); - foreach (var c in content) - { - PopulateAnnotations(c, annotatedContent); - } - - yield return CreateUpdate(annotatedContent); - break; - } - - goto default; - - case StreamingResponseOutputTextDeltaUpdate outputTextDeltaUpdate: - { - _ = outputIndexToMessages.TryGetValue(outputTextDeltaUpdate.OutputIndex, out MessageResponseItem? messageItem); - lastMessageId = messageItem?.Id; - lastRole = ToChatRole(messageItem?.Role); - - yield return CreateUpdate(new TextContent(outputTextDeltaUpdate.Delta)); - break; - } - - case StreamingResponseFunctionCallArgumentsDeltaUpdate functionCallArgumentsDeltaUpdate: - { - if (functionCallInfos?.TryGetValue(functionCallArgumentsDeltaUpdate.OutputIndex, out FunctionCallInfo? callInfo) is true) - { - _ = (callInfo.Arguments ??= new()).Append(functionCallArgumentsDeltaUpdate.Delta); - } - - goto default; - } - - case StreamingResponseFunctionCallArgumentsDoneUpdate functionCallOutputDoneUpdate: - { - if (functionCallInfos?.TryGetValue(functionCallOutputDoneUpdate.OutputIndex, out FunctionCallInfo? callInfo) is true) - { - _ = functionCallInfos.Remove(functionCallOutputDoneUpdate.OutputIndex); - - var fcc = OpenAIClientExtensions3.ParseCallContent( - callInfo.Arguments?.ToString() ?? string.Empty, - callInfo.ResponseItem.CallId, - callInfo.ResponseItem.FunctionName); - - lastMessageId = callInfo.ResponseItem.Id; - lastRole = ChatRole.Assistant; - - yield return CreateUpdate(fcc); - break; - } - - goto default; - } - - case StreamingResponseErrorUpdate errorUpdate: - yield return CreateUpdate(new ErrorContent(errorUpdate.Message) - { - ErrorCode = errorUpdate.Code, - Details = errorUpdate.Param, - }); - break; - - case StreamingResponseRefusalDoneUpdate refusalDone: - yield return CreateUpdate(new ErrorContent(refusalDone.Refusal) - { - ErrorCode = nameof(ResponseContentPart.Refusal), - }); - break; - - default: - { - // Capture streaming thinking contents - if (streamingUpdate.GetType().Name == "InternalResponseReasoningSummaryTextDeltaEvent") - { - var updateJson = JsonSerializer.Deserialize( - JsonSerializer.Serialize(streamingUpdate, s_jsonTypeInfo), - OpenAIJsonContext2.Default.JsonElement); - - if (updateJson.TryGetProperty("delta", out var deltaProperty)) - { - yield return CreateUpdate(new TextReasoningContent(deltaProperty.GetString())); - break; - } - } - - yield return CreateUpdate(); - break; - } - } - } - } - - /// - void IDisposable.Dispose() - { - // Nothing to dispose. Implementation required for the IChatClient interface. - } - - internal static ResponseTool ToResponseTool(AIFunction aiFunction, ChatOptions? options = null) - { - bool? strict = - OpenAIClientExtensions3.HasStrict(aiFunction.AdditionalProperties) ?? - OpenAIClientExtensions3.HasStrict(options?.AdditionalProperties); - - return ResponseTool.CreateFunctionTool( - aiFunction.Name, - aiFunction.Description, - OpenAIClientExtensions3.ToOpenAIFunctionParameters(aiFunction, strict), - strict ?? false); - } - - /// Creates a from a . - private static ChatRole ToChatRole(OpenAI.Responses.MessageRole? role) => - role switch - { - OpenAI.Responses.MessageRole.System => ChatRole.System, - OpenAI.Responses.MessageRole.Developer => OpenAIClientExtensions3.ChatRoleDeveloper, - OpenAI.Responses.MessageRole.User => ChatRole.User, - _ => ChatRole.Assistant, - }; - - /// Creates a from a . - private static ChatFinishReason? ToFinishReason(ResponseIncompleteStatusReason? statusReason) => - statusReason == ResponseIncompleteStatusReason.ContentFilter ? ChatFinishReason.ContentFilter : - statusReason == ResponseIncompleteStatusReason.MaxOutputTokens ? ChatFinishReason.Length : - null; - - /// Converts a to a . - private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? options) - { - if (options is null) - { - return new ResponseCreationOptions(); - } - - if (options.RawRepresentationFactory?.Invoke(this) is not ResponseCreationOptions result) - { - result = new ResponseCreationOptions(); - } - - // Handle strongly-typed properties. - result.MaxOutputTokenCount ??= options.MaxOutputTokens; - result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls; - result.PreviousResponseId ??= options.ConversationId; - result.Temperature ??= options.Temperature; - result.TopP ??= options.TopP; - - if (options.Instructions is { } instructions) - { - result.Instructions = string.IsNullOrEmpty(result.Instructions) ? - instructions : - $"{result.Instructions}{Environment.NewLine}{instructions}"; - } - - // Populate tools if there are any. - if (options.Tools is { Count: > 0 } tools) - { - foreach (AITool tool in tools) - { - switch (tool) - { - case AIFunction aiFunction: - result.Tools.Add(ToResponseTool(aiFunction, options)); - break; - - case HostedWebSearchTool webSearchTool: - WebSearchUserLocation? location = null; - if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchUserLocation), out object? objLocation)) - { - location = objLocation as WebSearchUserLocation; - } - - WebSearchContextSize? size = null; - if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchContextSize), out object? objSize) && - objSize is WebSearchContextSize) - { - size = (WebSearchContextSize)objSize; - } - - result.Tools.Add(ResponseTool.CreateWebSearchTool(location, size)); - break; - - case HostedFileSearchTool fileSearchTool: - result.Tools.Add(ResponseTool.CreateFileSearchTool( - fileSearchTool.Inputs?.OfType().Select(c => c.VectorStoreId) ?? [], - fileSearchTool.MaximumResultCount)); - break; - - case HostedCodeInterpreterTool codeTool: - string json; - if (codeTool.Inputs is { Count: > 0 } inputs) - { - string jsonArray = JsonSerializer.Serialize( - inputs.OfType().Select(c => c.FileId), - OpenAIJsonContext2.Default.IEnumerableString); - json = $$"""{"type":"code_interpreter","container":{"type":"auto",files:{{jsonArray}}} }"""; - } - else - { - json = """{"type":"code_interpreter","container":"auto"}"""; - } - - result.Tools.Add(ModelReaderWriter.Read(BinaryData.FromString(json))); - break; - } - } - - if (result.ToolChoice is null && result.Tools.Count > 0) - { - switch (options.ToolMode) - { - case NoneChatToolMode: - result.ToolChoice = ResponseToolChoice.CreateNoneChoice(); - break; - - case AutoChatToolMode: - case null: - result.ToolChoice = ResponseToolChoice.CreateAutoChoice(); - break; - - case RequiredChatToolMode required: - result.ToolChoice = required.RequiredFunctionName is not null ? - ResponseToolChoice.CreateFunctionChoice(required.RequiredFunctionName) : - ResponseToolChoice.CreateRequiredChoice(); - break; - } - } - } - - if (result.TextOptions is null) - { - if (options.ResponseFormat is ChatResponseFormatText) - { - result.TextOptions = new() - { - TextFormat = ResponseTextFormat.CreateTextFormat() - }; - } - else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) - { - result.TextOptions = new() - { - TextFormat = OpenAIClientExtensions3.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ? - ResponseTextFormat.CreateJsonSchemaFormat( - jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext2.Default.JsonElement)), - jsonFormat.SchemaDescription, - OpenAIClientExtensions3.HasStrict(options.AdditionalProperties)) : - ResponseTextFormat.CreateJsonObjectFormat(), - }; - } - } - - return result; - } - - /// Convert a sequence of s to s. - internal static IEnumerable ToOpenAIResponseItems(IEnumerable inputs, ChatOptions? options) - { - _ = options; // currently unused - - foreach (ChatMessage input in inputs) - { - if (input.Role == ChatRole.System || - input.Role == OpenAIClientExtensions3.ChatRoleDeveloper) - { - string text = input.Text; - if (!string.IsNullOrWhiteSpace(text)) - { - yield return input.Role == ChatRole.System ? - ResponseItem.CreateSystemMessageItem(text) : - ResponseItem.CreateDeveloperMessageItem(text); - } - - continue; - } - - if (input.Role == ChatRole.User) - { - yield return ResponseItem.CreateUserMessageItem(ToResponseContentParts(input.Contents)); - continue; - } - - if (input.Role == ChatRole.Tool) - { - foreach (AIContent item in input.Contents) - { - switch (item) - { - case AIContent when item.RawRepresentation is ResponseItem rawRep: - yield return rawRep; - break; - - case FunctionResultContent resultContent: - string? result = resultContent.Result as string; - if (result is null && resultContent.Result is not null) - { - try - { - result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); - } - catch (NotSupportedException) - { - // If the type can't be serialized, skip it. - } - } - - yield return ResponseItem.CreateFunctionCallOutputItem(resultContent.CallId, result ?? string.Empty); - break; - } - } - - continue; - } - - if (input.Role == ChatRole.Assistant) - { - foreach (AIContent item in input.Contents) - { - switch (item) - { - case AIContent when item.RawRepresentation is ResponseItem rawRep: - yield return rawRep; - break; - - case TextContent textContent: - yield return ResponseItem.CreateAssistantMessageItem(textContent.Text); - break; - - case TextReasoningContent reasoningContent: - yield return ResponseItem.CreateReasoningItem(reasoningContent.Text); - break; - - case FunctionCallContent callContent: - yield return ResponseItem.CreateFunctionCallItem( - callContent.CallId, - callContent.Name, - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes( - callContent.Arguments, - AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); - break; - } - } - - continue; - } - } - } - - /// Extract usage details from an . - private static UsageDetails? ToUsageDetails(OpenAIResponse? openAIResponse) - { - UsageDetails? ud = null; - if (openAIResponse?.Usage is { } usage) - { - ud = new() - { - InputTokenCount = usage.InputTokenCount, - OutputTokenCount = usage.OutputTokenCount, - TotalTokenCount = usage.TotalTokenCount, - }; - - if (usage.InputTokenDetails is { } inputDetails) - { - ud.AdditionalCounts ??= []; - ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.CachedTokenCount)}", inputDetails.CachedTokenCount); - } - - if (usage.OutputTokenDetails is { } outputDetails) - { - ud.AdditionalCounts ??= []; - ud.AdditionalCounts.Add($"{nameof(usage.OutputTokenDetails)}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); - } - } - - return ud; - } - - /// Convert a sequence of s to a list of . - private static List ToAIContents(IEnumerable contents) - { - List results = []; - - foreach (ResponseContentPart part in contents) - { - switch (part.Kind) - { - case ResponseContentPartKind.InputText or ResponseContentPartKind.OutputText: - TextContent text = new(part.Text) { RawRepresentation = part }; - PopulateAnnotations(part, text); - results.Add(text); - break; - - case ResponseContentPartKind.InputFile: - if (!string.IsNullOrWhiteSpace(part.InputImageFileId)) - { - results.Add(new HostedFileContent(part.InputImageFileId) { RawRepresentation = part }); - } - else if (!string.IsNullOrWhiteSpace(part.InputFileId)) - { - results.Add(new HostedFileContent(part.InputFileId) { RawRepresentation = part }); - } - else if (part.InputFileBytes is not null) - { - results.Add(new DataContent(part.InputFileBytes, part.InputFileBytesMediaType ?? "application/octet-stream") - { - Name = part.InputFilename, - RawRepresentation = part, - }); - } - - break; - - case ResponseContentPartKind.Refusal: - results.Add(new ErrorContent(part.Refusal) - { - ErrorCode = nameof(ResponseContentPartKind.Refusal), - RawRepresentation = part, - }); - break; - - default: - results.Add(new() { RawRepresentation = part }); - break; - } - } - - return results; - } - - /// Converts any annotations from and stores them in . - private static void PopulateAnnotations(ResponseContentPart source, AIContent destination) - { - if (source.OutputTextAnnotations is { Count: > 0 }) - { - foreach (var ota in source.OutputTextAnnotations) - { - (destination.Annotations ??= []).Add(new CitationAnnotation - { - RawRepresentation = ota, - AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = ota.UriCitationStartIndex, EndIndex = ota.UriCitationEndIndex }], - Title = ota.UriCitationTitle, - Url = ota.UriCitationUri, - FileId = ota.FileCitationFileId ?? ota.FilePathFileId, - }); - } - } - } - - /// Convert a list of s to a list of . - private static List ToResponseContentParts(IList contents) - { - List parts = []; - foreach (var content in contents) - { - switch (content) - { - case AIContent when content.RawRepresentation is ResponseContentPart rawRep: - parts.Add(rawRep); - break; - - case TextContent textContent: - parts.Add(ResponseContentPart.CreateInputTextPart(textContent.Text)); - break; - - case UriContent uriContent when uriContent.HasTopLevelMediaType("image"): - parts.Add(ResponseContentPart.CreateInputImagePart(uriContent.Uri)); - break; - - case DataContent dataContent when dataContent.HasTopLevelMediaType("image"): - parts.Add(ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType)); - break; - - case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase): - parts.Add(ResponseContentPart.CreateInputFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf")); - break; - - case HostedFileContent fileContent: - parts.Add(ResponseContentPart.CreateInputFilePart(fileContent.FileId)); - break; - - case ErrorContent errorContent when errorContent.ErrorCode == nameof(ResponseContentPartKind.Refusal): - parts.Add(ResponseContentPart.CreateRefusalPart(errorContent.Message)); - break; - } - } - - if (parts.Count == 0) - { - parts.Add(ResponseContentPart.CreateInputTextPart(string.Empty)); - } - - return parts; - } - - /// POCO representing function calling info. - /// Used to concatenation information for a single function call from across multiple streaming updates. - private sealed class FunctionCallInfo(FunctionCallResponseItem item) - { - public readonly FunctionCallResponseItem ResponseItem = item; - public StringBuilder? Arguments; - } -} - -internal static class OpenAIClientExtensions3 -{ - /// Key into AdditionalProperties used to store a strict option. - private const string StrictKey = "strictJsonSchema"; - - /// Gets the default OpenAI endpoint. - internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); - - /// Gets a for "developer". - internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer"); - - /// - /// Gets the JSON schema transformer cache conforming to OpenAI strict / structured output restrictions per - /// https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas. - /// - internal static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new() - { - DisallowAdditionalProperties = true, - ConvertBooleanSchemas = true, - MoveDefaultKeywordToDescription = true, - RequireAllProperties = true, - TransformSchemaNode = (ctx, node) => - { - // Move content from common but unsupported properties to description. In particular, we focus on properties that - // the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation. - - if (node is JsonObject schemaObj) - { - StringBuilder? additionalDescription = null; - - ReadOnlySpan unsupportedProperties = - [ - // Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties: - "contentEncoding", "contentMediaType", "not", - - // Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models: - "minLength", "maxLength", "pattern", "format", - "minimum", "maximum", "multipleOf", - "patternProperties", - "minItems", "maxItems", - - // Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords - // as being unsupported with Azure OpenAI: - "unevaluatedProperties", "propertyNames", "minProperties", "maxProperties", - "unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems", - ]; - - foreach (string propName in unsupportedProperties) - { - if (schemaObj[propName] is { } propNode) - { - _ = schemaObj.Remove(propName); - AppendLine(ref additionalDescription, propName, propNode); - } - } - - if (additionalDescription is not null) - { - schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ? - $"{descriptionNode.GetValue()}{Environment.NewLine}{additionalDescription}" : - additionalDescription.ToString(); - } - - return node; - - static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode) - { - sb ??= new(); - - if (sb.Length > 0) - { - _ = sb.AppendLine(); - } - - _ = sb.Append(propName).Append(": ").Append(propNode); - } - } - - return node; - }, - }); - - /// Gets whether the properties specify that strict schema handling is desired. - internal static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => - additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && - strictObj is bool strictValue ? - strictValue : null; - - /// Extracts from an the parameters and strictness setting for use with OpenAI's APIs. - internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict) - { - // Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting. - JsonElement jsonSchema = strict is true ? - StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) : - aiFunction.JsonSchema; - - // Roundtrip the schema through the ToolJson model type to remove extra properties - // and force missing ones into existence, then return the serialized UTF8 bytes as BinaryData. - var tool = JsonSerializer.Deserialize(jsonSchema, OpenAIJsonContext2.Default.ToolJson)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext2.Default.ToolJson)); - - return functionParameters; - } - - /// Creates a new instance of parsing arguments using a specified encoding and parser. - /// The input arguments to be parsed. - /// The function call ID. - /// The function name. - /// A new instance of containing the parse result. - /// is . - /// is . - internal static FunctionCallContent ParseCallContent(string json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(json, callId, name, - static json => JsonSerializer.Deserialize(json, OpenAIJsonContext2.Default.IDictionaryStringObject)!); - - /// Creates a new instance of parsing arguments using a specified encoding and parser. - /// The input arguments to be parsed. - /// The function call ID. - /// The function name. - /// A new instance of containing the parse result. - /// is . - /// is . - internal static FunctionCallContent ParseCallContent(BinaryData utf8json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(utf8json, callId, name, - static utf8json => JsonSerializer.Deserialize(utf8json, OpenAIJsonContext2.Default.IDictionaryStringObject)!); - - /// Used to create the JSON payload for an OpenAI tool description. - internal sealed class ToolJson - { - [JsonPropertyName("type")] - public string Type { get; set; } = "object"; - - [JsonPropertyName("required")] - public HashSet Required { get; set; } = []; - - [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } = []; - - [JsonPropertyName("additionalProperties")] - public bool AdditionalProperties { get; set; } - } -} - -/// Source-generated JSON type information for use by all OpenAI implementations. -[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true)] -[JsonSerializable(typeof(OpenAIClientExtensions3.ToolJson))] -[JsonSerializable(typeof(IDictionary))] -[JsonSerializable(typeof(string[]))] -[JsonSerializable(typeof(IEnumerable))] -[JsonSerializable(typeof(JsonElement))] -internal sealed partial class OpenAIJsonContext2 : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientExtensions.cs index 045dc3ef27..1abe056741 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -18,13 +19,13 @@ internal static class ChatClientExtensions chatBuilder.UseAgentInvocation(); } - if (chatClient.GetService() is null && chatClient.GetService() is null) + if (chatClient.GetService() is null) { _ = chatBuilder.Use((IChatClient innerClient, IServiceProvider services) => { var loggerFactory = services.GetService(); - return new NewFunctionInvokingChatClient(innerClient, loggerFactory, services); + return new FunctionInvokingChatClient(innerClient, loggerFactory, services); }); } @@ -33,17 +34,9 @@ internal static class ChatClientExtensions if (options?.ChatOptions?.Tools is { Count: > 0 }) { // When tools are provided in the constructor, set the tools for the whole lifecycle of the chat client - var newFunctionService = agentChatClient.GetService(); - var oldFunctionService = agentChatClient.GetService(); - - if (newFunctionService is not null) - { - newFunctionService.AdditionalTools = options.ChatOptions.Tools; - } - else - { - oldFunctionService!.AdditionalTools = options.ChatOptions.Tools; - } + var functionService = agentChatClient.GetService(); + Debug.Assert(functionService is not null, "FunctionInvokingChatClient should be registered in the chat client."); + functionService!.AdditionalTools = options.ChatOptions.Tools; } return agentChatClient; diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/ApprovalRequiredAIFunction.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/ApprovalRequiredAIFunction.cs deleted file mode 100644 index f1913efb0f..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/ApprovalRequiredAIFunction.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// -/// Marks an existing with additional metadata to indicate that it requires approval. -/// -/// The that requires approval. -public sealed class ApprovalRequiredAIFunction(AIFunction function) : DelegatingAIFunction(function) -{ - /// - /// An optional callback that can be used to determine if the function call requires approval, instead of the default behavior, which is to always require approval. - /// - public Func> RequiresApprovalCallback { get; set; } = _ => new(true); - - /// - /// Context object that provides information about the function call that requires approval. - /// - public sealed class ApprovalContext - { - /// - /// Initializes a new instance of the class. - /// - /// The containing the details of the invocation. - /// is null. - public ApprovalContext(FunctionCallContent functionCall) - { - this.FunctionCall = Throw.IfNull(functionCall); - } - - /// - /// Gets the containing the details of the invocation that will be made if approval is granted. - /// - public FunctionCallContent FunctionCall { get; } - } -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/LoggingHelpers.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/LoggingHelpers.cs deleted file mode 100644 index 6a0e13677f..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/LoggingHelpers.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// WARNING: -// This class has been temporarily copied here from MEAI, to allow prototyping -// functionality that will be moved to MEAI in the future. -// This file is not intended to be modified. - -#pragma warning disable CA1031 // Do not catch general exception types -#pragma warning disable S108 // Nested blocks of code should not be left empty -#pragma warning disable S2486 // Generic exceptions should not be ignored - -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; - -namespace Microsoft.Extensions.AI; - -/// Provides internal helpers for implementing logging. -[ExcludeFromCodeCoverage] -internal static class LoggingHelpers -{ - /// Serializes as JSON for logging purposes. - public static string AsJson(T value, JsonSerializerOptions? options) - { - if (options?.TryGetTypeInfo(typeof(T), out var typeInfo) is true || - AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo)) - { - try - { - return JsonSerializer.Serialize(value, typeInfo); - } - catch - { - } - } - - // If we're unable to get a type info for the value, or if we fail to serialize, - // return an empty JSON object. We do not want lack of type info to disrupt application behavior with exceptions. - return "{}"; - } -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/NewFunctionInvokingChatClient.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/NewFunctionInvokingChatClient.cs deleted file mode 100644 index 2b41af5671..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/NewFunctionInvokingChatClient.cs +++ /dev/null @@ -1,1553 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// WARNING: -// This class is a copy of FunctionInvokingChatClient from MEAI, and is intended to be modified with -// changes that we want to prototype here, before updating FunctionInvokingChatClient in MEAI. -// The intention is to keep the changes in this file to a minimum, so that we can easily -// merge them back into MEAI when ready. - -// AF repo suppressions for code copied from MEAI. -#pragma warning disable IDE1006 // Naming Styles -#pragma warning disable IDE0009 // Member access should be qualified. -#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task -#pragma warning disable VSTHRD111 // Use ConfigureAwait(bool) - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Runtime.ExceptionServices; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Shared.Diagnostics; - -#pragma warning disable CA2213 // Disposable fields should be disposed -#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test -#pragma warning disable SA1202 // 'protected' members should come before 'private' members -#pragma warning disable S107 // Methods should not have too many parameters - -namespace Microsoft.Extensions.AI; - -/// -/// A delegating chat client that invokes functions defined on . -/// Include this in a chat pipeline to resolve function calls automatically. -/// -/// -/// -/// When this client receives a in a chat response, it responds -/// by calling the corresponding defined in , -/// producing a that it sends back to the inner client. This loop -/// is repeated until there are no more function calls to make, or until another stop condition is met, -/// such as hitting . -/// -/// -/// The provided implementation of is thread-safe for concurrent use so long as the -/// instances employed as part of the supplied are also safe. -/// The property can be used to control whether multiple function invocation -/// requests as part of the same request are invocable concurrently, but even with that set to -/// (the default), multiple concurrent requests to this same instance and using the same tools could result in those -/// tools being used concurrently (one per request). For example, a function that accesses the HttpContext of a specific -/// ASP.NET web request should only be used as part of a single at a time, and only with -/// set to , in case the inner client decided to issue multiple -/// invocation requests to that same function. -/// -/// -public partial class NewFunctionInvokingChatClient : DelegatingChatClient -{ - /// The for the current function invocation. - private static readonly AsyncLocal _currentContext = new(); - - /// Gets the specified when constructing the , if any. - protected IServiceProvider? FunctionInvocationServices { get; } - - /// The logger to use for logging information about function invocation. - private readonly ILogger _logger; - - /// The to use for telemetry. - /// This component does not own the instance and should not dispose it. - private readonly ActivitySource? _activitySource; - - /// Maximum number of roundtrips allowed to the inner client. - private int _maximumIterationsPerRequest = 40; // arbitrary default to prevent runaway execution - - /// Maximum number of consecutive iterations that are allowed contain at least one exception result. If the limit is exceeded, we rethrow the exception instead of continuing. - private int _maximumConsecutiveErrorsPerRequest = 3; - - /// - /// Initializes a new instance of the class. - /// - /// The underlying , or the next instance in a chain of clients. - /// An to use for logging information about function invocation. - /// An optional to use for resolving services required by the instances being invoked. - public NewFunctionInvokingChatClient(IChatClient innerClient, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null) - : base(innerClient) - { - _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; - _activitySource = innerClient.GetService(); - FunctionInvocationServices = functionInvocationServices; - } - - /// - /// Gets or sets the for the current function invocation. - /// - /// - /// This value flows across async calls. - /// - public static FunctionInvocationContext? CurrentContext - { - get => _currentContext.Value; - protected set => _currentContext.Value = value; - } - - /// - /// Gets or sets a value indicating whether detailed exception information should be included - /// in the chat history when calling the underlying . - /// - /// - /// if the full exception message is added to the chat history - /// when calling the underlying . - /// if a generic error message is included in the chat history. - /// The default value is . - /// - /// - /// - /// Setting the value to prevents the underlying language model from disclosing - /// raw exception details to the end user, since it doesn't receive that information. Even in this - /// case, the raw object is available to application code by inspecting - /// the property. - /// - /// - /// Setting the value to can help the underlying bypass problems on - /// its own, for example by retrying the function call with different arguments. However it might - /// result in disclosing the raw exception information to external users, which can be a security - /// concern depending on the application scenario. - /// - /// - /// Changing the value of this property while the client is in use might result in inconsistencies - /// as to whether detailed errors are provided during an in-flight request. - /// - /// - public bool IncludeDetailedErrors { get; set; } - - /// - /// Gets or sets a value indicating whether to allow concurrent invocation of functions. - /// - /// - /// if multiple function calls can execute in parallel. - /// if function calls are processed serially. - /// The default value is . - /// - /// - /// An individual response from the inner client might contain multiple function call requests. - /// By default, such function calls are processed serially. Set to - /// to enable concurrent invocation such that multiple function calls can execute in parallel. - /// - public bool AllowConcurrentInvocation { get; set; } - - /// - /// Gets or sets the maximum number of iterations per request. - /// - /// - /// The maximum number of iterations per request. - /// The default value is 40. - /// - /// - /// - /// Each request to this might end up making - /// multiple requests to the inner client. Each time the inner client responds with - /// a function call request, this client might perform that invocation and send the results - /// back to the inner client in a new request. This property limits the number of times - /// such a roundtrip is performed. The value must be at least one, as it includes the initial request. - /// - /// - /// Changing the value of this property while the client is in use might result in inconsistencies - /// as to how many iterations are allowed for an in-flight request. - /// - /// - public int MaximumIterationsPerRequest - { - get => _maximumIterationsPerRequest; - set - { - if (value < 1) - { - Throw.ArgumentOutOfRangeException(nameof(value)); - } - - _maximumIterationsPerRequest = value; - } - } - - /// - /// Gets or sets the maximum number of consecutive iterations that are allowed to fail with an error. - /// - /// - /// The maximum number of consecutive iterations that are allowed to fail with an error. - /// The default value is 3. - /// - /// - /// - /// When function invocations fail with an exception, the - /// continues to make requests to the inner client, optionally supplying exception information (as - /// controlled by ). This allows the to - /// recover from errors by trying other function parameters that may succeed. - /// - /// - /// However, in case function invocations continue to produce exceptions, this property can be used to - /// limit the number of consecutive failing attempts. When the limit is reached, the exception will be - /// rethrown to the caller. - /// - /// - /// If the value is set to zero, all function calling exceptions immediately terminate the function - /// invocation loop and the exception will be rethrown to the caller. - /// - /// - /// Changing the value of this property while the client is in use might result in inconsistencies - /// as to how many iterations are allowed for an in-flight request. - /// - /// - public int MaximumConsecutiveErrorsPerRequest - { - get => _maximumConsecutiveErrorsPerRequest; - set => _maximumConsecutiveErrorsPerRequest = Throw.IfLessThan(value, 0); - } - - /// Gets or sets a collection of additional tools the client is able to invoke. - /// - /// These will not impact the requests sent by the , which will pass through the - /// unmodified. However, if the inner client requests the invocation of a tool - /// that was not in , this collection will also be consulted - /// to look for a corresponding tool to invoke. This is useful when the service may have been pre-configured to be aware - /// of certain tools that aren't also sent on each individual request. - /// - public IList? AdditionalTools { get; set; } - - /// Gets or sets a delegate used to invoke instances. - /// - /// By default, the protected method is called for each to be invoked, - /// invoking the instance and returning its result. If this delegate is set to a non- value, - /// will replace its normal invocation with a call to this delegate, enabling - /// this delegate to assume all invocation handling of the function. - /// - public Func>? FunctionInvoker { get; set; } - - /// - public override async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - // A single request into this GetResponseAsync may result in multiple requests to the inner client. - // Create an activity to group them together for better observability. - using Activity? activity = _activitySource?.StartActivity($"{nameof(FunctionInvokingChatClient)}.{nameof(GetResponseAsync)}"); - - // Copy the original messages in order to avoid enumerating the original messages multiple times. - // The IEnumerable can represent an arbitrary amount of work. - List originalMessages = [.. messages]; - messages = originalMessages; - - List? augmentedHistory = null; // the actual history of messages sent on turns other than the first - ChatResponse? response = null; // the response from the inner client, which is possibly modified and then eventually returned - List? responseMessages = null; // tracked list of messages, across multiple turns, to be used for the final response - UsageDetails? totalUsage = null; // tracked usage across all turns, to be used for the final response - List? functionCallContents = null; // function call contents that need responding to in the current turn - bool lastIterationHadConversationId = false; // whether the last iteration's response had a ConversationId set - int consecutiveErrorCount = 0; - - // Process approval requests (remove from originalMessages) and rejected approval responses (re-create FCC and create failed FRC). - var (preDownstreamCallHistory, notInvokedApprovals) = ProcessFunctionApprovalResponses(originalMessages, !string.IsNullOrWhiteSpace(options?.ConversationId), toolResponseId: null, functionCallContentFallbackMessageId: null); - - // Invoke approved approval responses, which generates some additional FRC wrapped in ChatMessage. - (IList? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) = - await InvokeApprovedFunctionApprovalResponses(notInvokedApprovals, originalMessages, options, consecutiveErrorCount, isStreaming: false, cancellationToken); - - if (invokedApprovedFunctionApprovalResponses is not null) - { - // We need to add the generated FRC to the list we'll return to callers as part of the next response. - preDownstreamCallHistory ??= []; - preDownstreamCallHistory.AddRange(invokedApprovedFunctionApprovalResponses); - } - - if (shouldTerminate) - { - return new ChatResponse(preDownstreamCallHistory); - } - - for (int iteration = 0; ; iteration++) - { - functionCallContents?.Clear(); - - // Make the call to the inner client. - response = await base.GetResponseAsync(messages, options, cancellationToken); - if (response is null) - { - Throw.InvalidOperationException($"The inner {nameof(IChatClient)} returned a null {nameof(ChatResponse)}."); - } - - // Before we do any function execution, make sure that any functions that require approval, have been turned into approval requests - // so that they don't get executed here. - response.Messages = await ReplaceFunctionCallsWithApprovalRequests(response.Messages, options?.Tools, AdditionalTools); - - // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. - bool requiresFunctionInvocation = - (options?.Tools is { Count: > 0 } || AdditionalTools is { Count: > 0 }) && - iteration < MaximumIterationsPerRequest && - CopyFunctionCalls(response.Messages, ref functionCallContents); - - // In a common case where we make a request and there's no function calling work required, - // fast path out by just returning the original response. - if (iteration == 0 && !requiresFunctionInvocation) - { - // Insert any pre-invocation FCC and FRC that were converted from approval responses into the response here, - // so they are returned to the caller. - response.Messages = UpdateResponseMessagesWithPreDownstreamCallHistory(response.Messages, preDownstreamCallHistory); - preDownstreamCallHistory = null; - - return response; - } - - // Track aggregate details from the response, including all of the response messages and usage details. - (responseMessages ??= []).AddRange(response.Messages); - if (response.Usage is not null) - { - if (totalUsage is not null) - { - totalUsage.Add(response.Usage); - } - else - { - totalUsage = response.Usage; - } - } - - // If there are no tools to call, or for any other reason we should stop, we're done. - // Break out of the loop and allow the handling at the end to configure the response - // with aggregated data from previous requests. - if (!requiresFunctionInvocation) - { - break; - } - - // Prepare the history for the next iteration. - FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadConversationId); - - // Add the responses from the function calls into the augmented history and also into the tracked - // list of response messages. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken); - responseMessages.AddRange(modeAndMessages.MessagesAdded); - consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; - - if (modeAndMessages.ShouldTerminate) - { - break; - } - - UpdateOptionsForNextIteration(ref options, response.ConversationId); - } - - Debug.Assert(responseMessages is not null, "Expected to only be here if we have response messages."); - response.Messages = responseMessages!; - response.Usage = totalUsage; - - AddUsageTags(activity, totalUsage); - - return response; - } - - /// - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - // A single request into this GetStreamingResponseAsync may result in multiple requests to the inner client. - // Create an activity to group them together for better observability. - using Activity? activity = _activitySource?.StartActivity($"{nameof(FunctionInvokingChatClient)}.{nameof(GetStreamingResponseAsync)}"); - UsageDetails? totalUsage = activity is { IsAllDataRequested: true } ? new() : null; // tracked usage across all turns, to be used for activity purposes - - // Copy the original messages in order to avoid enumerating the original messages multiple times. - // The IEnumerable can represent an arbitrary amount of work. - List originalMessages = [.. messages]; - messages = originalMessages; - - List? augmentedHistory = null; // the actual history of messages sent on turns other than the first - List? functionCallContents = null; // function call contents that need responding to in the current turn - List? responseMessages = null; // tracked list of messages, across multiple turns, to be used in fallback cases to reconstitute history - bool lastIterationHadConversationId = false; // whether the last iteration's response had a ConversationId set - List updates = []; // updates from the current response - int consecutiveErrorCount = 0; - - // This is a synthetic ID since we're generating the tool messages instead of getting them from - // the underlying provider. When emitting the streamed chunks, it's perfectly valid for us to - // use the same message ID for all of them within a given iteration, as this is a single logical - // message with multiple content items. We could also use different message IDs per tool content, - // but there's no benefit to doing so. - string toolResponseId = Guid.NewGuid().ToString("N"); - - // We also need a synthetic ID for the function call content for approved function calls - // where we don't know what the original message id of the function call was. - string functionCallContentFallbackMessageId = Guid.NewGuid().ToString("N"); - - ApprovalRequiredAIFunction[]? approvalRequiredFunctions = (options?.Tools ?? []).Concat(AdditionalTools ?? []).OfType().ToArray(); - bool hasApprovalRequiringFunctions = approvalRequiredFunctions.Length > 0; - - // Process approval requests (remove from original messages) and rejected approval responses (re-create FCC and create failed FRC). - var (preDownstreamCallHistory, notInvokedApprovals) = ProcessFunctionApprovalResponses(originalMessages, !string.IsNullOrWhiteSpace(options?.ConversationId), toolResponseId, functionCallContentFallbackMessageId); - if (preDownstreamCallHistory is not null) - { - foreach (var message in preDownstreamCallHistory) - { - yield return ConvertToolResultMessageToUpdate(message, options?.ConversationId, message.MessageId); - Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 - } - } - - // Invoke approved approval responses, which generates some additional FRC wrapped in ChatMessage. - (IList? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) = - await InvokeApprovedFunctionApprovalResponses(notInvokedApprovals, originalMessages, options, consecutiveErrorCount, isStreaming: true, cancellationToken); - - if (invokedApprovedFunctionApprovalResponses is not null) - { - foreach (var message in invokedApprovedFunctionApprovalResponses) - { - message.MessageId = toolResponseId; - yield return ConvertToolResultMessageToUpdate(message, options?.ConversationId, message.MessageId); - Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 - } - - if (shouldTerminate) - { - yield break; - } - } - - for (int iteration = 0; ; iteration++) - { - updates.Clear(); - functionCallContents?.Clear(); - - bool hasApprovalRequiringFcc = false; - int lastApprovalCheckedFCCIndex = 0; - int lastYieldedUpdateIndex = 0; - - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken)) - { - if (update is null) - { - Throw.InvalidOperationException($"The inner {nameof(IChatClient)} streamed a null {nameof(ChatResponseUpdate)}."); - } - - updates.Add(update); - - _ = CopyFunctionCalls(update.Contents, ref functionCallContents); - - if (totalUsage is not null) - { - IList contents = update.Contents; - int contentsCount = contents.Count; - for (int i = 0; i < contentsCount; i++) - { - if (contents[i] is UsageContent uc) - { - totalUsage.Add(uc.Details); - } - } - } - - if (functionCallContents?.Count is not > 0 || !hasApprovalRequiringFunctions) - { - // If there are no function calls to make yet, or if none of the functions require approval at all, - // we can yield the update as-is. - lastYieldedUpdateIndex++; - yield return update; - Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 - } - else - { - // Check if any of the function call contents in this update requires approval. - // Once we find the first one that requires approval, this method becomes a no-op. - (hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex) = await CheckForApprovalRequiringFCCAsync( - functionCallContents, approvalRequiredFunctions, hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex); - - // We've encountered a function call content that requires approval (either in this update or ealier) - // so we need to ask for approval for all functions, since we cannot mix and match. - if (hasApprovalRequiringFcc) - { - // Convert all function call contents into approval requests from the last yielded update index - // and yield all those updates. - for (; lastYieldedUpdateIndex < updates.Count; lastYieldedUpdateIndex++) - { - var updateToYield = updates[lastYieldedUpdateIndex]; - if (TryReplaceFunctionCallsWithApprovalRequests(updateToYield.Contents, out var updatedContents)) - { - updateToYield.Contents = updatedContents; - } - yield return updateToYield; - Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 - } - } - else - { - // We don't have any appoval requiring function calls yet, but we may receive some in future - // so we cannot yield the updates yet. We'll just keep them in the updates list - // for later. - // We will yield the updates as soon as we receive a function call content that requires approval or - // when we reach the end of the updates stream. - } - } - - Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 - } - - // If there are no tools to call, or for any other reason we should stop, return the response. - if (functionCallContents is not { Count: > 0 } || - hasApprovalRequiringFcc || - (options?.Tools is not { Count: > 0 } && AdditionalTools is not { Count: > 0 }) || - iteration >= _maximumIterationsPerRequest) - { - break; - } - - // Reconstitute a response from the response updates. - var response = updates.ToChatResponse(); - (responseMessages ??= []).AddRange(response.Messages); - - // Prepare the history for the next iteration. - FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadConversationId); - - // Process all of the functions, adding their results into the history. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken); - responseMessages.AddRange(modeAndMessages.MessagesAdded); - consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; - - // Stream any generated function results. This mirrors what's done for GetResponseAsync, where the returned messages - // includes all activities, including generated function results. - foreach (var message in modeAndMessages.MessagesAdded) - { - yield return ConvertToolResultMessageToUpdate(message, response.ConversationId, toolResponseId); - Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 - } - - if (modeAndMessages.ShouldTerminate) - { - break; - } - - UpdateOptionsForNextIteration(ref options, response.ConversationId); - } - - AddUsageTags(activity, totalUsage); - } - - /// Adds tags to for usage details in . - private static void AddUsageTags(Activity? activity, UsageDetails? usage) - { - if (usage is not null && activity is { IsAllDataRequested: true }) - { - if (usage.InputTokenCount is long inputTokens) - { - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, (int)inputTokens); - } - - if (usage.OutputTokenCount is long outputTokens) - { - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens); - } - } - } - - /// Prepares the various chat message lists after a response from the inner client and before invoking functions. - /// The original messages provided by the caller. - /// The messages reference passed to the inner client. - /// The augmented history containing all the messages to be sent. - /// The most recent response being handled. - /// A list of all response messages received up until this point. - /// Whether the previous iteration's response had a conversation ID. - private static void FixupHistories( - IEnumerable originalMessages, - ref IEnumerable messages, - [NotNull] ref List? augmentedHistory, - ChatResponse response, - List allTurnsResponseMessages, - ref bool lastIterationHadConversationId) - { - // We're now going to need to augment the history with function result contents. - // That means we need a separate list to store the augmented history. - if (response.ConversationId is not null) - { - // The response indicates the inner client is tracking the history, so we don't want to send - // anything we've already sent or received. - if (augmentedHistory is not null) - { - augmentedHistory.Clear(); - } - else - { - augmentedHistory = []; - } - - lastIterationHadConversationId = true; - } - else if (lastIterationHadConversationId) - { - // In the very rare case where the inner client returned a response with a conversation ID but then - // returned a subsequent response without one, we want to reconstitute the full history. To do that, - // we can populate the history with the original chat messages and then all of the response - // messages up until this point, which includes the most recent ones. - augmentedHistory ??= []; - augmentedHistory.Clear(); - augmentedHistory.AddRange(originalMessages); - augmentedHistory.AddRange(allTurnsResponseMessages); - - lastIterationHadConversationId = false; - } - else - { - // If augmentedHistory is already non-null, then we've already populated it with everything up - // until this point (except for the most recent response). If it's null, we need to seed it with - // the chat history provided by the caller. - augmentedHistory ??= originalMessages.ToList(); - - // Now add the most recent response messages. - augmentedHistory.AddMessages(response); - - lastIterationHadConversationId = false; - } - - // Use the augmented history as the new set of messages to send. - messages = augmentedHistory; - } - - /// Copies any from to . - private static bool CopyFunctionCalls( - IList messages, [NotNullWhen(true)] ref List? functionCalls) - { - bool any = false; - int count = messages.Count; - for (int i = 0; i < count; i++) - { - any |= CopyFunctionCalls(messages[i].Contents, ref functionCalls); - } - - return any; - } - - /// Copies any from to . - private static bool CopyFunctionCalls( - IList content, [NotNullWhen(true)] ref List? functionCalls) - { - bool any = false; - int count = content.Count; - for (int i = 0; i < count; i++) - { - if (content[i] is FunctionCallContent functionCall) - { - (functionCalls ??= []).Add(functionCall); - any = true; - } - } - - return any; - } - - private static void UpdateOptionsForNextIteration(ref ChatOptions? options, string? conversationId) - { - if (options is null) - { - if (conversationId is not null) - { - options = new() { ConversationId = conversationId }; - } - } - else if (options.ToolMode is RequiredChatToolMode) - { - // We have to reset the tool mode to be non-required after the first iteration, - // as otherwise we'll be in an infinite loop. - options = options.Clone(); - options.ToolMode = null; - options.ConversationId = conversationId; - } - else if (options.ConversationId != conversationId) - { - // As with the other modes, ensure we've propagated the chat conversation ID to the options. - // We only need to clone the options if we're actually mutating it. - options = options.Clone(); - options.ConversationId = conversationId; - } - } - - /// - /// Processes the function calls in the list. - /// - /// The current chat contents, inclusive of the function call contents being processed. - /// The options used for the response being processed. - /// The function call contents representing the functions to be invoked. - /// The iteration number of how many roundtrips have been made to the inner client. - /// The number of consecutive iterations, prior to this one, that were recorded as having function invocation errors. - /// Whether the function calls are being processed in a streaming context. - /// The to monitor for cancellation requests. - /// A value indicating how the caller should proceed. - private async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( - List messages, ChatOptions? options, List functionCallContents, int iteration, int consecutiveErrorCount, - bool isStreaming, CancellationToken cancellationToken) - { - // We must add a response for every tool call, regardless of whether we successfully executed it or not. - // If we successfully execute it, we'll add the result. If we don't, we'll add an error. - - Debug.Assert(functionCallContents.Count > 0, "Expected at least one function call."); - var shouldTerminate = false; - var captureCurrentIterationExceptions = consecutiveErrorCount < _maximumConsecutiveErrorsPerRequest; - - // Process all functions. If there's more than one and concurrent invocation is enabled, do so in parallel. - if (functionCallContents.Count == 1) - { - FunctionInvocationResult result = await ProcessFunctionCallAsync( - messages, options, functionCallContents, - iteration, 0, captureCurrentIterationExceptions, isStreaming, cancellationToken); - - IList addedMessages = CreateResponseMessages([result]); - ThrowIfNoFunctionResultsAdded(addedMessages); - UpdateConsecutiveErrorCountOrThrow(addedMessages, ref consecutiveErrorCount); - messages.AddRange(addedMessages); - - return (result.Terminate, consecutiveErrorCount, addedMessages); - } - else - { - List results = []; - - if (AllowConcurrentInvocation) - { - // Rather than awaiting each function before invoking the next, invoke all of them - // and then await all of them. We avoid forcibly introducing parallelism via Task.Run, - // but if a function invocation completes asynchronously, its processing can overlap - // with the processing of other the other invocation invocations. - results.AddRange(await Task.WhenAll( - from callIndex in Enumerable.Range(0, functionCallContents.Count) - select ProcessFunctionCallAsync( - messages, options, functionCallContents, - iteration, callIndex, captureExceptions: true, isStreaming, cancellationToken))); - - shouldTerminate = results.Any(r => r.Terminate); - } - else - { - // Invoke each function serially. - for (int callIndex = 0; callIndex < functionCallContents.Count; callIndex++) - { - var functionResult = await ProcessFunctionCallAsync( - messages, options, functionCallContents, - iteration, callIndex, captureCurrentIterationExceptions, isStreaming, cancellationToken); - - results.Add(functionResult); - - // If any function requested termination, we should stop right away. - if (functionResult.Terminate) - { - shouldTerminate = true; - break; - } - } - } - - IList addedMessages = CreateResponseMessages(results.ToArray()); - ThrowIfNoFunctionResultsAdded(addedMessages); - UpdateConsecutiveErrorCountOrThrow(addedMessages, ref consecutiveErrorCount); - messages.AddRange(addedMessages); - - return (shouldTerminate, consecutiveErrorCount, addedMessages); - } - } - -#pragma warning disable CA1851 // Possible multiple enumerations of 'IEnumerable' collection - /// - /// Updates the consecutive error count, and throws an exception if the count exceeds the maximum. - /// - /// Added messages. - /// Consecutive error count. - /// Thrown if the maximum consecutive error count is exceeded. - private void UpdateConsecutiveErrorCountOrThrow(IList added, ref int consecutiveErrorCount) - { - var allExceptions = added.SelectMany(m => m.Contents.OfType()) - .Select(frc => frc.Exception!) - .Where(e => e is not null); - - if (allExceptions.Any()) - { - consecutiveErrorCount++; - if (consecutiveErrorCount > _maximumConsecutiveErrorsPerRequest) - { - var allExceptionsArray = allExceptions.ToArray(); - if (allExceptionsArray.Length == 1) - { - ExceptionDispatchInfo.Capture(allExceptionsArray[0]).Throw(); - } - else - { - throw new AggregateException(allExceptionsArray); - } - } - } - else - { - consecutiveErrorCount = 0; - } - } -#pragma warning restore CA1851 - - /// - /// Throws an exception if doesn't create any messages. - /// - private void ThrowIfNoFunctionResultsAdded(IList? messages) - { - if (messages is null || messages.Count == 0) - { - Throw.InvalidOperationException($"{GetType().Name}.{nameof(CreateResponseMessages)} returned null or an empty collection of messages."); - } - } - - /// Processes the function call described in []. - /// The current chat contents, inclusive of the function call contents being processed. - /// The options used for the response being processed. - /// The function call contents representing all the functions being invoked. - /// The iteration number of how many roundtrips have been made to the inner client. - /// The 0-based index of the function being called out of . - /// If true, handles function-invocation exceptions by returning a value with . Otherwise, rethrows. - /// Whether the function calls are being processed in a streaming context. - /// The to monitor for cancellation requests. - /// A value indicating how the caller should proceed. - private async Task ProcessFunctionCallAsync( - List messages, ChatOptions? options, List callContents, - int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken) - { - var callContent = callContents[functionCallIndex]; - - // Look up the AIFunction for the function call. If the requested function isn't available, send back an error. - AIFunction? aiFunction = FindAIFunction(options?.Tools, callContent.Name) ?? FindAIFunction(AdditionalTools, callContent.Name); - if (aiFunction is null) - { - return new(terminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); - } - - FunctionInvocationContext context = new() - { - Function = aiFunction, - Arguments = new(callContent.Arguments) { Services = FunctionInvocationServices }, - Messages = messages, - Options = options, - CallContent = callContent, - Iteration = iteration, - FunctionCallIndex = functionCallIndex, - FunctionCount = callContents.Count, - IsStreaming = isStreaming - }; - - object? result; - try - { - result = await InstrumentedInvokeFunctionAsync(context, cancellationToken); - } - catch (Exception e) when (!cancellationToken.IsCancellationRequested) - { - if (!captureExceptions) - { - throw; - } - - return new( - terminate: false, - FunctionInvocationStatus.Exception, - callContent, - result: null, - exception: e); - } - - return new( - terminate: context.Terminate, - FunctionInvocationStatus.RanToCompletion, - callContent, - result, - exception: null); - - static AIFunction? FindAIFunction(IList? tools, string functionName) - { - if (tools is not null) - { - int count = tools.Count; - for (int i = 0; i < count; i++) - { - if (tools[i] is AIFunction function && function.Name == functionName) - { - return function; - } - } - } - - return null; - } - } - - /// Creates one or more response messages for function invocation results. - /// Information about the function call invocations and results. - /// A list of all chat messages created from . - protected virtual IList CreateResponseMessages( - ReadOnlySpan results) - { - var contents = new List(results.Length); - for (int i = 0; i < results.Length; i++) - { - contents.Add(CreateFunctionResultContent(results[i])); - } - - return [new(ChatRole.Tool, contents)]; - - FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult result) - { - _ = Throw.IfNull(result); - - object? functionResult; - if (result.Status == FunctionInvocationStatus.RanToCompletion) - { - functionResult = result.Result ?? "Success: Function completed."; - } - else - { - string message = result.Status switch - { - FunctionInvocationStatus.NotFound => $"Error: Requested function \"{result.CallContent.Name}\" not found.", - FunctionInvocationStatus.Exception => "Error: Function failed.", - _ => "Error: Unknown error.", - }; - - if (IncludeDetailedErrors && result.Exception is not null) - { - message = $"{message} Exception: {result.Exception.Message}"; - } - - functionResult = message; - } - - return new FunctionResultContent(result.CallContent.CallId, functionResult) { Exception = result.Exception }; - } - } - - /// Invokes the function asynchronously. - /// - /// The function invocation context detailing the function to be invoked and its arguments along with additional request information. - /// - /// The to monitor for cancellation requests. The default is . - /// The result of the function invocation, or if the function invocation returned . - /// is . - private async Task InstrumentedInvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken) - { - _ = Throw.IfNull(context); - - using Activity? activity = _activitySource?.StartActivity( - $"{OpenTelemetryConsts.GenAI.ExecuteTool} {context.Function.Name}", - ActivityKind.Internal, - default(ActivityContext), - [ - new(OpenTelemetryConsts.GenAI.Operation.Name, "execute_tool"), - new(OpenTelemetryConsts.GenAI.Tool.Call.Id, context.CallContent.CallId), - new(OpenTelemetryConsts.GenAI.Tool.Name, context.Function.Name), - new(OpenTelemetryConsts.GenAI.Tool.Description, context.Function.Description), - ]); - - long startingTimestamp = 0; - if (_logger.IsEnabled(LogLevel.Debug)) - { - startingTimestamp = Stopwatch.GetTimestamp(); - if (_logger.IsEnabled(LogLevel.Trace)) - { - LogInvokingSensitive(context.Function.Name, LoggingHelpers.AsJson(context.Arguments, context.Function.JsonSerializerOptions)); - } - else - { - LogInvoking(context.Function.Name); - } - } - - object? result = null; - try - { - CurrentContext = context; // doesn't need to be explicitly reset after, as that's handled automatically at async method exit - result = await InvokeFunctionAsync(context, cancellationToken); - } - catch (Exception e) - { - if (activity is not null) - { - _ = activity.SetTag("error.type", e.GetType().FullName) - .SetStatus(ActivityStatusCode.Error, e.Message); - } - - if (e is OperationCanceledException) - { - LogInvocationCanceled(context.Function.Name); - } - else - { - LogInvocationFailed(context.Function.Name, e); - } - - throw; - } - finally - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - TimeSpan elapsed = GetElapsedTime(startingTimestamp); - - if (result is not null && _logger.IsEnabled(LogLevel.Trace)) - { - LogInvocationCompletedSensitive(context.Function.Name, elapsed, LoggingHelpers.AsJson(result, context.Function.JsonSerializerOptions)); - } - else - { - LogInvocationCompleted(context.Function.Name, elapsed); - } - } - } - - return result; - } - - /// This method will invoke the function within the try block. - /// The function invocation context. - /// Cancellation token. - /// The function result. - protected virtual ValueTask InvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken) - { - _ = Throw.IfNull(context); - - return FunctionInvoker is { } invoker ? - invoker(context, cancellationToken) : - context.Function.InvokeAsync(context.Arguments, cancellationToken); - } - - /// - /// 1. Remove all and from the . - /// 2. Recreate for any that haven't been executed yet. - /// 3. Genreate failed for any rejected . - /// 4. add all the new content items to and return them as the pre-invocation history. - /// - private static (List? preDownstreamCallHistory, List? approvals) ProcessFunctionApprovalResponses( - List originalMessages, bool hasConversationId, string? toolResponseId, string? functionCallContentFallbackMessageId) - { - // Extract any approval responses where we need to execute or reject the function calls. - // The original messages are also modified to remove all approval requests and responses. - var notInvokedResponses = ExtractAndRemoveApprovalRequestsAndResponses(originalMessages); - - // Wrap the function call content in message(s). - ICollection? allPreDownstreamCallMessages = ConvertToFunctionCallContentMessages( - [.. notInvokedResponses.rejections ?? [], .. notInvokedResponses.approvals ?? []], functionCallContentFallbackMessageId); - - // Generate failed function result contents for any rejected requests and wrap it in a message. - List? rejectedFunctionCallResults = GenerateRejectedFunctionResults(notInvokedResponses.rejections, toolResponseId); - ChatMessage? rejectedPreDownstreamCallResultsMessage = rejectedFunctionCallResults != null ? - new ChatMessage(ChatRole.Tool, rejectedFunctionCallResults) { MessageId = toolResponseId } : - null; - - // Add all the FCC that we generated to the pre-downstream-call history so that they can be returned to the caller as part of the next response. - // Also, if we are not dealing with a service thread (i.e. we don't have a conversation ID), add them - // into the original messages list so that they are passed to the inner client and can be used to generate a result. - List? preDownstreamCallHistory = null; - if (allPreDownstreamCallMessages is not null) - { - preDownstreamCallHistory ??= []; - foreach (var message in allPreDownstreamCallMessages) - { - preDownstreamCallHistory.Add(message); - if (!hasConversationId) - { - originalMessages.Add(message); - } - } - } - - // Add all the FRC that we generated to the pre-downstream-call history so that they can be returned to the caller as part of the next response. - // Also, add them into the original messages list so that they are passed to the inner client and can be used to generate a result. - if (rejectedPreDownstreamCallResultsMessage is not null) - { - preDownstreamCallHistory ??= []; - originalMessages.Add(rejectedPreDownstreamCallResultsMessage); - preDownstreamCallHistory.Add(rejectedPreDownstreamCallResultsMessage); - } - - return (preDownstreamCallHistory, notInvokedResponses.approvals); - } - - /// - /// Execute the provided and return the resulting . - /// - private async Task<(IList? FunctionResultContent, bool ShouldTerminate, int ConsecutiveErrorCount)> InvokeApprovedFunctionApprovalResponses( - List? notInvokedApprovals, List originalMessages, ChatOptions? options, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) - { - // Check if there are any function calls to do for any approved functions and execute them. - if (notInvokedApprovals is { Count: > 0 }) - { - // The FRC that is generated here is already added to originalMessages by ProcessFunctionCallsAsync. - var modeAndMessages = await ProcessFunctionCallsAsync(originalMessages, options, notInvokedApprovals.Select(x => x.Response.FunctionCall).ToList(), 0, consecutiveErrorCount, isStreaming, cancellationToken); - consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; - - return (modeAndMessages.MessagesAdded, modeAndMessages.ShouldTerminate, consecutiveErrorCount); - } - - return (null, false, consecutiveErrorCount); - } - - /// - /// This method extracts the approval requests and responses from the provided list of messages, validates them, filters them to ones that require execution and splits them into approved and rejected. - /// - /// - /// 1st iteration: over all messages and content - /// ===== - /// Build a list of all function call ids that are already executed. - /// Build a list of all function approval requests and responses. - /// Build a list of the content we want to keep (everything except approval requests and responses) and create a new list of messages for those. - /// Validate that we have an approval response for each approval request. - /// - /// 2nd iteration: over all approval responses - /// ===== - /// Filter out any approval responses that already have a matching function result (i.e. already executed). - /// Find the matching function approval request for any response (where available). - /// Split the approval responses into two lists: approved and rejected, with their request messages (where available). - /// - /// We return the messages containing the approval requests since these are the same messages that originally contained the FunctionCallContent from the downstream service. - /// We can then use the metadata from these messages when we re-create the FunctionCallContent messages/updates to return to the caller. This way, when we finally do return - /// the FuncionCallContent to users it's part of a message/update that contains the same metadata as originally returned to the downstream service. - /// - private static (List? approvals, List? rejections) ExtractAndRemoveApprovalRequestsAndResponses(List messages) - { - Dictionary? allApprovalRequestsMessages = null; - List? allApprovalResponses = null; - HashSet? approvalRequestCallIds = null; - HashSet? functionResultCallIds = null; - - for (int i = 0; i < messages.Count; i++) - { - var message = messages[i]; - - List? keptContents = null; - - // Find contents we want to keep. - for (int j = 0; j < message.Contents.Count; j++) - { - var content = message.Contents[j]; - - // Maintain a list of function calls that have already been executed, so we can avoid executing them a second time. - if (content is FunctionResultContent functionResultContent) - { - functionResultCallIds ??= []; - functionResultCallIds.Add(functionResultContent.CallId); - } - - // Validation: Capture each call id for each approval request so that we can ensure that we have a matching response later. - if (content is FunctionApprovalRequestContent request_) - { - approvalRequestCallIds ??= []; - approvalRequestCallIds.Add(request_.FunctionCall.CallId); - } - - // Validation: Remove the call id for each approval response, to check it off the list of requests we need responses for. - if (content is FunctionApprovalResponseContent response_ && approvalRequestCallIds is not null) - { - approvalRequestCallIds.Remove(response_.FunctionCall.CallId); - } - - // Build the list of requets and responses and keep them out of the updated message list - // since they will be handled in this class, and don't need to be passed further down the stack. - if (content is FunctionApprovalRequestContent approvalRequest) - { - allApprovalRequestsMessages ??= new Dictionary(); - allApprovalRequestsMessages.Add(approvalRequest.Id, message); - continue; - } - - if (content is FunctionApprovalResponseContent approvalResponse) - { - allApprovalResponses ??= []; - allApprovalResponses.Add(approvalResponse); - continue; - } - - // If we get to here, we should have just the contents that we want to keep. - keptContents ??= []; - keptContents.Add(content); - } - - if (message.Contents.Count > 0 && keptContents?.Count != message.Contents.Count) - { - if (keptContents is null || keptContents.Count == 0) - { - // If we have no contents left after filtering, we can remove the message. - messages.RemoveAt(i); - i--; // Adjust index since we removed an item. - continue; - } - - // If we have any contents left after filtering, we can keep the message with the new remaining content. - var newMessage = message.Clone(); - newMessage.Contents = keptContents; - messages[i] = newMessage; - } - } - - // Validation: If we got an approval for each request, we should have no call ids left. - if (approvalRequestCallIds?.Count is > 0) - { - Throw.InvalidOperationException($"FunctionApprovalRequestContent found with FunctionCall.CallId(s) '{string.Join(", ", approvalRequestCallIds)}' that have no matching FunctionApprovalResponseContent."); - } - - List? approvedFunctionCalls = null; - List? rejectedFunctionCalls = null; - - for (int i = 0; i < (allApprovalResponses?.Count ?? 0); i++) - { - var approvalResponse = allApprovalResponses![i]; - - // Skip any approval responses that have already been executed. - if (functionResultCallIds?.Contains(approvalResponse.FunctionCall.CallId) is not true) - { - ChatMessage? requestMessage = null; - allApprovalRequestsMessages?.TryGetValue(approvalResponse.FunctionCall.CallId, out requestMessage); - - // Split the responses into approved and rejected. - if (approvalResponse.Approved) - { - approvedFunctionCalls ??= []; - approvedFunctionCalls.Add(new ApprovalResultWithRequestMessage { Response = approvalResponse, RequestMessage = requestMessage }); - } - else - { - rejectedFunctionCalls ??= []; - rejectedFunctionCalls.Add(new ApprovalResultWithRequestMessage { Response = approvalResponse, RequestMessage = requestMessage }); - } - } - } - - return (approvedFunctionCalls, rejectedFunctionCalls); - } - - /// - /// If we have any rejected approval responses, we need to generate failed function results for them. - /// - /// Any rejected approval responses. - /// The message id to use for the tool response. - /// The for the rejected function calls. - private static List? GenerateRejectedFunctionResults( - List? rejections, - string? toolResponseId) - { - List? functionResultContent = null; - - if (rejections is { Count: > 0 }) - { - functionResultContent = []; - - foreach (var rejectedCall in rejections) - { - // Create a FunctionResultContent for the rejected function call. - var functionResult = new FunctionResultContent(rejectedCall.Response.FunctionCall.CallId, "Error: Function invocation approval was not granted."); - functionResultContent.Add(functionResult); - } - } - - return functionResultContent; - } - - /// - /// Extracts the from the provided to recreate the original function call messages. - /// The output messages tries to mimic the original messages that contained the , e.g. if the had been split into separate messages, - /// this method will recreate similarly split messages, each with their own . - /// -#pragma warning disable CA1859 // Use concrete types when possible for improved performance - private static ICollection? ConvertToFunctionCallContentMessages(IEnumerable? resultWithRequestMessages, string? fallbackMessageId) -#pragma warning restore CA1859 // Use concrete types when possible for improved performance - { - if (resultWithRequestMessages is not null) - { - ChatMessage? currentMessage = null; - Dictionary? messagesById = null; - - foreach (var resultWithRequestMessage in resultWithRequestMessages) - { - if (currentMessage is not null && messagesById is null // Don't need to create a dictionary on the first iteration or if we alrady have one. - && !(resultWithRequestMessage.RequestMessage is null && currentMessage.MessageId == fallbackMessageId) // Everywhere we have no RequestMessage we use the fallbackMessageId, so in this case there is only one message. - && (resultWithRequestMessage.RequestMessage is not null && currentMessage.MessageId != resultWithRequestMessage.RequestMessage?.MessageId)) // Where we do have a RequestMessage, we can check if its message id differs from the current one. - { - // The majority of the time, all FCC would be part of a single message, so no need to create a dictionary for this case. - // If we are dealing with multiple messages though, we need to keep track of them by their message ID. - messagesById ??= new(); - messagesById[currentMessage.MessageId ?? string.Empty] = currentMessage; - } - - if (messagesById is not null) - { - messagesById.TryGetValue(resultWithRequestMessage.RequestMessage?.MessageId ?? string.Empty, out currentMessage); - } - - if (currentMessage is null) - { - currentMessage = ConvertToFunctionCallContentMessage(resultWithRequestMessage, fallbackMessageId); - } - else - { - currentMessage.Contents.Add(resultWithRequestMessage.Response.FunctionCall); - } - - if (messagesById is not null) - { - messagesById[currentMessage.MessageId ?? string.Empty] = currentMessage; - } - } - - return messagesById?.Values as ICollection ?? (currentMessage != null ? [currentMessage!] : null); - } - - return null; - } - - /// - /// Takes the from the and wraps it in a - /// using the same message id that the was originally returned with from the downstream . - /// - private static ChatMessage ConvertToFunctionCallContentMessage(ApprovalResultWithRequestMessage resultWithRequestMessage, string? fallbackMessageId) - { - if (resultWithRequestMessage.RequestMessage is not null) - { - var functionCallMessage = resultWithRequestMessage.RequestMessage.Clone(); - functionCallMessage.Contents = [resultWithRequestMessage.Response.FunctionCall]; - functionCallMessage.MessageId ??= fallbackMessageId; - return functionCallMessage; - } - - return new ChatMessage(ChatRole.Assistant, [resultWithRequestMessage.Response.FunctionCall]) { MessageId = fallbackMessageId }; - } - - /// - /// Check if any of the provided require approval. - /// Supports checking from a provided index up to the end of the list, to allow efficient incremental checking - /// when streaming. - /// - private static async Task<(bool hasApprovalRequiringFcc, int lastApprovalCheckedFCCIndex)> CheckForApprovalRequiringFCCAsync( - List? functionCallContents, - ApprovalRequiredAIFunction[] approvalRequiredFunctions, - bool hasApprovalRequiringFcc, - int lastApprovalCheckedFCCIndex) - { - // If we already found an approval requiring FCC, we can skip checking the rest. - if (hasApprovalRequiringFcc) - { - return (true, functionCallContents?.Count ?? 0); - } - - for (; lastApprovalCheckedFCCIndex < (functionCallContents?.Count ?? 0); lastApprovalCheckedFCCIndex++) - { - var fcc = functionCallContents![lastApprovalCheckedFCCIndex]; - if (approvalRequiredFunctions.FirstOrDefault(y => y.Name == fcc.Name) is ApprovalRequiredAIFunction approvalFunction && - await approvalFunction.RequiresApprovalCallback(new(fcc))) - { - hasApprovalRequiringFcc |= true; - } - } - - return (hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex); - } - - /// - /// Replaces all with and ouputs a new list if any of them were replaced. - /// - /// true if any was replaced, false otherwise. - private static bool TryReplaceFunctionCallsWithApprovalRequests(IList content, out IList? updatedContent) - { - updatedContent = null; - - if (content is { Count: > 0 }) - { - for (int i = 0; i < content.Count; i++) - { - if (content[i] is FunctionCallContent fcc) - { - updatedContent ??= [.. content]; // Clone the list if we haven't already - var approvalRequest = new FunctionApprovalRequestContent(fcc.CallId, fcc); - updatedContent[i] = approvalRequest; - } - } - } - - return updatedContent is not null; - } - - /// - /// Replaces all from with - /// if any one of them requires approval. - /// - private static async Task> ReplaceFunctionCallsWithApprovalRequests(IList messages, IList? requestOptionsTools, IList? additionalTools) - { - var outputMessages = messages; - ApprovalRequiredAIFunction[]? approvalRequiredFunctions = null; - - bool anyApprovalRequired = false; - List<(int, int)>? allFunctionCallContentIndices = null; - - // Build a list of the indices of all FunctionCallContent items. - // Also check if any of them require approval. - for (int i = 0; i < messages.Count; i++) - { - var content = messages[i].Contents; - for (int j = 0; j < content.Count; j++) - { - if (content[j] is FunctionCallContent functionCall) - { - allFunctionCallContentIndices ??= []; - allFunctionCallContentIndices.Add((i, j)); - - approvalRequiredFunctions ??= (requestOptionsTools ?? []).Concat(additionalTools ?? []) - .OfType() - .ToArray(); - - anyApprovalRequired |= approvalRequiredFunctions.FirstOrDefault(x => x.Name == functionCall.Name) is { } approvalFunction && await approvalFunction.RequiresApprovalCallback(new(functionCall)); - } - } - } - - // If any function calls were found, and any of them required approval, we should replace all of them with approval requests. - // This is because we do not have a way to deal with cases where some function calls require approval and others do not, so we just replace all of them. - if (allFunctionCallContentIndices is not null && anyApprovalRequired) - { - // Clone the list so, we don't mutate the input. - outputMessages = [.. messages]; - int lastMessageIndex = -1; - - foreach (var (messageIndex, contentIndex) in allFunctionCallContentIndices) - { - // Clone the message if we didn't already clone it in a previous iteration. - var message = lastMessageIndex != messageIndex ? outputMessages[messageIndex].Clone() : outputMessages[messageIndex]; - message.Contents = [.. message.Contents]; - - var functionCall = (FunctionCallContent)message.Contents[contentIndex]; - message.Contents[contentIndex] = new FunctionApprovalRequestContent(functionCall.CallId, functionCall); - outputMessages[messageIndex] = message; - - lastMessageIndex = messageIndex; - } - } - - return outputMessages; - } - - /// - /// Insert the given at the start of the . - /// - private static IList UpdateResponseMessagesWithPreDownstreamCallHistory(IList responseMessages, List? preDownstreamCallHistory) - { - if (preDownstreamCallHistory?.Count > 0) - { - // Since these messages are pre-invocation, we want to insert them at the start of the response messages. - return [.. preDownstreamCallHistory, .. responseMessages]; - } - - return responseMessages; - } - - private static ChatResponseUpdate ConvertToolResultMessageToUpdate(ChatMessage message, string? conversationId, string? messageId) - { - return new() - { - AdditionalProperties = message.AdditionalProperties, - AuthorName = message.AuthorName, - ConversationId = conversationId, - CreatedAt = DateTimeOffset.UtcNow, - Contents = message.Contents, - RawRepresentation = message.RawRepresentation, - ResponseId = messageId, - MessageId = messageId, - Role = message.Role, - }; - } - - private static TimeSpan GetElapsedTime(long startingTimestamp) => -#if NET - Stopwatch.GetElapsedTime(startingTimestamp); -#else - new((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency))); -#endif - - [LoggerMessage(LogLevel.Debug, "Invoking {MethodName}.", SkipEnabledCheck = true)] - private partial void LogInvoking(string methodName); - - [LoggerMessage(LogLevel.Trace, "Invoking {MethodName}({Arguments}).", SkipEnabledCheck = true)] - private partial void LogInvokingSensitive(string methodName, string arguments); - - [LoggerMessage(LogLevel.Debug, "{MethodName} invocation completed. Duration: {Duration}", SkipEnabledCheck = true)] - private partial void LogInvocationCompleted(string methodName, TimeSpan duration); - - [LoggerMessage(LogLevel.Trace, "{MethodName} invocation completed. Duration: {Duration}. Result: {Result}", SkipEnabledCheck = true)] - private partial void LogInvocationCompletedSensitive(string methodName, TimeSpan duration, string result); - - [LoggerMessage(LogLevel.Debug, "{MethodName} invocation canceled.")] - private partial void LogInvocationCanceled(string methodName); - - [LoggerMessage(LogLevel.Error, "{MethodName} invocation failed.")] - private partial void LogInvocationFailed(string methodName, Exception error); - - /// Provides information about the invocation of a function call. - public sealed class FunctionInvocationResult - { - /// - /// Initializes a new instance of the class. - /// - /// Indicates whether the caller should terminate the processing loop. - /// Indicates the status of the function invocation. - /// Contains information about the function call. - /// The result of the function call. - /// The exception thrown by the function call, if any. - internal FunctionInvocationResult(bool terminate, FunctionInvocationStatus status, FunctionCallContent callContent, object? result, Exception? exception) - { - Terminate = terminate; - Status = status; - CallContent = callContent; - Result = result; - Exception = exception; - } - - /// Gets status about how the function invocation completed. - public FunctionInvocationStatus Status { get; } - - /// Gets the function call content information associated with this invocation. - public FunctionCallContent CallContent { get; } - - /// Gets the result of the function call. - public object? Result { get; } - - /// Gets any exception the function call threw. - public Exception? Exception { get; } - - /// Gets a value indicating whether the caller should terminate the processing loop. - public bool Terminate { get; } - } - - /// Provides error codes for when errors occur as part of the function calling loop. - public enum FunctionInvocationStatus - { - /// The operation completed successfully. - RanToCompletion, - - /// The requested function could not be found. - NotFound, - - /// The function call failed with an exception. - Exception, - } - - private struct ApprovalResultWithRequestMessage - { - public FunctionApprovalResponseContent Response { get; set; } - public ChatMessage? RequestMessage { get; set; } - } -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/OpenTelemetryConsts.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/OpenTelemetryConsts.cs deleted file mode 100644 index 89eebe9f61..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/OpenTelemetryConsts.cs +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// WARNING: -// This class has been temporarily copied here from MEAI, to allow prototyping -// functionality that will be moved to MEAI in the future. -// This file is not intended to be modified. - -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.Extensions.AI; - -#pragma warning disable CA1716 // Identifiers should not match keywords -#pragma warning disable S4041 // Type names should not match namespaces - -/// Provides constants used by various telemetry services. -[ExcludeFromCodeCoverage] -internal static class OpenTelemetryConsts -{ - public const string DefaultSourceName = "Experimental.Microsoft.Extensions.AI"; - - public const string SecondsUnit = "s"; - public const string TokensUnit = "token"; - - public static class Event - { - public const string Name = "event.name"; - } - - public static class Error - { - public const string Type = "error.type"; - } - - public static class GenAI - { - public const string Choice = "gen_ai.choice"; - public const string SystemName = "gen_ai.system"; - - public const string Chat = "chat"; - public const string Embeddings = "embeddings"; - public const string ExecuteTool = "execute_tool"; - - public static class Assistant - { - public const string Message = "gen_ai.assistant.message"; - } - - public static class Client - { - public static class OperationDuration - { - public const string Description = "Measures the duration of a GenAI operation"; - public const string Name = "gen_ai.client.operation.duration"; - public static readonly double[] ExplicitBucketBoundaries = [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92]; - } - - public static class TokenUsage - { - public const string Description = "Measures number of input and output tokens used"; - public const string Name = "gen_ai.client.token.usage"; - public static readonly int[] ExplicitBucketBoundaries = [1, 4, 16, 64, 256, 1_024, 4_096, 16_384, 65_536, 262_144, 1_048_576, 4_194_304, 16_777_216, 67_108_864]; - } - } - - public static class Conversation - { - public const string Id = "gen_ai.conversation.id"; - } - - public static class Operation - { - public const string Name = "gen_ai.operation.name"; - } - - public static class Output - { - public const string Type = "gen_ai.output.type"; - } - - public static class Request - { - public const string EmbeddingDimensions = "gen_ai.request.embedding.dimensions"; - public const string FrequencyPenalty = "gen_ai.request.frequency_penalty"; - public const string Model = "gen_ai.request.model"; - public const string MaxTokens = "gen_ai.request.max_tokens"; - public const string PresencePenalty = "gen_ai.request.presence_penalty"; - public const string Seed = "gen_ai.request.seed"; - public const string StopSequences = "gen_ai.request.stop_sequences"; - public const string Temperature = "gen_ai.request.temperature"; - public const string TopK = "gen_ai.request.top_k"; - public const string TopP = "gen_ai.request.top_p"; - - public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.request.{parameterName}"; - } - - public static class Response - { - public const string FinishReasons = "gen_ai.response.finish_reasons"; - public const string Id = "gen_ai.response.id"; - public const string Model = "gen_ai.response.model"; - - public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.response.{parameterName}"; - } - - public static class System - { - public const string Message = "gen_ai.system.message"; - } - - public static class Token - { - public const string Type = "gen_ai.token.type"; - } - - public static class Tool - { - public const string Name = "gen_ai.tool.name"; - public const string Description = "gen_ai.tool.description"; - public const string Message = "gen_ai.tool.message"; - - public static class Call - { - public const string Id = "gen_ai.tool.call.id"; - } - } - - public static class Usage - { - public const string InputTokens = "gen_ai.usage.input_tokens"; - public const string OutputTokens = "gen_ai.usage.output_tokens"; - } - - public static class User - { - public const string Message = "gen_ai.user.message"; - } - } - - public static class Server - { - public const string Address = "server.address"; - public const string Port = "server.port"; - } -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/Microsoft.Extensions.AI.Agents.csproj b/dotnet/src/Microsoft.Extensions.AI.Agents/Microsoft.Extensions.AI.Agents.csproj index b7f37ba4b4..0548b595e2 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/Microsoft.Extensions.AI.Agents.csproj +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/Microsoft.Extensions.AI.Agents.csproj @@ -4,6 +4,7 @@ $(ProjectsTargetFrameworks) $(ProjectsDebugTargetFrameworks) alpha + $(NoWarn);MEAI001 diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests.csproj b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests.csproj index 38ae8712cb..ee3bc2d9fb 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests.csproj @@ -2,6 +2,7 @@ $(ProjectsTargetFrameworks) + $(NoWarn);MEAI001 diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/MEAI/AssertExtensions.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/MEAI/AssertExtensions.cs deleted file mode 100644 index da95df83d0..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/MEAI/AssertExtensions.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// WARNING: -// This class has been temporarily copied here from MEAI, to allow prototyping -// functionality that will be moved to MEAI in the future. -// This file is not intended to be modified. - -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Nodes; -using Xunit.Sdk; - -namespace Microsoft.Extensions.AI; - -internal static class AssertExtensions -{ - /// - /// Asserts that the two function call parameters are equal, up to JSON equivalence. - /// - public static void EqualFunctionCallParameters( - IDictionary? expected, - IDictionary? actual, - JsonSerializerOptions? options = null) - { - if (expected is null || actual is null) - { - Assert.Equal(expected, actual); - return; - } - - foreach (var expectedEntry in expected) - { - if (!actual.TryGetValue(expectedEntry.Key, out object? actualValue)) - { - throw new XunitException($"Expected parameter '{expectedEntry.Key}' not found in actual value."); - } - - AreJsonEquivalentValues(expectedEntry.Value, actualValue, options, propertyName: expectedEntry.Key); - } - - if (expected.Count != actual.Count) - { - var extraParameters = actual - .Where(e => !expected.ContainsKey(e.Key)) - .Select(e => $"'{e.Key}'") - .First(); - - throw new XunitException($"Actual value contains additional parameters {string.Join(", ", extraParameters)} not found in expected value."); - } - } - - /// - /// Asserts that the two function call results are equal, up to JSON equivalence. - /// - public static void EqualFunctionCallResults(object? expected, object? actual, JsonSerializerOptions? options = null) - => AreJsonEquivalentValues(expected, actual, options); - - /// - /// Asserts that the two JSON values are equal. - /// - public static void EqualJsonValues(JsonElement expectedJson, JsonElement actualJson, string? propertyName = null) - { - if (!JsonNode.DeepEquals( - JsonSerializer.SerializeToNode(expectedJson, AIJsonUtilities.DefaultOptions), - JsonSerializer.SerializeToNode(actualJson, AIJsonUtilities.DefaultOptions))) - { - string message = propertyName is null - ? $"JSON result does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}" - : $"Parameter '{propertyName}' does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}"; - - throw new XunitException(message); - } - } - - private static void AreJsonEquivalentValues(object? expected, object? actual, JsonSerializerOptions? options, string? propertyName = null) - { - options ??= AIJsonUtilities.DefaultOptions; - JsonElement expectedElement = NormalizeToElement(expected, options); - JsonElement actualElement = NormalizeToElement(actual, options); - EqualJsonValues(expectedElement, actualElement, propertyName); - - static JsonElement NormalizeToElement(object? value, JsonSerializerOptions options) - => value is JsonElement e ? e : JsonSerializer.SerializeToElement(value, options); - } -} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/MEAI/NewFunctionInvokingChatClientTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/MEAI/NewFunctionInvokingChatClientTests.cs deleted file mode 100644 index b03ac5fdd3..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/MEAI/NewFunctionInvokingChatClientTests.cs +++ /dev/null @@ -1,931 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// AF repo suppressions for code copied from MEAI. -#pragma warning disable CA5394 // Do not use insecure randomness -#pragma warning disable IDE0009 // Member access should be qualified. -#pragma warning disable IDE0039 // Use local function - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Extensions.AI; - -public class NewFunctionInvokingChatClientTests -{ - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task AllFunctionCallsReplacedWithApprovalsWhenAllRequireApprovalAsync(bool useAdditionalTools) - { - AITool[] tools = - [ - new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), - new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")) { RequiresApprovalCallback = async (context) => context.FunctionCall.Name == "Func2" }, - ]; - - var options = new ChatOptions - { - Tools = useAdditionalTools ? null : tools - }; - - List input = - [ - new ChatMessage(ChatRole.User, "hello"), - ]; - - List downstreamClientOutput = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - ]; - - List expectedOutput = - [ - new ChatMessage(ChatRole.Assistant, - [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]) - ]; - - await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null); - - await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null); - } - - [Fact] - public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequireApprovalAsync() - { - var options = new ChatOptions - { - Tools = - [ - new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), - AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), - ] - }; - - List input = - [ - new ChatMessage(ChatRole.User, "hello"), - ]; - - List downstreamClientOutput = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - ]; - - List expectedOutput = - [ - new ChatMessage(ChatRole.Assistant, - [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]) - ]; - - await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput); - - await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequestOrAdditionalRequireApprovalAsync(bool additionalToolsRequireApproval) - { - AIFunction func1 = AIFunctionFactory.Create(() => "Result 1", "Func1"); - AIFunction func2 = AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"); - AITool[] additionalTools = - [ - additionalToolsRequireApproval ? new ApprovalRequiredAIFunction(func1) : func1, - ]; - - var options = new ChatOptions - { - Tools = - [ - additionalToolsRequireApproval ? func2 : new ApprovalRequiredAIFunction(func2), - ] - }; - - List input = - [ - new ChatMessage(ChatRole.User, "hello"), - ]; - - List downstreamClientOutput = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - ]; - - List expectedOutput = - [ - new ChatMessage(ChatRole.Assistant, - [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]) - ]; - - await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: additionalTools); - - await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: additionalTools); - } - - [Fact] - public async Task ApprovedApprovalResponsesAreExecutedAsync() - { - var options = new ChatOptions - { - Tools = - [ - new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), - AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), - ] - }; - - List input = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, - [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]) { MessageId = "resp1" }, - new ChatMessage(ChatRole.User, - [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]), - ]; - - List expectedDownstreamClientInput = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), - ]; - - List downstreamClientOutput = - [ - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - List output = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - - await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - } - - [Fact] - public async Task ApprovedApprovalResponsesFromSeparateFCCMessagesAreExecutedAsync() - { - var options = new ChatOptions - { - Tools = - [ - new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), - AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), - ] - }; - - List input = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, - [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - ]) { MessageId = "resp1" }, - new ChatMessage(ChatRole.Assistant, - [ - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]) { MessageId = "resp2" }, - new ChatMessage(ChatRole.User, - [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), - ]), - new ChatMessage(ChatRole.User, - [ - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]), - ]; - - List expectedDownstreamClientInput = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = "resp1" }, - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]) { MessageId = "resp2" }, - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), - ]; - - List downstreamClientOutput = - [ - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - List output = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = "resp1" }, - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]) { MessageId = "resp2" }, - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - - await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - } - - [Fact] - public async Task RejectedApprovalResponsesAreFailedAsync() - { - var options = new ChatOptions - { - Tools = - [ - new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), - AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), - ] - }; - - List input = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, - [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]) { MessageId = "resp1" }, - new ChatMessage(ChatRole.User, - [ - new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]), - ]; - - List expectedDownstreamClientInput = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Function invocation approval was not granted."), new FunctionResultContent("callId2", result: "Error: Function invocation approval was not granted.")]), - ]; - - List downstreamClientOutput = - [ - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - List output = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Function invocation approval was not granted."), new FunctionResultContent("callId2", result: "Error: Function invocation approval was not granted.")]), - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - - await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - } - - [Fact] - public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedAsync() - { - var options = new ChatOptions - { - Tools = - [ - new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), - AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), - ] - }; - - List input = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, - [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]) { MessageId = "resp1" }, - new ChatMessage(ChatRole.User, - [ - new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]), - ]; - - List expectedDownstreamClientInput = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Function invocation approval was not granted.")]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), - ]; - - List downstreamClientOutput = - [ - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - List nonStreamingOutput = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Function invocation approval was not granted.")]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - List streamingOutput = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Function invocation approval was not granted."), new FunctionResultContent("callId2", result: "Result 2: 42")]), - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - await InvokeAndAssertAsync(options, input, downstreamClientOutput, nonStreamingOutput, expectedDownstreamClientInput); - - await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, streamingOutput, expectedDownstreamClientInput); - } - - [Fact] - public async Task ApprovedInputsAreExecutedAndFunctionResultsAreConvertedAsync() - { - var options = new ChatOptions - { - Tools = - [ - AIFunctionFactory.Create(() => "Result 1", "Func1"), - new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")), - ] - }; - - List input = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, - [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]) { MessageId = "resp1" }, - new ChatMessage(ChatRole.User, - [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]), - ]; - - List expectedDownstreamClientInput = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), - ]; - - List downstreamClientOutput = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 3 } })]), - ]; - - List output = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), - new ChatMessage(ChatRole.Assistant, [new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 3 } }))]), - ]; - - await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - - await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - } - - [Fact] - public async Task AlreadyExecutedApprovalsAreIgnoredAsync() - { - var options = new ChatOptions - { - Tools = - [ - AIFunctionFactory.Create(() => "Result 1", "Func1"), - new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")), - ] - }; - - List input = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, - [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]) { MessageId = "resp1" }, - new ChatMessage(ChatRole.User, - [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), - new ChatMessage(ChatRole.Assistant, - [ - new FunctionApprovalRequestContent("callId3", new FunctionCallContent("callId3", "Func1")), - ]) { MessageId = "resp2" }, - new ChatMessage(ChatRole.User, - [ - new FunctionApprovalResponseContent("callId3", true, new FunctionCallContent("callId3", "Func1")), - ]), - ]; - - List expectedDownstreamClientInput = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "Func1")]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 1")]), - ]; - - List downstreamClientOutput = - [ - new ChatMessage(ChatRole.Assistant, "World"), - ]; - - List output = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "Func1")]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 1")]), - new ChatMessage(ChatRole.Assistant, "World"), - ]; - - await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - - await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - } - - [Fact] - public async Task ApprovalRequestWithoutApprovalResponseThrowsAsync() - { - var options = new ChatOptions - { - Tools = - [ - new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), - AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), - ] - }; - - List input = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, - [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - ]) { MessageId = "resp1" }, - ]; - - var invokeException = await Assert.ThrowsAsync( - async () => await InvokeAndAssertAsync(options, input, [], [], [])); - Assert.Equal("FunctionApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeException.Message); - - var invokeStreamingException = await Assert.ThrowsAsync( - async () => await InvokeAndAssertStreamingAsync(options, input, [], [], [])); - Assert.Equal("FunctionApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeStreamingException.Message); - } - - [Fact] - public async Task ApprovedApprovalResponsesWithoutApprovalRequestAreExecutedAsync() - { - var options = new ChatOptions - { - Tools = - [ - new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), - AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), - ] - }; - - List input = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.User, - [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]), - ]; - - List expectedDownstreamClientInput = - [ - new ChatMessage(ChatRole.User, "hello"), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), - ]; - - List downstreamClientOutput = - [ - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - List output = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - - await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - } - - [Fact] - public async Task FunctionCallContentIsNotPassedToDownstreamServiceWithServiceThreadsAsync() - { - var options = new ChatOptions - { - Tools = - [ - new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), - AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), - ], - ConversationId = "test-conversation", - }; - - List input = - [ - new ChatMessage(ChatRole.User, - [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) - ]), - ]; - - List expectedDownstreamClientInput = - [ - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), - ]; - - List downstreamClientOutput = - [ - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - List output = - [ - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), - new ChatMessage(ChatRole.Assistant, "world"), - ]; - - await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - - await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); - } - - /// - /// Since we do not have a way of supporting both functions that require approval and those that do not - /// in one invocation, we always require all function calls to be approved if any require approval. - /// If we are therefore unsure as to whether we will encounter a function call that requires approval, - /// we have to wait until we find one before yielding any function call content. - /// If we don't have any function calls that require approval at all though, we can just yield all content normally - /// since this issue won't apply. - /// - [Fact] - public async Task FunctionCallContentIsYieldedImmediatelyIfNoApprovalRequiredWhenStreamingAsync() - { - var options = new ChatOptions - { - Tools = - [ - AIFunctionFactory.Create(() => "Result 1", "Func1"), - AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), - ] - }; - - List input = [new ChatMessage(ChatRole.User, "hello")]; - - Func configurePipeline = b => b.Use(s => new NewFunctionInvokingChatClient(s)); - using CancellationTokenSource cts = new(); - - var updateYieldCount = 0; - - async IAsyncEnumerable YieldInnerClientUpdates(IEnumerable contents, ChatOptions? actualOptions, [EnumeratorCancellation] CancellationToken actualCancellationToken) - { - Assert.Equal(cts.Token, actualCancellationToken); - await Task.Yield(); - var messageId = Guid.NewGuid().ToString("N"); - - updateYieldCount++; - yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = messageId }; - updateYieldCount++; - yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]) { MessageId = messageId }; - } - - using var innerClient = new TestChatClient { GetStreamingResponseAsyncCallback = YieldInnerClientUpdates }; - IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); - - var updates = service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token); - - var updateCount = 0; - await foreach (var update in updates) - { - if (updateCount < 2) - { - var functionCall = update.Contents.OfType().First(); - if (functionCall.CallId == "callId1") - { - Assert.Equal("Func1", functionCall.Name); - Assert.Equal(1, updateYieldCount); - } - else if (functionCall.CallId == "callId2") - { - Assert.Equal("Func2", functionCall.Name); - Assert.Equal(2, updateYieldCount); - } - } - - updateCount++; - } - } - - /// - /// Since we do not have a way of supporting both functions that require approval and those that do not - /// in one invocation, we always require all function calls to be approved if any require approval. - /// If we are therefore unsure as to whether we will encounter a function call that requires approval, - /// we have to wait until we find one before yielding any function call content. - /// We can however, yield any other content until we encounter the first function call. - /// - [Fact] - public async Task FunctionCalsAreBufferedUntilApprovalRequirementEncounteredWhenStreamingAsync() - { - var options = new ChatOptions - { - Tools = - [ - AIFunctionFactory.Create(() => "Result 1", "Func1"), - new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")), - AIFunctionFactory.Create(() => "Result 3", "Func3"), - ] - }; - - List input = [new ChatMessage(ChatRole.User, "hello")]; - - Func configurePipeline = b => b.Use(s => new NewFunctionInvokingChatClient(s)); - using CancellationTokenSource cts = new(); - - var updateYieldCount = 0; - - async IAsyncEnumerable YieldInnerClientUpdates(IEnumerable contents, ChatOptions? actualOptions, [EnumeratorCancellation] CancellationToken actualCancellationToken) - { - Assert.Equal(cts.Token, actualCancellationToken); - await Task.Yield(); - var messageId = Guid.NewGuid().ToString("N"); - - updateYieldCount++; - yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextContent("Text 1")]) { MessageId = messageId }; - updateYieldCount++; - yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextContent("Text 2")]) { MessageId = messageId }; - updateYieldCount++; - yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = messageId }; - updateYieldCount++; - yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]) { MessageId = messageId }; - updateYieldCount++; - yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func3")]) { MessageId = messageId }; - } - - using var innerClient = new TestChatClient { GetStreamingResponseAsyncCallback = YieldInnerClientUpdates }; - IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); - - var updates = service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token); - - var updateCount = 0; - await foreach (var update in updates) - { - switch (updateCount) - { - case 0: - Assert.Equal("Text 1", update.Contents.OfType().First().Text); - // First content should be yielded immedately, since we don't have any function calls yet. - Assert.Equal(1, updateYieldCount); - break; - case 1: - Assert.Equal("Text 2", update.Contents.OfType().First().Text); - // Second content should be yielded immedately, since we don't have any function calls yet. - Assert.Equal(2, updateYieldCount); - break; - case 2: - var approvalRequest1 = update.Contents.OfType().First(); - Assert.Equal("callId1", approvalRequest1.FunctionCall.CallId); - Assert.Equal("Func1", approvalRequest1.FunctionCall.Name); - // Third content should have been buffered, since we have not yet encountered a function call that requires approval. - Assert.Equal(4, updateYieldCount); - break; - case 3: - var approvalRequest2 = update.Contents.OfType().First(); - Assert.Equal("callId2", approvalRequest2.FunctionCall.CallId); - Assert.Equal("Func2", approvalRequest2.FunctionCall.Name); - // Fourth content can be yielded immediately, since it is the first function call that requires approval. - Assert.Equal(4, updateYieldCount); - break; - case 4: - var approvalRequest3 = update.Contents.OfType().First(); - Assert.Equal("callId1", approvalRequest3.FunctionCall.CallId); - Assert.Equal("Func3", approvalRequest3.FunctionCall.Name); - // Fifth content can be yielded immediately, since we previously encountered a function call that requires approval. - Assert.Equal(5, updateYieldCount); - break; - } - - updateCount++; - } - } - - private static async Task> InvokeAndAssertAsync( - ChatOptions? options, - List input, - List downstreamClientOutput, - List expectedOutput, - List? expectedDownstreamClientInput = null, - Func? configurePipeline = null, - AITool[]? additionalTools = null, - IServiceProvider? services = null) - { - Assert.NotEmpty(input); - - configurePipeline ??= b => b.Use(s => new NewFunctionInvokingChatClient(s) { AdditionalTools = additionalTools }); - - using CancellationTokenSource cts = new(); - long expectedTotalTokenCounts = 0; - - using var innerClient = new TestChatClient - { - GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) => - { - Assert.Equal(cts.Token, actualCancellationToken); - if (expectedDownstreamClientInput is not null) - { - CompareMessageLists(expectedDownstreamClientInput, contents.ToList()); - } - - await Task.Yield(); - - var usage = CreateRandomUsage(); - expectedTotalTokenCounts += usage.InputTokenCount!.Value; - - downstreamClientOutput.ForEach(m => m.MessageId = Guid.NewGuid().ToString("N")); - return new ChatResponse(downstreamClientOutput) { Usage = usage }; - } - }; - - IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(services); - - var result = await service.GetResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token); - Assert.NotNull(result); - - var actualOutput = result.Messages as List ?? result.Messages.ToList(); - CompareMessageLists(expectedOutput, actualOutput); - - // Usage should be aggregated over all responses, including AdditionalUsage - var actualUsage = result.Usage!; - Assert.Equal(expectedTotalTokenCounts, actualUsage.InputTokenCount); - Assert.Equal(expectedTotalTokenCounts, actualUsage.OutputTokenCount); - Assert.Equal(expectedTotalTokenCounts, actualUsage.TotalTokenCount); - Assert.Equal(2, actualUsage.AdditionalCounts!.Count); - Assert.Equal(expectedTotalTokenCounts, actualUsage.AdditionalCounts["firstValue"]); - Assert.Equal(expectedTotalTokenCounts, actualUsage.AdditionalCounts["secondValue"]); - - return actualOutput; - } - - private static UsageDetails CreateRandomUsage() - { - // We'll set the same random number on all the properties so that, when determining the - // correct sum in tests, we only have to total the values once - var value = new Random().Next(100); - return new UsageDetails - { - InputTokenCount = value, - OutputTokenCount = value, - TotalTokenCount = value, - AdditionalCounts = new() { ["firstValue"] = value, ["secondValue"] = value }, - }; - } - - private static async Task> InvokeAndAssertStreamingAsync( - ChatOptions? options, - List input, - List downstreamClientOutput, - List expectedOutput, - List? expectedDownstreamClientInput = null, - Func? configurePipeline = null, - AITool[]? additionalTools = null, - IServiceProvider? services = null) - { - Assert.NotEmpty(input); - - configurePipeline ??= b => b.Use(s => new NewFunctionInvokingChatClient(s) { AdditionalTools = additionalTools }); - - using CancellationTokenSource cts = new(); - - using var innerClient = new TestChatClient - { - GetStreamingResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) => - { - Assert.Equal(cts.Token, actualCancellationToken); - if (expectedDownstreamClientInput is not null) - { - CompareMessageLists(expectedDownstreamClientInput, contents.ToList()); - } - - downstreamClientOutput.ForEach(m => m.MessageId = Guid.NewGuid().ToString("N")); - return YieldAsync(new ChatResponse(downstreamClientOutput).ToChatResponseUpdates()); - } - }; - - IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(services); - - var result = await service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token).ToChatResponseAsync(); - Assert.NotNull(result); - - var actualOutput = result.Messages as List ?? result.Messages.ToList(); - - expectedOutput ??= input; - CompareMessageLists(expectedOutput, actualOutput); - - return actualOutput; - } - - private static async IAsyncEnumerable YieldAsync(params T[] items) - { - await Task.Yield(); - foreach (var item in items) - { - yield return item; - } - } - - private static void CompareMessageLists(List expectedMessages, List actualMessages) - { - Assert.Equal(expectedMessages.Count, actualMessages.Count); - for (int i = 0; i < expectedMessages.Count; i++) - { - var expectedMessage = expectedMessages[i]; - var chatMessage = actualMessages[i]; - - Assert.Equal(expectedMessage.Role, chatMessage.Role); - Assert.Equal(expectedMessage.Text, chatMessage.Text); - Assert.Equal(expectedMessage.GetType(), chatMessage.GetType()); - - Assert.Equal(expectedMessage.Contents.Count, chatMessage.Contents.Count); - for (int j = 0; j < expectedMessage.Contents.Count; j++) - { - var expectedItem = expectedMessage.Contents[j]; - var chatItem = chatMessage.Contents[j]; - - Assert.Equal(expectedItem.GetType(), chatItem.GetType()); - Assert.Equal(expectedItem.ToString(), chatItem.ToString()); - if (expectedItem is FunctionCallContent expectedFunctionCall) - { - var chatFunctionCall = (FunctionCallContent)chatItem; - Assert.Equal(expectedFunctionCall.Name, chatFunctionCall.Name); - AssertExtensions.EqualFunctionCallParameters(expectedFunctionCall.Arguments, chatFunctionCall.Arguments); - } - else if (expectedItem is FunctionResultContent expectedFunctionResult) - { - var chatFunctionResult = (FunctionResultContent)chatItem; - AssertExtensions.EqualFunctionCallResults(expectedFunctionResult.Result, chatFunctionResult.Result); - } - } - } - } - - private sealed class EnumeratedOnceEnumerable(IEnumerable items) : IEnumerable - { - private int _iterated; - - public IEnumerator GetEnumerator() - { - if (Interlocked.Exchange(ref _iterated, 1) != 0) - { - throw new InvalidOperationException("This enumerable can only be enumerated once."); - } - - foreach (var item in items) - { - yield return item; - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - } -} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/MEAI/TestChatClient.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/MEAI/TestChatClient.cs deleted file mode 100644 index 436d4308ab..0000000000 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/MEAI/TestChatClient.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// WARNING: -// This class has been temporarily copied here from MEAI, to allow prototyping -// functionality that will be moved to MEAI in the future. -// This file is not intended to be modified. - -// AF repo suppressions for code copied from MEAI. -#pragma warning disable IDE0009 // Member access should be qualified. -#pragma warning disable CA1859 // Use concrete types when possible for improved performance -#pragma warning disable CA1063 // Implement IDisposable Correctly - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Extensions.AI; - -public sealed class TestChatClient : IChatClient -{ - public TestChatClient() - { - GetServiceCallback = DefaultGetServiceCallback; - } - - public IServiceProvider? Services { get; set; } - - public Func, ChatOptions?, CancellationToken, Task>? GetResponseAsyncCallback { get; set; } - - public Func, ChatOptions?, CancellationToken, IAsyncEnumerable>? GetStreamingResponseAsyncCallback { get; set; } - - public Func GetServiceCallback { get; set; } - - private object? DefaultGetServiceCallback(Type serviceType, object? serviceKey) => - serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; - - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => GetResponseAsyncCallback!.Invoke(messages, options, cancellationToken); - - public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => GetStreamingResponseAsyncCallback!.Invoke(messages, options, cancellationToken); - - public object? GetService(Type serviceType, object? serviceKey = null) - => GetServiceCallback(serviceType, serviceKey); - - void IDisposable.Dispose() - { - // No resources need disposing. - } -} diff --git a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs index 1dd8a760f8..de094e46a2 100644 --- a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs +++ b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs @@ -70,7 +70,7 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture IList? aiTools = null) { return Task.FromResult(new ChatClientAgent( - this._openAIResponseClient.AsNewIChatClient(), + this._openAIResponseClient.AsIChatClient(), options: new() { Name = name,