.NET: Enable access to hosted AIAgents via OpenAI Responses (#947)

* init

* wip

* wip wip wip

* wip wip

* open up API

* enable for multiple agents

* more wip

* make frontend respond.

* wip

* not sure if proper setup

* define type

* cleanup

* frontend streaming wip

* use System.Net.ServerSentEvents

* usings

* reformat via ichatclient

* merge main renaming + refactor

* fix main merge + fix sample (a2a change)

* fix sample

* some rebase (not working yet)

* make it at least build somehow

* make non-stream work without internal types

* Input without custom models

* implement streaming

* test frontend

* enable alerts and fix

* build fixes & rereview

* Update dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI.Responses/Microsoft.Agents.AI.Hosting.OpenAI.Responses.csproj

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI.Responses/Utils/ResponseItemJsonConverter.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix agent discovery

* rename

* rename project into Microsoft.Agents.AI.Hosting.OpenAI (no responses in name)

* PR address comments x1

* address PR comments x2

* correctly instantiate OpenAIResponse

* address PR comments x3

* reconfigure JSON serialization & handle AOT warnings

* fix build

* proper ref

* check update differently

* correct check

* exclude dotnet format diagnostics for IL2026 and IL3050

* space  :)

* re-review

* add comments

* remove unnecessary using

* always take last openai response item

* set responseItem Id explicitly

* add agent.name validation for uri

* cleanup

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
This commit is contained in:
Korolev Dmitry
2025-10-07 14:39:08 +02:00
committed by GitHub
Unverified
parent 3fd5768b34
commit d10b44d6bb
23 changed files with 994 additions and 174 deletions
+2 -1
View File
@@ -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
+1
View File
@@ -290,6 +290,7 @@
<Project Path="src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
@@ -13,6 +13,7 @@
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
</ItemGroup>
@@ -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");
@@ -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<AgentDiscoveryC
public class AgentDiscoveryCard
{
public string? Name { get; set; }
[JsonPropertyName("name")]
public required string Name { get; set; }
[JsonPropertyName("description")]
public string? Description { get; set; }
}
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
@@ -8,6 +8,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
@@ -4,6 +4,7 @@
@inject IJSRuntime JSRuntime
@inject ILogger<Home> Logger
@inject A2AAgentClient A2AActorClient
@inject OpenAIResponsesAgentClient OpenAIResponsesAgentClient
@rendermode InteractiveServer
@using System.Text
@using System.Text.Json
@@ -51,18 +52,18 @@
<label for="protocol-select" class="protocol-select-label">Choose communication protocol:</label>
<div class="protocol-select-wrapper">
<select id="protocol-select" class="protocol-select" @bind="selectedProtocol" disabled="@(isStreaming)">
<option value="AgenticFramework">Agentic Framework</option>
<option value="A2A">A2A (Agent2Agent)</option>
<option value="OpenAIResponses">OpenAI Responses</option>
<option value="A2A">A2A (Agent-to-Agent)</option>
</select>
<div class="protocol-info">
@switch (selectedProtocol)
{
case Protocol.A2A:
<span class="protocol-description">🔗 A2A protocol supports long-running agentic processes</span>
case Protocol.OpenAIResponses:
<span class="protocol-description">֎ OpenAI Responses</span>
break;
case Protocol.AgenticFramework:
default:
<span class="protocol-description">⚡ Direct agentic framework communication</span>
case Protocol.A2A:
default:
<span class="protocol-description">🔗 A2A protocol supports long-running agentic processes</span>
break;
}
</div>
@@ -881,208 +882,212 @@
@code {
private string currentMessage = "";
private bool isStreaming = false;
private bool isLoadingAgents = true;
private string currentStreamedMessage = "";
private string selectedAgentName = "";
private List<AgentDiscoveryClient.AgentDiscoveryCard> availableAgents = new();
private List<Conversation> conversations = new();
private Conversation? currentConversation;
private string currentMessage = "";
private bool isStreaming = false;
private bool isLoadingAgents = true;
private string currentStreamedMessage = "";
private string selectedAgentName = "";
private List<AgentDiscoveryClient.AgentDiscoveryCard> availableAgents = new();
private List<Conversation> 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<ChatMessage> Messages { get; set; } = new();
}
private sealed class Conversation
{
public string SessionId { get; set; } = Guid.NewGuid().ToString("N");
public string AgentName { get; set; } = "";
public List<ChatMessage> 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<ChatMessage> { new(ChatRole.User, userMessage) };
await foreach (var update in agentClient.RunStreamingAsync(
@@ -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;
/// <summary>
/// Is a simple frontend client which exercises the ability of exposed agent to communicate via OpenAI Responses protocol.
/// </summary>
internal sealed class OpenAIResponsesAgentClient : IAgentClient
{
private readonly Uri _baseUri;
public OpenAIResponsesAgentClient(string baseUri)
{
this._baseUri = new Uri(baseUri.TrimEnd('/'));
}
public async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
string agentName,
IList<ChatMessage> 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<AgentCard?> GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default)
=> Task.FromResult<AgentCard?>(null!);
}
@@ -23,6 +23,7 @@ Uri a2aAddress = new("http://localhost:5390/a2a");
builder.Services.AddHttpClient<AgentDiscoveryClient>(client => client.BaseAddress = baseAddress);
builder.Services.AddSingleton(sp => new A2AAgentClient(sp.GetRequiredService<ILogger<A2AAgentClient>>(), a2aAddress));
builder.Services.AddSingleton(sp => new OpenAIResponsesAgentClient("http://localhost:5390"));
var app = builder.Build();
@@ -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;
/// <summary>
/// Provides extension methods for mapping OpenAI Responses capabilities to an <see cref="AIAgent"/>.
/// </summary>
public static class EndpointRouteBuilderExtensions
{
/// <summary>
/// Maps OpenAI Responses API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI Responses endpoints to.</param>
/// <param name="agentName">The name of the AI agent service registered in the dependency injection container. This name is used to resolve the <see cref="AIAgent"/> instance from the keyed services.</param>
/// <param name="responsesPath">Custom route path for the responses endpoint.</param>
/// <param name="conversationsPath">Custom route path for the conversations endpoint.</param>
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<AIAgent>(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<ResponseCreationOptions>;
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));
}
}
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<NoWarn>$(NoWarn);IDE1006;IDE0130;NU1504;OPENAI001</NoWarn>
<RootNamespace>Microsoft.Agents.AI.Hosting.OpenAI</RootNamespace>
<VersionSuffix>alpha</VersionSuffix>
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated</InterceptorsNamespaces>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.1.25451.107" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
</ItemGroup>
</Project>
@@ -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<IResult> GetConversationAsync(string conversationId, CancellationToken cancellationToken)
{
// TODO come back to it later
throw new NotImplementedException();
}
}
@@ -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;
/// <summary>
/// OpenAI Responses processor associated with a specific <see cref="AIAgent"/>.
/// </summary>
internal sealed class AIAgentResponsesProcessor
{
private readonly AIAgent _agent;
public AIAgentResponsesProcessor(AIAgent agent)
{
this._agent = agent ?? throw new ArgumentNullException(nameof(agent));
}
public async Task<IResult> 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<OpenAIResponse>;
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<ChatMessage> 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<IHttpResponseBodyFeature>().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<SseItem<StreamingResponseEventBase>> 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);
}
}
}
}
@@ -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;
/// <summary>
/// Abstract base class for all streaming response events in the OpenAI Responses API.
/// Provides common properties shared across all streaming event types.
/// </summary>
[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
{
/// <summary>
/// Gets or sets the type identifier for the streaming response event.
/// This property is used to discriminate between different event types during serialization.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; }
/// <summary>
/// Gets or sets the sequence number of this event in the streaming response.
/// Events are numbered sequentially starting from 1 to maintain ordering.
/// </summary>
[JsonPropertyName("sequence_number")]
public int SequenceNumber { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="StreamingResponseEventBase"/> class.
/// </summary>
/// <param name="type">The type identifier for this streaming response event.</param>
/// <param name="sequenceNumber">The sequence number of this event in the streaming response.</param>
[JsonConstructor]
public StreamingResponseEventBase(string type, int sequenceNumber)
{
this.Type = type;
this.SequenceNumber = sequenceNumber;
}
}
/// <summary>
/// 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.
/// </summary>
internal sealed class StreamingOutputItemAddedResponse : StreamingResponseEventBase
{
/// <summary>
/// The constant event type identifier for output item added events.
/// </summary>
public const string EventType = "response.output_item.added";
/// <summary>
/// Initializes a new instance of the <see cref="StreamingOutputItemAddedResponse"/> class.
/// </summary>
/// <param name="sequenceNumber">The sequence number of this event in the streaming response.</param>
public StreamingOutputItemAddedResponse(int sequenceNumber) : base(EventType, sequenceNumber)
{
}
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("output_index")]
public int OutputIndex { get; set; }
/// <summary>
/// Gets or sets the response item that was added to the output.
/// This contains the actual content or data produced by the AI agent.
/// </summary>
[JsonPropertyName("item")]
public ResponseItem? Item { get; set; }
}
/// <summary>
/// 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.
/// </summary>
internal sealed class StreamingOutputItemDoneResponse : StreamingResponseEventBase
{
/// <summary>
/// The constant event type identifier for output item done events.
/// </summary>
public const string EventType = "response.output_item.done";
/// <summary>
/// Initializes a new instance of the <see cref="StreamingOutputItemDoneResponse"/> class.
/// </summary>
/// <param name="sequenceNumber">The sequence number of this event in the streaming response.</param>
public StreamingOutputItemDoneResponse(int sequenceNumber) : base(EventType, sequenceNumber)
{
}
/// <summary>
/// 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 <see cref="StreamingOutputItemAddedResponse"/>.
/// </summary>
[JsonPropertyName("output_index")]
public int OutputIndex { get; set; }
/// <summary>
/// Gets or sets the completed response item.
/// This contains the final version of the content produced by the AI agent.
/// </summary>
[JsonPropertyName("item")]
public ResponseItem? Item { get; set; }
}
/// <summary>
/// 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.
/// </summary>
internal sealed class StreamingCreatedResponse : StreamingResponseEventBase
{
/// <summary>
/// The constant event type identifier for response created events.
/// </summary>
public const string EventType = "response.created";
/// <summary>
/// Initializes a new instance of the <see cref="StreamingCreatedResponse"/> class.
/// </summary>
/// <param name="sequenceNumber">The sequence number of this event in the streaming response.</param>
public StreamingCreatedResponse(int sequenceNumber) : base(EventType, sequenceNumber)
{
}
/// <summary>
/// Gets or sets the OpenAI response object that was created.
/// This contains metadata about the response including ID, creation timestamp, and other properties.
/// </summary>
[JsonPropertyName("response")]
public required OpenAIResponse Response { get; set; }
}
/// <summary>
/// Represents a streaming response event indicating that the response has been completed.
/// This is typically the last event sent in a streaming response sequence.
/// </summary>
internal sealed class StreamingCompletedResponse : StreamingResponseEventBase
{
/// <summary>
/// The constant event type identifier for response completed events.
/// </summary>
public const string EventType = "response.completed";
/// <summary>
/// Initializes a new instance of the <see cref="StreamingCompletedResponse"/> class.
/// </summary>
/// <param name="sequenceNumber">The sequence number of this event in the streaming response.</param>
public StreamingCompletedResponse(int sequenceNumber) : base(EventType, sequenceNumber)
{
}
/// <summary>
/// Gets or sets the completed OpenAI response object.
/// This contains the final state of the response including all generated content and metadata.
/// </summary>
[JsonPropertyName("response")]
public required OpenAIResponse Response { get; set; }
}
@@ -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; }
}
@@ -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
{
/// <summary>
/// Converts an <see cref="AgentRunResponseUpdate"/> instance to a <see cref="ChatResponse"/>.
/// </summary>
/// <param name="response">The <see cref="AgentRunResponse"/> to convert. Cannot be null.</param>
/// <param name="role">The role of agent run response contents. By default is <see cref="ChatRole.Assistant"/>.</param>
/// <returns>A <see cref="ChatResponse"/> populated with values from <paramref name="response"/>.</returns>
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)]
};
}
@@ -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<OpenAIResponse>
{
public override OpenAIResponse? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var item = OpenAIResponsesModelFactory.OpenAIResponse();
var jsonModel = item as IJsonModel<OpenAIResponse>;
Debug.Assert(jsonModel is not null, "OpenAIResponse should implement IJsonModel<OpenAIResponse>");
return jsonModel.Create(ref reader, ModelReaderWriterOptions.Json);
}
public override void Write(Utf8JsonWriter writer, OpenAIResponse value, JsonSerializerOptions options)
{
var jsonModel = value as IJsonModel<OpenAIResponse>;
Debug.Assert(jsonModel is not null, "OpenAIResponse should implement IJsonModel<OpenAIResponse>");
jsonModel.Write(writer, ModelReaderWriterOptions.Json);
}
}
@@ -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
{
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
/// </summary>
/// <remarks>
/// <para>
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
/// includes source generated contracts for all common exchange types contained in this library.
/// </para>
/// <para>
/// It additionally turns on the following settings:
/// <list type="number">
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
/// </list>
/// </para>
/// </remarks>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
/// <summary>
/// Creates default options to use for agents-related serialization.
/// </summary>
/// <returns>The configured options.</returns>
[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;
}
@@ -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<ResponseCreationOptions, bool?> _getStreamNullable;
private static readonly Func<ResponseCreationOptions, IList<ResponseItem>> _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<Func<ResponseCreationOptions, bool?>>();
// --- Input (internal IList<ResponseItem> 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<Func<ResponseCreationOptions, IList<ResponseItem>>>();
}
public static bool GetStream(this ResponseCreationOptions options)
{
Throw.IfNull(options);
return _getStreamNullable(options) ?? false;
}
public static IList<ResponseItem> GetInput(this ResponseCreationOptions options)
{
Throw.IfNull(options);
return _getInput(options);
}
}
@@ -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<ResponseItem, string> _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<Action<ResponseItem, string>>();
}
/// <summary>
/// Sets the Id property on a ResponseItem using reflection to access the internal setter.
/// </summary>
/// <param name="responseItem">The ResponseItem to set the Id on.</param>
/// <param name="id">The Id value to set.</param>
public static void SetId(this ResponseItem responseItem, string id)
{
Throw.IfNull(responseItem);
_setId(responseItem, id);
}
}
@@ -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<ResponseItem?>
{
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<ResponseItem>;
Debug.Assert(jsonModel is not null, "ResponseItem should implement IJsonModel<ResponseItem>");
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<ResponseItem>;
Debug.Assert(jsonmodel is not null, "ResponseItem should implement IJsonModel<ResponseItem>");
jsonmodel.Write(writer, ModelReaderWriterOptions.Json);
}
}
@@ -1,10 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Sent to an <see cref="AIAgent"/>-based executor to request
/// a response to accumulated <see cref="Extensions.AI.ChatMessage"/>.
/// a response to accumulated <see cref="ChatMessage"/>.
/// </summary>
/// <param name="emitEvents">Whether to raise AgentRunEvents for this executor.</param>
public class TurnToken(bool? emitEvents = null)
@@ -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.
""");
}