Skip to content

Commit 3388864

Browse files
.NET: Map additional props <-> A2A metadata (#3137)
* map additional props from agent run options to a2a request metadata * small touches * add unit tests for new extension methods * Sort using * add unit test * add additiona unit tests * special case json element to avoid unnecessary serialization
1 parent 299a511 commit 3388864

File tree

10 files changed

+907
-7
lines changed

10 files changed

+907
-7
lines changed

dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,13 @@ protected override async Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMes
8484
}
8585
else
8686
{
87-
var a2aMessage = CreateA2AMessage(typedThread, messages);
87+
MessageSendParams sendParams = new()
88+
{
89+
Message = CreateA2AMessage(typedThread, messages),
90+
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
91+
};
8892

89-
a2aResponse = await this._a2aClient.SendMessageAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false);
93+
a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
9094
}
9195

9296
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
@@ -154,9 +158,13 @@ protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreami
154158
// a2aSseEvents = this._a2aClient.SubscribeToTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
155159
}
156160

157-
var a2aMessage = CreateA2AMessage(typedThread, messages);
161+
MessageSendParams sendParams = new()
162+
{
163+
Message = CreateA2AMessage(typedThread, messages),
164+
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
165+
};
158166

159-
a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false);
167+
a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(sendParams, cancellationToken).ConfigureAwait(false);
160168

161169
this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name);
162170

@@ -198,10 +206,10 @@ protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreami
198206
protected override string? IdCore => this._id;
199207

200208
/// <inheritdoc/>
201-
public override string? Name => this._name ?? base.Name;
209+
public override string? Name => this._name;
202210

203211
/// <inheritdoc/>
204-
public override string? Description => this._description ?? base.Description;
212+
public override string? Description => this._description;
205213

206214
private A2AAgentThread GetA2AThread(AgentThread? thread, AgentRunOptions? options)
207215
{

dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AMetadataExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ internal static class A2AMetadataExtensions
1414
/// <summary>
1515
/// Converts a dictionary of metadata to an <see cref="AdditionalPropertiesDictionary"/>.
1616
/// </summary>
17+
/// <remarks>
18+
/// This method can be replaced by the one from A2A SDK once it is public.
19+
/// </remarks>
1720
/// <param name="metadata">The metadata dictionary to convert.</param>
1821
/// <returns>The converted <see cref="AdditionalPropertiesDictionary"/>, or null if the input is null or empty.</returns>
1922
internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
using System.Text.Json;
5+
using Microsoft.Agents.AI;
6+
7+
namespace Microsoft.Extensions.AI;
8+
9+
/// <summary>
10+
/// Extension methods for AdditionalPropertiesDictionary.
11+
/// </summary>
12+
internal static class AdditionalPropertiesDictionaryExtensions
13+
{
14+
/// <summary>
15+
/// Converts an <see cref="AdditionalPropertiesDictionary"/> to a dictionary of <see cref="JsonElement"/> values suitable for A2A metadata.
16+
/// </summary>
17+
/// <remarks>
18+
/// This method can be replaced by the one from A2A SDK once it is available.
19+
/// </remarks>
20+
/// <param name="additionalProperties">The additional properties dictionary to convert, or <c>null</c>.</param>
21+
/// <returns>A dictionary of JSON elements representing the metadata, or <c>null</c> if the input is null or empty.</returns>
22+
internal static Dictionary<string, JsonElement>? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties)
23+
{
24+
if (additionalProperties is not { Count: > 0 })
25+
{
26+
return null;
27+
}
28+
29+
var metadata = new Dictionary<string, JsonElement>();
30+
31+
foreach (var kvp in additionalProperties)
32+
{
33+
if (kvp.Value is JsonElement)
34+
{
35+
metadata[kvp.Key] = (JsonElement)kvp.Value!;
36+
continue;
37+
}
38+
39+
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
40+
}
41+
42+
return metadata;
43+
}
44+
}

dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,14 @@ async Task<A2AResponse> OnMessageReceivedAsync(MessageSendParams messageSendPara
4343
{
4444
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
4545
var thread = await hostAgent.GetOrCreateThreadAsync(contextId, cancellationToken).ConfigureAwait(false);
46+
var options = messageSendParams.Metadata is not { Count: > 0 }
47+
? null
48+
: new AgentRunOptions { AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
4649

4750
var response = await hostAgent.RunAsync(
4851
messageSendParams.ToChatMessages(),
4952
thread: thread,
53+
options: options,
5054
cancellationToken: cancellationToken).ConfigureAwait(false);
5155

5256
await hostAgent.SaveThreadAsync(contextId, thread, cancellationToken).ConfigureAwait(false);
@@ -56,7 +60,8 @@ async Task<A2AResponse> OnMessageReceivedAsync(MessageSendParams messageSendPara
5660
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
5761
ContextId = contextId,
5862
Role = MessageRole.Agent,
59-
Parts = parts
63+
Parts = parts,
64+
Metadata = response.AdditionalProperties?.ToA2AMetadata()
6065
};
6166
}
6267
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
using System.Text.Json;
5+
using Microsoft.Extensions.AI;
6+
7+
namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
8+
9+
/// <summary>
10+
/// Extension methods for A2A metadata dictionary.
11+
/// </summary>
12+
internal static class A2AMetadataExtensions
13+
{
14+
/// <summary>
15+
/// Converts a dictionary of metadata to an <see cref="AdditionalPropertiesDictionary"/>.
16+
/// </summary>
17+
/// <remarks>
18+
/// This method can be replaced by the one from A2A SDK once it is public.
19+
/// </remarks>
20+
/// <param name="metadata">The metadata dictionary to convert.</param>
21+
/// <returns>The converted <see cref="AdditionalPropertiesDictionary"/>, or null if the input is null or empty.</returns>
22+
internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
23+
{
24+
if (metadata is not { Count: > 0 })
25+
{
26+
return null;
27+
}
28+
29+
var additionalProperties = new AdditionalPropertiesDictionary();
30+
foreach (var kvp in metadata)
31+
{
32+
additionalProperties[kvp.Key] = kvp.Value;
33+
}
34+
return additionalProperties;
35+
}
36+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
using System.Text.Json;
5+
using A2A;
6+
using Microsoft.Extensions.AI;
7+
8+
namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
9+
10+
/// <summary>
11+
/// Extension methods for AdditionalPropertiesDictionary.
12+
/// </summary>
13+
internal static class AdditionalPropertiesDictionaryExtensions
14+
{
15+
/// <summary>
16+
/// Converts an <see cref="AdditionalPropertiesDictionary"/> to a dictionary of <see cref="JsonElement"/> values suitable for A2A metadata.
17+
/// </summary>
18+
/// <remarks>
19+
/// This method can be replaced by the one from A2A SDK once it is available.
20+
/// </remarks>
21+
/// <param name="additionalProperties">The additional properties dictionary to convert, or <c>null</c>.</param>
22+
/// <returns>A dictionary of JSON elements representing the metadata, or <c>null</c> if the input is null or empty.</returns>
23+
internal static Dictionary<string, JsonElement>? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties)
24+
{
25+
if (additionalProperties is not { Count: > 0 })
26+
{
27+
return null;
28+
}
29+
30+
var metadata = new Dictionary<string, JsonElement>();
31+
32+
foreach (var kvp in additionalProperties)
33+
{
34+
if (kvp.Value is JsonElement)
35+
{
36+
metadata[kvp.Key] = (JsonElement)kvp.Value!;
37+
continue;
38+
}
39+
40+
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
41+
}
42+
43+
return metadata;
44+
}
45+
}

dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,174 @@ await Assert.ThrowsAsync<InvalidOperationException>(async () =>
832832
});
833833
}
834834

835+
[Fact]
836+
public async Task RunAsync_WithAgentMessageResponseMetadata_ReturnsMetadataAsAdditionalPropertiesAsync()
837+
{
838+
// Arrange
839+
this._handler.ResponseToReturn = new AgentMessage
840+
{
841+
MessageId = "response-123",
842+
Role = MessageRole.Agent,
843+
Parts = [new TextPart { Text = "Response with metadata" }],
844+
Metadata = new Dictionary<string, JsonElement>
845+
{
846+
{ "responseKey1", JsonSerializer.SerializeToElement("responseValue1") },
847+
{ "responseCount", JsonSerializer.SerializeToElement(99) }
848+
}
849+
};
850+
851+
var inputMessages = new List<ChatMessage>
852+
{
853+
new(ChatRole.User, "Test message")
854+
};
855+
856+
// Act
857+
var result = await this._agent.RunAsync(inputMessages);
858+
859+
// Assert
860+
Assert.NotNull(result.AdditionalProperties);
861+
Assert.NotNull(result.AdditionalProperties["responseKey1"]);
862+
Assert.Equal("responseValue1", ((JsonElement)result.AdditionalProperties["responseKey1"]!).GetString());
863+
Assert.NotNull(result.AdditionalProperties["responseCount"]);
864+
Assert.Equal(99, ((JsonElement)result.AdditionalProperties["responseCount"]!).GetInt32());
865+
}
866+
867+
[Fact]
868+
public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync()
869+
{
870+
// Arrange
871+
this._handler.ResponseToReturn = new AgentMessage
872+
{
873+
MessageId = "response-123",
874+
Role = MessageRole.Agent,
875+
Parts = [new TextPart { Text = "Response" }]
876+
};
877+
878+
var inputMessages = new List<ChatMessage>
879+
{
880+
new(ChatRole.User, "Test message")
881+
};
882+
883+
var options = new AgentRunOptions
884+
{
885+
AdditionalProperties = new()
886+
{
887+
{ "key1", "value1" },
888+
{ "key2", 42 },
889+
{ "key3", true }
890+
}
891+
};
892+
893+
// Act
894+
await this._agent.RunAsync(inputMessages, null, options);
895+
896+
// Assert
897+
Assert.NotNull(this._handler.CapturedMessageSendParams);
898+
Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata);
899+
Assert.Equal("value1", this._handler.CapturedMessageSendParams.Metadata["key1"].GetString());
900+
Assert.Equal(42, this._handler.CapturedMessageSendParams.Metadata["key2"].GetInt32());
901+
Assert.True(this._handler.CapturedMessageSendParams.Metadata["key3"].GetBoolean());
902+
}
903+
904+
[Fact]
905+
public async Task RunAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync()
906+
{
907+
// Arrange
908+
this._handler.ResponseToReturn = new AgentMessage
909+
{
910+
MessageId = "response-123",
911+
Role = MessageRole.Agent,
912+
Parts = [new TextPart { Text = "Response" }]
913+
};
914+
915+
var inputMessages = new List<ChatMessage>
916+
{
917+
new(ChatRole.User, "Test message")
918+
};
919+
920+
var options = new AgentRunOptions
921+
{
922+
AdditionalProperties = null
923+
};
924+
925+
// Act
926+
await this._agent.RunAsync(inputMessages, null, options);
927+
928+
// Assert
929+
Assert.NotNull(this._handler.CapturedMessageSendParams);
930+
Assert.Null(this._handler.CapturedMessageSendParams.Metadata);
931+
}
932+
933+
[Fact]
934+
public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync()
935+
{
936+
// Arrange
937+
this._handler.StreamingResponseToReturn = new AgentMessage
938+
{
939+
MessageId = "stream-123",
940+
Role = MessageRole.Agent,
941+
Parts = [new TextPart { Text = "Streaming response" }]
942+
};
943+
944+
var inputMessages = new List<ChatMessage>
945+
{
946+
new(ChatRole.User, "Test streaming message")
947+
};
948+
949+
var options = new AgentRunOptions
950+
{
951+
AdditionalProperties = new()
952+
{
953+
{ "streamKey1", "streamValue1" },
954+
{ "streamKey2", 100 },
955+
{ "streamKey3", false }
956+
}
957+
};
958+
959+
// Act
960+
await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options))
961+
{
962+
}
963+
964+
// Assert
965+
Assert.NotNull(this._handler.CapturedMessageSendParams);
966+
Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata);
967+
Assert.Equal("streamValue1", this._handler.CapturedMessageSendParams.Metadata["streamKey1"].GetString());
968+
Assert.Equal(100, this._handler.CapturedMessageSendParams.Metadata["streamKey2"].GetInt32());
969+
Assert.False(this._handler.CapturedMessageSendParams.Metadata["streamKey3"].GetBoolean());
970+
}
971+
972+
[Fact]
973+
public async Task RunStreamingAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync()
974+
{
975+
// Arrange
976+
this._handler.StreamingResponseToReturn = new AgentMessage
977+
{
978+
MessageId = "stream-123",
979+
Role = MessageRole.Agent,
980+
Parts = [new TextPart { Text = "Streaming response" }]
981+
};
982+
983+
var inputMessages = new List<ChatMessage>
984+
{
985+
new(ChatRole.User, "Test streaming message")
986+
};
987+
988+
var options = new AgentRunOptions
989+
{
990+
AdditionalProperties = null
991+
};
992+
993+
// Act
994+
await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options))
995+
{
996+
}
997+
998+
// Assert
999+
Assert.NotNull(this._handler.CapturedMessageSendParams);
1000+
Assert.Null(this._handler.CapturedMessageSendParams.Metadata);
1001+
}
1002+
8351003
[Fact]
8361004
public async Task RunAsync_WithInvalidThreadType_ThrowsInvalidOperationExceptionAsync()
8371005
{

0 commit comments

Comments
 (0)