diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml index 581318c538..a9fe090013 100644 --- a/.github/workflows/dotnet-format.yml +++ b/.github/workflows/dotnet-format.yml @@ -86,10 +86,11 @@ jobs: run: docker pull mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} # This step will run dotnet format on each of the unique csproj files and fail if any changes are made + # exclude-diagnostics should be removed after fixes for IL2026 and IL3050 are out: https://github.com/dotnet/sdk/issues/51136 - name: Run dotnet format if: steps.find-csproj.outputs.csproj_files != '' run: | for csproj in ${{ steps.find-csproj.outputs.csproj_files }}; do echo "Running dotnet format on $csproj" - docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} /bin/sh -c "dotnet format $csproj --verify-no-changes --verbosity diagnostic" + docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} /bin/sh -c "dotnet format $csproj --verify-no-changes --verbosity diagnostic --exclude-diagnostics IL2026 IL3050" done diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 04eb1f54b9..19fcb08fe7 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -290,6 +290,7 @@ + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj index 9843a1144d..128ef4651a 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj @@ -13,6 +13,7 @@ + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 2224bc1b40..9998953bba 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -1,10 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using A2A.AspNetCore; using AgentWebChat.AgentHost; using AgentWebChat.AgentHost.Utilities; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.A2A.AspNetCore; +using Microsoft.Agents.AI.Hosting.OpenAI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -84,6 +86,9 @@ app.MapA2A(agentName: "knights-and-knaves", path: "/a2a/knights-and-knaves", age // Url = "http://localhost:5390/a2a/knights-and-knaves" }); +app.MapOpenAIResponses("pirate"); +app.MapOpenAIResponses("knights-and-knaves"); + // Map the agents HTTP endpoints app.MapAgentDiscovery("/agents"); diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentDiscoveryClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentDiscoveryClient.cs index 7351ba6625..bb45e25e92 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentDiscoveryClient.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentDiscoveryClient.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Text.Json; +using System.Text.Json.Serialization; namespace AgentWebChat.Web; @@ -20,7 +21,10 @@ public class AgentDiscoveryClient(HttpClient httpClient, ILogger + net9.0 @@ -8,6 +8,7 @@ + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor index 4a5792966a..2347f31c64 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor @@ -4,6 +4,7 @@ @inject IJSRuntime JSRuntime @inject ILogger Logger @inject A2AAgentClient A2AActorClient +@inject OpenAIResponsesAgentClient OpenAIResponsesAgentClient @rendermode InteractiveServer @using System.Text @using System.Text.Json @@ -51,18 +52,18 @@
@switch (selectedProtocol) { - case Protocol.A2A: - 🔗 A2A protocol supports long-running agentic processes + case Protocol.OpenAIResponses: + ֎ OpenAI Responses break; - case Protocol.AgenticFramework: - default: - ⚡ Direct agentic framework communication + case Protocol.A2A: + default: + 🔗 A2A protocol supports long-running agentic processes break; }
@@ -881,208 +882,212 @@ @code { - private string currentMessage = ""; - private bool isStreaming = false; - private bool isLoadingAgents = true; - private string currentStreamedMessage = ""; - private string selectedAgentName = ""; - private List availableAgents = new(); - private List conversations = new(); - private Conversation? currentConversation; + private string currentMessage = ""; + private bool isStreaming = false; + private bool isLoadingAgents = true; + private string currentStreamedMessage = ""; + private string selectedAgentName = ""; + private List availableAgents = new(); + private List conversations = new(); + private Conversation? currentConversation; - // protocol - private Protocol selectedProtocol; + // protocol + private Protocol selectedProtocol; - // a2a agent card - private bool isA2AExpanded = false; - private bool isDiscoveringCard = false; - private string? discoveredAgentCardJson = null; - private string? discoveryError = null; + // a2a agent card + private bool isA2AExpanded = false; + private bool isDiscoveringCard = false; + private string? discoveredAgentCardJson = null; + private string? discoveryError = null; - private enum Protocol - { - AgenticFramework, - A2A // Agent2Agent protocol - } + private enum Protocol + { + A2A, // Agent-to-Agent protocol + OpenAIResponses + } - private sealed class Conversation - { - public string SessionId { get; set; } = Guid.NewGuid().ToString("N"); - public string AgentName { get; set; } = ""; - public List Messages { get; set; } = new(); - } + private sealed class Conversation + { + public string SessionId { get; set; } = Guid.NewGuid().ToString("N"); + public string AgentName { get; set; } = ""; + public List Messages { get; set; } = new(); + } - protected override async Task OnInitializedAsync() - { - Logger.LogDebug("Initializing Agent Chat component"); + protected override async Task OnInitializedAsync() + { + Logger.LogDebug("Initializing Agent Chat component"); - // Load agents - try - { - availableAgents = await AgentClient.GetAgentsAsync(); - Logger.LogInformation("Loaded {AgentCount} agents", availableAgents.Count); - Logger.LogInformation("Loaded Agents info: {AgentData}", JsonSerializer.Serialize(availableAgents, new JsonSerializerOptions() { WriteIndented = true })); + // Load agents + try + { + availableAgents = await AgentClient.GetAgentsAsync(); + Logger.LogInformation("Loaded {AgentCount} agents", availableAgents.Count); + Logger.LogInformation("Loaded Agents info: {AgentData}", JsonSerializer.Serialize(availableAgents, new JsonSerializerOptions() { WriteIndented = true })); - // Default to first agent and start a conversation - if (availableAgents.Any()) - { - selectedAgentName = availableAgents.First().Name!; - StartNewConversation(); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to load agents"); - } - finally - { - isLoadingAgents = false; - } + // Default to first agent and start a conversation + if (availableAgents.Any()) + { + selectedAgentName = availableAgents.First().Name!; + StartNewConversation(); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load agents"); + } + finally + { + isLoadingAgents = false; + } - // Conversations start fresh on page load - } + // Conversations start fresh on page load + } - private string GetAgentIcon(string agentName) => agentName?.ToLower() switch - { - "pirate" => "🏴‍☠️", - "knights-and-knaves" => "⚔️", - _ => "🤖" - }; + private string GetAgentIcon(string agentName) => agentName?.ToLower() switch + { + "pirate" => "🏴‍☠️", + "knights-and-knaves" => "⚔️", + _ => "🤖" + }; - private string GetAgentDisplayName(string agentName) => agentName?.ToLower() switch - { - "pirate" => "Pirate", - "knights-and-knaves" => "Knights & Knaves", - _ => agentName ?? "Agent" - }; + private string GetAgentDisplayName(string agentName) => agentName?.ToLower() switch + { + "pirate" => "Pirate", + "knights-and-knaves" => "Knights & Knaves", + _ => agentName ?? "Agent" + }; - private void ToggleA2AExpanded() => isA2AExpanded = !isA2AExpanded; + private void ToggleA2AExpanded() => isA2AExpanded = !isA2AExpanded; - private async Task DiscoverAgentCard() - { - if (string.IsNullOrEmpty(selectedAgentName) || isDiscoveringCard) - return; + private async Task DiscoverAgentCard() + { + if (string.IsNullOrEmpty(selectedAgentName) || isDiscoveringCard) + return; - isDiscoveringCard = true; - discoveryError = null; - discoveredAgentCardJson = null; - StateHasChanged(); + isDiscoveringCard = true; + discoveryError = null; + discoveredAgentCardJson = null; + StateHasChanged(); - try - { - Logger.LogInformation("Discovering agent card for agent: {AgentName}", selectedAgentName); - var agentCard = await A2AActorClient.GetAgentCardAsync(selectedAgentName); - if (agentCard is not null) - { - discoveredAgentCardJson = JsonSerializer.Serialize(agentCard, new JsonSerializerOptions() { WriteIndented = true }); - Logger.LogInformation("Successfully discovered agent card for {AgentName}: {CardData}", selectedAgentName, discoveredAgentCardJson); - } - else - { - discoveryError = "No agent card found for this agent."; - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to discover agent card for {AgentName}", selectedAgentName); - discoveryError = $"Failed to discover agent card: {ex.Message}"; - } - finally - { - isDiscoveringCard = false; - StateHasChanged(); - } - } + try + { + Logger.LogInformation("Discovering agent card for agent: {AgentName}", selectedAgentName); + var agentCard = await A2AActorClient.GetAgentCardAsync(selectedAgentName); + if (agentCard is not null) + { + discoveredAgentCardJson = JsonSerializer.Serialize(agentCard, new JsonSerializerOptions() { WriteIndented = true }); + Logger.LogInformation("Successfully discovered agent card for {AgentName}: {CardData}", selectedAgentName, discoveredAgentCardJson); + } + else + { + discoveryError = "No agent card found for this agent."; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to discover agent card for {AgentName}", selectedAgentName); + discoveryError = $"Failed to discover agent card: {ex.Message}"; + } + finally + { + isDiscoveringCard = false; + StateHasChanged(); + } + } - private void StartNewConversation() - { - if (string.IsNullOrEmpty(selectedAgentName)) - return; + private void StartNewConversation() + { + if (string.IsNullOrEmpty(selectedAgentName)) + return; - var newConversation = new Conversation + var newConversation = new Conversation { AgentName = selectedAgentName }; - conversations.Add(newConversation); - currentConversation = newConversation; + conversations.Add(newConversation); + currentConversation = newConversation; - Logger.LogInformation("Started new conversation with agent: {AgentName}, session: {SessionId}", - newConversation.AgentName, newConversation.SessionId); + Logger.LogInformation("Started new conversation with agent: {AgentName}, session: {SessionId}", + newConversation.AgentName, newConversation.SessionId); - StateHasChanged(); - } + StateHasChanged(); + } - private void SelectConversation(string sessionId) - { - currentConversation = conversations.FirstOrDefault(c => c.SessionId == sessionId); - if (currentConversation is not null) - { - selectedAgentName = currentConversation.AgentName; - Logger.LogDebug("Selected conversation with session: {SessionId}", sessionId); - } - StateHasChanged(); - } + private void SelectConversation(string sessionId) + { + currentConversation = conversations.FirstOrDefault(c => c.SessionId == sessionId); + if (currentConversation is not null) + { + selectedAgentName = currentConversation.AgentName; + Logger.LogDebug("Selected conversation with session: {SessionId}", sessionId); + } + StateHasChanged(); + } - private void CloseConversation(string sessionId) - { - var conversationToRemove = conversations.FirstOrDefault(c => c.SessionId == sessionId); - if (conversationToRemove is not null) - { - conversations.Remove(conversationToRemove); + private void CloseConversation(string sessionId) + { + var conversationToRemove = conversations.FirstOrDefault(c => c.SessionId == sessionId); + if (conversationToRemove is not null) + { + conversations.Remove(conversationToRemove); - if (currentConversation?.SessionId == sessionId) - { - currentConversation = conversations.FirstOrDefault(); - if (currentConversation is not null) - { - selectedAgentName = currentConversation.AgentName; - } - } + if (currentConversation?.SessionId == sessionId) + { + currentConversation = conversations.FirstOrDefault(); + if (currentConversation is not null) + { + selectedAgentName = currentConversation.AgentName; + } + } - Logger.LogInformation("Closed conversation with session: {SessionId}", sessionId); - } - StateHasChanged(); - } + Logger.LogInformation("Closed conversation with session: {SessionId}", sessionId); + } + StateHasChanged(); + } - private async Task SendMessage() - { - if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming || currentConversation is null) - return; + private async Task SendMessage() + { + if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming || currentConversation is null) + return; - var userMessage = currentMessage.Trim(); - currentMessage = ""; + var userMessage = currentMessage.Trim(); + currentMessage = ""; - Logger.LogInformation("User sending message: '{UserMessage}' to agent {AgentName} in session {SessionId}", - userMessage, currentConversation.AgentName, currentConversation.SessionId); + Logger.LogInformation("User sending message: '{UserMessage}' to agent {AgentName} in session {SessionId}", + userMessage, currentConversation.AgentName, currentConversation.SessionId); - // Add user message to chat - currentConversation.Messages.Add(new ChatMessage(ChatRole.User, userMessage)); - StateHasChanged(); - await ScrollToBottom(); + // Add user message to chat + currentConversation.Messages.Add(new ChatMessage(ChatRole.User, userMessage)); + StateHasChanged(); + await ScrollToBottom(); - // Start streaming response - isStreaming = true; - currentStreamedMessage = ""; - StateHasChanged(); + // Start streaming response + isStreaming = true; + currentStreamedMessage = ""; + StateHasChanged(); - StringBuilder responseContent = new(); - var hasReceivedContent = false; + StringBuilder responseContent = new(); + var hasReceivedContent = false; - using var timeoutCts = new CancellationTokenSource( + using var timeoutCts = new CancellationTokenSource( #if DEBUG TimeSpan.FromSeconds(120) #else - TimeSpan.FromSeconds(20) + TimeSpan.FromSeconds(20) #endif - ); + ); - try - { + try + { - // Select the appropriate client based on protocol + // Select the appropriate client based on protocol + IAgentClient agentClient = selectedProtocol switch + { + Protocol.OpenAIResponses => OpenAIResponsesAgentClient, + Protocol.A2A or _ => A2AActorClient + }; - var agentClient = A2AActorClient; var messages = new List { new(ChatRole.User, userMessage) }; await foreach (var update in agentClient.RunStreamingAsync( diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs new file mode 100644 index 0000000000..8ec81c0e48 --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel; +using System.Runtime.CompilerServices; +using A2A; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Responses; + +namespace AgentWebChat.Web; + +/// +/// Is a simple frontend client which exercises the ability of exposed agent to communicate via OpenAI Responses protocol. +/// +internal sealed class OpenAIResponsesAgentClient : IAgentClient +{ + private readonly Uri _baseUri; + + public OpenAIResponsesAgentClient(string baseUri) + { + this._baseUri = new Uri(baseUri.TrimEnd('/')); + } + + public async IAsyncEnumerable RunStreamingAsync( + string agentName, + IList messages, + string? threadId = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + OpenAIClientOptions options = new() + { + Endpoint = new Uri(this._baseUri, $"/{agentName}/v1/") + }; + + var openAiClient = new OpenAIResponseClient(model: "myModel!", credential: new ApiKeyCredential("dummy-key"), options: options).AsIChatClient(); + var chatOptions = new ChatOptions() + { + ConversationId = threadId + }; + + await foreach (var update in openAiClient.GetStreamingResponseAsync(messages, chatOptions, cancellationToken: cancellationToken)) + { + yield return new AgentRunResponseUpdate(update); + } + } + + public Task GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default) + => Task.FromResult(null!); +} diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs index 0467990e1f..2cee27e269 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs @@ -23,6 +23,7 @@ Uri a2aAddress = new("http://localhost:5390/a2a"); builder.Services.AddHttpClient(client => client.BaseAddress = baseAddress); builder.Services.AddSingleton(sp => new A2AAgentClient(sp.GetRequiredService>(), a2aAddress)); +builder.Services.AddSingleton(sp => new OpenAIResponsesAgentClient("http://localhost:5390")); var app = builder.Build(); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000000..ca58dfd27b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +/// +/// Provides extension methods for mapping OpenAI Responses capabilities to an . +/// +public static class EndpointRouteBuilderExtensions +{ + /// + /// Maps OpenAI Responses API endpoints to the specified for the given . + /// + /// The to add the OpenAI Responses endpoints to. + /// The name of the AI agent service registered in the dependency injection container. This name is used to resolve the instance from the keyed services. + /// Custom route path for the responses endpoint. + /// Custom route path for the conversations endpoint. + public static void MapOpenAIResponses( + this IEndpointRouteBuilder endpoints, + string agentName, + [StringSyntax("Route")] string? responsesPath = null, + [StringSyntax("Route")] string? conversationsPath = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentName); + if (responsesPath is null || conversationsPath is null) + { + ValidateAgentName(agentName); + } + + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + + responsesPath ??= $"/{agentName}/v1/responses"; + var responsesRouteGroup = endpoints.MapGroup(responsesPath); + MapResponses(responsesRouteGroup, agent); + + // Will be included once we obtain the API to operate with thread (conversation). + + // conversationsPath ??= $"/{agentName}/v1/conversations"; + // var conversationsRouteGroup = endpoints.MapGroup(conversationsPath); + // MapConversations(conversationsRouteGroup, agent, loggerFactory); + } + + private static void MapResponses(IEndpointRouteBuilder routeGroup, AIAgent agent) + { + var endpointAgentName = agent.DisplayName; + var responsesProcessor = new AIAgentResponsesProcessor(agent); + + routeGroup.MapPost("/", async (HttpContext requestContext, CancellationToken cancellationToken) => + { + var requestBinary = await BinaryData.FromStreamAsync(requestContext.Request.Body, cancellationToken).ConfigureAwait(false); + + var responseOptions = new ResponseCreationOptions(); + var responseOptionsJsonModel = responseOptions as IJsonModel; + Debug.Assert(responseOptionsJsonModel is not null); + + responseOptions = responseOptionsJsonModel.Create(requestBinary, ModelReaderWriterOptions.Json); + if (responseOptions is null) + { + return Results.BadRequest("Invalid request payload."); + } + + return await responsesProcessor.CreateModelResponseAsync(responseOptions, cancellationToken).ConfigureAwait(false); + }).WithName(endpointAgentName + "/CreateResponse"); + } + +#pragma warning disable IDE0051 // Remove unused private members + private static void MapConversations(IEndpointRouteBuilder routeGroup, AIAgent agent) +#pragma warning restore IDE0051 // Remove unused private members + { + var endpointAgentName = agent.DisplayName; + var conversationsProcessor = new AIAgentConversationsProcessor(agent); + + routeGroup.MapGet("/{conversation_id}", (string conversationId, CancellationToken cancellationToken) + => conversationsProcessor.GetConversationAsync(conversationId, cancellationToken) + ).WithName(endpointAgentName + "/RetrieveConversation"); + } + + private static void ValidateAgentName([NotNull] string agentName) + { + var escaped = Uri.EscapeDataString(agentName); + if (!string.Equals(escaped, agentName, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Agent name '{agentName}' contains characters invalid for URL routes.", nameof(agentName)); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj new file mode 100644 index 0000000000..cc618b54bc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj @@ -0,0 +1,36 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(ProjectsCoreTargetFrameworks) + $(NoWarn);IDE1006;IDE0130;NU1504;OPENAI001 + Microsoft.Agents.AI.Hosting.OpenAI + alpha + $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated + true + + + + + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentConversationsProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentConversationsProcessor.cs new file mode 100644 index 0000000000..b846d4c32c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentConversationsProcessor.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; + +internal sealed class AIAgentConversationsProcessor +{ +#pragma warning disable IDE0052 // Remove unread private members + private readonly AIAgent _aiAgent; +#pragma warning restore IDE0052 // Remove unread private members + + public AIAgentConversationsProcessor(AIAgent aiAgent) + { + this._aiAgent = aiAgent ?? throw new ArgumentNullException(nameof(aiAgent)); + } + + public async Task GetConversationAsync(string conversationId, CancellationToken cancellationToken) + { + // TODO come back to it later + throw new NotImplementedException(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponsesProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponsesProcessor.cs new file mode 100644 index 0000000000..0eefa37f2c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponsesProcessor.cs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Buffers; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.ServerSentEvents; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Model; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; + +/// +/// OpenAI Responses processor associated with a specific . +/// +internal sealed class AIAgentResponsesProcessor +{ + private readonly AIAgent _agent; + + public AIAgentResponsesProcessor(AIAgent agent) + { + this._agent = agent ?? throw new ArgumentNullException(nameof(agent)); + } + + public async Task CreateModelResponseAsync(ResponseCreationOptions responseCreationOptions, CancellationToken cancellationToken) + { + var options = new OpenAIResponsesRunOptions(); + AgentThread? agentThread = null; // not supported to resolve from conversationId + + var inputItems = responseCreationOptions.GetInput(); + var chatMessages = inputItems.AsChatMessages(); + + if (responseCreationOptions.GetStream()) + { + return new OpenAIStreamingResponsesResult(this._agent, chatMessages); + } + + var agentResponse = await this._agent.RunAsync(chatMessages, agentThread, options, cancellationToken).ConfigureAwait(false); + return new OpenAIResponseResult(agentResponse); + } + + private sealed class OpenAIResponseResult(AgentRunResponse agentResponse) : IResult + { + public async Task ExecuteAsync(HttpContext httpContext) + { + // note: OpenAI SDK types provide their own serialization implementation + // so we cant simply return IResult wrap for the typed-object. + // instead writing to the response body can be done. + + var cancellationToken = httpContext.RequestAborted; + var response = httpContext.Response; + + var chatResponse = agentResponse.AsChatResponse(); + var openAIResponse = chatResponse.AsOpenAIResponse(); + var openAIResponseJsonModel = openAIResponse as IJsonModel; + Debug.Assert(openAIResponseJsonModel is not null); + + var writer = new Utf8JsonWriter(response.BodyWriter, new JsonWriterOptions { SkipValidation = false }); + openAIResponseJsonModel.Write(writer, ModelReaderWriterOptions.Json); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + + private sealed class OpenAIStreamingResponsesResult(AIAgent agent, IEnumerable chatMessages) : IResult + { + public Task ExecuteAsync(HttpContext httpContext) + { + var cancellationToken = httpContext.RequestAborted; + var response = httpContext.Response; + + // Set SSE headers + response.Headers.ContentType = "text/event-stream"; + response.Headers.CacheControl = "no-cache,no-store"; + response.Headers.Connection = "keep-alive"; + response.Headers.ContentEncoding = "identity"; + httpContext.Features.GetRequiredFeature().DisableBuffering(); + + return SseFormatter.WriteAsync( + source: this.GetStreamingResponsesAsync(cancellationToken), + destination: response.Body, + itemFormatter: (sseItem, bufferWriter) => + { + var jsonTypeInfo = OpenAIResponsesJsonUtilities.DefaultOptions.GetTypeInfo(sseItem.Data.GetType()); + var json = JsonSerializer.SerializeToUtf8Bytes(sseItem.Data, jsonTypeInfo); + bufferWriter.Write(json); + }, + cancellationToken); + } + + private async IAsyncEnumerable> GetStreamingResponsesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var sequenceNumber = 1; + var outputIndex = 1; + AgentThread? agentThread = null; + + ResponseItem? lastResponseItem = null; + OpenAIResponse? lastOpenAIResponse = null; + + await foreach (var update in agent.RunStreamingAsync(chatMessages, thread: agentThread, cancellationToken: cancellationToken).ConfigureAwait(false)) + { + if (string.IsNullOrEmpty(update.ResponseId) + && string.IsNullOrEmpty(update.MessageId) + && update.Contents is not { Count: > 0 }) + { + continue; + } + + if (sequenceNumber == 1) + { + lastOpenAIResponse = update.AsChatResponse().AsOpenAIResponse(); + + var responseCreated = new StreamingCreatedResponse(sequenceNumber++) + { + Response = lastOpenAIResponse + }; + yield return new(responseCreated, responseCreated.Type); + } + + if (update.Contents is not { Count: > 0 }) + { + continue; + } + + // to help convert the AIContent into OpenAI ResponseItem we pack it into the known "chatMessage" + // and use existing convertion extension method + var chatMessage = new ChatMessage(ChatRole.Assistant, update.Contents) + { + MessageId = update.MessageId, + CreatedAt = update.CreatedAt, + RawRepresentation = update.RawRepresentation + }; + + foreach (var openAIResponseItem in MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseItems([chatMessage])) + { + if (chatMessage.MessageId is not null) + { + openAIResponseItem.SetId(chatMessage.MessageId); + } + + lastResponseItem = openAIResponseItem; + + var responseOutputItemAdded = new StreamingOutputItemAddedResponse(sequenceNumber++) + { + OutputIndex = outputIndex++, + Item = openAIResponseItem + }; + yield return new(responseOutputItemAdded, responseOutputItemAdded.Type); + } + } + + if (lastResponseItem is not null) + { + // we were streaming "response.output_item.added" before + // so we should complete it now via "response.output_item.done" + var responseOutputDoneAdded = new StreamingOutputItemDoneResponse(sequenceNumber++) + { + OutputIndex = outputIndex++, + Item = lastResponseItem + }; + yield return new(responseOutputDoneAdded, responseOutputDoneAdded.Type); + } + + if (lastOpenAIResponse is not null) + { + // complete the whole streaming with the full response model + var responseCompleted = new StreamingCompletedResponse(sequenceNumber++) + { + Response = lastOpenAIResponse + }; + yield return new(responseCompleted, responseCompleted.Type); + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Model/ResponseStreamEvent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Model/ResponseStreamEvent.cs new file mode 100644 index 0000000000..b9bc0ed51c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Model/ResponseStreamEvent.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Model; + +/// +/// Abstract base class for all streaming response events in the OpenAI Responses API. +/// Provides common properties shared across all streaming event types. +/// +[JsonPolymorphic(UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] +[JsonDerivedType(typeof(StreamingOutputItemAddedResponse), StreamingOutputItemAddedResponse.EventType)] +[JsonDerivedType(typeof(StreamingOutputItemDoneResponse), StreamingOutputItemDoneResponse.EventType)] +[JsonDerivedType(typeof(StreamingCreatedResponse), StreamingCreatedResponse.EventType)] +[JsonDerivedType(typeof(StreamingCompletedResponse), StreamingCompletedResponse.EventType)] +internal abstract class StreamingResponseEventBase +{ + /// + /// Gets or sets the type identifier for the streaming response event. + /// This property is used to discriminate between different event types during serialization. + /// + [JsonPropertyName("type")] + public string Type { get; set; } + + /// + /// Gets or sets the sequence number of this event in the streaming response. + /// Events are numbered sequentially starting from 1 to maintain ordering. + /// + [JsonPropertyName("sequence_number")] + public int SequenceNumber { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The type identifier for this streaming response event. + /// The sequence number of this event in the streaming response. + [JsonConstructor] + public StreamingResponseEventBase(string type, int sequenceNumber) + { + this.Type = type; + this.SequenceNumber = sequenceNumber; + } +} + +/// +/// Represents a streaming response event indicating that a new output item has been added to the response. +/// This event is sent when the AI agent produces a new piece of content during streaming. +/// +internal sealed class StreamingOutputItemAddedResponse : StreamingResponseEventBase +{ + /// + /// The constant event type identifier for output item added events. + /// + public const string EventType = "response.output_item.added"; + + /// + /// Initializes a new instance of the class. + /// + /// The sequence number of this event in the streaming response. + public StreamingOutputItemAddedResponse(int sequenceNumber) : base(EventType, sequenceNumber) + { + } + + /// + /// Gets or sets the index of the output in the response where this item was added. + /// Multiple outputs can exist in a single response, and this identifies which one. + /// + [JsonPropertyName("output_index")] + public int OutputIndex { get; set; } + + /// + /// Gets or sets the response item that was added to the output. + /// This contains the actual content or data produced by the AI agent. + /// + [JsonPropertyName("item")] + public ResponseItem? Item { get; set; } +} + +/// +/// Represents a streaming response event indicating that an output item has been completed. +/// This event is sent when the AI agent finishes producing a particular piece of content. +/// +internal sealed class StreamingOutputItemDoneResponse : StreamingResponseEventBase +{ + /// + /// The constant event type identifier for output item done events. + /// + public const string EventType = "response.output_item.done"; + + /// + /// Initializes a new instance of the class. + /// + /// The sequence number of this event in the streaming response. + public StreamingOutputItemDoneResponse(int sequenceNumber) : base(EventType, sequenceNumber) + { + } + + /// + /// Gets or sets the index of the output in the response where this item was completed. + /// This corresponds to the same output index from the associated . + /// + [JsonPropertyName("output_index")] + public int OutputIndex { get; set; } + + /// + /// Gets or sets the completed response item. + /// This contains the final version of the content produced by the AI agent. + /// + [JsonPropertyName("item")] + public ResponseItem? Item { get; set; } +} + +/// +/// Represents a streaming response event indicating that a new response has been created and streaming has begun. +/// This is typically the first event sent in a streaming response sequence. +/// +internal sealed class StreamingCreatedResponse : StreamingResponseEventBase +{ + /// + /// The constant event type identifier for response created events. + /// + public const string EventType = "response.created"; + + /// + /// Initializes a new instance of the class. + /// + /// The sequence number of this event in the streaming response. + public StreamingCreatedResponse(int sequenceNumber) : base(EventType, sequenceNumber) + { + } + + /// + /// Gets or sets the OpenAI response object that was created. + /// This contains metadata about the response including ID, creation timestamp, and other properties. + /// + [JsonPropertyName("response")] + public required OpenAIResponse Response { get; set; } +} + +/// +/// Represents a streaming response event indicating that the response has been completed. +/// This is typically the last event sent in a streaming response sequence. +/// +internal sealed class StreamingCompletedResponse : StreamingResponseEventBase +{ + /// + /// The constant event type identifier for response completed events. + /// + public const string EventType = "response.completed"; + + /// + /// Initializes a new instance of the class. + /// + /// The sequence number of this event in the streaming response. + public StreamingCompletedResponse(int sequenceNumber) : base(EventType, sequenceNumber) + { + } + + /// + /// Gets or sets the completed OpenAI response object. + /// This contains the final state of the response including all generated content and metadata. + /// + [JsonPropertyName("response")] + public required OpenAIResponse Response { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponsesRunOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponsesRunOptions.cs new file mode 100644 index 0000000000..14ec11fe8e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/OpenAIResponsesRunOptions.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; + +internal sealed class OpenAIResponsesRunOptions : AgentRunOptions +{ + public bool Background { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/AgentRunResponseUpdateExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/AgentRunResponseUpdateExtensions.cs new file mode 100644 index 0000000000..63e66f25cb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/AgentRunResponseUpdateExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils; + +internal static class AgentRunResponseUpdateExtensions +{ + /// + /// Converts an instance to a . + /// + /// The to convert. Cannot be null. + /// The role of agent run response contents. By default is . + /// A populated with values from . + public static ChatResponse AsChatResponse(this AgentRunResponseUpdate response, ChatRole? role = null) => new() + { + CreatedAt = response.CreatedAt, + ResponseId = response.ResponseId, + RawRepresentation = response.RawRepresentation, + AdditionalProperties = response.AdditionalProperties, + Messages = [new ChatMessage(response.Role ?? role ?? ChatRole.Assistant, response.Contents)] + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/OpenAIResponseJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/OpenAIResponseJsonConverter.cs new file mode 100644 index 0000000000..c2ff8131d8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/OpenAIResponseJsonConverter.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils; + +internal sealed class OpenAIResponseJsonConverter : JsonConverter +{ + public override OpenAIResponse? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var item = OpenAIResponsesModelFactory.OpenAIResponse(); + var jsonModel = item as IJsonModel; + Debug.Assert(jsonModel is not null, "OpenAIResponse should implement IJsonModel"); + + return jsonModel.Create(ref reader, ModelReaderWriterOptions.Json); + } + + public override void Write(Utf8JsonWriter writer, OpenAIResponse value, JsonSerializerOptions options) + { + var jsonModel = value as IJsonModel; + Debug.Assert(jsonModel is not null, "OpenAIResponse should implement IJsonModel"); + + jsonModel.Write(writer, ModelReaderWriterOptions.Json); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/OpenAIResponsesJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/OpenAIResponsesJsonUtilities.cs new file mode 100644 index 0000000000..d7ee539526 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/OpenAIResponsesJsonUtilities.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Model; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils; + +internal static partial class OpenAIResponsesJsonUtilities +{ + /// + /// Gets the singleton used as the default in JSON serialization operations. + /// + /// + /// + /// For Native AOT or applications disabling , this instance + /// includes source generated contracts for all common exchange types contained in this library. + /// + /// + /// It additionally turns on the following settings: + /// + /// Enables defaults. + /// Enables as the default ignore condition for properties. + /// Enables as the default number handling for number types. + /// + /// + /// + public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + + /// + /// Creates default options to use for agents-related serialization. + /// + /// The configured options. + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + private static JsonSerializerOptions CreateDefaultOptions() + { + JsonSerializerOptions options = new(JsonContext.Default.Options); + + options.Converters.Add(new ResponseItemJsonConverter()); + options.Converters.Add(new OpenAIResponseJsonConverter()); + + options.MakeReadOnly(); + return options; + } + + [JsonSerializable(typeof(StreamingResponseEventBase))] + + [ExcludeFromCodeCoverage] + private sealed partial class JsonContext : JsonSerializerContext; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/ResponseCreationOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/ResponseCreationOptionsExtensions.cs new file mode 100644 index 0000000000..a91811c793 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/ResponseCreationOptionsExtensions.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.Shared.Diagnostics; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1810:Initialize reference type static fields inline", Justification = "Specifically for accessing hidden members")] +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Specifically for accessing hidden members")] +internal static class ResponseCreationOptionsExtensions +{ + private static readonly Func _getStreamNullable; + private static readonly Func> _getInput; + + static ResponseCreationOptionsExtensions() + { + // OpenAI SDK does not have a simple way to get the input as a c# object. + // However, it does parse most of the interesting fields into internal properties of `ResponseCreationOptions` object. + + // --- Stream (internal bool? Stream { get; set; }) --- + const string streamPropName = "Stream"; + var streamProp = typeof(ResponseCreationOptions).GetProperty(streamPropName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMemberException(typeof(ResponseCreationOptions).FullName!, streamPropName); + var streamGetter = streamProp.GetGetMethod(nonPublic: true) ?? throw new MissingMethodException($"{streamPropName} getter not found."); + + _getStreamNullable = streamGetter.CreateDelegate>(); + + // --- Input (internal IList Input { get; set; }) --- + const string inputPropName = "Input"; + var inputProp = typeof(ResponseCreationOptions).GetProperty(inputPropName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMemberException(typeof(ResponseCreationOptions).FullName!, inputPropName); + var inputGetter = inputProp.GetGetMethod(nonPublic: true) + ?? throw new MissingMethodException($"{inputPropName} getter not found."); + + _getInput = inputGetter.CreateDelegate>>(); + } + + public static bool GetStream(this ResponseCreationOptions options) + { + Throw.IfNull(options); + return _getStreamNullable(options) ?? false; + } + + public static IList GetInput(this ResponseCreationOptions options) + { + Throw.IfNull(options); + return _getInput(options); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/ResponseItemExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/ResponseItemExtensions.cs new file mode 100644 index 0000000000..f3e6dbb98d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/ResponseItemExtensions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Reflection; +using Microsoft.Shared.Diagnostics; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1810:Initialize reference type static fields inline", Justification = "Specifically for accessing hidden members")] +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Specifically for accessing hidden members")] +internal static class ResponseItemExtensions +{ + private static readonly Action _setId; + + static ResponseItemExtensions() + { + // OpenAI SDK ResponseItem has an internal setter for Id property. + // We need to access it via reflection to set the Id when creating response items. + + // --- Id (public string Id { get; internal set; }) --- + const string idPropName = "Id"; + var idProp = typeof(ResponseItem).GetProperty(idPropName, BindingFlags.Instance | BindingFlags.Public) + ?? throw new MissingMemberException(typeof(ResponseItem).FullName!, idPropName); + var idSetter = idProp.GetSetMethod(nonPublic: true) ?? throw new MissingMethodException($"{idPropName} setter not found."); + + _setId = idSetter.CreateDelegate>(); + } + + /// + /// Sets the Id property on a ResponseItem using reflection to access the internal setter. + /// + /// The ResponseItem to set the Id on. + /// The Id value to set. + public static void SetId(this ResponseItem responseItem, string id) + { + Throw.IfNull(responseItem); + _setId(responseItem, id); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/ResponseItemJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/ResponseItemJsonConverter.cs new file mode 100644 index 0000000000..6b39029b84 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Utils/ResponseItemJsonConverter.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Utils; + +internal sealed class ResponseItemJsonConverter : JsonConverter +{ + public override ResponseItem? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var item = ResponseItem.CreateUserMessageItem(""); // no other way to instantiate it. + var jsonModel = item as IJsonModel; + Debug.Assert(jsonModel is not null, "ResponseItem should implement IJsonModel"); + return jsonModel.Create(ref reader, ModelReaderWriterOptions.Json); + } + + public override void Write(Utf8JsonWriter writer, ResponseItem? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + var jsonmodel = value as IJsonModel; + Debug.Assert(jsonmodel is not null, "ResponseItem should implement IJsonModel"); + jsonmodel.Write(writer, ModelReaderWriterOptions.Json); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/TurnToken.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/TurnToken.cs index 7f65e3e9d3..91a0833cc0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/TurnToken.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/TurnToken.cs @@ -1,10 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Extensions.AI; + namespace Microsoft.Agents.AI.Workflows; /// /// Sent to an -based executor to request -/// a response to accumulated . +/// a response to accumulated . /// /// Whether to raise AgentRunEvents for this executor. public class TurnToken(bool? emitEvents = null) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 27436f42d0..edca371f5d 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -601,7 +601,7 @@ public sealed partial class ChatClientAgent : AIAgent { throw new InvalidOperationException( $""" - The {nameof(chatOptions.ConversationId)} provided via {nameof(Extensions.AI.ChatOptions)} is different to the id of the provided {nameof(AgentThread)}. + The {nameof(chatOptions.ConversationId)} provided via {nameof(this.ChatOptions)} is different to the id of the provided {nameof(AgentThread)}. Only one id can be used for a run. """); }