mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-foundry-agents
This commit is contained in:
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
@@ -152,6 +153,8 @@ public sealed class AGUIChatClient : DelegatingChatClient
|
||||
|
||||
private sealed class AGUIChatClientHandler : IChatClient
|
||||
{
|
||||
private static readonly MediaTypeHeaderValue s_json = new("application/json");
|
||||
|
||||
private readonly AGUIHttpService _httpService;
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
private readonly ILogger _logger;
|
||||
@@ -199,6 +202,9 @@ public sealed class AGUIChatClient : DelegatingChatClient
|
||||
var threadId = ExtractTemporaryThreadId(messagesList) ??
|
||||
ExtractThreadIdFromOptions(options) ?? $"thread_{Guid.NewGuid():N}";
|
||||
|
||||
// Extract state from the last message if it contains DataContent with application/json
|
||||
JsonElement state = this.ExtractAndRemoveStateFromMessages(messagesList);
|
||||
|
||||
// Create the input for the AGUI service
|
||||
var input = new RunAgentInput
|
||||
{
|
||||
@@ -207,6 +213,7 @@ public sealed class AGUIChatClient : DelegatingChatClient
|
||||
ThreadId = threadId,
|
||||
RunId = runId,
|
||||
Messages = messagesList.AsAGUIMessages(this._jsonSerializerOptions),
|
||||
State = state,
|
||||
};
|
||||
|
||||
// Add tools if provided
|
||||
@@ -300,6 +307,51 @@ public sealed class AGUIChatClient : DelegatingChatClient
|
||||
return threadId;
|
||||
}
|
||||
|
||||
// Extract state from the last message's DataContent with application/json media type
|
||||
// and remove that message from the list
|
||||
private JsonElement ExtractAndRemoveStateFromMessages(List<ChatMessage> messagesList)
|
||||
{
|
||||
if (messagesList.Count == 0)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
// Check the last message for state DataContent
|
||||
ChatMessage lastMessage = messagesList[messagesList.Count - 1];
|
||||
for (int i = 0; i < lastMessage.Contents.Count; i++)
|
||||
{
|
||||
if (lastMessage.Contents[i] is DataContent dataContent &&
|
||||
MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) &&
|
||||
mediaType.Equals(s_json))
|
||||
{
|
||||
// Deserialize the state JSON directly from UTF-8 bytes
|
||||
try
|
||||
{
|
||||
JsonElement stateElement = (JsonElement)JsonSerializer.Deserialize(
|
||||
dataContent.Data.Span,
|
||||
this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))!;
|
||||
|
||||
// Remove the DataContent from the message contents
|
||||
lastMessage.Contents.RemoveAt(i);
|
||||
|
||||
// If no contents remain, remove the entire message
|
||||
if (lastMessage.Contents.Count == 0)
|
||||
{
|
||||
messagesList.RemoveAt(messagesList.Count - 1);
|
||||
}
|
||||
|
||||
return stateElement;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize state JSON from DataContent: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// No resources to dispose
|
||||
@@ -316,7 +368,7 @@ public sealed class AGUIChatClient : DelegatingChatClient
|
||||
}
|
||||
}
|
||||
|
||||
private class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent
|
||||
private sealed class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent
|
||||
{
|
||||
public FunctionCallContent FunctionCallContent { get; } = functionCall;
|
||||
}
|
||||
|
||||
@@ -27,4 +27,8 @@ internal static class AGUIEventTypes
|
||||
public const string ToolCallEnd = "TOOL_CALL_END";
|
||||
|
||||
public const string ToolCallResult = "TOOL_CALL_RESULT";
|
||||
|
||||
public const string StateSnapshot = "STATE_SNAPSHOT";
|
||||
|
||||
public const string StateDelta = "STATE_DELTA";
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ namespace Microsoft.Agents.AI.AGUI;
|
||||
[JsonSerializable(typeof(ToolCallArgsEvent))]
|
||||
[JsonSerializable(typeof(ToolCallEndEvent))]
|
||||
[JsonSerializable(typeof(ToolCallResultEvent))]
|
||||
[JsonSerializable(typeof(StateSnapshotEvent))]
|
||||
[JsonSerializable(typeof(StateDeltaEvent))]
|
||||
[JsonSerializable(typeof(IDictionary<string, object?>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
[JsonSerializable(typeof(IDictionary<string, System.Text.Json.JsonElement?>))]
|
||||
@@ -57,6 +59,6 @@ namespace Microsoft.Agents.AI.AGUI;
|
||||
[JsonSerializable(typeof(float))]
|
||||
[JsonSerializable(typeof(bool))]
|
||||
[JsonSerializable(typeof(decimal))]
|
||||
internal partial class AGUIJsonSerializerContext : JsonSerializerContext
|
||||
internal sealed partial class AGUIJsonSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
|
||||
AGUIEventTypes.ToolCallArgs => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallArgsEvent))) as ToolCallArgsEvent,
|
||||
AGUIEventTypes.ToolCallEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallEndEvent))) as ToolCallEndEvent,
|
||||
AGUIEventTypes.ToolCallResult => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallResultEvent))) as ToolCallResultEvent,
|
||||
AGUIEventTypes.StateSnapshot => jsonElement.Deserialize(options.GetTypeInfo(typeof(StateSnapshotEvent))) as StateSnapshotEvent,
|
||||
_ => throw new JsonException($"Unknown BaseEvent type discriminator: '{discriminator}'")
|
||||
};
|
||||
|
||||
@@ -95,8 +96,14 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
|
||||
case ToolCallResultEvent toolCallResult:
|
||||
JsonSerializer.Serialize(writer, toolCallResult, options.GetTypeInfo(typeof(ToolCallResultEvent)));
|
||||
break;
|
||||
case StateSnapshotEvent stateSnapshot:
|
||||
JsonSerializer.Serialize(writer, stateSnapshot, options.GetTypeInfo(typeof(StateSnapshotEvent)));
|
||||
break;
|
||||
case StateDeltaEvent stateDelta:
|
||||
JsonSerializer.Serialize(writer, stateDelta, options.GetTypeInfo(typeof(StateDeltaEvent)));
|
||||
break;
|
||||
default:
|
||||
throw new JsonException($"Unknown BaseEvent type: {value.GetType().Name}");
|
||||
throw new InvalidOperationException($"Unknown event type: {value.GetType().Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -18,6 +19,9 @@ namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
|
||||
internal static class ChatResponseUpdateAGUIExtensions
|
||||
{
|
||||
private static readonly MediaTypeHeaderValue? s_jsonPatchMediaType = new("application/json-patch+json");
|
||||
private static readonly MediaTypeHeaderValue? s_json = new("application/json");
|
||||
|
||||
public static async IAsyncEnumerable<ChatResponseUpdate> AsChatResponseUpdatesAsync(
|
||||
this IAsyncEnumerable<BaseEvent> events,
|
||||
JsonSerializerOptions jsonSerializerOptions,
|
||||
@@ -70,11 +74,73 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
case ToolCallResultEvent toolCallResult:
|
||||
yield return toolCallAccumulator.EmitToolCallResult(toolCallResult, jsonSerializerOptions);
|
||||
break;
|
||||
|
||||
// State snapshot events
|
||||
case StateSnapshotEvent stateSnapshot:
|
||||
if (stateSnapshot.Snapshot.HasValue)
|
||||
{
|
||||
yield return CreateStateSnapshotUpdate(stateSnapshot, conversationId, responseId, jsonSerializerOptions);
|
||||
}
|
||||
break;
|
||||
case StateDeltaEvent stateDelta:
|
||||
if (stateDelta.Delta.HasValue)
|
||||
{
|
||||
yield return CreateStateDeltaUpdate(stateDelta, conversationId, responseId, jsonSerializerOptions);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class TextMessageBuilder()
|
||||
private static ChatResponseUpdate CreateStateSnapshotUpdate(
|
||||
StateSnapshotEvent stateSnapshot,
|
||||
string? conversationId,
|
||||
string? responseId,
|
||||
JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
// Serialize JsonElement directly to UTF-8 bytes using AOT-safe overload
|
||||
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(
|
||||
stateSnapshot.Snapshot!.Value,
|
||||
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
|
||||
DataContent dataContent = new(jsonBytes, "application/json");
|
||||
|
||||
return new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
ResponseId = responseId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["is_state_snapshot"] = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatResponseUpdate CreateStateDeltaUpdate(
|
||||
StateDeltaEvent stateDelta,
|
||||
string? conversationId,
|
||||
string? responseId,
|
||||
JsonSerializerOptions jsonSerializerOptions)
|
||||
{
|
||||
// Serialize JsonElement directly to UTF-8 bytes using AOT-safe overload
|
||||
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(
|
||||
stateDelta.Delta!.Value,
|
||||
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
|
||||
DataContent dataContent = new(jsonBytes, "application/json-patch+json");
|
||||
|
||||
return new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
ResponseId = responseId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["is_state_delta"] = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TextMessageBuilder()
|
||||
{
|
||||
private ChatRole _currentRole;
|
||||
private string? _currentMessageId;
|
||||
@@ -154,7 +220,7 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
};
|
||||
}
|
||||
|
||||
private class ToolCallBuilder
|
||||
private sealed class ToolCallBuilder
|
||||
{
|
||||
private string? _conversationId;
|
||||
private string? _responseId;
|
||||
@@ -348,6 +414,55 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
Role = AGUIRoles.Tool
|
||||
};
|
||||
}
|
||||
else if (content is DataContent dataContent)
|
||||
{
|
||||
if (MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) && mediaType.Equals(s_json))
|
||||
{
|
||||
// State snapshot event
|
||||
yield return new StateSnapshotEvent
|
||||
{
|
||||
#if NET472 || NETSTANDARD2_0
|
||||
Snapshot = (JsonElement?)JsonSerializer.Deserialize(
|
||||
dataContent.Data.ToArray(),
|
||||
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
|
||||
#else
|
||||
Snapshot = (JsonElement?)JsonSerializer.Deserialize(
|
||||
dataContent.Data.Span,
|
||||
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
|
||||
#endif
|
||||
};
|
||||
}
|
||||
else if (mediaType is { } && mediaType.Equals(s_jsonPatchMediaType))
|
||||
{
|
||||
// State snapshot patch event must be a valid JSON patch,
|
||||
// but its not up to us to validate that here.
|
||||
yield return new StateDeltaEvent
|
||||
{
|
||||
#if NET472 || NETSTANDARD2_0
|
||||
Delta = (JsonElement?)JsonSerializer.Deserialize(
|
||||
dataContent.Data.ToArray(),
|
||||
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
|
||||
#else
|
||||
Delta = (JsonElement?)JsonSerializer.Deserialize(
|
||||
dataContent.Data.Span,
|
||||
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
|
||||
#endif
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Text content event
|
||||
yield return new TextMessageContentEvent
|
||||
{
|
||||
MessageId = chatResponse.MessageId!,
|
||||
#if NET472 || NETSTANDARD2_0
|
||||
Delta = Encoding.UTF8.GetString(dataContent.Data.ToArray())
|
||||
#else
|
||||
Delta = Encoding.UTF8.GetString(dataContent.Data.Span)
|
||||
#endif
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class StateDeltaEvent : BaseEvent
|
||||
{
|
||||
public StateDeltaEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.StateDelta;
|
||||
}
|
||||
|
||||
[JsonPropertyName("delta")]
|
||||
public JsonElement? Delta { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class StateSnapshotEvent : BaseEvent
|
||||
{
|
||||
public StateSnapshotEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.StateSnapshot;
|
||||
}
|
||||
|
||||
[JsonPropertyName("snapshot")]
|
||||
public JsonElement? Snapshot { get; set; }
|
||||
}
|
||||
@@ -9,23 +9,23 @@ namespace Microsoft.Agents.AI.DevUI;
|
||||
/// </summary>
|
||||
public static class DevUIExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the necessary services for the DevUI to the application builder.
|
||||
/// </summary>
|
||||
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
builder.Services.AddOpenAIConversations();
|
||||
builder.Services.AddOpenAIResponses();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an endpoint that serves the DevUI from the '/devui' path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DevUI requires the OpenAI Responses and Conversations services to be registered with
|
||||
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIResponses(IServiceCollection)"/> and
|
||||
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIConversations(IServiceCollection)"/>,
|
||||
/// and the corresponding endpoints to be mapped using
|
||||
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses(IEndpointRouteBuilder)"/> and
|
||||
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIConversations(IEndpointRouteBuilder)"/>.
|
||||
/// </remarks>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
|
||||
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
|
||||
/// <seealso cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIResponses(IServiceCollection)"/>
|
||||
/// <seealso cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIConversations(IServiceCollection)"/>
|
||||
/// <seealso cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses(IEndpointRouteBuilder)"/>
|
||||
/// <seealso cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIConversations(IEndpointRouteBuilder)"/>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="endpoints"/> is null.</exception>
|
||||
public static IEndpointConventionBuilder MapDevUI(
|
||||
this IEndpointRouteBuilder endpoints)
|
||||
@@ -33,8 +33,6 @@ public static class DevUIExtensions
|
||||
var group = endpoints.MapGroup("");
|
||||
group.MapDevUI(pattern: "/devui");
|
||||
group.MapEntities();
|
||||
group.MapOpenAIConversations();
|
||||
group.MapOpenAIResponses();
|
||||
return group;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,16 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
// Register your agents
|
||||
builder.AddAIAgent("assistant", "You are a helpful assistant.");
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
// Add DevUI services
|
||||
builder.AddDevUI();
|
||||
}
|
||||
// Register services for OpenAI responses and conversations (also required for DevUI)
|
||||
builder.Services.AddOpenAIResponses();
|
||||
builder.Services.AddOpenAIConversations();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map endpoints for OpenAI responses and conversations (also required for DevUI)
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIConversations();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
// Map DevUI endpoint to /devui
|
||||
|
||||
@@ -83,7 +83,16 @@ public static class AIAgentExtensions
|
||||
{
|
||||
// A2A SDK assigns the url on its own
|
||||
// we can help user if they did not set Url explicitly.
|
||||
agentCard.Url ??= context;
|
||||
if (string.IsNullOrEmpty(agentCard.Url))
|
||||
{
|
||||
var agentCardUrl = context.TrimEnd('/');
|
||||
if (!context.EndsWith("/v1/card", StringComparison.Ordinal))
|
||||
{
|
||||
agentCardUrl += "/v1/card";
|
||||
}
|
||||
|
||||
agentCard.Url = agentCardUrl;
|
||||
}
|
||||
|
||||
return Task.FromResult(agentCard);
|
||||
};
|
||||
|
||||
+15
-10
@@ -44,22 +44,27 @@ public static class AGUIEndpointRouteBuilderExtensions
|
||||
var jsonSerializerOptions = jsonOptions.Value.SerializerOptions;
|
||||
|
||||
var messages = input.Messages.AsChatMessages(jsonSerializerOptions);
|
||||
var agent = aiAgent;
|
||||
var clientTools = input.Tools?.AsAITools().ToList();
|
||||
|
||||
ChatClientAgentRunOptions? runOptions = null;
|
||||
List<AITool>? clientTools = input.Tools?.AsAITools().ToList();
|
||||
if (clientTools?.Count > 0)
|
||||
// Create run options with AG-UI context in AdditionalProperties
|
||||
var runOptions = new ChatClientAgentRunOptions
|
||||
{
|
||||
runOptions = new ChatClientAgentRunOptions
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
Tools = clientTools,
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
Tools = clientTools
|
||||
["ag_ui_state"] = input.State,
|
||||
["ag_ui_context"] = input.Context?.Select(c => new KeyValuePair<string, string>(c.Description, c.Value)).ToArray(),
|
||||
["ag_ui_forwarded_properties"] = input.ForwardedProperties,
|
||||
["ag_ui_thread_id"] = input.ThreadId,
|
||||
["ag_ui_run_id"] = input.RunId
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var events = agent.RunStreamingAsync(
|
||||
// Run the agent and convert to AG-UI events
|
||||
var events = aiAgent.RunStreamingAsync(
|
||||
messages,
|
||||
options: runOptions,
|
||||
cancellationToken: cancellationToken)
|
||||
|
||||
@@ -18,7 +18,7 @@ internal abstract record Tool
|
||||
/// <summary>
|
||||
/// The type of the tool.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
[JsonIgnore]
|
||||
public abstract string Type { get; }
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ internal sealed record FunctionTool : Tool
|
||||
/// <summary>
|
||||
/// The type of the tool. Always "function".
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
[JsonIgnore]
|
||||
public override string Type => "function";
|
||||
|
||||
/// <summary>
|
||||
@@ -88,7 +88,7 @@ internal sealed record CustomTool : Tool
|
||||
/// <summary>
|
||||
/// The type of the tool. Always "custom".
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
[JsonIgnore]
|
||||
public override string Type => "custom";
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -24,6 +24,10 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
|
||||
this._agent = agent;
|
||||
}
|
||||
|
||||
public ValueTask<ResponseError?> ValidateRequestAsync(
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken = default) => ValueTask.FromResult<ResponseError?>(null);
|
||||
|
||||
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
|
||||
AgentInvocationContext context,
|
||||
CreateResponse request,
|
||||
|
||||
+2
-2
@@ -56,7 +56,7 @@ internal static class AgentRunResponseExtensions
|
||||
MaxOutputTokens = request.MaxOutputTokens,
|
||||
MaxToolCalls = request.MaxToolCalls,
|
||||
Metadata = request.Metadata is IReadOnlyDictionary<string, string> metadata ? new Dictionary<string, string>(metadata) : [],
|
||||
Model = request.Agent?.Name ?? request.Model,
|
||||
Model = request.Model,
|
||||
Output = output,
|
||||
ParallelToolCalls = request.ParallelToolCalls ?? true,
|
||||
PreviousResponseId = request.PreviousResponseId,
|
||||
@@ -64,7 +64,7 @@ internal static class AgentRunResponseExtensions
|
||||
PromptCacheKey = request.PromptCacheKey,
|
||||
Reasoning = request.Reasoning,
|
||||
SafetyIdentifier = request.SafetyIdentifier,
|
||||
ServiceTier = request.ServiceTier ?? "default",
|
||||
ServiceTier = request.ServiceTier,
|
||||
Status = ResponseStatus.Completed,
|
||||
Store = request.Store ?? true,
|
||||
Temperature = request.Temperature ?? 1.0,
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@ internal static class AgentRunResponseUpdateExtensions
|
||||
MaxOutputTokens = request.MaxOutputTokens,
|
||||
MaxToolCalls = request.MaxToolCalls,
|
||||
Metadata = request.Metadata != null ? new Dictionary<string, string>(request.Metadata) : [],
|
||||
Model = request.Agent?.Name ?? request.Model,
|
||||
Model = request.Model,
|
||||
Output = outputs?.ToList() ?? [],
|
||||
ParallelToolCalls = request.ParallelToolCalls ?? true,
|
||||
PreviousResponseId = request.PreviousResponseId,
|
||||
|
||||
+45
-38
@@ -13,8 +13,9 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Response executor that routes requests to hosted AIAgent services based on the model or agent.name parameter.
|
||||
/// Response executor that routes requests to hosted AIAgent services based on agent.name or metadata["entity_id"].
|
||||
/// This executor resolves agents from keyed services registered via AddAIAgent().
|
||||
/// The model field is reserved for actual model names and is never used for entity/agent identification.
|
||||
/// </summary>
|
||||
internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
{
|
||||
@@ -37,16 +38,46 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
this._logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<ResponseError?> ValidateRequestAsync(
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Extract agent name from agent.name or model parameter
|
||||
string? agentName = GetAgentName(request);
|
||||
|
||||
if (string.IsNullOrEmpty(agentName))
|
||||
{
|
||||
return ValueTask.FromResult<ResponseError?>(new ResponseError
|
||||
{
|
||||
Code = "missing_required_parameter",
|
||||
Message = "No 'agent.name' or 'metadata[\"entity_id\"]' specified in the request."
|
||||
});
|
||||
}
|
||||
|
||||
// Validate that the agent can be resolved
|
||||
AIAgent? agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
|
||||
if (agent is null)
|
||||
{
|
||||
this._logger.LogWarning("Failed to resolve agent with name '{AgentName}'", agentName);
|
||||
return ValueTask.FromResult<ResponseError?>(new ResponseError
|
||||
{
|
||||
Code = "agent_not_found",
|
||||
Message = $"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent()."
|
||||
});
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<ResponseError?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
|
||||
AgentInvocationContext context,
|
||||
CreateResponse request,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Validate and resolve agent synchronously to ensure validation errors are thrown immediately
|
||||
AIAgent agent = this.ResolveAgent(request);
|
||||
|
||||
// Create options with properties from the request
|
||||
string agentName = GetAgentName(request)!;
|
||||
AIAgent agent = this._serviceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
var chatOptions = new ChatOptions
|
||||
{
|
||||
ConversationId = request.Conversation?.Id,
|
||||
@@ -57,8 +88,6 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
ModelId = request.Model,
|
||||
};
|
||||
var options = new ChatClientAgentRunOptions(chatOptions);
|
||||
|
||||
// Convert input to chat messages
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var inputMessage in request.Input.GetInputMessages())
|
||||
@@ -66,7 +95,6 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
messages.Add(inputMessage.ToChatMessage());
|
||||
}
|
||||
|
||||
// Use the extension method to convert streaming updates to streaming response events
|
||||
await foreach (var streamingEvent in agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken)
|
||||
.ToStreamingResponseAsync(request, context, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
@@ -75,41 +103,20 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an agent from the service provider based on the request.
|
||||
/// Extracts the agent name for a request from the agent.name property, falling back to metadata["entity_id"].
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request.</param>
|
||||
/// <returns>The resolved AIAgent instance.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the agent cannot be resolved.</exception>
|
||||
private AIAgent ResolveAgent(CreateResponse request)
|
||||
/// <returns>The agent name.</returns>
|
||||
private static string? GetAgentName(CreateResponse request)
|
||||
{
|
||||
// Extract agent name from agent.name or model parameter
|
||||
var agentName = request.Agent?.Name ?? request.Model;
|
||||
if (string.IsNullOrEmpty(agentName))
|
||||
string? agentName = request.Agent?.Name;
|
||||
|
||||
// Fall back to metadata["entity_id"] if agent.name is not present
|
||||
if (string.IsNullOrEmpty(agentName) && request.Metadata?.TryGetValue("entity_id", out string? entityId) == true)
|
||||
{
|
||||
throw new InvalidOperationException("No 'agent.name' or 'model' specified in the request.");
|
||||
agentName = entityId;
|
||||
}
|
||||
|
||||
// Resolve the keyed agent service
|
||||
try
|
||||
{
|
||||
return this._serviceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Failed to resolve agent with name '{AgentName}'", agentName);
|
||||
throw new InvalidOperationException($"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent().", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the agent can be resolved without actually resolving it.
|
||||
/// This allows early validation before starting async execution.
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the agent cannot be resolved.</exception>
|
||||
public void ValidateAgent(CreateResponse request)
|
||||
{
|
||||
// Use the same logic as ResolveAgent but don't return the agent
|
||||
_ = this.ResolveAgent(request);
|
||||
return agentName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
|
||||
@@ -12,6 +13,16 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
|
||||
/// </summary>
|
||||
internal interface IResponseExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates a create response request before execution.
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request to validate.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A <see cref="ResponseError"/> if validation fails, null if validation succeeds.</returns>
|
||||
ValueTask<ResponseError?> ValidateRequestAsync(
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Executes a response generation request and returns streaming events.
|
||||
/// </summary>
|
||||
|
||||
@@ -18,6 +18,17 @@ internal interface IResponsesService
|
||||
/// Default limit for list operations.
|
||||
/// </summary>
|
||||
const int DefaultListLimit = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a create response request before execution.
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request to validate.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A ResponseError if validation fails, null if validation succeeds.</returns>
|
||||
ValueTask<ResponseError?> ValidateRequestAsync(
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a model response for the given input.
|
||||
/// </summary>
|
||||
|
||||
+18
-20
@@ -147,18 +147,27 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
|
||||
this._conversationStorage = conversationStorage;
|
||||
}
|
||||
|
||||
public async ValueTask<ResponseError?> ValidateRequestAsync(
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request.Conversation is not null && !string.IsNullOrEmpty(request.Conversation.Id) &&
|
||||
!string.IsNullOrEmpty(request.PreviousResponseId))
|
||||
{
|
||||
return new ResponseError
|
||||
{
|
||||
Code = "invalid_request",
|
||||
Message = "Mutually exclusive parameters: 'conversation' and 'previous_response_id'. Ensure you are only providing one of: 'previous_response_id' or 'conversation'."
|
||||
};
|
||||
}
|
||||
|
||||
return await this._executor.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Response> CreateResponseAsync(
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateRequest(request);
|
||||
|
||||
// Validate agent resolution early for HostedAgentResponseExecutor
|
||||
if (this._executor is HostedAgentResponseExecutor hostedExecutor)
|
||||
{
|
||||
hostedExecutor.ValidateAgent(request);
|
||||
}
|
||||
|
||||
if (request.Stream == true)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot create a streaming response using CreateResponseAsync. Use CreateResponseStreamingAsync instead.");
|
||||
@@ -189,8 +198,6 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
|
||||
CreateResponse request,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateRequest(request);
|
||||
|
||||
if (request.Stream == false)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot create a non-streaming response using CreateResponseStreamingAsync. Use CreateResponseAsync instead.");
|
||||
@@ -342,15 +349,6 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
|
||||
});
|
||||
}
|
||||
|
||||
private static void ValidateRequest(CreateResponse request)
|
||||
{
|
||||
if (request.Conversation is not null && !string.IsNullOrEmpty(request.Conversation.Id) &&
|
||||
!string.IsNullOrEmpty(request.PreviousResponseId))
|
||||
{
|
||||
throw new InvalidOperationException("Mutually exclusive parameters: 'conversation' and 'previous_response_id'. Ensure you are only providing one of: 'previous_response_id' or 'conversation'.");
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseState InitializeResponse(string responseId, CreateResponse request)
|
||||
{
|
||||
var metadata = request.Metadata ?? [];
|
||||
@@ -371,7 +369,7 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
|
||||
MaxOutputTokens = request.MaxOutputTokens,
|
||||
MaxToolCalls = request.MaxToolCalls,
|
||||
Metadata = metadata,
|
||||
Model = request.Model ?? "default",
|
||||
Model = request.Model,
|
||||
Output = [],
|
||||
ParallelToolCalls = request.ParallelToolCalls ?? true,
|
||||
PreviousResponseId = request.PreviousResponseId,
|
||||
|
||||
@@ -182,7 +182,9 @@ internal sealed class ResponseInputJsonConverter : JsonConverter<ResponseInput>
|
||||
return messages is not null ? ResponseInput.FromMessages(messages) : null;
|
||||
}
|
||||
|
||||
throw new JsonException($"Unexpected token type for ResponseInput: {reader.TokenType}");
|
||||
throw new JsonException(
|
||||
"ResponseInput must be either a string or an array of messages. " +
|
||||
$"Objects are not supported. Received token type: {reader.TokenType}");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -34,6 +34,21 @@ internal sealed class ResponsesHttpHandler
|
||||
[FromQuery] bool? stream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate the request first
|
||||
ResponseError? validationError = await this._responsesService.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return Results.BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = new ErrorDetails
|
||||
{
|
||||
Message = validationError.Message,
|
||||
Type = "invalid_request_error",
|
||||
Code = validationError.Code
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Handle streaming vs non-streaming
|
||||
@@ -55,45 +70,24 @@ internal sealed class ResponsesHttpHandler
|
||||
request,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Mutually exclusive"))
|
||||
{
|
||||
// Return OpenAI-style error for mutual exclusivity violations
|
||||
return Results.BadRequest(new ErrorResponse
|
||||
return response.Status switch
|
||||
{
|
||||
Error = new ErrorDetails
|
||||
{
|
||||
Message = ex.Message,
|
||||
Type = "invalid_request_error",
|
||||
Code = "mutually_exclusive_parameters"
|
||||
}
|
||||
});
|
||||
ResponseStatus.Failed when response.Error is { } error => Results.Problem(
|
||||
detail: error.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: error.Code ?? "Internal Server Error"),
|
||||
ResponseStatus.Failed => Results.Problem(),
|
||||
ResponseStatus.Queued => Results.Accepted(value: response),
|
||||
_ => Results.Ok(response)
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found") || ex.Message.Contains("does not exist"))
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Return OpenAI-style error for not found errors
|
||||
return Results.NotFound(new ErrorResponse
|
||||
{
|
||||
Error = new ErrorDetails
|
||||
{
|
||||
Message = ex.Message,
|
||||
Type = "invalid_request_error"
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("No 'agent.name' or 'model' specified"))
|
||||
{
|
||||
// Return OpenAI-style error for missing required parameters
|
||||
return Results.BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = new ErrorDetails
|
||||
{
|
||||
Message = ex.Message,
|
||||
Type = "invalid_request_error",
|
||||
Code = "missing_required_parameter"
|
||||
}
|
||||
});
|
||||
// Return InternalServerError for unexpected exceptions
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Internal Server Error");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user