mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Add Azure AI Foundry Responses hosting adapter
Implement Microsoft.Agents.AI.Hosting.AzureAIResponses to host agent-framework AIAgents and workflows within Azure Foundry as hosted agents via the Azure.AI.AgentServer.Responses SDK. - AgentFrameworkResponseHandler: bridges ResponseHandler to AIAgent execution - InputConverter: converts Responses API inputs/history to MEAI ChatMessage - OutputConverter: converts agent response updates to SSE event stream - ServiceCollectionExtensions: DI registration helpers - 336 unit tests across net8.0/net9.0/net10.0 (112 per TFM) - ResponseStreamValidator: SSE protocol validation tool for samples - FoundryResponsesHosting sample app Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+186
@@ -0,0 +1,186 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ResponseHandler"/> implementation that bridges the Azure AI Responses Server SDK
|
||||
/// with agent-framework <see cref="AIAgent"/> instances, enabling agent-framework agents and workflows
|
||||
/// to be hosted as Azure Foundry Hosted Agents.
|
||||
/// </summary>
|
||||
public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<AgentFrameworkResponseHandler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
|
||||
/// that resolves agents from keyed DI services.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The service provider for resolving agents.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public AgentFrameworkResponseHandler(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AgentFrameworkResponseHandler> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
this._serviceProvider = serviceProvider;
|
||||
this._logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
|
||||
CreateResponse request,
|
||||
ResponseContext context,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Resolve agent
|
||||
var agent = this.ResolveAgent(request);
|
||||
|
||||
// 2. Create the SDK event stream builder
|
||||
var stream = new ResponseEventStream(context, request);
|
||||
|
||||
// 3. Emit lifecycle events
|
||||
yield return stream.EmitCreated();
|
||||
yield return stream.EmitInProgress();
|
||||
|
||||
// 4. Convert input: history + current input → ChatMessage[]
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// Load conversation history if available
|
||||
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (history.Count > 0)
|
||||
{
|
||||
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history));
|
||||
}
|
||||
|
||||
// Load and convert current input items
|
||||
var inputItems = await context.GetInputItemsAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (inputItems.Count > 0)
|
||||
{
|
||||
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(inputItems));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to raw request input
|
||||
messages.AddRange(InputConverter.ConvertInputToMessages(request));
|
||||
}
|
||||
|
||||
// 5. Build chat options
|
||||
var chatOptions = InputConverter.ConvertToChatOptions(request);
|
||||
chatOptions.Instructions = request.Instructions;
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
|
||||
// 6. Run the agent and convert output
|
||||
// NOTE: C# forbids 'yield return' inside a try block that has a catch clause,
|
||||
// and inside catch blocks. We use a flag to defer the yield to outside the try/catch.
|
||||
bool emittedTerminal = false;
|
||||
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
|
||||
agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken),
|
||||
stream,
|
||||
cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
bool shutdownDetected = false;
|
||||
ResponseStreamEvent? evt = null;
|
||||
try
|
||||
{
|
||||
if (!await enumerator.MoveNextAsync().ConfigureAwait(false))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
evt = enumerator.Current;
|
||||
}
|
||||
catch (OperationCanceledException) when (context.IsShutdownRequested && !emittedTerminal)
|
||||
{
|
||||
shutdownDetected = true;
|
||||
}
|
||||
|
||||
if (shutdownDetected)
|
||||
{
|
||||
// Server is shutting down — emit incomplete so clients can resume
|
||||
this._logger.LogInformation("Shutdown detected, emitting incomplete response.");
|
||||
yield return stream.EmitIncomplete();
|
||||
yield break;
|
||||
}
|
||||
|
||||
// yield is in the outer try (finally-only) — allowed by C#
|
||||
yield return evt!;
|
||||
|
||||
if (evt is ResponseCompletedEvent or ResponseFailedEvent or ResponseIncompleteEvent)
|
||||
{
|
||||
emittedTerminal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await enumerator.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an <see cref="AIAgent"/> from the request.
|
||||
/// Tries <c>agent.name</c> first, then falls back to <c>metadata["entity_id"]</c>.
|
||||
/// If neither is present, attempts to resolve a default (non-keyed) <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
private AIAgent ResolveAgent(CreateResponse request)
|
||||
{
|
||||
var agentName = GetAgentName(request);
|
||||
|
||||
if (!string.IsNullOrEmpty(agentName))
|
||||
{
|
||||
var agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
|
||||
if (agent is not null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
this._logger.LogWarning("Agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName);
|
||||
}
|
||||
|
||||
// Try non-keyed default
|
||||
var defaultAgent = this._serviceProvider.GetService<AIAgent>();
|
||||
if (defaultAgent is not null)
|
||||
{
|
||||
return defaultAgent;
|
||||
}
|
||||
|
||||
var errorMessage = string.IsNullOrEmpty(agentName)
|
||||
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered."
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AIAgent.";
|
||||
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
private static string? GetAgentName(CreateResponse request)
|
||||
{
|
||||
// Try agent.name from AgentReference
|
||||
var agentName = request.AgentReference?.Name;
|
||||
|
||||
// Fall back to "model" field (OpenAI clients send the agent name as the model)
|
||||
if (string.IsNullOrEmpty(agentName))
|
||||
{
|
||||
agentName = request.Model;
|
||||
}
|
||||
|
||||
// Fall back to metadata["entity_id"]
|
||||
if (string.IsNullOrEmpty(agentName) && request.Metadata?.AdditionalProperties is not null)
|
||||
{
|
||||
request.Metadata.AdditionalProperties.TryGetValue("entity_id", out agentName);
|
||||
}
|
||||
|
||||
return agentName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses;
|
||||
|
||||
/// <summary>
|
||||
/// Converts Responses Server SDK input types to agent-framework <see cref="ChatMessage"/> types.
|
||||
/// </summary>
|
||||
internal static class InputConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts the SDK <see cref="CreateResponse"/> request input items into a list of <see cref="ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request from the SDK.</param>
|
||||
/// <returns>A list of chat messages representing the request input.</returns>
|
||||
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in request.GetInputExpanded())
|
||||
{
|
||||
var message = ConvertInputItemToMessage(item);
|
||||
if (message is not null)
|
||||
{
|
||||
messages.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts resolved SDK <see cref="OutputItem"/> history/input items into <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="items">The resolved output items from the SDK context.</param>
|
||||
/// <returns>A list of chat messages.</returns>
|
||||
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var message = ConvertOutputItemToMessage(item);
|
||||
if (message is not null)
|
||||
{
|
||||
messages.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates <see cref="ChatOptions"/> from the SDK request properties.
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request.</param>
|
||||
/// <returns>A configured <see cref="ChatOptions"/> instance.</returns>
|
||||
public static ChatOptions ConvertToChatOptions(CreateResponse request)
|
||||
{
|
||||
return new ChatOptions
|
||||
{
|
||||
Temperature = (float?)request.Temperature,
|
||||
TopP = (float?)request.TopP,
|
||||
MaxOutputTokens = (int?)request.MaxOutputTokens,
|
||||
ModelId = request.Model,
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatMessage? ConvertInputItemToMessage(Item item)
|
||||
{
|
||||
return item switch
|
||||
{
|
||||
ItemMessage msg => ConvertItemMessage(msg),
|
||||
FunctionCallOutputItemParam funcOutput => ConvertFunctionCallOutput(funcOutput),
|
||||
ItemFunctionToolCall funcCall => ConvertItemFunctionToolCall(funcCall),
|
||||
ItemReferenceParam => null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertItemMessage(ItemMessage msg)
|
||||
{
|
||||
var role = ConvertMessageRole(msg.Role);
|
||||
var contents = new List<AIContent>();
|
||||
|
||||
foreach (var content in msg.GetContentExpanded())
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case MessageContentInputTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case MessageContentInputImageContent imageContent:
|
||||
if (imageContent.ImageUrl is not null)
|
||||
{
|
||||
var url = imageContent.ImageUrl.ToString();
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contents.Add(new DataContent(url, "image/*"));
|
||||
}
|
||||
else
|
||||
{
|
||||
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(imageContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(imageContent.FileId));
|
||||
}
|
||||
|
||||
break;
|
||||
case MessageContentInputFileContent fileContent:
|
||||
if (fileContent.FileUrl is not null)
|
||||
{
|
||||
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileData))
|
||||
{
|
||||
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(fileContent.FileId));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.Filename))
|
||||
{
|
||||
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (contents.Count == 0)
|
||||
{
|
||||
contents.Add(new MeaiTextContent(string.Empty));
|
||||
}
|
||||
|
||||
return new ChatMessage(role, contents);
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertFunctionCallOutput(FunctionCallOutputItemParam funcOutput)
|
||||
{
|
||||
var output = funcOutput.Output?.ToString() ?? string.Empty;
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
[new FunctionResultContent(funcOutput.CallId, output)]);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK input.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK input.")]
|
||||
private static ChatMessage ConvertItemFunctionToolCall(ItemFunctionToolCall funcCall)
|
||||
{
|
||||
IDictionary<string, object?>? arguments = null;
|
||||
if (funcCall.Arguments is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
arguments = JsonSerializer.Deserialize<Dictionary<string, object?>>(funcCall.Arguments);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
arguments = new Dictionary<string, object?> { ["_raw"] = funcCall.Arguments };
|
||||
}
|
||||
}
|
||||
|
||||
return new ChatMessage(
|
||||
ChatRole.Assistant,
|
||||
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
|
||||
}
|
||||
|
||||
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item)
|
||||
{
|
||||
return item switch
|
||||
{
|
||||
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
|
||||
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
|
||||
FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput),
|
||||
OutputItemReasoningItem => null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertOutputItemMessageToChat(OutputItemMessage msg)
|
||||
{
|
||||
var role = ConvertMessageRole(msg.Role);
|
||||
var contents = new List<AIContent>();
|
||||
|
||||
foreach (var content in msg.Content)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case MessageContentInputTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case MessageContentOutputTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case MessageContentRefusalContent refusal:
|
||||
contents.Add(new MeaiTextContent($"[Refusal: {refusal.Refusal}]"));
|
||||
break;
|
||||
case MessageContentInputImageContent imageContent:
|
||||
if (imageContent.ImageUrl is not null)
|
||||
{
|
||||
var url = imageContent.ImageUrl.ToString();
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contents.Add(new DataContent(url, "image/*"));
|
||||
}
|
||||
else
|
||||
{
|
||||
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(imageContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(imageContent.FileId));
|
||||
}
|
||||
|
||||
break;
|
||||
case MessageContentInputFileContent fileContent:
|
||||
if (fileContent.FileUrl is not null)
|
||||
{
|
||||
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileData))
|
||||
{
|
||||
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(fileContent.FileId));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.Filename))
|
||||
{
|
||||
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (contents.Count == 0)
|
||||
{
|
||||
contents.Add(new MeaiTextContent(string.Empty));
|
||||
}
|
||||
|
||||
return new ChatMessage(role, contents);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK output history.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK output history.")]
|
||||
private static ChatMessage ConvertOutputItemFunctionCall(OutputItemFunctionToolCall funcCall)
|
||||
{
|
||||
IDictionary<string, object?>? arguments = null;
|
||||
if (funcCall.Arguments is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
arguments = JsonSerializer.Deserialize<Dictionary<string, object?>>(funcCall.Arguments);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
arguments = new Dictionary<string, object?> { ["_raw"] = funcCall.Arguments };
|
||||
}
|
||||
}
|
||||
|
||||
return new ChatMessage(
|
||||
ChatRole.Assistant,
|
||||
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput)
|
||||
{
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
[new FunctionResultContent(funcOutput.CallId, funcOutput.Output)]);
|
||||
}
|
||||
|
||||
private static ChatRole ConvertMessageRole(MessageRole role)
|
||||
{
|
||||
return role switch
|
||||
{
|
||||
MessageRole.User => ChatRole.User,
|
||||
MessageRole.Assistant => ChatRole.Assistant,
|
||||
MessageRole.System => ChatRole.System,
|
||||
MessageRole.Developer => new ChatRole("developer"),
|
||||
_ => ChatRole.User
|
||||
};
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>Microsoft.Agents.AI.Hosting.AzureAIResponses</RootNamespace>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);MEAI001;NU1903</NoWarn>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.Responses" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,346 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses;
|
||||
|
||||
/// <summary>
|
||||
/// Converts agent-framework <see cref="AgentResponseUpdate"/> streams into
|
||||
/// Responses Server SDK <see cref="ResponseStreamEvent"/> sequences using the
|
||||
/// <see cref="ResponseEventStream"/> builder pattern.
|
||||
/// </summary>
|
||||
internal static class OutputConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a stream of <see cref="AgentResponseUpdate"/> into a stream of
|
||||
/// <see cref="ResponseStreamEvent"/> using the SDK builder pattern.
|
||||
/// </summary>
|
||||
/// <param name="updates">The agent response updates to convert.</param>
|
||||
/// <param name="stream">The SDK event stream builder.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of SDK response stream events (excluding lifecycle events).</returns>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call arguments dictionary.")]
|
||||
public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEventsAsync(
|
||||
IAsyncEnumerable<AgentResponseUpdate> updates,
|
||||
ResponseEventStream stream,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ResponseUsage? accumulatedUsage = null;
|
||||
OutputItemMessageBuilder? currentMessageBuilder = null;
|
||||
TextContentBuilder? currentTextBuilder = null;
|
||||
StringBuilder? accumulatedText = null;
|
||||
string? previousMessageId = null;
|
||||
bool hasTerminalEvent = false;
|
||||
var executorItemIds = new Dictionary<string, string>();
|
||||
|
||||
await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Handle workflow events from RawRepresentation
|
||||
if (update.RawRepresentation is WorkflowEvent workflowEvent)
|
||||
{
|
||||
// Close any open message builder before emitting workflow items
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
foreach (var evt in EmitWorkflowEvent(stream, workflowEvent, executorItemIds))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case MeaiTextContent textContent:
|
||||
{
|
||||
if (!IsSameMessage(update.MessageId, previousMessageId) && currentMessageBuilder is not null)
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
}
|
||||
|
||||
previousMessageId = update.MessageId;
|
||||
|
||||
if (currentMessageBuilder is null)
|
||||
{
|
||||
currentMessageBuilder = stream.AddOutputItemMessage();
|
||||
yield return currentMessageBuilder.EmitAdded();
|
||||
|
||||
currentTextBuilder = currentMessageBuilder.AddTextContent();
|
||||
yield return currentTextBuilder.EmitAdded();
|
||||
|
||||
accumulatedText = new StringBuilder();
|
||||
}
|
||||
|
||||
if (textContent.Text is { Length: > 0 })
|
||||
{
|
||||
accumulatedText!.Append(textContent.Text);
|
||||
yield return currentTextBuilder!.EmitDelta(textContent.Text);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case FunctionCallContent funcCall:
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
var callId = funcCall.CallId ?? Guid.NewGuid().ToString("N");
|
||||
var funcBuilder = stream.AddOutputItemFunctionCall(funcCall.Name, callId);
|
||||
yield return funcBuilder.EmitAdded();
|
||||
|
||||
var arguments = funcCall.Arguments is not null
|
||||
? JsonSerializer.Serialize(funcCall.Arguments)
|
||||
: "{}";
|
||||
|
||||
yield return funcBuilder.EmitArgumentsDelta(arguments);
|
||||
yield return funcBuilder.EmitArgumentsDone(arguments);
|
||||
yield return funcBuilder.EmitDone();
|
||||
break;
|
||||
}
|
||||
|
||||
case TextReasoningContent reasoningContent:
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
var reasoningBuilder = stream.AddOutputItemReasoningItem();
|
||||
yield return reasoningBuilder.EmitAdded();
|
||||
|
||||
var summaryPart = reasoningBuilder.AddSummaryPart();
|
||||
yield return summaryPart.EmitAdded();
|
||||
|
||||
var text = reasoningContent.Text ?? string.Empty;
|
||||
yield return summaryPart.EmitTextDelta(text);
|
||||
yield return summaryPart.EmitTextDone(text);
|
||||
yield return summaryPart.EmitDone();
|
||||
reasoningBuilder.EmitSummaryPartDone(summaryPart);
|
||||
|
||||
yield return reasoningBuilder.EmitDone();
|
||||
break;
|
||||
}
|
||||
|
||||
case UsageContent usageContent when usageContent.Details is not null:
|
||||
{
|
||||
accumulatedUsage = ConvertUsage(usageContent.Details, accumulatedUsage);
|
||||
break;
|
||||
}
|
||||
|
||||
case ErrorContent errorContent:
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
hasTerminalEvent = true;
|
||||
|
||||
yield return stream.EmitFailed(
|
||||
ResponseErrorCode.ServerError,
|
||||
errorContent.Message ?? "An error occurred during agent execution.",
|
||||
accumulatedUsage);
|
||||
yield break;
|
||||
}
|
||||
|
||||
case DataContent:
|
||||
case UriContent:
|
||||
// Image/audio/file content from agents is not currently supported
|
||||
// as streaming output items in the Responses Server SDK builder pattern.
|
||||
// These would need to be serialized as base64 or URL references.
|
||||
break;
|
||||
|
||||
case FunctionResultContent:
|
||||
// Function results are internal to the agent's tool-calling loop
|
||||
// and are not emitted as output items in the response stream.
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining open message
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
if (!hasTerminalEvent)
|
||||
{
|
||||
yield return stream.EmitCompleted(accumulatedUsage);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ResponseStreamEvent> CloseCurrentMessage(
|
||||
OutputItemMessageBuilder? messageBuilder,
|
||||
TextContentBuilder? textBuilder,
|
||||
StringBuilder? accumulatedText)
|
||||
{
|
||||
if (messageBuilder is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (textBuilder is not null)
|
||||
{
|
||||
var finalText = accumulatedText?.ToString() ?? string.Empty;
|
||||
yield return textBuilder.EmitDone(finalText);
|
||||
yield return messageBuilder.EmitContentDone(textBuilder);
|
||||
}
|
||||
|
||||
yield return messageBuilder.EmitDone();
|
||||
}
|
||||
|
||||
private static bool IsSameMessage(string? currentId, string? previousId) =>
|
||||
currentId is not { Length: > 0 } || previousId is not { Length: > 0 } || currentId == previousId;
|
||||
|
||||
private static ResponseUsage ConvertUsage(UsageDetails details, ResponseUsage? existing)
|
||||
{
|
||||
var inputTokens = (long)(details.InputTokenCount ?? 0);
|
||||
var outputTokens = (long)(details.OutputTokenCount ?? 0);
|
||||
var totalTokens = (long)(details.TotalTokenCount ?? 0);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
inputTokens += existing.InputTokens;
|
||||
outputTokens += existing.OutputTokens;
|
||||
totalTokens += existing.TotalTokens;
|
||||
}
|
||||
|
||||
return AzureAIAgentServerResponsesModelFactory.ResponseUsage(
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
totalTokens: totalTokens);
|
||||
}
|
||||
|
||||
private static IEnumerable<ResponseStreamEvent> EmitWorkflowEvent(
|
||||
ResponseEventStream stream,
|
||||
WorkflowEvent workflowEvent,
|
||||
Dictionary<string, string> executorItemIds)
|
||||
{
|
||||
switch (workflowEvent)
|
||||
{
|
||||
case ExecutorInvokedEvent invokedEvent:
|
||||
{
|
||||
var itemId = GenerateItemId("wfa");
|
||||
executorItemIds[invokedEvent.ExecutorId] = itemId;
|
||||
|
||||
var item = new WorkflowActionOutputItem(
|
||||
kind: "InvokeExecutor",
|
||||
actionId: invokedEvent.ExecutorId,
|
||||
status: WorkflowActionOutputItemStatus.InProgress,
|
||||
id: itemId);
|
||||
|
||||
var builder = stream.AddOutputItem<WorkflowActionOutputItem>(itemId);
|
||||
yield return builder.EmitAdded(item);
|
||||
yield return builder.EmitDone(item);
|
||||
break;
|
||||
}
|
||||
|
||||
case ExecutorCompletedEvent completedEvent:
|
||||
{
|
||||
var itemId = GenerateItemId("wfa");
|
||||
|
||||
var item = new WorkflowActionOutputItem(
|
||||
kind: "InvokeExecutor",
|
||||
actionId: completedEvent.ExecutorId,
|
||||
status: WorkflowActionOutputItemStatus.Completed,
|
||||
id: itemId);
|
||||
|
||||
var builder = stream.AddOutputItem<WorkflowActionOutputItem>(itemId);
|
||||
yield return builder.EmitAdded(item);
|
||||
yield return builder.EmitDone(item);
|
||||
executorItemIds.Remove(completedEvent.ExecutorId);
|
||||
break;
|
||||
}
|
||||
|
||||
case ExecutorFailedEvent failedEvent:
|
||||
{
|
||||
var itemId = GenerateItemId("wfa");
|
||||
|
||||
var item = new WorkflowActionOutputItem(
|
||||
kind: "InvokeExecutor",
|
||||
actionId: failedEvent.ExecutorId,
|
||||
status: WorkflowActionOutputItemStatus.Failed,
|
||||
id: itemId);
|
||||
|
||||
var builder = stream.AddOutputItem<WorkflowActionOutputItem>(itemId);
|
||||
yield return builder.EmitAdded(item);
|
||||
yield return builder.EmitDone(item);
|
||||
executorItemIds.Remove(failedEvent.ExecutorId);
|
||||
break;
|
||||
}
|
||||
|
||||
// Informational/lifecycle events — no SDK output needed.
|
||||
// Note: AgentResponseUpdateEvent and WorkflowErrorEvent are unwrapped by
|
||||
// WorkflowSession.InvokeStageAsync() into regular AgentResponseUpdate objects
|
||||
// with populated Contents (TextContent, ErrorContent, etc.), so they flow
|
||||
// through the normal content processing path above — not through this method.
|
||||
case SuperStepStartedEvent:
|
||||
case SuperStepCompletedEvent:
|
||||
case WorkflowStartedEvent:
|
||||
case WorkflowWarningEvent:
|
||||
case RequestInfoEvent:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a valid item ID matching the SDK's <c>{prefix}_{50chars}</c> format.
|
||||
/// </summary>
|
||||
private static string GenerateItemId(string prefix)
|
||||
{
|
||||
// SDK format: {prefix}_{50 char body}
|
||||
var bytes = RandomNumberGenerator.GetBytes(25);
|
||||
var body = Convert.ToHexString(bytes); // 50 hex chars, uppercase
|
||||
return $"{prefix}_{body}";
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="IServiceCollection"/> to register the agent-framework
|
||||
/// response handler with the Azure AI Responses Server SDK.
|
||||
/// </summary>
|
||||
public static class AgentFrameworkResponsesServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers <see cref="AgentFrameworkResponseHandler"/> as the <see cref="ResponseHandler"/>
|
||||
/// for the Azure AI Responses Server SDK. Agents are resolved from keyed DI services
|
||||
/// using the <c>agent.name</c> or <c>metadata["entity_id"]</c> from incoming requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Call this method <b>after</b> <c>AddResponsesServer()</c> and after registering your
|
||||
/// <see cref="AIAgent"/> instances (e.g., via <c>AddAIAgent()</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Example:
|
||||
/// <code>
|
||||
/// builder.Services.AddResponsesServer();
|
||||
/// builder.AddAIAgent("my-agent", ...);
|
||||
/// builder.Services.AddAgentFrameworkHandler();
|
||||
///
|
||||
/// var app = builder.Build();
|
||||
/// app.MapResponsesServer();
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAgentFrameworkHandler(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a specific <see cref="AIAgent"/> as the handler for all incoming requests,
|
||||
/// regardless of the <c>agent.name</c> in the request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Use this overload when hosting a single agent. The provided agent instance is
|
||||
/// registered both as a keyed service and as the default <see cref="AIAgent"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Example:
|
||||
/// <code>
|
||||
/// builder.Services.AddResponsesServer();
|
||||
/// builder.Services.AddAgentFrameworkHandler(myAgent);
|
||||
///
|
||||
/// var app = builder.Build();
|
||||
/// app.MapResponsesServer();
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="agent">The agent instance to register.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAgentFrameworkHandler(this IServiceCollection services, AIAgent agent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
services.TryAddSingleton(agent);
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user