Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 45 additions & 9 deletions dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ namespace Microsoft.Agents.AI.Hosting.A2A;
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
internal sealed class A2AAgentHandler : IAgentHandler
{
/// <summary>
/// The <see cref="AgentRunOptions.AdditionalProperties"/> key under which the caller supplied
/// <c>MessageSendParams.configuration</c> is forwarded to the hosted agent.
/// </summary>
private const string ConfigurationPropertyKey = "a2a.configuration";

private readonly AIHostAgent _hostAgent;
private readonly AgentRunMode _runMode;

Expand Down Expand Up @@ -84,9 +90,7 @@ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue
var decisionContext = new A2ARunDecisionContext(context);
var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);

var options = context.Metadata is not { Count: > 0 }
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
var options = CreateRunOptions(context, allowBackgroundResponses);

AgentResponse response;
try
Expand Down Expand Up @@ -137,9 +141,7 @@ private async Task HandleNewMessageStreamingAsync(RequestContext context, AgentE

List<ChatMessage> chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : [];

var options = context.Metadata is { Count: > 0 }
? new AgentRunOptions { AdditionalProperties = context.Metadata.ToAdditionalProperties() }
: null;
var options = CreateRunOptions(context);

try
{
Expand All @@ -165,9 +167,7 @@ private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue
var decisionContext = new A2ARunDecisionContext(context);
var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);

var options = context.Metadata is not { Count: > 0 }
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
var options = CreateRunOptions(context, allowBackgroundResponses);

AgentResponse response;
try
Expand Down Expand Up @@ -213,6 +213,42 @@ private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue
}
}

/// <summary>
/// Creates the <see cref="AgentRunOptions"/> for a run, forwarding the caller supplied A2A
/// <c>MessageSendParams.metadata</c> and <c>MessageSendParams.configuration</c> to the hosted agent.
/// </summary>
/// <param name="context">The A2A request context of the incoming request.</param>
/// <param name="allowBackgroundResponses">
/// The value to assign to <see cref="AgentRunOptions.AllowBackgroundResponses"/>. Defaults to <see langword="null"/>, which leaves it unset.
/// </param>
/// <returns>
/// The run options to invoke the agent with, or <see langword="null"/> when there is nothing to forward.
/// </returns>
private static AgentRunOptions? CreateRunOptions(RequestContext context, bool? allowBackgroundResponses = null)
{
AdditionalPropertiesDictionary? additionalProperties = context.Metadata is { Count: > 0 }
? context.Metadata.ToAdditionalProperties()
: null;

// Forward the whole configuration object under a well-known key so that agents can observe
// the caller's requested configuration, including fields added to the A2A protocol in the future.
if (context.Configuration is { } configuration)
{
(additionalProperties ??= [])[ConfigurationPropertyKey] = configuration;
}
Comment thread
SergeyMenshykh marked this conversation as resolved.

if (allowBackgroundResponses is null && additionalProperties is null)
{
return null;
}

return new AgentRunOptions
{
AllowBackgroundResponses = allowBackgroundResponses,
AdditionalProperties = additionalProperties
};
}

private static Message CreateMessageFromResponse(string contextId, AgentResponse response) =>
new()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// </summary>
public sealed class A2AAgentHandlerTests
{
/// <summary>
/// The <see cref="AgentRunOptions.AdditionalProperties"/> key the handler forwards the A2A configuration under.
/// </summary>
private const string ConfigurationPropertyKey = "a2a.configuration";

/// <summary>
/// Verifies that when metadata is null, the options passed to RunAsync have
/// AllowBackgroundResponses disabled and no AdditionalProperties.
Expand Down Expand Up @@ -72,6 +77,93 @@ public async Task ExecuteAsync_WhenMetadataIsNonEmpty_PassesOptionsWithAdditiona
Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString());
}

/// <summary>
/// Verifies that when the caller supplies a <c>MessageSendParams.configuration</c>, it is forwarded to the
/// agent through <see cref="AgentRunOptions.AdditionalProperties"/>.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenConfigurationIsProvided_ForwardsConfigurationToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options));
SendMessageConfiguration configuration = new()
{
AcceptedOutputModes = ["text/plain", "image/png"],
HistoryLength = 10
};

// Act
await InvokeExecuteAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false,
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
Configuration = configuration
});

// Assert
Assert.NotNull(capturedOptions);
Assert.NotNull(capturedOptions.AdditionalProperties);
Assert.Same(configuration, Assert.Single(capturedOptions.AdditionalProperties).Value);
Assert.Equal(ConfigurationPropertyKey, Assert.Single(capturedOptions.AdditionalProperties).Key);
}

/// <summary>
/// Verifies that the caller supplied configuration and metadata are both forwarded to the agent.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenConfigurationAndMetadataAreProvided_ForwardsBothToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options));
SendMessageConfiguration configuration = new() { HistoryLength = 5 };

// Act
await InvokeExecuteAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false,
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
Metadata = new Dictionary<string, JsonElement>
{
["key1"] = JsonSerializer.SerializeToElement("value1")
},
Configuration = configuration
});

// Assert
Assert.NotNull(capturedOptions);
Assert.NotNull(capturedOptions.AdditionalProperties);
Assert.Equal(2, capturedOptions.AdditionalProperties.Count);
Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString());
Assert.Same(configuration, capturedOptions.AdditionalProperties[ConfigurationPropertyKey]);
}

/// <summary>
/// Verifies that the caller supplied configuration does not override the run mode configured on the server.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenConfigurationRequestsImmediateReturn_DoesNotOverrideRunModeAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(options => capturedOptions = options),
runMode: AgentRunMode.DisallowBackground);

// Act
await InvokeExecuteAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false,
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
Configuration = new SendMessageConfiguration { ReturnImmediately = true }
});

// Assert
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
}

/// <summary>
/// Verifies that when the agent response has AdditionalProperties, the returned Message.Metadata contains the converted values.
/// </summary>
Expand Down Expand Up @@ -672,6 +764,36 @@ public async Task ExecuteAsync_Streaming_WithNullMetadata_PassesNullOptionsAsync
Assert.Null(capturedOptions);
}

/// <summary>
/// Verifies that in streaming mode, when only a configuration is present, options carrying the
/// configuration are passed to RunStreamingAsync.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_WithConfiguration_PassesOptionsWithConfigurationAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMockWithOptionsCapture(
options => capturedOptions = options));
SendMessageConfiguration configuration = new() { AcceptedOutputModes = ["text/plain"] };

// Act
await InvokeExecuteAsync(handler, new RequestContext
{
StreamingResponse = true,
TaskId = "",
ContextId = "ctx",
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
Configuration = configuration
});

// Assert
Assert.NotNull(capturedOptions);
Assert.Null(capturedOptions.AllowBackgroundResponses);
Assert.NotNull(capturedOptions.AdditionalProperties);
Assert.Same(configuration, capturedOptions.AdditionalProperties[ConfigurationPropertyKey]);
}

/// <summary>
/// Verifies that in streaming mode, ReferenceTaskIds throws NotSupportedException.
/// </summary>
Expand Down
Loading