Merge branch 'main' into features/foundry-hosting-it-msb3026-fix

This commit is contained in:
Roger Barreto
2026-05-07 19:16:45 +01:00
committed by GitHub
Unverified
33 changed files with 2404 additions and 195 deletions
+1 -1
View File
@@ -98,7 +98,7 @@
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
<!-- Agent SDKs -->
<PackageVersion Include="GitHub.Copilot.SDK" Version="0.1.29" />
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0-beta.2" />
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
<!-- M365 Agents SDK -->
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
@@ -12,7 +12,9 @@ static Task<PermissionRequestResult> PromptPermission(PermissionRequest request,
Console.Write("Approve? (y/n): ");
string? input = Console.ReadLine()?.Trim().ToUpperInvariant();
string kind = input is "Y" or "YES" ? "approved" : "denied-interactively-by-user";
PermissionRequestResultKind kind = input is "Y" or "YES"
? PermissionRequestResultKind.Approved
: PermissionRequestResultKind.Rejected;
return Task.FromResult(new PermissionRequestResult { Kind = kind });
}
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI;
@@ -55,6 +56,32 @@ internal static class AGUIChatMessageExtensions
break;
}
case AGUIReasoningMessage reasoningMessage:
{
var contents = new List<AIContent>();
if (!string.IsNullOrEmpty(reasoningMessage.Content))
{
contents.Add(new TextReasoningContent(reasoningMessage.Content)
{
ProtectedData = reasoningMessage.EncryptedValue
});
}
else if (!string.IsNullOrEmpty(reasoningMessage.EncryptedValue))
{
contents.Add(new TextReasoningContent("")
{
ProtectedData = reasoningMessage.EncryptedValue
});
}
yield return new ChatMessage(role, contents)
{
MessageId = message.Id
};
break;
}
case AGUIAssistantMessage assistantMessage when assistantMessage.ToolCalls is { Length: > 0 }:
{
var contents = new List<AIContent>();
@@ -125,6 +152,12 @@ internal static class AGUIChatMessageExtensions
}
else if (message.Role == ChatRole.Assistant)
{
var reasoningMessage = MapReasoningMessage(message);
if (reasoningMessage != null)
{
yield return reasoningMessage;
}
var assistantMessage = MapAssistantMessage(jsonSerializerOptions, message);
if (assistantMessage != null)
{
@@ -144,6 +177,32 @@ internal static class AGUIChatMessageExtensions
}
}
private static AGUIReasoningMessage? MapReasoningMessage(ChatMessage message)
{
var reasoning = message.Contents.OfType<TextReasoningContent>().FirstOrDefault();
if (reasoning is null)
{
return null;
}
var text = string.Join(
string.Empty,
message.Contents.OfType<TextReasoningContent>()
.Where(r => !string.IsNullOrEmpty(r.Text))
.Select(r => r.Text));
var protectedData = message.Contents.OfType<TextReasoningContent>()
.Select(r => r.ProtectedData)
.LastOrDefault(p => !string.IsNullOrEmpty(p));
return new AGUIReasoningMessage
{
Id = message.MessageId,
Content = text,
EncryptedValue = protectedData,
};
}
private static AGUIAssistantMessage? MapAssistantMessage(JsonSerializerOptions jsonSerializerOptions, ChatMessage message)
{
List<AGUIToolCall>? toolCalls = null;
@@ -212,5 +271,6 @@ internal static class AGUIChatMessageExtensions
string.Equals(role, AGUIRoles.Assistant, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant :
string.Equals(role, AGUIRoles.Developer, StringComparison.OrdinalIgnoreCase) ? s_developerChatRole :
string.Equals(role, AGUIRoles.Tool, StringComparison.OrdinalIgnoreCase) ? ChatRole.Tool :
string.Equals(role, AGUIRoles.Reasoning, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant :
throw new InvalidOperationException($"Unknown chat role: {role}");
}
@@ -31,4 +31,18 @@ internal static class AGUIEventTypes
public const string StateSnapshot = "STATE_SNAPSHOT";
public const string StateDelta = "STATE_DELTA";
public const string ReasoningStart = "REASONING_START";
public const string ReasoningMessageStart = "REASONING_MESSAGE_START";
public const string ReasoningMessageContent = "REASONING_MESSAGE_CONTENT";
public const string ReasoningMessageEnd = "REASONING_MESSAGE_END";
public const string ReasoningEnd = "REASONING_END";
public const string ReasoningMessageChunk = "REASONING_MESSAGE_CHUNK";
public const string ReasoningEncryptedValue = "REASONING_ENCRYPTED_VALUE";
}
@@ -28,6 +28,7 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(AGUIUserMessage))]
[JsonSerializable(typeof(AGUIAssistantMessage))]
[JsonSerializable(typeof(AGUIToolMessage))]
[JsonSerializable(typeof(AGUIReasoningMessage))]
[JsonSerializable(typeof(AGUITool))]
[JsonSerializable(typeof(AGUIToolCall))]
[JsonSerializable(typeof(AGUIToolCall[]))]
@@ -46,6 +47,13 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(ToolCallResultEvent))]
[JsonSerializable(typeof(StateSnapshotEvent))]
[JsonSerializable(typeof(StateDeltaEvent))]
[JsonSerializable(typeof(ReasoningStartEvent))]
[JsonSerializable(typeof(ReasoningMessageStartEvent))]
[JsonSerializable(typeof(ReasoningMessageContentEvent))]
[JsonSerializable(typeof(ReasoningMessageEndEvent))]
[JsonSerializable(typeof(ReasoningEndEvent))]
[JsonSerializable(typeof(ReasoningMessageChunkEvent))]
[JsonSerializable(typeof(ReasoningEncryptedValueEvent))]
[JsonSerializable(typeof(IDictionary<string, object?>))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(IDictionary<string, System.Text.Json.JsonElement?>))]
@@ -41,6 +41,7 @@ internal sealed class AGUIMessageJsonConverter : JsonConverter<AGUIMessage>
AGUIRoles.User => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIUserMessage))) as AGUIUserMessage,
AGUIRoles.Assistant => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIAssistantMessage))) as AGUIAssistantMessage,
AGUIRoles.Tool => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIToolMessage))) as AGUIToolMessage,
AGUIRoles.Reasoning => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIReasoningMessage))) as AGUIReasoningMessage,
_ => throw new JsonException($"Unknown AGUIMessage role discriminator: '{discriminator}'")
};
@@ -75,6 +76,9 @@ internal sealed class AGUIMessageJsonConverter : JsonConverter<AGUIMessage>
case AGUIToolMessage tool:
JsonSerializer.Serialize(writer, tool, options.GetTypeInfo(typeof(AGUIToolMessage)));
break;
case AGUIReasoningMessage reasoning:
JsonSerializer.Serialize(writer, reasoning, options.GetTypeInfo(typeof(AGUIReasoningMessage)));
break;
default:
throw new JsonException($"Unknown AGUIMessage type: {value.GetType().Name}");
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
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 AGUIReasoningMessage : AGUIMessage
{
public AGUIReasoningMessage()
{
this.Role = AGUIRoles.Reasoning;
}
[JsonPropertyName("encryptedValue")]
public string? EncryptedValue { get; set; }
}
@@ -17,4 +17,6 @@ internal static class AGUIRoles
public const string Developer = "developer";
public const string Tool = "tool";
public const string Reasoning = "reasoning";
}
@@ -47,6 +47,13 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
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,
AGUIEventTypes.ReasoningStart => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningStartEvent))) as ReasoningStartEvent,
AGUIEventTypes.ReasoningMessageStart => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageStartEvent))) as ReasoningMessageStartEvent,
AGUIEventTypes.ReasoningMessageContent => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageContentEvent))) as ReasoningMessageContentEvent,
AGUIEventTypes.ReasoningMessageEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageEndEvent))) as ReasoningMessageEndEvent,
AGUIEventTypes.ReasoningEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningEndEvent))) as ReasoningEndEvent,
AGUIEventTypes.ReasoningMessageChunk => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageChunkEvent))) as ReasoningMessageChunkEvent,
AGUIEventTypes.ReasoningEncryptedValue => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningEncryptedValueEvent))) as ReasoningEncryptedValueEvent,
_ => throw new JsonException($"Unknown BaseEvent type discriminator: '{discriminator}'")
};
@@ -102,6 +109,27 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
case StateDeltaEvent stateDelta:
JsonSerializer.Serialize(writer, stateDelta, options.GetTypeInfo(typeof(StateDeltaEvent)));
break;
case ReasoningStartEvent reasoningStart:
JsonSerializer.Serialize(writer, reasoningStart, options.GetTypeInfo(typeof(ReasoningStartEvent)));
break;
case ReasoningMessageStartEvent reasoningMessageStart:
JsonSerializer.Serialize(writer, reasoningMessageStart, options.GetTypeInfo(typeof(ReasoningMessageStartEvent)));
break;
case ReasoningMessageContentEvent reasoningMessageContent:
JsonSerializer.Serialize(writer, reasoningMessageContent, options.GetTypeInfo(typeof(ReasoningMessageContentEvent)));
break;
case ReasoningMessageEndEvent reasoningMessageEnd:
JsonSerializer.Serialize(writer, reasoningMessageEnd, options.GetTypeInfo(typeof(ReasoningMessageEndEvent)));
break;
case ReasoningEndEvent reasoningEnd:
JsonSerializer.Serialize(writer, reasoningEnd, options.GetTypeInfo(typeof(ReasoningEndEvent)));
break;
case ReasoningMessageChunkEvent reasoningMessageChunk:
JsonSerializer.Serialize(writer, reasoningMessageChunk, options.GetTypeInfo(typeof(ReasoningMessageChunkEvent)));
break;
case ReasoningEncryptedValueEvent reasoningEncryptedValue:
JsonSerializer.Serialize(writer, reasoningEncryptedValue, options.GetTypeInfo(typeof(ReasoningEncryptedValueEvent)));
break;
default:
throw new InvalidOperationException($"Unknown event type: {value.GetType().Name}");
}
@@ -31,6 +31,7 @@ internal static class ChatResponseUpdateAGUIExtensions
string? responseId = null;
var textMessageBuilder = new TextMessageBuilder();
var toolCallAccumulator = new ToolCallBuilder();
var reasoningBuilder = new ReasoningMessageBuilder();
await foreach (var evt in events.WithCancellation(cancellationToken).ConfigureAwait(false))
{
switch (evt)
@@ -41,6 +42,7 @@ internal static class ChatResponseUpdateAGUIExtensions
responseId = runStarted.RunId;
toolCallAccumulator.SetConversationAndResponseIds(conversationId, responseId);
textMessageBuilder.SetConversationAndResponseIds(conversationId, responseId);
reasoningBuilder.SetConversationAndResponseIds(conversationId, responseId);
yield return ValidateAndEmitRunStart(runStarted);
break;
case RunFinishedEvent runFinished:
@@ -88,6 +90,36 @@ internal static class ChatResponseUpdateAGUIExtensions
yield return CreateStateDeltaUpdate(stateDelta, conversationId, responseId, jsonSerializerOptions);
}
break;
// Reasoning events (explicit lifecycle form)
case ReasoningMessageStartEvent reasoningStart:
reasoningBuilder.AddReasoningStart(reasoningStart);
break;
case ReasoningMessageContentEvent reasoningContent:
yield return reasoningBuilder.EmitReasoningContent(reasoningContent);
break;
case ReasoningMessageEndEvent reasoningEnd:
reasoningBuilder.EndCurrentMessage(reasoningEnd);
break;
// Reasoning events (chunk shorthand form)
case ReasoningMessageChunkEvent reasoningChunk:
var chunkUpdate = reasoningBuilder.EmitReasoningChunk(reasoningChunk);
if (chunkUpdate is not null)
{
yield return chunkUpdate;
}
break;
// Encrypted reasoning value (emitted by either form)
case ReasoningEncryptedValueEvent encryptedValue:
yield return reasoningBuilder.EmitEncryptedValue(encryptedValue);
break;
// ReasoningStartEvent and ReasoningEndEvent are bracket markers only — no content to emit
case ReasoningStartEvent:
case ReasoningEndEvent:
break;
}
}
}
@@ -305,6 +337,81 @@ internal static class ChatResponseUpdateAGUIExtensions
}
}
private sealed class ReasoningMessageBuilder()
{
private string? _currentMessageId;
private string? _conversationId;
private string? _responseId;
public void SetConversationAndResponseIds(string? conversationId, string? responseId)
{
this._conversationId = conversationId;
this._responseId = responseId;
}
public void AddReasoningStart(ReasoningMessageStartEvent reasoningStart)
{
if (this._currentMessageId != null)
{
throw new InvalidOperationException(
"Received ReasoningMessageStartEvent while another message is being processed.");
}
this._currentMessageId = reasoningStart.MessageId;
}
public ChatResponseUpdate EmitReasoningContent(ReasoningMessageContentEvent contentEvent)
{
return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent(contentEvent.Delta)])
{
ConversationId = this._conversationId,
ResponseId = this._responseId,
MessageId = contentEvent.MessageId,
CreatedAt = DateTimeOffset.UtcNow
};
}
public ChatResponseUpdate? EmitReasoningChunk(ReasoningMessageChunkEvent chunkEvent)
{
if (string.IsNullOrEmpty(chunkEvent.Delta))
{
// Empty delta is the implicit close signal for chunk-based streaming
this._currentMessageId = null;
return null;
}
this._currentMessageId ??= chunkEvent.MessageId;
return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent(chunkEvent.Delta)])
{
ConversationId = this._conversationId,
ResponseId = this._responseId,
MessageId = chunkEvent.MessageId,
CreatedAt = DateTimeOffset.UtcNow
};
}
public ChatResponseUpdate EmitEncryptedValue(ReasoningEncryptedValueEvent encryptedEvent)
{
return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("") { ProtectedData = encryptedEvent.EncryptedValue }])
{
ConversationId = this._conversationId,
ResponseId = this._responseId,
MessageId = encryptedEvent.EntityId,
CreatedAt = DateTimeOffset.UtcNow
};
}
public void EndCurrentMessage(ReasoningMessageEndEvent reasoningEnd)
{
if (!string.Equals(this._currentMessageId, reasoningEnd.MessageId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
"Received ReasoningMessageEndEvent for a different message than the current one.");
}
this._currentMessageId = null;
}
}
private static IDictionary<string, object?>? DeserializeArgumentsIfAvailable(string argsJson, JsonSerializerOptions options)
{
if (!string.IsNullOrEmpty(argsJson))
@@ -342,6 +449,9 @@ internal static class ChatResponseUpdateAGUIExtensions
string? currentMessageId = null;
string? streamingMessageId = null;
string? currentReasoningBaseId = null;
string? currentReasoningId = null;
string? currentReasoningMessageId = null;
await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
// Generate a fallback MessageId when the provider doesn't supply one.
@@ -356,6 +466,25 @@ internal static class ChatResponseUpdateAGUIExtensions
chatResponse.Contents[0] is TextContent &&
!string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal))
{
// Close any open reasoning block before opening a text message, so AG-UI
// events are properly bracketed. MEAI providers share one MessageId across
// reasoning and text content, so the reasoning-block state alone wouldn't
// detect the transition.
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
currentReasoningBaseId = null;
currentReasoningId = null;
currentReasoningMessageId = null;
}
// End the previous message if there was one
if (currentMessageId is not null)
{
@@ -381,7 +510,7 @@ internal static class ChatResponseUpdateAGUIExtensions
{
yield return new TextMessageContentEvent
{
MessageId = chatResponse.MessageId!,
MessageId = currentMessageId!,
Delta = textContent.Text
};
}
@@ -393,6 +522,22 @@ internal static class ChatResponseUpdateAGUIExtensions
{
if (content is FunctionCallContent functionCallContent)
{
// Close any open reasoning block before emitting tool events.
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
currentReasoningBaseId = null;
currentReasoningId = null;
currentReasoningMessageId = null;
}
yield return new ToolCallStartEvent
{
ToolCallId = functionCallContent.CallId,
@@ -415,6 +560,22 @@ internal static class ChatResponseUpdateAGUIExtensions
}
else if (content is FunctionResultContent functionResultContent)
{
// Close any open reasoning block before emitting tool result events.
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
currentReasoningBaseId = null;
currentReasoningId = null;
currentReasoningMessageId = null;
}
yield return new ToolCallResultEvent
{
MessageId = chatResponse.MessageId,
@@ -423,6 +584,55 @@ internal static class ChatResponseUpdateAGUIExtensions
Role = AGUIRoles.Tool
};
}
else if (content is TextReasoningContent reasoningContent
&& (!string.IsNullOrEmpty(reasoningContent.Text) || !string.IsNullOrEmpty(reasoningContent.ProtectedData)))
{
if (!string.Equals(currentReasoningBaseId, chatResponse.MessageId, StringComparison.Ordinal))
{
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
}
currentReasoningBaseId = chatResponse.MessageId;
currentReasoningId = Guid.NewGuid().ToString("N");
currentReasoningMessageId = Guid.NewGuid().ToString("N");
yield return new ReasoningStartEvent
{
MessageId = currentReasoningId
};
yield return new ReasoningMessageStartEvent
{
MessageId = currentReasoningMessageId
};
}
if (!string.IsNullOrEmpty(reasoningContent.Text))
{
yield return new ReasoningMessageContentEvent
{
MessageId = currentReasoningMessageId!,
Delta = reasoningContent.Text
};
}
if (!string.IsNullOrEmpty(reasoningContent.ProtectedData))
{
yield return new ReasoningEncryptedValueEvent
{
EntityId = currentReasoningMessageId!,
EncryptedValue = reasoningContent.ProtectedData
};
}
}
else if (content is DataContent dataContent)
{
if (MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) && mediaType.Equals(s_json))
@@ -476,6 +686,19 @@ internal static class ChatResponseUpdateAGUIExtensions
}
}
// End the last reasoning block if there was one
if (currentReasoningMessageId is not null)
{
yield return new ReasoningMessageEndEvent
{
MessageId = currentReasoningMessageId
};
yield return new ReasoningEndEvent
{
MessageId = currentReasoningId!
};
}
// End the last message if there was one
if (currentMessageId is not null)
{
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
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 ReasoningEncryptedValueEvent : BaseEvent
{
public ReasoningEncryptedValueEvent()
{
this.Type = AGUIEventTypes.ReasoningEncryptedValue;
}
[JsonPropertyName("subtype")]
public string Subtype { get; set; } = "message";
[JsonPropertyName("entityId")]
public string EntityId { get; set; } = string.Empty;
[JsonPropertyName("encryptedValue")]
public string EncryptedValue { get; set; } = string.Empty;
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
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 ReasoningEndEvent : BaseEvent
{
public ReasoningEndEvent()
{
this.Type = AGUIEventTypes.ReasoningEnd;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
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 ReasoningMessageChunkEvent : BaseEvent
{
public ReasoningMessageChunkEvent()
{
this.Type = AGUIEventTypes.ReasoningMessageChunk;
}
[JsonPropertyName("messageId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? MessageId { get; set; }
[JsonPropertyName("delta")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Delta { get; set; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
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 ReasoningMessageContentEvent : BaseEvent
{
public ReasoningMessageContentEvent()
{
this.Type = AGUIEventTypes.ReasoningMessageContent;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
[JsonPropertyName("delta")]
public string Delta { get; set; } = string.Empty;
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
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 ReasoningMessageEndEvent : BaseEvent
{
public ReasoningMessageEndEvent()
{
this.Type = AGUIEventTypes.ReasoningMessageEnd;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
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 ReasoningMessageStartEvent : BaseEvent
{
public ReasoningMessageStartEvent()
{
this.Type = AGUIEventTypes.ReasoningMessageStart;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
[JsonPropertyName("role")]
public string Role { get; set; } = AGUIRoles.Reasoning;
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
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 ReasoningStartEvent : BaseEvent
{
public ReasoningStartEvent()
{
this.Type = AGUIEventTypes.ReasoningStart;
}
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
}
@@ -210,7 +210,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
string prompt = string.Join("\n", messages.Select(m => m.Text));
// Handle DataContent as attachments
(List<UserMessageDataAttachmentsItem>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
(List<UserMessageAttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
messages,
cancellationToken).ConfigureAwait(false);
@@ -443,11 +443,11 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage };
}
private static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
private static async Task<(List<UserMessageAttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken)
{
List<UserMessageDataAttachmentsItem>? attachments = null;
List<UserMessageAttachmentFile>? attachments = null;
string? tempDir = null;
foreach (ChatMessage message in messages)
{
@@ -461,7 +461,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
attachments ??= [];
attachments.Add(new UserMessageDataAttachmentsItemFile
attachments.Add(new UserMessageAttachmentFile
{
Path = tempFilePath,
DisplayName = Path.GetFileName(tempFilePath)
@@ -102,18 +102,20 @@ public sealed class AGUIChatMessageExtensionsTests
new AGUISystemMessage { Id = "msg1", Content = "System message" },
new AGUIUserMessage { Id = "msg2", Content = "User message" },
new AGUIAssistantMessage { Id = "msg3", Content = "Assistant message" },
new AGUIDeveloperMessage { Id = "msg4", Content = "Developer message" }
new AGUIDeveloperMessage { Id = "msg4", Content = "Developer message" },
new AGUIReasoningMessage { Id = "msg5", Content = "Reasoning message" }
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
Assert.Equal(4, chatMessages.Count);
Assert.Equal(5, chatMessages.Count);
Assert.Equal(ChatRole.System, chatMessages[0].Role);
Assert.Equal(ChatRole.User, chatMessages[1].Role);
Assert.Equal(ChatRole.Assistant, chatMessages[2].Role);
Assert.Equal("developer", chatMessages[3].Role.Value);
Assert.Equal(ChatRole.Assistant, chatMessages[4].Role);
}
[Fact]
@@ -367,6 +369,277 @@ public sealed class AGUIChatMessageExtensionsTests
Assert.Equal(ChatRole.Tool, role);
}
[Fact]
public void AsChatMessages_WithReasoningMessage_ConvertsToTextReasoningContent()
{
// Arrange
List<AGUIMessage> aguiMessages =
[
new AGUIReasoningMessage
{
Id = "reason1",
Content = "I need to consider the user's request.",
EncryptedValue = "ErgDCkgIDB..."
}
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
ChatMessage message = Assert.Single(chatMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("reason1", message.MessageId);
var reasoningContent = Assert.IsType<TextReasoningContent>(message.Contents[0]);
Assert.Equal("I need to consider the user's request.", reasoningContent.Text);
Assert.Equal("ErgDCkgIDB...", reasoningContent.ProtectedData);
}
[Fact]
public void AsChatMessages_WithReasoningMessageWithoutEncryptedValue_ConvertsToTextReasoningContent()
{
// Arrange
List<AGUIMessage> aguiMessages =
[
new AGUIReasoningMessage
{
Id = "reason1",
Content = "Thinking about this problem."
}
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
ChatMessage message = Assert.Single(chatMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
var reasoningContent = Assert.IsType<TextReasoningContent>(message.Contents[0]);
Assert.Equal("Thinking about this problem.", reasoningContent.Text);
Assert.Null(reasoningContent.ProtectedData);
}
[Fact]
public void AsChatMessages_WithReasoningMessageWithOnlyEncryptedValue_ConvertsToTextReasoningContent()
{
// Arrange
List<AGUIMessage> aguiMessages =
[
new AGUIReasoningMessage
{
Id = "reason1",
Content = string.Empty,
EncryptedValue = "ErgDCkgIDB..."
}
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
ChatMessage message = Assert.Single(chatMessages);
var reasoningContent = Assert.IsType<TextReasoningContent>(message.Contents[0]);
Assert.Equal("", reasoningContent.Text);
Assert.Equal("ErgDCkgIDB...", reasoningContent.ProtectedData);
}
[Fact]
public void AsChatMessages_WithEmptyReasoningMessage_ProducesEmptyContents()
{
// Arrange
List<AGUIMessage> aguiMessages =
[
new AGUIReasoningMessage
{
Id = "reason1",
Content = string.Empty
}
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
ChatMessage message = Assert.Single(chatMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Empty(message.Contents);
}
[Fact]
public void MapChatRole_WithReasoningRole_ReturnsAssistantChatRole()
{
// Arrange & Act
ChatRole role = AGUIChatMessageExtensions.MapChatRole(AGUIRoles.Reasoning);
// Assert
Assert.Equal(ChatRole.Assistant, role);
}
[Fact]
public void AsChatMessages_WithMixedMessagesIncludingReasoning_PreservesOrder()
{
// Arrange
List<AGUIMessage> aguiMessages =
[
new AGUIUserMessage { Id = "msg1", Content = "What is 2+2?" },
new AGUIReasoningMessage { Id = "msg2", Content = "I need to add 2 and 2.", EncryptedValue = "tok-123" },
new AGUIAssistantMessage { Id = "msg3", Content = "The answer is 4." }
];
// Act
List<ChatMessage> chatMessages = aguiMessages.AsChatMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
Assert.Equal(3, chatMessages.Count);
Assert.Equal(ChatRole.User, chatMessages[0].Role);
Assert.Equal(ChatRole.Assistant, chatMessages[1].Role);
Assert.IsType<TextReasoningContent>(chatMessages[1].Contents[0]);
Assert.Equal(ChatRole.Assistant, chatMessages[2].Role);
Assert.Equal("The answer is 4.", chatMessages[2].Text);
}
[Fact]
public void AsAGUIMessages_WithReasoningContent_ProducesReasoningMessage()
{
// Arrange
List<ChatMessage> chatMessages =
[
new ChatMessage(ChatRole.Assistant, [
new TextReasoningContent("I need to think about this.") { ProtectedData = "encrypted-tok-1" }
]) { MessageId = "reason-1" }
];
// Act
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
AGUIMessage message = Assert.Single(aguiMessages);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(message);
Assert.Equal("reason-1", reasoningMessage.Id);
Assert.Equal(AGUIRoles.Reasoning, reasoningMessage.Role);
Assert.Equal("I need to think about this.", reasoningMessage.Content);
Assert.Equal("encrypted-tok-1", reasoningMessage.EncryptedValue);
}
[Fact]
public void AsAGUIMessages_WithReasoningContentWithoutProtectedData_ProducesReasoningMessage()
{
// Arrange
List<ChatMessage> chatMessages =
[
new ChatMessage(ChatRole.Assistant, [
new TextReasoningContent("Just thinking.")
]) { MessageId = "reason-2" }
];
// Act
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
AGUIMessage message = Assert.Single(aguiMessages);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(message);
Assert.Equal("Just thinking.", reasoningMessage.Content);
Assert.Null(reasoningMessage.EncryptedValue);
}
[Fact]
public void AsAGUIMessages_WithMultipleReasoningChunksInOneMessage_ConcatenatesText()
{
// Arrange
List<ChatMessage> chatMessages =
[
new ChatMessage(ChatRole.Assistant, [
new TextReasoningContent("First part. "),
new TextReasoningContent("Second part.") { ProtectedData = "final-token" }
]) { MessageId = "reason-3" }
];
// Act
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
AGUIMessage message = Assert.Single(aguiMessages);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(message);
Assert.Equal("First part. Second part.", reasoningMessage.Content);
Assert.Equal("final-token", reasoningMessage.EncryptedValue);
}
[Fact]
public void AsAGUIMessages_WithMixedReasoningAndTextContent_EmitsBothMessages()
{
// Arrange
List<ChatMessage> chatMessages =
[
new ChatMessage(ChatRole.Assistant, [
new TextReasoningContent("Thinking about the answer.") { ProtectedData = "enc-tok" },
new TextContent("The answer is 42.")
]) { MessageId = "msg-mixed" }
];
// Act
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
Assert.Equal(2, aguiMessages.Count);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(aguiMessages[0]);
Assert.Equal("msg-mixed", reasoningMessage.Id);
Assert.Equal("Thinking about the answer.", reasoningMessage.Content);
Assert.Equal("enc-tok", reasoningMessage.EncryptedValue);
var assistantMessage = Assert.IsType<AGUIAssistantMessage>(aguiMessages[1]);
Assert.Equal("msg-mixed", assistantMessage.Id);
Assert.Equal("The answer is 42.", assistantMessage.Content);
}
[Fact]
public void AsAGUIMessages_WithReasoningAndToolCallInSameMessage_EmitsBothMessages()
{
// Arrange
var arguments = new Dictionary<string, object?> { ["location"] = "Seattle" };
List<ChatMessage> chatMessages =
[
new ChatMessage(ChatRole.Assistant, [
new TextReasoningContent("I should look up the weather."),
new FunctionCallContent("call-1", "GetWeather", arguments)
]) { MessageId = "msg-toolcall" }
];
// Act
List<AGUIMessage> aguiMessages = chatMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).ToList();
// Assert
Assert.Equal(2, aguiMessages.Count);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(aguiMessages[0]);
Assert.Equal("I should look up the weather.", reasoningMessage.Content);
var assistantMessage = Assert.IsType<AGUIAssistantMessage>(aguiMessages[1]);
Assert.NotNull(assistantMessage.ToolCalls);
var toolCall = Assert.Single(assistantMessage.ToolCalls);
Assert.Equal("call-1", toolCall.Id);
Assert.Equal("GetWeather", toolCall.Function.Name);
}
[Fact]
public void RoundTrip_ReasoningMessage_PreservesData()
{
// Arrange
List<ChatMessage> originalMessages =
[
new ChatMessage(ChatRole.Assistant, [
new TextReasoningContent("Thinking about the problem.") { ProtectedData = "ErgDCkgIDB..." }
]) { MessageId = "reason-rt" }
];
// Act - Convert to AGUI and back
AGUIMessage aguiMessage = originalMessages.AsAGUIMessages(AGUIJsonSerializerContext.Default.Options).Single();
List<AGUIMessage> aguiList = [aguiMessage];
ChatMessage reconstructed = aguiList.AsChatMessages(AGUIJsonSerializerContext.Default.Options).Single();
// Assert
Assert.Equal(ChatRole.Assistant, reconstructed.Role);
var reasoningContent = Assert.IsType<TextReasoningContent>(reconstructed.Contents[0]);
Assert.Equal("Thinking about the problem.", reasoningContent.Text);
Assert.Equal("ErgDCkgIDB...", reasoningContent.ProtectedData);
}
#region Custom Type Serialization Tests
[Fact]
@@ -64,6 +64,49 @@ public sealed class AGUIJsonSerializerContextTests
Assert.Single(input.Messages);
}
[Fact]
public void RunAgentInput_Deserializes_FromJsonWithReasoningMessages()
{
// Arrange
const string Json = """
{
"threadId": "thread1",
"runId": "run1",
"messages": [
{
"id": "m1",
"role": "user",
"content": "Hello"
},
{
"id": "m2",
"role": "reasoning",
"content": "I need to consider this.",
"encryptedValue": "ErgDCkgIDB..."
},
{
"id": "m3",
"role": "assistant",
"content": "Here is my answer."
}
]
}
""";
// Act
RunAgentInput? input = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.RunAgentInput);
// Assert
Assert.NotNull(input);
var messages = input.Messages.ToList();
Assert.Equal(3, messages.Count);
Assert.IsType<AGUIUserMessage>(messages[0]);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(messages[1]);
Assert.Equal("I need to consider this.", reasoningMessage.Content);
Assert.Equal("ErgDCkgIDB...", reasoningMessage.EncryptedValue);
Assert.IsType<AGUIAssistantMessage>(messages[2]);
}
[Fact]
public void RunAgentInput_HandlesOptionalFields_StateContextAndForwardedProperties()
{
@@ -963,7 +1006,76 @@ public sealed class AGUIJsonSerializerContextTests
}
[Fact]
public void AllFiveMessageTypes_SerializeAsPolymorphicArray_Correctly()
public void AGUIReasoningMessage_SerializesAndDeserializes_Correctly()
{
// Arrange
var originalMessage = new AGUIReasoningMessage
{
Id = "reason1",
Content = "I need to consider the user's request carefully.",
EncryptedValue = "ErgDCkgIDB..."
};
// Act
string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIReasoningMessage);
var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIReasoningMessage);
// Assert
Assert.NotNull(deserialized);
Assert.Equal("reason1", deserialized.Id);
Assert.Equal("I need to consider the user's request carefully.", deserialized.Content);
Assert.Equal("ErgDCkgIDB...", deserialized.EncryptedValue);
Assert.Equal(AGUIRoles.Reasoning, deserialized.Role);
}
[Fact]
public void AGUIReasoningMessage_WithoutEncryptedValue_SerializesAndDeserializes_Correctly()
{
// Arrange
var originalMessage = new AGUIReasoningMessage
{
Id = "reason2",
Content = "Thinking about this problem."
};
// Act
string json = JsonSerializer.Serialize(originalMessage, AGUIJsonSerializerContext.Default.AGUIReasoningMessage);
var deserialized = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.AGUIReasoningMessage);
// Assert
Assert.NotNull(deserialized);
Assert.Equal("reason2", deserialized.Id);
Assert.Equal("Thinking about this problem.", deserialized.Content);
Assert.Null(deserialized.EncryptedValue);
}
[Fact]
public void AGUIReasoningMessage_DeserializesViaPolymorphicConverter_Correctly()
{
// Arrange
const string Json = """
{
"id": "reason1",
"role": "reasoning",
"content": "Let me think about this.",
"encryptedValue": "tok-encrypted"
}
""";
// Act
AGUIMessage? message = JsonSerializer.Deserialize(Json, AGUIJsonSerializerContext.Default.AGUIMessage);
// Assert
Assert.NotNull(message);
var reasoningMessage = Assert.IsType<AGUIReasoningMessage>(message);
Assert.Equal("reason1", reasoningMessage.Id);
Assert.Equal(AGUIRoles.Reasoning, reasoningMessage.Role);
Assert.Equal("Let me think about this.", reasoningMessage.Content);
Assert.Equal("tok-encrypted", reasoningMessage.EncryptedValue);
}
[Fact]
public void AllSixMessageTypes_SerializeAsPolymorphicArray_Correctly()
{
// Arrange
AGUIMessage[] messages =
@@ -972,7 +1084,8 @@ public sealed class AGUIJsonSerializerContextTests
new AGUIDeveloperMessage { Id = "2", Content = "Developer message" },
new AGUIUserMessage { Id = "3", Content = "User message" },
new AGUIAssistantMessage { Id = "4", Content = "Assistant message" },
new AGUIToolMessage { Id = "5", ToolCallId = "call_1", Content = "{\"result\":\"success\"}" }
new AGUIToolMessage { Id = "5", ToolCallId = "call_1", Content = "{\"result\":\"success\"}" },
new AGUIReasoningMessage { Id = "6", Content = "Reasoning message", EncryptedValue = "tok-123" }
];
// Act
@@ -981,12 +1094,13 @@ public sealed class AGUIJsonSerializerContextTests
// Assert
Assert.NotNull(deserialized);
Assert.Equal(5, deserialized.Length);
Assert.Equal(6, deserialized.Length);
Assert.IsType<AGUISystemMessage>(deserialized[0]);
Assert.IsType<AGUIDeveloperMessage>(deserialized[1]);
Assert.IsType<AGUIUserMessage>(deserialized[2]);
Assert.IsType<AGUIAssistantMessage>(deserialized[3]);
Assert.IsType<AGUIToolMessage>(deserialized[4]);
Assert.IsType<AGUIReasoningMessage>(deserialized[5]);
}
#endregion
@@ -1111,4 +1225,149 @@ public sealed class AGUIJsonSerializerContextTests
}
#endregion
#region Reasoning Event Serialization Tests
[Fact]
public void ReasoningStartEvent_Serializes_WithCorrectTypeDiscriminator()
{
// Arrange
ReasoningStartEvent evt = new() { MessageId = "reason1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningStartEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningStart, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
}
[Fact]
public void ReasoningMessageStartEvent_Serializes_WithRoleReasoningAndMessageId()
{
// Arrange
ReasoningMessageStartEvent evt = new() { MessageId = "reason1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningMessageStartEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningMessageStart, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
Assert.Equal("reasoning", jsonElement.GetProperty("role").GetString());
}
[Fact]
public void ReasoningMessageContentEvent_Serializes_WithDeltaAndMessageId()
{
// Arrange
ReasoningMessageContentEvent evt = new() { MessageId = "reason1", Delta = "I am thinking" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningMessageContentEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningMessageContent, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
Assert.Equal("I am thinking", jsonElement.GetProperty("delta").GetString());
}
[Fact]
public void ReasoningMessageEndEvent_Serializes_WithMessageId()
{
// Arrange
ReasoningMessageEndEvent evt = new() { MessageId = "reason1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningMessageEndEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningMessageEnd, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
}
[Fact]
public void ReasoningEndEvent_Serializes_WithMessageId()
{
// Arrange
ReasoningEndEvent evt = new() { MessageId = "reason1" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningEndEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningEnd, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
}
[Fact]
public void ReasoningMessageChunkEvent_Serializes_WithDeltaAndMessageId()
{
// Arrange
ReasoningMessageChunkEvent evt = new() { MessageId = "reason1", Delta = "chunk" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningMessageChunkEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningMessageChunk, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("messageId").GetString());
Assert.Equal("chunk", jsonElement.GetProperty("delta").GetString());
}
[Fact]
public void ReasoningEncryptedValueEvent_Serializes_WithAllFields()
{
// Arrange
ReasoningEncryptedValueEvent evt = new() { EntityId = "reason1", EncryptedValue = "tok-abc123" };
// Act
string json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.ReasoningEncryptedValueEvent);
JsonElement jsonElement = JsonElement.Parse(json);
// Assert
Assert.Equal(AGUIEventTypes.ReasoningEncryptedValue, jsonElement.GetProperty("type").GetString());
Assert.Equal("reason1", jsonElement.GetProperty("entityId").GetString());
Assert.Equal("tok-abc123", jsonElement.GetProperty("encryptedValue").GetString());
Assert.Equal("message", jsonElement.GetProperty("subtype").GetString());
}
[Fact]
public void AllReasoningEventTypes_DeserializeViaBaseEventConverter_ToCorrectTypes()
{
// Arrange
BaseEvent[] events =
[
new ReasoningStartEvent { MessageId = "r1" },
new ReasoningMessageStartEvent { MessageId = "r1" },
new ReasoningMessageContentEvent { MessageId = "r1", Delta = "thinking" },
new ReasoningMessageEndEvent { MessageId = "r1" },
new ReasoningEndEvent { MessageId = "r1" },
new ReasoningMessageChunkEvent { MessageId = "r1", Delta = "chunk" },
new ReasoningEncryptedValueEvent { EntityId = "r1", EncryptedValue = "tok" }
];
// Act
string json = JsonSerializer.Serialize(events, AGUIJsonSerializerContext.Default.Options);
var deserialized = JsonSerializer.Deserialize<BaseEvent[]>(json, AGUIJsonSerializerContext.Default.Options);
// Assert
Assert.NotNull(deserialized);
Assert.Equal(7, deserialized.Length);
Assert.IsType<ReasoningStartEvent>(deserialized[0]);
Assert.IsType<ReasoningMessageStartEvent>(deserialized[1]);
Assert.IsType<ReasoningMessageContentEvent>(deserialized[2]);
Assert.IsType<ReasoningMessageEndEvent>(deserialized[3]);
Assert.IsType<ReasoningEndEvent>(deserialized[4]);
Assert.IsType<ReasoningMessageChunkEvent>(deserialized[5]);
Assert.IsType<ReasoningEncryptedValueEvent>(deserialized[6]);
}
#endregion Reasoning Event Serialization Tests
}
@@ -777,4 +777,469 @@ public sealed class ChatResponseUpdateAGUIExtensionsTests
}
#endregion State Delta Tests
#region Reasoning Tests
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageEndForWrongMessageId_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageStartEvent { MessageId = "reason1" },
new ReasoningMessageContentEvent { MessageId = "reason1", Delta = "thinking..." },
new ReasoningMessageEndEvent { MessageId = "reason2" } // Wrong message ID
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
// Consume stream to trigger exception
}
});
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningContent_EmitsCorrectReasoningEventSequenceAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("I need to think about this")]) { MessageId = "reason1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
Assert.IsType<RunStartedEvent>(outputEvents[0]);
var reasoningStart = Assert.IsType<ReasoningStartEvent>(outputEvents[1]);
var reasoningId = reasoningStart.MessageId;
Assert.NotEqual("reason1", reasoningId);
var reasoningMessageStart = Assert.IsType<ReasoningMessageStartEvent>(outputEvents[2]);
var reasoningMessageId = reasoningMessageStart.MessageId;
Assert.NotEqual(reasoningId, reasoningMessageId);
var reasoningContent = Assert.IsType<ReasoningMessageContentEvent>(outputEvents[3]);
Assert.Equal(reasoningMessageId, reasoningContent.MessageId);
Assert.Equal("I need to think about this", reasoningContent.Delta);
var reasoningMessageEnd = Assert.IsType<ReasoningMessageEndEvent>(outputEvents[4]);
Assert.Equal(reasoningMessageId, reasoningMessageEnd.MessageId);
var reasoningEnd = Assert.IsType<ReasoningEndEvent>(outputEvents[5]);
Assert.Equal(reasoningId, reasoningEnd.MessageId);
Assert.IsType<RunFinishedEvent>(outputEvents[6]);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithMultipleReasoningDeltas_EmitsContentEventPerDeltaAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("First")]) { MessageId = "reason1" },
new(ChatRole.Assistant, [new TextReasoningContent(" step")]) { MessageId = "reason1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
var contentEvents = outputEvents.OfType<ReasoningMessageContentEvent>().ToList();
Assert.Equal(2, contentEvents.Count);
Assert.Equal("First", contentEvents[0].Delta);
Assert.Equal(" step", contentEvents[1].Delta);
// Only one START/END pair
Assert.Single(outputEvents.OfType<ReasoningStartEvent>());
Assert.Single(outputEvents.OfType<ReasoningMessageStartEvent>());
Assert.Single(outputEvents.OfType<ReasoningMessageEndEvent>());
Assert.Single(outputEvents.OfType<ReasoningEndEvent>());
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningAndProtectedData_EmitsEncryptedValueEventAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking") { ProtectedData = "encrypted-abc" }]) { MessageId = "reason1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
var reasoningMessageId = outputEvents.OfType<ReasoningMessageStartEvent>().Single().MessageId;
Assert.NotEqual("reason1", reasoningMessageId);
var encryptedEvent = outputEvents.OfType<ReasoningEncryptedValueEvent>().Single();
Assert.Equal(reasoningMessageId, encryptedEvent.EntityId);
Assert.Equal("encrypted-abc", encryptedEvent.EncryptedValue);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningFollowedByText_EmitsBothEventSequencesAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking")]) { MessageId = "reason1" },
new(ChatRole.Assistant, [new TextContent("Hello")]) { MessageId = "msg1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
Assert.Contains(outputEvents, e => e is ReasoningStartEvent);
Assert.Contains(outputEvents, e => e is ReasoningMessageContentEvent);
Assert.Contains(outputEvents, e => e is ReasoningEndEvent);
Assert.Contains(outputEvents, e => e is TextMessageStartEvent);
Assert.Contains(outputEvents, e => e is TextMessageContentEvent);
Assert.Contains(outputEvents, e => e is TextMessageEndEvent);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningAndTextSharingSameMessageId_EmitsDistinctEventIdsAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking")]) { MessageId = "shared1" },
new(ChatRole.Assistant, [new TextContent("Hello")]) { MessageId = "shared1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
var reasoningId = outputEvents.OfType<ReasoningStartEvent>().Single().MessageId;
var reasoningMessageId = outputEvents.OfType<ReasoningMessageStartEvent>().Single().MessageId;
var textMessageId = outputEvents.OfType<TextMessageStartEvent>().Single().MessageId;
Assert.NotEqual(reasoningId, reasoningMessageId);
Assert.NotEqual(reasoningId, textMessageId);
Assert.NotEqual(reasoningMessageId, textMessageId);
Assert.Equal("shared1", textMessageId);
Assert.All(outputEvents.OfType<ReasoningMessageContentEvent>(), e => Assert.Equal(reasoningMessageId, e.MessageId));
Assert.Equal(reasoningMessageId, outputEvents.OfType<ReasoningMessageEndEvent>().Single().MessageId);
Assert.Equal(reasoningId, outputEvents.OfType<ReasoningEndEvent>().Single().MessageId);
Assert.All(outputEvents.OfType<TextMessageContentEvent>(), e => Assert.Equal("shared1", e.MessageId));
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningThenTextSharingSameMessageId_ClosesReasoningBlockBeforeTextStartAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking")]) { MessageId = "shared1" },
new(ChatRole.Assistant, [new TextContent("Hello")]) { MessageId = "shared1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
int reasoningMessageEndIndex = outputEvents.FindIndex(e => e is ReasoningMessageEndEvent);
int reasoningEndIndex = outputEvents.FindIndex(e => e is ReasoningEndEvent);
int textMessageStartIndex = outputEvents.FindIndex(e => e is TextMessageStartEvent);
Assert.True(reasoningMessageEndIndex < textMessageStartIndex);
Assert.True(reasoningEndIndex < textMessageStartIndex);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningThenToolCallSharingSameMessageId_ClosesReasoningBlockBeforeToolCallStartAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("thinking about which tool to use")]) { MessageId = "shared1" },
new(ChatRole.Assistant, [new FunctionCallContent("call-1", "GetWeather", new Dictionary<string, object?> { ["location"] = "Seattle" })]) { MessageId = "shared1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
int reasoningEndIndex = outputEvents.FindIndex(e => e is ReasoningEndEvent);
int toolCallStartIndex = outputEvents.FindIndex(e => e is ToolCallStartEvent);
Assert.True(reasoningEndIndex < toolCallStartIndex);
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithReasoningThenToolResultSharingSameMessageId_ClosesReasoningBlockBeforeToolResultAsync()
{
// Arrange
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("reflecting on result")]) { MessageId = "shared1" },
new(ChatRole.Tool, [new FunctionResultContent("call-1", "72F and sunny")]) { MessageId = "shared1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
int reasoningEndIndex = outputEvents.FindIndex(e => e is ReasoningEndEvent);
int toolCallResultIndex = outputEvents.FindIndex(e => e is ToolCallResultEvent);
Assert.True(reasoningEndIndex < toolCallResultIndex);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageSequence_ProducesTextReasoningContentPerDeltaAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningStartEvent { MessageId = "reason1" },
new ReasoningMessageStartEvent { MessageId = "reason1" },
new ReasoningMessageContentEvent { MessageId = "reason1", Delta = "First thought" },
new ReasoningMessageContentEvent { MessageId = "reason1", Delta = " and more" },
new ReasoningMessageEndEvent { MessageId = "reason1" },
new ReasoningEndEvent { MessageId = "reason1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Equal(2, updates.Count);
Assert.All(updates, u => Assert.Equal(ChatRole.Assistant, u.Role));
Assert.All(updates, u => Assert.Equal("reason1", u.MessageId));
var firstContent = Assert.IsType<TextReasoningContent>(updates[0].Contents[0]);
Assert.Equal("First thought", firstContent.Text);
var secondContent = Assert.IsType<TextReasoningContent>(updates[1].Contents[0]);
Assert.Equal(" and more", secondContent.Text);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningStartAndEndEvents_DoNotProduceUpdatesAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningStartEvent { MessageId = "reason1" },
new ReasoningEndEvent { MessageId = "reason1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Empty(updates);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningEncryptedValueEvent_ProducesTextReasoningContentWithProtectedDataAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningEncryptedValueEvent { EntityId = "reason1", EncryptedValue = "secret-token" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
Assert.Equal(ChatRole.Assistant, updates[0].Role);
Assert.Equal("reason1", updates[0].MessageId);
var content = Assert.IsType<TextReasoningContent>(updates[0].Contents[0]);
Assert.Equal("secret-token", content.ProtectedData);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageChunks_ProducesTextReasoningContentPerChunkAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = "chunk one" },
new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = " chunk two" },
new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = "" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Equal(2, updates.Count);
Assert.All(updates, u => Assert.Equal(ChatRole.Assistant, u.Role));
var firstContent = Assert.IsType<TextReasoningContent>(updates[0].Contents[0]);
Assert.Equal("chunk one", firstContent.Text);
var secondContent = Assert.IsType<TextReasoningContent>(updates[1].Contents[0]);
Assert.Equal(" chunk two", secondContent.Text);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageChunkEmptyDelta_ProducesNoUpdateAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageChunkEvent { MessageId = "reason1", Delta = "" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Empty(updates);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageStartWhileMessageInProgress_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageStartEvent { MessageId = "reason1" },
new ReasoningMessageStartEvent { MessageId = "reason2" } // Overlapping start
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
// Consume stream to trigger exception
}
});
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithReasoningMessageEndWithoutStart_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
List<BaseEvent> events =
[
new ReasoningMessageEndEvent { MessageId = "reason1" } // End without start
];
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
// Consume stream to trigger exception
}
});
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithProtectedDataOnly_EmitsEncryptedValueEventWithoutContentDeltaAsync()
{
// Arrange — TextReasoningContent with empty text but non-empty ProtectedData
List<ChatResponseUpdate> updates =
[
new(ChatRole.Assistant, [new TextReasoningContent("") { ProtectedData = "encrypted-only" }]) { MessageId = "reason1" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
Assert.Contains(outputEvents, e => e is ReasoningStartEvent);
Assert.Contains(outputEvents, e => e is ReasoningMessageStartEvent);
Assert.DoesNotContain(outputEvents, e => e is ReasoningMessageContentEvent);
var reasoningMessageId = outputEvents.OfType<ReasoningMessageStartEvent>().Single().MessageId;
Assert.NotEqual("reason1", reasoningMessageId);
var encryptedEvent = outputEvents.OfType<ReasoningEncryptedValueEvent>().Single();
Assert.Equal(reasoningMessageId, encryptedEvent.EntityId);
Assert.Equal("encrypted-only", encryptedEvent.EncryptedValue);
Assert.Contains(outputEvents, e => e is ReasoningMessageEndEvent);
Assert.Contains(outputEvents, e => e is ReasoningEndEvent);
}
[Fact]
public async Task ReasoningContent_RoundTrip_OutboundThenInbound_PreservesTextAndProtectedDataAsync()
{
// Arrange
List<ChatResponseUpdate> outboundUpdates =
[
new(ChatRole.Assistant, [new TextReasoningContent("I'm thinking") { ProtectedData = "enc-value" }]) { MessageId = "reason1" }
];
// Act - outbound: ChatResponseUpdate → AGUI events
List<BaseEvent> aguilEvents = [];
await foreach (BaseEvent evt in outboundUpdates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
aguilEvents.Add(evt);
}
// Act - inbound: AGUI events → ChatResponseUpdate
List<ChatResponseUpdate> inboundUpdates = [];
await foreach (ChatResponseUpdate update in aguilEvents.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
inboundUpdates.Add(update);
}
// Assert
var reasoningContents = inboundUpdates
.SelectMany(u => u.Contents)
.OfType<TextReasoningContent>()
.ToList();
Assert.Contains(reasoningContents, c => c.Text == "I'm thinking");
Assert.Contains(reasoningContents, c => c.ProtectedData == "enc-value");
}
#endregion Reasoning Tests
}
@@ -14,7 +14,7 @@ public class GitHubCopilotAgentTests
private const string SkipReason = "Integration tests require GitHub Copilot CLI installed. For local execution only.";
private static Task<PermissionRequestResult> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
=> Task.FromResult(new PermissionRequestResult { Kind = "approved" });
=> Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved });
[Fact(Skip = SkipReason)]
public async Task RunAsync_WithSimplePrompt_ReturnsResponseAsync()
@@ -201,11 +201,10 @@ public class GitHubCopilotAgentTests
SessionConfig sessionConfig = new()
{
OnPermissionRequest = OnPermissionRequestAsync,
McpServers = new Dictionary<string, object>
McpServers = new Dictionary<string, McpServerConfig>
{
["filesystem"] = new McpLocalServerConfig
["filesystem"] = new McpStdioServerConfig
{
Type = "stdio",
Command = "npx",
Args = ["-y", "@modelcontextprotocol/server-filesystem", "."],
Tools = ["*"],
@@ -234,11 +233,10 @@ public class GitHubCopilotAgentTests
SessionConfig sessionConfig = new()
{
OnPermissionRequest = OnPermissionRequestAsync,
McpServers = new Dictionary<string, object>
McpServers = new Dictionary<string, McpServerConfig>
{
["microsoft-learn"] = new McpRemoteServerConfig
["microsoft-learn"] = new McpHttpServerConfig
{
Type = "http",
Url = "https://learn.microsoft.com/api/mcp",
Tools = ["*"],
},
@@ -111,7 +111,7 @@ public sealed class GitHubCopilotAgentTests
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
var mcpServers = new Dictionary<string, object> { ["server1"] = new McpLocalServerConfig() };
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
var source = new SessionConfig
{
@@ -162,7 +162,7 @@ public sealed class GitHubCopilotAgentTests
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
var mcpServers = new Dictionary<string, object> { ["server1"] = new McpLocalServerConfig() };
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
var source = new SessionConfig
{
@@ -216,10 +216,12 @@ class AnthropicSettings(TypedDict, total=False):
Keys:
api_key: The Anthropic API key.
chat_model: The Anthropic chat model.
base_url: Optional base URL for the Anthropic API endpoint.
"""
api_key: SecretString | None
chat_model: str | None
base_url: str | None
class RawAnthropicClient(
@@ -248,6 +250,7 @@ class RawAnthropicClient(
*,
api_key: str | None = None,
model: str | None = None,
base_url: str | None = None,
anthropic_client: AnthropicAsyncClient | None = None,
additional_beta_flags: list[str] | None = None,
additional_properties: dict[str, Any] | None = None,
@@ -259,6 +262,8 @@ class RawAnthropicClient(
Keyword Args:
api_key: The Anthropic API key to use for authentication.
model: The model to use.
base_url: Optional base URL for the Anthropic API endpoint. Useful for Foundry or
other compatible deployments. Falls back to ``ANTHROPIC_BASE_URL`` env variable.
anthropic_client: An existing Anthropic client to use. If not provided, one will be created.
This can be used to further configure the client before passing it in.
For instance if you need to set a different base_url for testing or private deployments.
@@ -284,6 +289,13 @@ class RawAnthropicClient(
api_key="your_anthropic_api_key",
)
# Or with a custom base URL (e.g. for Foundry-compatible endpoints)
client = RawAnthropicClient(
model="claude-sonnet-4-5-20250929",
api_key="your_anthropic_api_key",
base_url="https://custom-anthropic-endpoint.com",
)
# Or loading from a .env file
client = RawAnthropicClient(env_file_path="path/to/.env")
@@ -316,12 +328,14 @@ class RawAnthropicClient(
env_prefix="ANTHROPIC_",
api_key=api_key,
chat_model=model,
base_url=base_url,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
api_key_secret = anthropic_settings.get("api_key")
model_setting = anthropic_settings.get("chat_model")
base_url_setting = anthropic_settings.get("base_url")
if anthropic_client is None:
if api_key_secret is None:
@@ -332,6 +346,7 @@ class RawAnthropicClient(
anthropic_client = AsyncAnthropic(
api_key=api_key_secret.get_secret_value(),
base_url=base_url_setting,
default_headers={"User-Agent": get_user_agent()},
)
@@ -1409,6 +1424,7 @@ class AnthropicClient(
*,
api_key: str | None = None,
model: str | None = None,
base_url: str | None = None,
anthropic_client: AnthropicAsyncClient | None = None,
additional_beta_flags: list[str] | None = None,
additional_properties: dict[str, Any] | None = None,
@@ -1422,6 +1438,8 @@ class AnthropicClient(
Keyword Args:
api_key: The Anthropic API key to use for authentication.
model: The model to use.
base_url: Optional base URL for the Anthropic API endpoint. Useful for Foundry or
other compatible deployments. Falls back to ``ANTHROPIC_BASE_URL`` env variable.
anthropic_client: An existing Anthropic client to use. If not provided, one will be created.
This can be used to further configure the client before passing it in.
For instance if you need to set a different base_url for testing or private deployments.
@@ -1448,6 +1466,13 @@ class AnthropicClient(
api_key="your_anthropic_api_key",
)
# Or with a custom base URL (e.g. for Foundry-compatible endpoints)
client = AnthropicClient(
model="claude-sonnet-4-5-20250929",
api_key="your_anthropic_api_key",
base_url="https://custom-anthropic-endpoint.com",
)
# Or loading from a .env file
client = AnthropicClient(env_file_path="path/to/.env")
@@ -1477,6 +1502,7 @@ class AnthropicClient(
super().__init__(
api_key=api_key,
model=model,
base_url=base_url,
anthropic_client=anthropic_client,
additional_beta_flags=additional_beta_flags,
additional_properties=additional_properties,
@@ -149,6 +149,108 @@ def test_anthropic_client_init_auto_create_client(
assert client.model == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"]
def test_anthropic_client_init_with_base_url(
anthropic_unit_test_env: dict[str, str],
) -> None:
"""Test AnthropicClient accepts a base_url and passes it to the underlying AsyncAnthropic client."""
custom_url = "https://custom-anthropic-endpoint.com"
client = AnthropicClient(
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
base_url=custom_url,
)
assert custom_url in str(client.anthropic_client.base_url)
def test_raw_anthropic_client_init_with_base_url(
anthropic_unit_test_env: dict[str, str],
) -> None:
"""Test RawAnthropicClient accepts a base_url and passes it to the underlying AsyncAnthropic client."""
custom_url = "https://custom-anthropic-endpoint.com"
client = RawAnthropicClient(
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
base_url=custom_url,
)
assert custom_url in str(client.anthropic_client.base_url)
@pytest.mark.parametrize(
"override_env_param_dict",
[{"ANTHROPIC_BASE_URL": "https://env-base-url.example.com"}],
indirect=True,
)
def test_anthropic_client_init_base_url_from_env(
anthropic_unit_test_env: dict[str, str],
) -> None:
"""Test AnthropicClient picks up base_url from ANTHROPIC_BASE_URL env variable when not passed explicitly."""
client = AnthropicClient(
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
)
assert anthropic_unit_test_env["ANTHROPIC_BASE_URL"] in str(client.anthropic_client.base_url)
@pytest.mark.parametrize(
"override_env_param_dict",
[{"ANTHROPIC_BASE_URL": "https://env-base-url.example.com"}],
indirect=True,
)
def test_raw_anthropic_client_init_base_url_from_env(
anthropic_unit_test_env: dict[str, str],
) -> None:
"""Test RawAnthropicClient picks up base_url from ANTHROPIC_BASE_URL env variable when not passed explicitly."""
client = RawAnthropicClient(
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
)
assert anthropic_unit_test_env["ANTHROPIC_BASE_URL"] in str(client.anthropic_client.base_url)
@pytest.mark.parametrize(
"override_env_param_dict",
[{"ANTHROPIC_BASE_URL": "https://env-base-url.example.com"}],
indirect=True,
)
def test_anthropic_client_init_explicit_base_url_wins_over_env(
anthropic_unit_test_env: dict[str, str],
) -> None:
"""Test that an explicit base_url kwarg takes priority over ANTHROPIC_BASE_URL env variable."""
explicit_url = "https://explicit-endpoint.example.com"
client = AnthropicClient(
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
base_url=explicit_url,
)
assert explicit_url in str(client.anthropic_client.base_url)
assert anthropic_unit_test_env["ANTHROPIC_BASE_URL"] not in str(client.anthropic_client.base_url)
@pytest.mark.parametrize(
"override_env_param_dict",
[{"ANTHROPIC_BASE_URL": "https://env-base-url.example.com"}],
indirect=True,
)
def test_raw_anthropic_client_init_explicit_base_url_wins_over_env(
anthropic_unit_test_env: dict[str, str],
) -> None:
"""Test that an explicit base_url kwarg takes priority over ANTHROPIC_BASE_URL env variable."""
explicit_url = "https://explicit-endpoint.example.com"
client = RawAnthropicClient(
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"],
base_url=explicit_url,
)
assert explicit_url in str(client.anthropic_client.base_url)
assert anthropic_unit_test_env["ANTHROPIC_BASE_URL"] not in str(client.anthropic_client.base_url)
def test_anthropic_client_init_missing_api_key() -> None:
"""Test AnthropicClient initialization when API key is missing."""
with patch("agent_framework_anthropic._chat_client.load_settings") as mock_load:
@@ -352,8 +352,8 @@ __all__ = [
"ContinuationToken",
"ConversationSplit",
"ConversationSplitter",
"Default",
"DeduplicatingSkillsSource",
"Default",
"DelegatingSkillsSource",
"Edge",
"EdgeCondition",
@@ -446,14 +446,10 @@ class FileSkillScript(SkillScript):
"""
if not isinstance(skill, FileSkill):
raise TypeError(
f"File-based script '{self.name}' requires a FileSkill "
f"but received '{type(skill).__name__}'."
f"File-based script '{self.name}' requires a FileSkill but received '{type(skill).__name__}'."
)
if self._runner is None:
raise ValueError(
f"Script '{self.name}' requires a runner. "
"Provide a script_runner for file-based scripts."
)
raise ValueError(f"Script '{self.name}' requires a runner. Provide a script_runner for file-based scripts.")
result = self._runner(skill, self, args)
if inspect.isawaitable(result):
return await result
@@ -570,8 +566,7 @@ def _validate_skill_description(name: str, description: str) -> None:
raise ValueError("Skill description cannot be empty.")
if len(description) > MAX_DESCRIPTION_LENGTH:
raise ValueError(
f"Skill '{name}' has an invalid description: "
f"Must be {MAX_DESCRIPTION_LENGTH} characters or fewer."
f"Skill '{name}' has an invalid description: Must be {MAX_DESCRIPTION_LENGTH} characters or fewer."
)
@@ -1993,10 +1988,7 @@ class FileSkillsSource(SkillsSource):
raise ValueError(f"Resource file '{resource_name}' not found in skill directory '{skill_dir}'.")
if FileSkillsSource._has_symlink_in_path(resource_full_path, root_directory_path):
raise ValueError(
f"Resource file '{resource_name}' "
"has a symlink in its path; symlinks are not allowed."
)
raise ValueError(f"Resource file '{resource_name}' has a symlink in its path; symlinks are not allowed.")
return resource_full_path
+15 -9
View File
@@ -1190,7 +1190,9 @@ class TestSkillsProviderCodeSkill:
provider = SkillsProvider([skill])
await _init_provider(provider)
result = await provider._read_skill_resource(_raw_skills(provider), "prog-skill", "get_user_data", auth_token="abc")
result = await provider._read_skill_resource(
_raw_skills(provider), "prog-skill", "get_user_data", auth_token="abc"
)
assert result == "data with token=abc"
async def test_read_callable_resource_without_kwargs_ignores_extra_args(self) -> None:
@@ -2059,6 +2061,7 @@ class TestSkillResourceRead:
async def test_read_async_function(self) -> None:
"""read() awaits an async function and returns its result."""
async def get_data() -> str:
return "async result"
@@ -2068,6 +2071,7 @@ class TestSkillResourceRead:
async def test_read_function_with_kwargs(self) -> None:
"""read() forwards kwargs to functions that accept them."""
def get_config(**kwargs: Any) -> str:
return f"user={kwargs.get('user_id')}"
@@ -2077,6 +2081,7 @@ class TestSkillResourceRead:
async def test_read_async_function_with_kwargs(self) -> None:
"""read() forwards kwargs to async functions that accept them."""
async def get_config(**kwargs: Any) -> str:
return f"user={kwargs.get('user_id')}"
@@ -2086,6 +2091,7 @@ class TestSkillResourceRead:
async def test_read_function_without_kwargs_ignores_extra(self) -> None:
"""read() does not pass kwargs to functions that don't accept them."""
def simple() -> str:
return "fixed"
@@ -2095,6 +2101,7 @@ class TestSkillResourceRead:
async def test_read_function_raises_propagates(self) -> None:
"""read() propagates exceptions from the function."""
def failing() -> str:
raise RuntimeError("boom")
@@ -2747,6 +2754,7 @@ class TestSkillsProviderFactories:
async def test_code_script_returns_object(self) -> None:
"""Code-defined scripts can return non-string objects."""
def returns_dict() -> dict:
return {"status": "ok", "value": 42}
@@ -2855,8 +2863,8 @@ class TestSkillsProviderFactories:
provider = SkillsProvider([skill])
await _init_provider(provider)
result = await provider._run_skill_script(_raw_skills(provider),
"my-skill", "process", args={"mode": "llm-value"}, mode="runtime-value"
result = await provider._run_skill_script(
_raw_skills(provider), "my-skill", "process", args={"mode": "llm-value"}, mode="runtime-value"
)
assert "Error" in result
@@ -2946,6 +2954,7 @@ class TestSkillsProviderFactories:
async def test_code_script_exception_returns_error(self) -> None:
"""A code script function that raises should return an error string."""
def failing_script() -> str:
raise RuntimeError("Something went wrong")
@@ -3170,6 +3179,7 @@ class TestLoadSkillWithScripts:
async def test_code_skill_scripts_element_contains_parameters(self) -> None:
"""Scripts XML includes parameters schema when the function has typed parameters."""
def analyze(query: str, limit: int = 10) -> str:
return "result"
@@ -3755,9 +3765,7 @@ class TestSourceComposition:
)
(skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
source = DeduplicatingSkillsSource(
FileSkillsSource(str(tmp_path), script_runner=_noop_script_runner)
)
source = DeduplicatingSkillsSource(FileSkillsSource(str(tmp_path), script_runner=_noop_script_runner))
provider = SkillsProvider(source)
await _init_provider(provider)
assert "my-skill" in _ctx(provider)[0]
@@ -3798,9 +3806,7 @@ class TestSourceComposition:
call_log.append("source")
return "source"
source = DeduplicatingSkillsSource(
FileSkillsSource(str(tmp_path), script_runner=source_runner)
)
source = DeduplicatingSkillsSource(FileSkillsSource(str(tmp_path), script_runner=source_runner))
provider = SkillsProvider(source)
await _init_provider(provider)
@@ -7,8 +7,11 @@ import base64
import json
import logging
import os
import tempfile
import threading
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from typing import cast
from contextlib import suppress
from typing import Protocol, cast
from agent_framework import (
ChatOptions,
@@ -109,11 +112,105 @@ from typing_extensions import Any
logger = logging.getLogger(__name__)
class ApprovalStorage(Protocol):
"""Storage for saving function approval requests."""
async def save_approval_request(self, approval_request_id: str, request: Content) -> None:
"""Save a function approval request under the given ID."""
...
async def load_approval_request(self, approval_request_id: str) -> Content:
"""Load a function approval request by its ID."""
...
class InMemoryFunctionApprovalStorage:
"""An in-memory storage for function approval requests."""
def __init__(self) -> None:
self._store: dict[str, Content] = {}
async def save_approval_request(self, approval_request_id: str, request: Content) -> None:
if approval_request_id in self._store:
raise ValueError(f"Approval request with ID '{approval_request_id}' already exists.")
self._store[approval_request_id] = request
async def load_approval_request(self, approval_request_id: str) -> Content:
if approval_request_id not in self._store:
raise KeyError(f"Approval request with ID '{approval_request_id}' does not exist.")
return self._store[approval_request_id]
class FileBasedFunctionApprovalStorage:
"""A simple file-based storage for function approval requests.
Concurrent writes from multiple threads in the same process are
serialized by a ``threading.Lock``, and the on-disk JSON file is
updated atomically (write to a temp file, then ``os.replace``) so a
crash mid-write cannot leave a partially written file behind.
"""
def __init__(self, storage_path: str) -> None:
self._storage_path = storage_path
self._lock = threading.Lock()
def _create_storage_file_if_not_exists_sync(self) -> None:
"""Lazy-create the storage file (and its parent directory) if it does not already exist.
Uses exclusive-create mode (``"x"``) so a concurrent creator cannot
be truncated by an ``open(..., "w")`` after a stale existence check.
"""
os.makedirs(os.path.dirname(self._storage_path) or ".", exist_ok=True)
with suppress(FileExistsError), open(self._storage_path, "x") as f:
json.dump({}, f)
def _atomic_write(self, data: dict[str, Any]) -> None:
"""Atomically replace the storage file with the serialized ``data``."""
directory = os.path.dirname(self._storage_path) or "."
# Serialize first so any error doesn't leave a partial file behind.
serialized = json.dumps(data)
fd, tmp_path = tempfile.mkstemp(prefix=".approvals-", suffix=".tmp", dir=directory)
try:
with os.fdopen(fd, "w") as tmp:
tmp.write(serialized)
os.replace(tmp_path, self._storage_path)
except BaseException:
with suppress(OSError):
os.unlink(tmp_path)
raise
def _save_sync(self, approval_request_id: str, request: Content) -> None:
with self._lock:
self._create_storage_file_if_not_exists_sync()
with open(self._storage_path) as f:
data = json.load(f)
if approval_request_id in data:
raise ValueError(f"Approval request with ID '{approval_request_id}' already exists.")
data[approval_request_id] = request.to_dict()
self._atomic_write(data)
def _load_sync(self, approval_request_id: str) -> Content:
with self._lock:
self._create_storage_file_if_not_exists_sync()
with open(self._storage_path) as f:
data = json.load(f)
if approval_request_id not in data:
raise KeyError(f"Approval request with ID '{approval_request_id}' does not exist.")
return Content.from_dict(data[approval_request_id])
async def save_approval_request(self, approval_request_id: str, request: Content) -> None:
await asyncio.to_thread(self._save_sync, approval_request_id, request)
async def load_approval_request(self, approval_request_id: str) -> Content:
return await asyncio.to_thread(self._load_sync, approval_request_id)
class ResponsesHostServer(ResponsesAgentServerHost):
"""A responses server host for an agent."""
# TODO(@taochen): Allow a different checkpoint storage that stores checkpoints externally
CHECKPOINT_STORAGE_PATH = "/.checkpoints"
FUNCTION_APPROVAL_STORAGE_PATH = "/.function_approvals/approval_requests.json"
def __init__(
self,
@@ -171,6 +268,11 @@ class ResponsesHostServer(ResponsesAgentServerHost):
self._is_workflow_agent = True
self._agent = agent
self._approval_storage = (
FileBasedFunctionApprovalStorage(self.FUNCTION_APPROVAL_STORAGE_PATH)
if self.config.is_hosted
else InMemoryFunctionApprovalStorage()
)
self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType]
async def _handle_response(
@@ -192,10 +294,15 @@ class ResponsesHostServer(ResponsesAgentServerHost):
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Handle the creation of a response for a regular (non-workflow) agent."""
input_items = await context.get_input_items()
input_messages = _items_to_messages(input_items)
input_messages = await _items_to_messages(input_items, approval_storage=self._approval_storage)
history = await context.get_history()
run_kwargs: dict[str, Any] = {"messages": [*_output_items_to_messages(history), *input_messages]}
run_kwargs: dict[str, Any] = {
"messages": [
*(await _output_items_to_messages(history, approval_storage=self._approval_storage)),
*input_messages,
]
}
is_streaming_request = request.stream is not None and request.stream is True
chat_options, are_options_set = _to_chat_options(request)
@@ -216,7 +323,11 @@ class ResponsesHostServer(ResponsesAgentServerHost):
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(response_event_stream, content):
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
yield item
yield response_event_stream.emit_completed()
@@ -232,7 +343,11 @@ class ResponsesHostServer(ResponsesAgentServerHost):
for event in tracker.handle(content):
yield event
if tracker.needs_async:
async for item in _to_outputs(response_event_stream, content):
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
yield item
tracker.needs_async = False
@@ -254,7 +369,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
by the hosting infrastructure or files will be preserved upon deactivation.
"""
input_items = await context.get_input_items()
input_messages = _items_to_messages(input_items)
input_messages = await _items_to_messages(input_items)
is_streaming_request = request.stream is not None and request.stream is True
_, are_options_set = _to_chat_options(request)
@@ -581,26 +696,32 @@ def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]:
# region Input Message Conversion
def _items_to_messages(input_items: Sequence[Item]) -> list[Message]:
async def _items_to_messages(
input_items: Sequence[Item], *, approval_storage: ApprovalStorage | None = None
) -> list[Message]:
"""Converts a sequence of input items to a list of Messages, one per item.
Args:
input_items: The input items to convert.
approval_storage: An optional ApprovalStorage instance used to look up
approval requests when converting MCP approval response items.
Returns:
A list of Messages, one per supported input item.
"""
messages: list[Message] = []
for item in input_items:
messages.append(_item_to_message(item))
messages.append(await _item_to_message(item, approval_storage=approval_storage))
return messages
def _item_to_message(item: Item) -> Message:
async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | None = None) -> Message:
"""Converts an Item to a Message.
Args:
item: The Item to convert.
approval_storage: An optional ApprovalStorage instance used to look up
approval requests when converting MCP approval response items.
Returns:
The converted Message.
@@ -659,27 +780,26 @@ def _item_to_message(item: Item) -> Message:
if item.type == "mcp_approval_request":
mcp_req = cast(ItemMcpApprovalRequest, item)
mcp_call_content = Content.from_mcp_server_tool_call(
mcp_req.id,
mcp_req.name,
server_name=mcp_req.server_label,
arguments=mcp_req.arguments,
)
if approval_storage is not None:
function_approval_request_content = await approval_storage.load_approval_request(mcp_req.id)
else:
raise ValueError("ApprovalStorage is required to load approval request.")
return Message(
role="assistant",
contents=[Content.from_function_approval_request(mcp_req.id, mcp_call_content)],
contents=[function_approval_request_content],
)
if item.type == "mcp_approval_response":
mcp_resp = cast(MCPApprovalResponse, item)
placeholder_content = Content.from_function_call(mcp_resp.approval_request_id, "mcp_approval")
if approval_storage is not None:
function_approval_request_content = await approval_storage.load_approval_request(
mcp_resp.approval_request_id
)
else:
raise ValueError("ApprovalStorage is required to load approval request.")
return Message(
role="user",
contents=[
Content.from_function_approval_response(
mcp_resp.approve, mcp_resp.approval_request_id, placeholder_content
)
],
contents=[function_approval_request_content.to_function_approval_response(mcp_resp.approve)],
)
if item.type == "code_interpreter_call":
@@ -846,26 +966,34 @@ def _item_to_message(item: Item) -> Message:
raise ValueError(f"Unsupported Item type: {item.type}")
def _output_items_to_messages(history: Sequence[OutputItem]) -> list[Message]:
async def _output_items_to_messages(
history: Sequence[OutputItem],
*,
approval_storage: ApprovalStorage | None = None,
) -> list[Message]:
"""Converts a sequence of OutputItem objects to a list of Message objects.
Args:
history (Sequence[OutputItem]): The sequence of OutputItem objects to convert.
approval_storage (ApprovalStorage | None, optional): The approval storage to use for
resolving MCP approval requests. Defaults to None.
Returns:
list[Message]: The list of Message objects.
"""
messages: list[Message] = []
for item in history:
messages.append(_output_item_to_message(item))
messages.append(await _output_item_to_message(item, approval_storage=approval_storage))
return messages
def _output_item_to_message(item: OutputItem) -> Message:
async def _output_item_to_message(item: OutputItem, *, approval_storage: ApprovalStorage | None = None) -> Message:
"""Converts an OutputItem to a Message.
Args:
item (OutputItem): The OutputItem to convert.
approval_storage (ApprovalStorage | None, optional): The approval storage to use for
resolving MCP approval requests. Defaults to None.
Returns:
Message: The converted Message.
@@ -922,24 +1050,27 @@ def _output_item_to_message(item: OutputItem) -> Message:
if item.type == "mcp_approval_request":
mcp_req = cast(OutputItemMcpApprovalRequest, item)
mcp_call_content = Content.from_mcp_server_tool_call(
mcp_req.id,
mcp_req.name,
server_name=mcp_req.server_label,
arguments=mcp_req.arguments,
)
if approval_storage is not None:
function_approval_request_content = await approval_storage.load_approval_request(mcp_req.id)
else:
raise ValueError("ApprovalStorage is required to load approval request.")
return Message(
role="assistant",
contents=[Content.from_function_approval_request(mcp_req.id, mcp_call_content)],
contents=[function_approval_request_content],
)
if item.type == "mcp_approval_response":
mcp_resp = cast(OutputItemMcpApprovalResponseResource, item)
# Build a placeholder function_call Content since the original call details are not available
placeholder_content = Content.from_function_call(mcp_resp.approval_request_id, "mcp_approval")
if approval_storage is not None:
function_approval_request_content = await approval_storage.load_approval_request(
mcp_resp.approval_request_id
)
else:
raise ValueError("ApprovalStorage is required to load approval request.")
return Message(
role="user",
contents=[Content.from_function_approval_response(mcp_resp.approve, mcp_resp.id, placeholder_content)],
contents=[function_approval_request_content.to_function_approval_response(mcp_resp.approve)],
)
if item.type == "code_interpreter_call":
@@ -1237,12 +1368,18 @@ def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
return json.dumps(arguments)
async def _to_outputs(stream: ResponseEventStream, content: Content) -> AsyncIterator[ResponseStreamEvent]:
async def _to_outputs(
stream: ResponseEventStream,
content: Content,
*,
approval_storage: ApprovalStorage | None = None,
) -> AsyncIterator[ResponseStreamEvent]:
"""Converts a Content object to an async sequence of ResponseStreamEvent objects.
Args:
stream: The ResponseEventStream to use for building events.
content: The Content to convert.
approval_storage: An optional ApprovalStorage instance to use for saving and loading function approval requests.
Yields:
ResponseStreamEvent: The converted event objects.
@@ -1320,6 +1457,31 @@ async def _to_outputs(stream: ResponseEventStream, content: Content) -> AsyncIte
max_output_length=content.max_output_length,
):
yield event
elif content.type == "function_approval_request":
function_call: Content = content.function_call # type: ignore
server_label = function_call.additional_properties.get("server_label", "agent_framework")
request_saved = False
async for event in stream.aoutput_item_mcp_approval_request(
server_label,
function_call.name, # type: ignore
_arguments_to_str(function_call.arguments),
):
if approval_storage is not None and not request_saved:
# Extract the approval request ID generated by the infrastructure
# when the approval request item is added to the stream. Save the
# approval request to the approval storage so it can be retrieved later
# for round trips where the original approval request needs to be looked up.
item = getattr(event, "item", None)
if item is not None and getattr(item, "id", None) is not None:
approval_request_id = cast(str, item.id) # type: ignore
await approval_storage.save_approval_request(approval_request_id, content)
request_saved = True
yield event
if approval_storage is not None and not request_saved:
logger.warning(
"Approval request was not saved to approval storage because the approval request ID "
"could not be extracted from the stream event."
)
else:
# Log a warning for unsupported content types instead of raising an error to avoid breaking the response stream.
logger.warning(f"Content type '{content.type}' is not supported yet. This is usually safe to ignore.")
File diff suppressed because it is too large Load Diff
@@ -157,7 +157,7 @@ cd agent-framework/python/samples/04-hosting/foundry-hosted-agents/responses
2. Install dependencies:
```bash
pip install -r requirements.txt
uv pip install -r requirements.txt
```
3. Create a `.env` file with your Foundry configuration following the `env.example` file in the sample.
@@ -10,6 +10,16 @@ The agent uses `FoundryChatClient` from the Agent Framework to create a Response
See [main.py](main.py) for the full implementation.
### Tools
Local tools are Python functions decorated with the Agent Framework's `@tool` decorator and registered with the agent. When the model chooses to call a tool during a conversation, the agent executes the corresponding function and returns the result to the model.
Each tool can be configured with one of two approval modes: **always_require** or **never_require**. With **always_require**, the agent requests explicit user approval before every invocation; with **never_require**, the agent invokes the tool automatically. To illustrate both behaviors, this sample defines two tools—one using `always_require` and the other using `never_require`.
When a tool is set to `always_require`, the agent host emits an `mcp_approval_request` output containing the approval request ID and details of the pending tool call. The client must reply with an `mcp_approval_response` indicating the same request ID and whether the user approved or denied the call before the agent will proceed.
> IMPORTANT: We are temporarily reusing the **mcp_approval_request** and **mcp_approval_response** message types defined in the [AzureAI AgentServer SDK](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-responses/docs/handler-implementation-guide.md#other-tool-call-types) because they map closely to this approval flow. They will likely be superseded by a more formal tool-approval content type in the Responses protocol in the future.
### Agent Hosting
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol.
@@ -28,6 +38,24 @@ Send a POST request to the server with a JSON body containing an `"input"` field
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "What is the weather in Seattle?"}'
```
Send a POST request that triggers a tool call configured with `always_require` to see the approval flow in action:
```bash
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "List all the files in the current directory."}'
```
Sample output:
```bash
{"id":"caresp_3b6cba8c972b1d2f00bXmjpUGzfgSFsmgjtlgqUwqvROwl5lyG","object":"response","output":[{"type":"function_call","id":"fc_3b6cba8c972b1d2f00JIAQktGC1upcB6Dgxp1AVVLp0MoyRTX4","call_id":"call_hWwwZ8lqVQCAuo8ZyY4LXIya","name":"run_bash","arguments":"{\"command\":\"ls -la\"}","status":"completed","response_id":"caresp_3b6cba8c972b1d2f00bXmjpUGzfgSFsmgjtlgqUwqvROwl5lyG","agent_reference":null},{"type":"mcp_approval_request","id":"mcpr_3b6cba8c972b1d2f00IdqsjB6iidFmtsuYp6oI1AoAtUKQZxje","server_label":"agent_framework","name":"run_bash","arguments":"{\"command\":\"ls -la\"}","response_id":"caresp_3b6cba8c972b1d2f00bXmjpUGzfgSFsmgjtlgqUwqvROwl5lyG","agent_reference":null}],"created_at":1778021855,"model":"","status":"completed","completed_at":1778021865,"response_id":"caresp_3b6cba8c972b1d2f00bXmjpUGzfgSFsmgjtlgqUwqvROwl5lyG","agent_reference":{"type":"agent_reference"},"agent_session_id":"8caaaa19598306a1f2fb6d8939ef06874c52c63a83b57681ea4e4b75cf6a179","background":false}
```
To approve:
```bash
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": [{"type": "mcp_approval_response", "approval_request_id": "mcpr_3b6cba8c972b1d2f00IdqsjB6iidFmtsuYp6oI1AoAtUKQZxje", "approve": true}], "previous_response_id": "caresp_3b6cba8c972b1d2f00bXmjpUGzfgSFsmgjtlgqUwqvROwl5lyG"}'
```
## Deploying the Agent to Foundry
To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory.
@@ -25,7 +25,7 @@ def get_weather(
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
@tool(approval_mode="never_require")
@tool(approval_mode="always_require")
def run_bash(command: str) -> str:
"""Execute a shell command locally and return stdout, stderr, and exit code."""
try: