mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into features/foundry-hosting-it-msb3026-fix
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
+465
@@ -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
|
||||
}
|
||||
|
||||
+5
-7
@@ -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 = ["*"],
|
||||
},
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user