From 9f5a02c364c36b0cf501ba734ce2fa8cf6aa2b75 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 6 Feb 2026 17:28:57 +0100 Subject: [PATCH 01/22] implement task support? --- .../AIAgentExtensions.cs | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index da3fd782de..3f89448030 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using A2A; using Microsoft.Agents.AI.Hosting.A2A.Converters; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.Hosting.A2A; @@ -36,7 +37,20 @@ public static ITaskManager MapA2A( sessionStore: agentSessionStore ?? new NoopAgentSessionStore()); taskManager ??= new TaskManager(); + + // OnMessageReceived handles both message-only and task-based flows. + // The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set, + // so we consolidate all initial message handling here and return either + // an AgentMessage or AgentTask depending on the agent response. + // When the agent returns a ContinuationToken (long-running operation), a task is + // created for stateful tracking. Otherwise a lightweight AgentMessage is returned. + // See https://github.com/a2aproject/a2a-dotnet/issues/275 taskManager.OnMessageReceived += OnMessageReceivedAsync; + + // task flow for subsequent updates and cancellations + taskManager.OnTaskUpdated += OnTaskUpdatedAsync; + taskManager.OnTaskCancelled += OnTaskCancelledAsync; + return taskManager; async Task OnMessageReceivedAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) @@ -54,6 +68,19 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara cancellationToken: cancellationToken).ConfigureAwait(false); await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + + // If the agent returned a continuation token, this is a long-running operation + // that requires task-based tracking. Otherwise return a lightweight message. + if (response.ContinuationToken is not null) + { + return await CreateTaskFromResponseAsync(contextId, response, cancellationToken).ConfigureAwait(false); + } + + return CreateMessageFromResponse(contextId, response); + } + + AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) + { var parts = response.Messages.ToParts(); return new AgentMessage { @@ -64,6 +91,130 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara Metadata = response.AdditionalProperties?.ToA2AMetadata() }; } + + async Task CreateTaskFromResponseAsync( + string contextId, + AgentResponse response, + CancellationToken cancellationToken) + { + AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false); + await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); + + try + { + var parts = response.Messages.ToParts(); + var agentMessage = new AgentMessage + { + MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), + ContextId = contextId, + Role = MessageRole.Agent, + Parts = parts, + Metadata = response.AdditionalProperties?.ToA2AMetadata() + }; + + await taskManager.UpdateStatusAsync( + agentTask.Id, + TaskState.Completed, + message: agentMessage, + final: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + + return (await taskManager.GetTaskAsync( + new TaskQueryParams { Id = agentTask.Id }, + cancellationToken).ConfigureAwait(false))!; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + await taskManager.UpdateStatusAsync( + agentTask.Id, + TaskState.Failed, + final: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + throw; + } + } + + async Task OnTaskUpdatedAsync(AgentTask agentTask, CancellationToken cancellationToken) + { + var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N"); + var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + + // Extract the latest user message from task history + var chatMessages = ExtractChatMessagesFromTaskHistory(agentTask); + + // Update task status to working + await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); + + try + { + var response = await hostAgent.RunAsync( + chatMessages, + session: session, + cancellationToken: cancellationToken).ConfigureAwait(false); + + await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + + var parts = response.Messages.ToParts(); + var agentMessage = new AgentMessage + { + MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), + ContextId = contextId, + Role = MessageRole.Agent, + Parts = parts, + Metadata = response.AdditionalProperties?.ToA2AMetadata() + }; + + // Update task status to completed with the response message + await taskManager.UpdateStatusAsync( + agentTask.Id, + TaskState.Completed, + message: agentMessage, + final: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + await taskManager.UpdateStatusAsync( + agentTask.Id, + TaskState.Failed, + final: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + throw; + } + } + + Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken) + { + // The task has already been marked as cancelled by the TaskManager. + // This callback is for any cleanup or notification logic needed. + // Currently, no additional action is required. + return Task.CompletedTask; + } + } + + private static System.Collections.Generic.List ExtractChatMessagesFromTaskHistory(AgentTask agentTask) + { + var chatMessages = new System.Collections.Generic.List(); + + if (agentTask.History is null || agentTask.History.Count == 0) + { + return chatMessages; + } + + foreach (var message in agentTask.History) + { + chatMessages.Add(message.ToChatMessage()); + } + + return chatMessages; } /// From 0e642e1558d7018258d881509dbf3eba3263fb6d Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 6 Feb 2026 19:10:13 +0100 Subject: [PATCH 02/22] some metadata + session store impl --- .../AIAgentExtensions.cs | 165 +++++---- .../AIAgentExtensionsTests.cs | 325 +++++++++++++++++- 2 files changed, 420 insertions(+), 70 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index 3f89448030..abff290feb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using A2A; @@ -38,6 +40,10 @@ public static ITaskManager MapA2A( taskManager ??= new TaskManager(); + // Metadata key used to store continuation tokens for long-running background operations + // in the AgentTask.Metadata dictionary, persisted by the task store. + const string ContinuationTokenMetadataKey = "__a2a__continuationToken"; + // OnMessageReceived handles both message-only and task-based flows. // The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set, // so we consolidate all initial message handling here and return either @@ -58,8 +64,8 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); var options = messageSendParams.Metadata is not { Count: > 0 } - ? null - : new AgentRunOptions { AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; + ? new AgentRunOptions { AllowBackgroundResponses = true } + : new AgentRunOptions { AllowBackgroundResponses = true, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; var response = await hostAgent.RunAsync( messageSendParams.ToChatMessages(), @@ -69,11 +75,12 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - // If the agent returned a continuation token, this is a long-running operation - // that requires task-based tracking. Otherwise return a lightweight message. + // If the agent returned a continuation token, this is a long-running operation. + // Create a task in Working state and return it immediately. The client can check + // back later by sending a follow-up message to the task (triggering OnTaskUpdated). if (response.ContinuationToken is not null) { - return await CreateTaskFromResponseAsync(contextId, response, cancellationToken).ConfigureAwait(false); + return await CreateWorkingTaskAsync(contextId, response, cancellationToken).ConfigureAwait(false); } return CreateMessageFromResponse(contextId, response); @@ -92,50 +99,34 @@ AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) }; } - async Task CreateTaskFromResponseAsync( + async Task CreateWorkingTaskAsync( string contextId, - AgentResponse response, + AgentResponse initialResponse, CancellationToken cancellationToken) { AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false); - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); - - try - { - var parts = response.Messages.ToParts(); - var agentMessage = new AgentMessage - { - MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), - ContextId = contextId, - Role = MessageRole.Agent, - Parts = parts, - Metadata = response.AdditionalProperties?.ToA2AMetadata() - }; - - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Completed, - message: agentMessage, - final: true, - cancellationToken: cancellationToken).ConfigureAwait(false); - return (await taskManager.GetTaskAsync( - new TaskQueryParams { Id = agentTask.Id }, - cancellationToken).ConfigureAwait(false))!; - } - catch (OperationCanceledException) + // Serialize the continuation token into the task's metadata so it survives + // across requests and is cleaned up with the task itself. +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + agentTask.Metadata ??= []; + agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement( + initialResponse.ContinuationToken, + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken))); +#pragma warning restore MEAI001 + + // Include any intermediate messages from the initial response + if (initialResponse.Messages.Count > 0) { - throw; + var initialMessage = CreateMessageFromResponse(contextId, initialResponse); + await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, message: initialMessage, cancellationToken: cancellationToken).ConfigureAwait(false); } - catch (Exception) + else { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Failed, - final: true, - cancellationToken: cancellationToken).ConfigureAwait(false); - throw; + await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); } + + return agentTask; } async Task OnTaskUpdatedAsync(AgentTask agentTask, CancellationToken cancellationToken) @@ -143,36 +134,69 @@ async Task OnTaskUpdatedAsync(AgentTask agentTask, CancellationToken cancellatio var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N"); var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); - // Extract the latest user message from task history - var chatMessages = ExtractChatMessagesFromTaskHistory(agentTask); - - // Update task status to working - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); - try { - var response = await hostAgent.RunAsync( + // If this task has a pending continuation token in its metadata, check on + // the background operation instead of processing new messages from history. + if (TryExtractContinuationToken(agentTask, out var continuationToken)) + { + var pollOptions = new AgentRunOptions { ContinuationToken = continuationToken }; + var response = await hostAgent.RunAsync( + session: session, + options: pollOptions, + cancellationToken: cancellationToken).ConfigureAwait(false); + + await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + + if (response.ContinuationToken is not null) + { + // Still working — update the token in metadata and keep the task in Working state +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + agentTask.Metadata![ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement( + response.ContinuationToken, + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken))); +#pragma warning restore MEAI001 + + if (response.Messages.Count > 0) + { + var progressMessage = CreateMessageFromResponse(contextId, response); + await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + else + { + // Background operation completed — remove the token from metadata + agentTask.Metadata!.Remove(ContinuationTokenMetadataKey); + + var agentMessage = CreateMessageFromResponse(contextId, response); + await taskManager.UpdateStatusAsync( + agentTask.Id, + TaskState.Completed, + message: agentMessage, + final: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + + return; + } + + // No pending continuation — process new user messages from task history + var chatMessages = ExtractChatMessagesFromTaskHistory(agentTask); + + await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); + + var newResponse = await hostAgent.RunAsync( chatMessages, session: session, cancellationToken: cancellationToken).ConfigureAwait(false); await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - var parts = response.Messages.ToParts(); - var agentMessage = new AgentMessage - { - MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), - ContextId = contextId, - Role = MessageRole.Agent, - Parts = parts, - Metadata = response.AdditionalProperties?.ToA2AMetadata() - }; - - // Update task status to completed with the response message + var completedMessage = CreateMessageFromResponse(contextId, newResponse); await taskManager.UpdateStatusAsync( agentTask.Id, TaskState.Completed, - message: agentMessage, + message: completedMessage, final: true, cancellationToken: cancellationToken).ConfigureAwait(false); } @@ -193,16 +217,31 @@ await taskManager.UpdateStatusAsync( Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken) { + // Remove the continuation token from metadata if present. // The task has already been marked as cancelled by the TaskManager. - // This callback is for any cleanup or notification logic needed. - // Currently, no additional action is required. + agentTask.Metadata?.Remove(ContinuationTokenMetadataKey); return Task.CompletedTask; } + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + static bool TryExtractContinuationToken(AgentTask agentTask, out ResponseContinuationToken? continuationToken) + { + if (agentTask.Metadata is not null && + agentTask.Metadata.TryGetValue(ContinuationTokenMetadataKey, out var tokenElement)) + { + continuationToken = (ResponseContinuationToken?)JsonSerializer.Deserialize(tokenElement, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken))); + return continuationToken is not null; + } + + continuationToken = null; + return false; + } +#pragma warning restore MEAI001 } - private static System.Collections.Generic.List ExtractChatMessagesFromTaskHistory(AgentTask agentTask) + private static List ExtractChatMessagesFromTaskHistory(AgentTask agentTask) { - var chatMessages = new System.Collections.Generic.List(); + var chatMessages = new List(); if (agentTask.History is null || agentTask.History.Count == 0) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs index 4b9cbce2c4..f454eddb10 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -18,10 +19,11 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; public sealed class AIAgentExtensionsTests { /// - /// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync are null. + /// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync have + /// AllowBackgroundResponses enabled and no AdditionalProperties. /// [Fact] - public async Task MapA2A_WhenMetadataIsNull_PassesNullOptionsToRunAsync() + public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync() { // Arrange AgentRunOptions? capturedOptions = null; @@ -35,7 +37,9 @@ public async Task MapA2A_WhenMetadataIsNull_PassesNullOptionsToRunAsync() }); // Assert - Assert.Null(capturedOptions); + Assert.NotNull(capturedOptions); + Assert.True(capturedOptions.AllowBackgroundResponses); + Assert.Null(capturedOptions.AdditionalProperties); } /// @@ -68,11 +72,11 @@ public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalProper } /// - /// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync is null - /// because the ToAdditionalProperties extension method returns null for empty dictionaries. + /// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync have + /// AllowBackgroundResponses enabled and no AdditionalProperties. /// [Fact] - public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesNullOptionsToRunAsync() + public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditionalPropertiesToRunAsync() { // Arrange AgentRunOptions? capturedOptions = null; @@ -86,7 +90,9 @@ public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesNullOptionsToRunAsy }); // Assert - Assert.Null(capturedOptions); + Assert.NotNull(capturedOptions); + Assert.True(capturedOptions.AllowBackgroundResponses); + Assert.Null(capturedOptions.AdditionalProperties); } /// @@ -171,6 +177,262 @@ public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMe Assert.Null(agentMessage.Metadata); } +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + /// + /// Verifies that when the agent returns a ContinuationToken, an AgentTask in Working state is returned. + /// + [Fact] + public async Task MapA2A_WhenResponseHasContinuationToken_ReturnsAgentTaskInWorkingStateAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) + { + ContinuationToken = CreateTestContinuationToken() + }; + ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + + // Assert + AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.Equal(TaskState.Working, agentTask.Status.State); + } + + /// + /// Verifies that when the agent returns a ContinuationToken, the returned task includes + /// intermediate messages from the initial response in its status message. + /// + [Fact] + public async Task MapA2A_WhenResponseHasContinuationToken_TaskStatusHasIntermediateMessageAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) + { + ContinuationToken = CreateTestContinuationToken() + }; + ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + + // Assert + AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.NotNull(agentTask.Status.Message); + TextPart textPart = Assert.IsType(Assert.Single(agentTask.Status.Message.Parts)); + Assert.Equal("Starting work...", textPart.Text); + } + + /// + /// Verifies that when the agent returns a ContinuationToken, the continuation token + /// is serialized into the AgentTask.Metadata for persistence. + /// + [Fact] + public async Task MapA2A_WhenResponseHasContinuationToken_StoresTokenInTaskMetadataAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) + { + ContinuationToken = CreateTestContinuationToken() + }; + ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + + // Assert + AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.NotNull(agentTask.Metadata); + Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); + } + + /// + /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token + /// and the agent returns a completed response (null ContinuationToken), the task is updated to Completed. + /// + [Fact] + public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCompletedAsync() + { + // Arrange + int callCount = 0; + Mock agentMock = CreateAgentMockWithSequentialResponses( + // First call: return response with ContinuationToken (long-running) + new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) + { + ContinuationToken = CreateTestContinuationToken() + }, + // Second call (via OnTaskUpdated): return completed response + new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]), + ref callCount); + ITaskManager taskManager = agentMock.Object.MapA2A(); + + // Act — trigger OnMessageReceived to create the task + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.Equal(TaskState.Working, agentTask.Status.State); + + // Act — invoke OnTaskUpdated to check on the background operation + await InvokeOnTaskUpdatedAsync(taskManager, agentTask); + + // Assert — task should now be completed + AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + Assert.NotNull(updatedTask); + Assert.Equal(TaskState.Completed, updatedTask.Status.State); + Assert.NotNull(updatedTask.Status.Message); + TextPart textPart = Assert.IsType(Assert.Single(updatedTask.Status.Message.Parts)); + Assert.Equal("Done!", textPart.Text); + } + + /// + /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token + /// and the agent returns another ContinuationToken, the task stays in Working state. + /// + [Fact] + public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskRemainsWorkingAsync() + { + // Arrange + int callCount = 0; + Mock agentMock = CreateAgentMockWithSequentialResponses( + // First call: return response with ContinuationToken + new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) + { + ContinuationToken = CreateTestContinuationToken() + }, + // Second call (via OnTaskUpdated): still working, return another token + new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")]) + { + ContinuationToken = CreateTestContinuationToken() + }, + ref callCount); + ITaskManager taskManager = agentMock.Object.MapA2A(); + + // Act — trigger OnMessageReceived to create the task + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + AgentTask agentTask = Assert.IsType(a2aResponse); + + // Act — invoke OnTaskUpdated; agent still working + await InvokeOnTaskUpdatedAsync(taskManager, agentTask); + + // Assert — task should still be in Working state + AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + Assert.NotNull(updatedTask); + Assert.Equal(TaskState.Working, updatedTask.Status.State); + } + + /// + /// Verifies the full lifecycle: agent starts background work, first poll returns still working, + /// second poll returns completed. + /// + [Fact] + public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync() + { + // Arrange + int callCount = 0; + Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => + { + return invocation switch + { + // First call: start background work + 1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) + { + ContinuationToken = CreateTestContinuationToken() + }, + // Second call: still working + 2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")]) + { + ContinuationToken = CreateTestContinuationToken() + }, + // Third call: done + _ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "All done!")]) + }; + }); + ITaskManager taskManager = agentMock.Object.MapA2A(); + + // Act — create the task + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Do work" }] } + }); + AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.Equal(TaskState.Working, agentTask.Status.State); + + // Act — first poll: still working + AgentTask? currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + Assert.NotNull(currentTask); + await InvokeOnTaskUpdatedAsync(taskManager, currentTask); + currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + Assert.NotNull(currentTask); + Assert.Equal(TaskState.Working, currentTask.Status.State); + + // Act — second poll: completed + await InvokeOnTaskUpdatedAsync(taskManager, currentTask); + currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + Assert.NotNull(currentTask); + Assert.Equal(TaskState.Completed, currentTask.Status.State); + + // Assert — final message + Assert.NotNull(currentTask.Status.Message); + TextPart textPart = Assert.IsType(Assert.Single(currentTask.Status.Message.Parts)); + Assert.Equal("All done!", textPart.Text); + } + + /// + /// Verifies that when the agent throws during a background operation poll, + /// the task is updated to Failed state. + /// + [Fact] + public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync() + { + // Arrange + int callCount = 0; + Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => + { + if (invocation == 1) + { + return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) + { + ContinuationToken = CreateTestContinuationToken() + }; + } + + throw new InvalidOperationException("Agent failed"); + }); + ITaskManager taskManager = agentMock.Object.MapA2A(); + + // Act — create the task + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + AgentTask agentTask = Assert.IsType(a2aResponse); + + // Act — poll the task; agent throws + await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask)); + + // Assert — task should be Failed + AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + Assert.NotNull(updatedTask); + Assert.Equal(TaskState.Failed, updatedTask.Status.State); + } + +#pragma warning restore MEAI001 + private static Mock CreateAgentMock(Action optionsCallback) { Mock agentMock = new() { CallBase = true }; @@ -214,5 +476,54 @@ private static async Task InvokeOnMessageReceivedAsync(ITaskManager return await handler.Invoke(messageSendParams, CancellationToken.None); } + private static async Task InvokeOnTaskUpdatedAsync(ITaskManager taskManager, AgentTask agentTask) + { + Func? handler = taskManager.OnTaskUpdated; + Assert.NotNull(handler); + await handler.Invoke(agentTask, CancellationToken.None); + } + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + private static ResponseContinuationToken CreateTestContinuationToken() + { + return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 }); + } +#pragma warning restore MEAI001 + + private static Mock CreateAgentMockWithSequentialResponses( + AgentResponse firstResponse, + AgentResponse secondResponse, + ref int callCount) + { + return CreateAgentMockWithCallCount(ref callCount, invocation => + invocation == 1 ? firstResponse : secondResponse); + } + + private static Mock CreateAgentMockWithCallCount( + ref int callCount, + Func responseFactory) + { + // Use a StrongBox to allow the lambda to capture a mutable reference + StrongBox callCountBox = new(callCount); + + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock.Setup(x => x.GetNewSessionAsync()).ReturnsAsync(new TestAgentSession()); + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + int currentCall = Interlocked.Increment(ref callCountBox.Value); + return responseFactory(currentCall); + }); + + return agentMock; + } + private sealed class TestAgentSession : AgentSession; } From a4238c2efd8b9dcda76bd23dd906a25fb467b89f Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 11 Feb 2026 11:57:01 +0100 Subject: [PATCH 03/22] address PR comments x1 --- .../A2AHostingJsonUtilities.cs | 33 +++++ .../A2AResponseMode.cs | 31 +++++ .../AIAgentExtensions.cs | 92 ++++++++++--- ...dditionalPropertiesDictionaryExtensions.cs | 2 +- .../AIAgentExtensionsTests.cs | 124 ++++++++++++++++++ 5 files changed, 263 insertions(+), 19 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingJsonUtilities.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingJsonUtilities.cs new file mode 100644 index 0000000000..8f0b26c2bc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingJsonUtilities.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Agents.AI.Hosting.A2A; + +/// +/// Provides JSON serialization options for A2A Hosting APIs to support AOT and trimming. +/// Chains the agent abstractions resolver (for M.E.AI types such as ResponseContinuationToken) +/// with the A2A SDK resolver (for A2A protocol types such as AgentTask and AgentMessage). +/// +internal static class A2AHostingJsonUtilities +{ + /// + /// Gets the default instance used for A2A Hosting serialization. + /// + public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + + private static JsonSerializerOptions CreateDefaultOptions() + { + JsonSerializerOptions options = new(global::A2A.A2AJsonUtilities.DefaultOptions); + + // Chain in the resolvers from both AgentAbstractionsJsonUtilities and the A2A SDK context. + // AgentAbstractionsJsonUtilities is first to ensure M.E.AI types (e.g. ResponseContinuationToken) + // are handled via its resolver, followed by the A2A SDK resolver for protocol types. + options.TypeInfoResolverChain.Clear(); + options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); + options.TypeInfoResolverChain.Add(global::A2A.A2AJsonUtilities.DefaultOptions.TypeInfoResolver!); + + options.MakeReadOnly(); + return options; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs new file mode 100644 index 0000000000..1ef70d8921 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.A2A; + +/// +/// Specifies how the A2A hosting layer determines whether to return an +/// AgentMessage or an AgentTask from +/// . +/// +public enum A2AResponseMode +{ + /// + /// The decision is delegated to the agent. Background responses are enabled + /// and if the agent returns a continuation token (indicating a long-running operation), + /// an AgentTask is returned. Otherwise an AgentMessage is returned. + /// + Auto = 0, + + /// + /// Always return an AgentMessage. Background responses are not enabled. + /// This is suitable for lightweight, single-shot request-response interactions. + /// + Message = 1, + + /// + /// Always return an AgentTask. A task is created and tracked for every + /// request, even if the agent completes immediately. Background responses are enabled + /// so the agent can signal long-running operations if supported. + /// + Task = 2, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index abff290feb..4ddc2ecfcc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -24,12 +24,16 @@ public static class AIAgentExtensions /// Instance of to configure for A2A messaging. New instance will be created if not passed. /// The logger factory to use for creating instances. /// The store to store session contents and metadata. + /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. /// The configured . public static ITaskManager MapA2A( this AIAgent agent, ITaskManager? taskManager = null, ILoggerFactory? loggerFactory = null, - AgentSessionStore? agentSessionStore = null) + AgentSessionStore? agentSessionStore = null, + A2AResponseMode responseMode = A2AResponseMode.Auto, + JsonSerializerOptions? jsonSerializerOptions = null) { ArgumentNullException.ThrowIfNull(agent); ArgumentNullException.ThrowIfNull(agent.Name); @@ -40,6 +44,11 @@ public static ITaskManager MapA2A( taskManager ??= new TaskManager(); + // Resolve the JSON serializer options for continuation token serialization. + // Falls back to A2AHostingJsonUtilities.DefaultOptions which chains + // AgentAbstractionsJsonUtilities (for M.E.AI types) and A2A SDK resolvers. + JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions; + // Metadata key used to store continuation tokens for long-running background operations // in the AgentTask.Metadata dictionary, persisted by the task store. const string ContinuationTokenMetadataKey = "__a2a__continuationToken"; @@ -63,9 +72,13 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara { var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + + // Only enable background responses when the mode allows task-based results. + // In Message mode, background responses are never enabled so the agent always completes synchronously. + bool allowBackground = responseMode != A2AResponseMode.Message; var options = messageSendParams.Metadata is not { Count: > 0 } - ? new AgentRunOptions { AllowBackgroundResponses = true } - : new AgentRunOptions { AllowBackgroundResponses = true, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; + ? new AgentRunOptions { AllowBackgroundResponses = allowBackground } + : new AgentRunOptions { AllowBackgroundResponses = allowBackground, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; var response = await hostAgent.RunAsync( messageSendParams.ToChatMessages(), @@ -75,15 +88,23 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - // If the agent returned a continuation token, this is a long-running operation. - // Create a task in Working state and return it immediately. The client can check - // back later by sending a follow-up message to the task (triggering OnTaskUpdated). - if (response.ContinuationToken is not null) + return responseMode switch { - return await CreateWorkingTaskAsync(contextId, response, cancellationToken).ConfigureAwait(false); - } - - return CreateMessageFromResponse(contextId, response); + // Message mode: always return a lightweight AgentMessage. + A2AResponseMode.Message => CreateMessageFromResponse(contextId, response), + + // Task mode: always create a task. If the agent returned a continuation token, + // the task is in Working state for later polling. Otherwise it completes immediately. + A2AResponseMode.Task => response.ContinuationToken is not null + ? await CreateWorkingTaskAsync(contextId, messageSendParams.Message, response, cancellationToken).ConfigureAwait(false) + : await CreateCompletedTaskAsync(contextId, messageSendParams.Message, response, cancellationToken).ConfigureAwait(false), + + // Auto mode: delegate the decision to the agent. If it returned a continuation + // token, create a task for tracking. Otherwise return an AgentMessage. + _ => response.ContinuationToken is not null + ? await CreateWorkingTaskAsync(contextId, messageSendParams.Message, response, cancellationToken).ConfigureAwait(false) + : CreateMessageFromResponse(contextId, response), + }; } AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) @@ -101,18 +122,26 @@ AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) async Task CreateWorkingTaskAsync( string contextId, + AgentMessage originalMessage, AgentResponse initialResponse, CancellationToken cancellationToken) { AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false); + // Add the original user message to the task history. + // The A2A SDK does this internally when it creates tasks via OnTaskCreated, + // but since we use OnMessageReceived and create the task ourselves, we must + // add the message manually to maintain a consistent history. + agentTask.History ??= []; + agentTask.History.Add(originalMessage); + // Serialize the continuation token into the task's metadata so it survives // across requests and is cleaned up with the task itself. #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. agentTask.Metadata ??= []; agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement( initialResponse.ContinuationToken, - AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken))); + continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); #pragma warning restore MEAI001 // Include any intermediate messages from the initial response @@ -129,6 +158,29 @@ async Task CreateWorkingTaskAsync( return agentTask; } + async Task CreateCompletedTaskAsync( + string contextId, + AgentMessage originalMessage, + AgentResponse response, + CancellationToken cancellationToken) + { + AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Add the original user message to the task history (see CreateWorkingTaskAsync). + agentTask.History ??= []; + agentTask.History.Add(originalMessage); + + var agentMessage = CreateMessageFromResponse(contextId, response); + await taskManager.UpdateStatusAsync( + agentTask.Id, + TaskState.Completed, + message: agentMessage, + final: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + + return agentTask; + } + async Task OnTaskUpdatedAsync(AgentTask agentTask, CancellationToken cancellationToken) { var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N"); @@ -138,7 +190,7 @@ async Task OnTaskUpdatedAsync(AgentTask agentTask, CancellationToken cancellatio { // If this task has a pending continuation token in its metadata, check on // the background operation instead of processing new messages from history. - if (TryExtractContinuationToken(agentTask, out var continuationToken)) + if (TryExtractContinuationToken(agentTask, continuationTokenJsonOptions, out var continuationToken)) { var pollOptions = new AgentRunOptions { ContinuationToken = continuationToken }; var response = await hostAgent.RunAsync( @@ -154,7 +206,7 @@ async Task OnTaskUpdatedAsync(AgentTask agentTask, CancellationToken cancellatio #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. agentTask.Metadata![ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement( response.ContinuationToken, - AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken))); + continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); #pragma warning restore MEAI001 if (response.Messages.Count > 0) @@ -224,12 +276,12 @@ Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationTok } #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - static bool TryExtractContinuationToken(AgentTask agentTask, out ResponseContinuationToken? continuationToken) + static bool TryExtractContinuationToken(AgentTask agentTask, JsonSerializerOptions jsonOptions, out ResponseContinuationToken? continuationToken) { if (agentTask.Metadata is not null && agentTask.Metadata.TryGetValue(ContinuationTokenMetadataKey, out var tokenElement)) { - continuationToken = (ResponseContinuationToken?)JsonSerializer.Deserialize(tokenElement, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken))); + continuationToken = (ResponseContinuationToken?)JsonSerializer.Deserialize(tokenElement, jsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); return continuationToken is not null; } @@ -264,15 +316,19 @@ private static List ExtractChatMessagesFromTaskHistory(AgentTask ag /// Instance of to configure for A2A messaging. New instance will be created if not passed. /// The logger factory to use for creating instances. /// The store to store session contents and metadata. + /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. /// The configured . public static ITaskManager MapA2A( this AIAgent agent, AgentCard agentCard, ITaskManager? taskManager = null, ILoggerFactory? loggerFactory = null, - AgentSessionStore? agentSessionStore = null) + AgentSessionStore? agentSessionStore = null, + A2AResponseMode responseMode = A2AResponseMode.Auto, + JsonSerializerOptions? jsonSerializerOptions = null) { - taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore); + taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, responseMode, jsonSerializerOptions); taskManager.OnAgentCardQuery += (context, query) => { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs index d46ef72d1f..0b88fb2e8f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs @@ -37,7 +37,7 @@ internal static class AdditionalPropertiesDictionaryExtensions continue; } - metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); } return metadata; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs index f454eddb10..bcbe1531d0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -177,6 +177,78 @@ public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMe Assert.Null(agentMessage.Metadata); } + /// + /// Verifies that when responseMode is Message, the result is always an AgentMessage even when + /// the agent would otherwise support background responses. + /// + [Fact] + public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) + .Object.MapA2A(responseMode: A2AResponseMode.Message); + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + + // Assert + Assert.IsType(a2aResponse); + Assert.NotNull(capturedOptions); + Assert.False(capturedOptions.AllowBackgroundResponses); + } + + /// + /// Verifies that when responseMode is Task and the agent completes immediately (no ContinuationToken), + /// the result is an AgentTask in Completed state. + /// + [Fact] + public async Task MapA2A_TaskMode_WhenNoContinuationToken_ReturnsCompletedAgentTaskAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done immediately")]); + ITaskManager taskManager = CreateAgentMockWithResponse(response) + .Object.MapA2A(responseMode: A2AResponseMode.Task); + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + + // Assert + AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.Equal(TaskState.Completed, agentTask.Status.State); + Assert.NotNull(agentTask.Status.Message); + TextPart textPart = Assert.IsType(Assert.Single(agentTask.Status.Message.Parts)); + Assert.Equal("Done immediately", textPart.Text); + } + + /// + /// Verifies that when responseMode is Auto and the agent completes immediately (no ContinuationToken), + /// the result is an AgentMessage (not a task). + /// + [Fact] + public async Task MapA2A_AutoMode_WhenNoContinuationToken_ReturnsAgentMessageAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]); + ITaskManager taskManager = CreateAgentMockWithResponse(response) + .Object.MapA2A(responseMode: A2AResponseMode.Auto); + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + + // Assert + Assert.IsType(a2aResponse); + } + #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. /// @@ -256,6 +328,58 @@ public async Task MapA2A_WhenResponseHasContinuationToken_StoresTokenInTaskMetad Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); } + /// + /// Verifies that when a task is created (Working or Completed), the original user message + /// is added to the task history, matching the A2A SDK's behavior when it creates tasks internally. + /// + [Fact] + public async Task MapA2A_WhenTaskIsCreated_OriginalMessageIsInHistoryAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) + { + ContinuationToken = CreateTestContinuationToken() + }; + ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + AgentMessage originalMessage = new() { MessageId = "user-msg-1", Role = MessageRole.User, Parts = [new TextPart { Text = "Do something" }] }; + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = originalMessage + }); + + // Assert + AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.NotNull(agentTask.History); + Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-1" && m.Role == MessageRole.User); + } + + /// + /// Verifies that in Task mode when the agent completes immediately, the original user message + /// is added to the completed task's history. + /// + [Fact] + public async Task MapA2A_TaskMode_CompletedTask_OriginalMessageIsInHistoryAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); + ITaskManager taskManager = CreateAgentMockWithResponse(response) + .Object.MapA2A(responseMode: A2AResponseMode.Task); + AgentMessage originalMessage = new() { MessageId = "user-msg-2", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] }; + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = originalMessage + }); + + // Assert + AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.NotNull(agentTask.History); + Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-2" && m.Role == MessageRole.User); + } + /// /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token /// and the agent returns a completed response (null ContinuationToken), the task is updated to Completed. From f7ce0961b41ad4fa700997236811c1b24fd14a49 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 11 Feb 2026 12:49:41 +0100 Subject: [PATCH 04/22] API reivew --- .../EndpointRouteBuilderExtensions.cs | 148 +++++++++++++++- .../A2AHostingJsonUtilities.cs | 4 +- .../AIAgentExtensions.cs | 161 ++++++++---------- .../AIAgentExtensionsTests.cs | 17 +- 4 files changed, 231 insertions(+), 99 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs index 943b6e4a5c..b6f39ee6b3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs @@ -33,6 +33,20 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path) => endpoints.MapA2A(agentBuilder, path, _ => { }); + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The configuration builder for . + /// The route group to use for A2A endpoints. + /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Configured for A2A integration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, A2AResponseMode responseMode) + { + ArgumentNullException.ThrowIfNull(agentBuilder); + return endpoints.MapA2A(agentBuilder.Name, path, responseMode); + } + /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. /// @@ -43,6 +57,21 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path) => endpoints.MapA2A(agentName, path, _ => { }); + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The name of the agent to use for A2A protocol integration. + /// The route group to use for A2A endpoints. + /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Configured for A2A integration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, A2AResponseMode responseMode) + { + ArgumentNullException.ThrowIfNull(endpoints); + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + return endpoints.MapA2A(agent, path, _ => { }, responseMode); + } + /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. /// @@ -109,6 +138,37 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard) => endpoints.MapA2A(agentName, path, agentCard, _ => { }); + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The configuration builder for . + /// The route group to use for A2A endpoints. + /// Agent card info to return on query. + /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Configured for A2A integration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, A2AResponseMode responseMode) + { + ArgumentNullException.ThrowIfNull(agentBuilder); + return endpoints.MapA2A(agentBuilder.Name, path, agentCard, responseMode); + } + + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The name of the agent to use for A2A protocol integration. + /// The route group to use for A2A endpoints. + /// Agent card info to return on query. + /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Configured for A2A integration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, A2AResponseMode responseMode) + { + ArgumentNullException.ThrowIfNull(endpoints); + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + return endpoints.MapA2A(agent, path, agentCard, responseMode); + } + /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. /// @@ -144,10 +204,34 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager) + => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, A2AResponseMode.Auto); + + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The name of the agent to use for A2A protocol integration. + /// The route group to use for A2A endpoints. + /// Agent card info to return on query. + /// The callback to configure . + /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Configured for A2A integration. + /// + /// This method can be used to access A2A agents that support the + /// Curated Registries (Catalog-Based Discovery) + /// discovery mechanism. + /// + public static IEndpointConventionBuilder MapA2A( + this IEndpointRouteBuilder endpoints, + string agentName, + string path, + AgentCard agentCard, + Action configureTaskManager, + A2AResponseMode responseMode) { ArgumentNullException.ThrowIfNull(endpoints); var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, agentCard, configureTaskManager); + return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, responseMode); } /// @@ -160,6 +244,17 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path) => endpoints.MapA2A(agent, path, _ => { }); + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The agent to use for A2A protocol integration. + /// The route group to use for A2A endpoints. + /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Configured for A2A integration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, A2AResponseMode responseMode) + => endpoints.MapA2A(agent, path, _ => { }, responseMode); + /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. /// @@ -169,13 +264,25 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The callback to configure . /// Configured for A2A integration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager) + => endpoints.MapA2A(agent, path, configureTaskManager, A2AResponseMode.Auto); + + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The agent to use for A2A protocol integration. + /// The route group to use for A2A endpoints. + /// The callback to configure . + /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Configured for A2A integration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager, A2AResponseMode responseMode) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agent); var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); - var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore); + var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, responseMode: responseMode); var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); configureTaskManager(taskManager); @@ -198,6 +305,23 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard) => endpoints.MapA2A(agent, path, agentCard, _ => { }); + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The agent to use for A2A protocol integration. + /// The route group to use for A2A endpoints. + /// Agent card info to return on query. + /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Configured for A2A integration. + /// + /// This method can be used to access A2A agents that support the + /// Curated Registries (Catalog-Based Discovery) + /// discovery mechanism. + /// + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, A2AResponseMode responseMode) + => endpoints.MapA2A(agent, path, agentCard, _ => { }, responseMode); + /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. /// @@ -213,13 +337,31 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager) + => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, A2AResponseMode.Auto); + + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The agent to use for A2A protocol integration. + /// The route group to use for A2A endpoints. + /// Agent card info to return on query. + /// The callback to configure . + /// The mode for the Agent to respond with A2A Task or Message with. + /// Configured for A2A integration. + /// + /// This method can be used to access A2A agents that support the + /// Curated Registries (Catalog-Based Discovery) + /// discovery mechanism. + /// + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager, A2AResponseMode responseMode) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agent); var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); - var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory); + var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, responseMode: responseMode); var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); configureTaskManager(taskManager); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingJsonUtilities.cs index 8f0b26c2bc..0a4bd98c65 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingJsonUtilities.cs @@ -6,10 +6,8 @@ namespace Microsoft.Agents.AI.Hosting.A2A; /// /// Provides JSON serialization options for A2A Hosting APIs to support AOT and trimming. -/// Chains the agent abstractions resolver (for M.E.AI types such as ResponseContinuationToken) -/// with the A2A SDK resolver (for A2A protocol types such as AgentTask and AgentMessage). /// -internal static class A2AHostingJsonUtilities +public static class A2AHostingJsonUtilities { /// /// Gets the default instance used for A2A Hosting serialization. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index 4ddc2ecfcc..491c33bd34 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -88,42 +88,62 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - return responseMode switch + // Determine whether to return a task or a message based on the response mode + // and whether the agent signaled a long-running operation via ContinuationToken. + bool createTask = responseMode == A2AResponseMode.Task || response.ContinuationToken is not null; + if (responseMode == A2AResponseMode.Message) { - // Message mode: always return a lightweight AgentMessage. - A2AResponseMode.Message => CreateMessageFromResponse(contextId, response), - - // Task mode: always create a task. If the agent returned a continuation token, - // the task is in Working state for later polling. Otherwise it completes immediately. - A2AResponseMode.Task => response.ContinuationToken is not null - ? await CreateWorkingTaskAsync(contextId, messageSendParams.Message, response, cancellationToken).ConfigureAwait(false) - : await CreateCompletedTaskAsync(contextId, messageSendParams.Message, response, cancellationToken).ConfigureAwait(false), - - // Auto mode: delegate the decision to the agent. If it returned a continuation - // token, create a task for tracking. Otherwise return an AgentMessage. - _ => response.ContinuationToken is not null - ? await CreateWorkingTaskAsync(contextId, messageSendParams.Message, response, cancellationToken).ConfigureAwait(false) - : CreateMessageFromResponse(contextId, response), - }; + createTask = false; + } + + if (!createTask) + { + return CreateMessageFromResponse(contextId, response); + } + + var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, cancellationToken).ConfigureAwait(false); + + if (response.ContinuationToken is not null) + { + StoreContinuationToken(agentTask, response.ContinuationToken); + await TransitionToWorkingAsync(agentTask.Id, contextId, response, cancellationToken).ConfigureAwait(false); + } + else + { + await CompleteWithArtifactAsync(agentTask.Id, response, cancellationToken).ConfigureAwait(false); + } + + return agentTask; } AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) { - var parts = response.Messages.ToParts(); return new AgentMessage { MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), ContextId = contextId, Role = MessageRole.Agent, - Parts = parts, + Parts = response.Messages.ToParts(), Metadata = response.AdditionalProperties?.ToA2AMetadata() }; } - async Task CreateWorkingTaskAsync( + Artifact CreateArtifactFromResponse(AgentResponse response) + { + // Per the A2A spec (§3.7), task outputs SHOULD be returned as artifacts + // rather than messages. Messages are for communication; artifacts are for + // data output produced by the agent. + return new Artifact + { + ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"), + Parts = response.Messages.ToParts(), + Metadata = response.AdditionalProperties?.ToA2AMetadata() + }; + } + + async Task InitializeTaskAsync( string contextId, AgentMessage originalMessage, - AgentResponse initialResponse, CancellationToken cancellationToken) { AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -135,50 +155,48 @@ async Task CreateWorkingTaskAsync( agentTask.History ??= []; agentTask.History.Add(originalMessage); + // Notify subscribers of the Submitted state per the A2A spec (§4.1.3). + // CreateTaskAsync persists the task with Submitted status but does not emit + // an event, so we call UpdateStatusAsync to ensure SSE subscribers see the + // full Submitted → Working state transition. + await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false); + + return agentTask; + } + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + void StoreContinuationToken(AgentTask agentTask, ResponseContinuationToken token) + { // Serialize the continuation token into the task's metadata so it survives // across requests and is cleaned up with the task itself. -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. agentTask.Metadata ??= []; agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement( - initialResponse.ContinuationToken, + token, continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); + } #pragma warning restore MEAI001 - // Include any intermediate messages from the initial response - if (initialResponse.Messages.Count > 0) - { - var initialMessage = CreateMessageFromResponse(contextId, initialResponse); - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, message: initialMessage, cancellationToken: cancellationToken).ConfigureAwait(false); - } - else - { - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); - } + async Task TransitionToWorkingAsync(string taskId, string contextId, AgentResponse response, CancellationToken cancellationToken) + { + // Include any intermediate progress messages from the response as a status message. + AgentMessage? progressMessage = response.Messages.Count > 0 + ? CreateMessageFromResponse(contextId, response) + : null; - return agentTask; + await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false); } - async Task CreateCompletedTaskAsync( - string contextId, - AgentMessage originalMessage, - AgentResponse response, - CancellationToken cancellationToken) + async Task CompleteWithArtifactAsync(string taskId, AgentResponse response, CancellationToken cancellationToken) { - AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false); - - // Add the original user message to the task history (see CreateWorkingTaskAsync). - agentTask.History ??= []; - agentTask.History.Add(originalMessage); + // Return the output as an artifact per the A2A spec (§3.7). + var artifact = CreateArtifactFromResponse(response); + await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false); - var agentMessage = CreateMessageFromResponse(contextId, response); await taskManager.UpdateStatusAsync( - agentTask.Id, + taskId, TaskState.Completed, - message: agentMessage, final: true, cancellationToken: cancellationToken).ConfigureAwait(false); - - return agentTask; } async Task OnTaskUpdatedAsync(AgentTask agentTask, CancellationToken cancellationToken) @@ -192,65 +210,37 @@ async Task OnTaskUpdatedAsync(AgentTask agentTask, CancellationToken cancellatio // the background operation instead of processing new messages from history. if (TryExtractContinuationToken(agentTask, continuationTokenJsonOptions, out var continuationToken)) { - var pollOptions = new AgentRunOptions { ContinuationToken = continuationToken }; var response = await hostAgent.RunAsync( session: session, - options: pollOptions, + options: new AgentRunOptions { ContinuationToken = continuationToken }, cancellationToken: cancellationToken).ConfigureAwait(false); await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); if (response.ContinuationToken is not null) { - // Still working — update the token in metadata and keep the task in Working state -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - agentTask.Metadata![ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement( - response.ContinuationToken, - continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); -#pragma warning restore MEAI001 - - if (response.Messages.Count > 0) - { - var progressMessage = CreateMessageFromResponse(contextId, response); - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false); - } + StoreContinuationToken(agentTask, response.ContinuationToken); + await TransitionToWorkingAsync(agentTask.Id, contextId, response, cancellationToken).ConfigureAwait(false); } else { - // Background operation completed — remove the token from metadata agentTask.Metadata!.Remove(ContinuationTokenMetadataKey); - - var agentMessage = CreateMessageFromResponse(contextId, response); - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Completed, - message: agentMessage, - final: true, - cancellationToken: cancellationToken).ConfigureAwait(false); + await CompleteWithArtifactAsync(agentTask.Id, response, cancellationToken).ConfigureAwait(false); } return; } // No pending continuation — process new user messages from task history - var chatMessages = ExtractChatMessagesFromTaskHistory(agentTask); - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); var newResponse = await hostAgent.RunAsync( - chatMessages, + ExtractChatMessagesFromTaskHistory(agentTask), session: session, cancellationToken: cancellationToken).ConfigureAwait(false); await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - - var completedMessage = CreateMessageFromResponse(contextId, newResponse); - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Completed, - message: completedMessage, - final: true, - cancellationToken: cancellationToken).ConfigureAwait(false); + await CompleteWithArtifactAsync(agentTask.Id, newResponse, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -293,13 +283,12 @@ static bool TryExtractContinuationToken(AgentTask agentTask, JsonSerializerOptio private static List ExtractChatMessagesFromTaskHistory(AgentTask agentTask) { - var chatMessages = new List(); - - if (agentTask.History is null || agentTask.History.Count == 0) + if (agentTask.History is not { Count: > 0 }) { - return chatMessages; + return []; } + var chatMessages = new List(agentTask.History.Count); foreach (var message in agentTask.History) { chatMessages.Add(message.ToChatMessage()); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs index bcbe1531d0..aa2def40f2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -222,8 +222,9 @@ public async Task MapA2A_TaskMode_WhenNoContinuationToken_ReturnsCompletedAgentT // Assert AgentTask agentTask = Assert.IsType(a2aResponse); Assert.Equal(TaskState.Completed, agentTask.Status.State); - Assert.NotNull(agentTask.Status.Message); - TextPart textPart = Assert.IsType(Assert.Single(agentTask.Status.Message.Parts)); + Assert.NotNull(agentTask.Artifacts); + Artifact artifact = Assert.Single(agentTask.Artifacts); + TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); Assert.Equal("Done immediately", textPart.Text); } @@ -415,8 +416,9 @@ public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCo AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); Assert.NotNull(updatedTask); Assert.Equal(TaskState.Completed, updatedTask.Status.State); - Assert.NotNull(updatedTask.Status.Message); - TextPart textPart = Assert.IsType(Assert.Single(updatedTask.Status.Message.Parts)); + Assert.NotNull(updatedTask.Artifacts); + Artifact artifact = Assert.Single(updatedTask.Artifacts); + TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); Assert.Equal("Done!", textPart.Text); } @@ -510,9 +512,10 @@ public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync() Assert.NotNull(currentTask); Assert.Equal(TaskState.Completed, currentTask.Status.State); - // Assert — final message - Assert.NotNull(currentTask.Status.Message); - TextPart textPart = Assert.IsType(Assert.Single(currentTask.Status.Message.Parts)); + // Assert — final output as artifact + Assert.NotNull(currentTask.Artifacts); + Artifact artifact = Assert.Single(currentTask.Artifacts); + TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); Assert.Equal("All done!", textPart.Text); } From d1e5f9917649da1a417885c03bc6a04b1b60d7ff Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 11 Feb 2026 13:07:07 +0100 Subject: [PATCH 05/22] llast changes --- .../AIAgentExtensions.cs | 67 +++++-------------- 1 file changed, 18 insertions(+), 49 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index 491c33bd34..b9a59dc8bd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -44,9 +44,7 @@ public static ITaskManager MapA2A( taskManager ??= new TaskManager(); - // Resolve the JSON serializer options for continuation token serialization. - // Falls back to A2AHostingJsonUtilities.DefaultOptions which chains - // AgentAbstractionsJsonUtilities (for M.E.AI types) and A2A SDK resolvers. + // Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent. JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions; // Metadata key used to store continuation tokens for long-running background operations @@ -62,7 +60,7 @@ public static ITaskManager MapA2A( // See https://github.com/a2aproject/a2a-dotnet/issues/275 taskManager.OnMessageReceived += OnMessageReceivedAsync; - // task flow for subsequent updates and cancellations + // Task flow for subsequent updates and cancellations taskManager.OnTaskUpdated += OnTaskUpdatedAsync; taskManager.OnTaskCancelled += OnTaskCancelledAsync; @@ -76,6 +74,7 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara // Only enable background responses when the mode allows task-based results. // In Message mode, background responses are never enabled so the agent always completes synchronously. bool allowBackground = responseMode != A2AResponseMode.Message; + var options = messageSendParams.Metadata is not { Count: > 0 } ? new AgentRunOptions { AllowBackgroundResponses = allowBackground } : new AgentRunOptions { AllowBackgroundResponses = allowBackground, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; @@ -88,21 +87,12 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - // Determine whether to return a task or a message based on the response mode - // and whether the agent signaled a long-running operation via ContinuationToken. - bool createTask = responseMode == A2AResponseMode.Task || response.ContinuationToken is not null; - if (responseMode == A2AResponseMode.Message) - { - createTask = false; - } - - if (!createTask) + if (responseMode is A2AResponseMode.Message || (responseMode is A2AResponseMode.Auto && response.ContinuationToken is null)) { return CreateMessageFromResponse(contextId, response); } var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, cancellationToken).ConfigureAwait(false); - if (response.ContinuationToken is not null) { StoreContinuationToken(agentTask, response.ContinuationToken); @@ -128,37 +118,25 @@ AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) }; } - Artifact CreateArtifactFromResponse(AgentResponse response) + // Task outputs should be returned as artifacts rather than messages: + // https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts + Artifact CreateArtifactFromResponse(AgentResponse response) => new() { - // Per the A2A spec (§3.7), task outputs SHOULD be returned as artifacts - // rather than messages. Messages are for communication; artifacts are for - // data output produced by the agent. - return new Artifact - { - ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"), - Parts = response.Messages.ToParts(), - Metadata = response.AdditionalProperties?.ToA2AMetadata() - }; - } + ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"), + Parts = response.Messages.ToParts(), + Metadata = response.AdditionalProperties?.ToA2AMetadata() + }; - async Task InitializeTaskAsync( - string contextId, - AgentMessage originalMessage, - CancellationToken cancellationToken) + async Task InitializeTaskAsync(string contextId, AgentMessage originalMessage, CancellationToken cancellationToken) { AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false); // Add the original user message to the task history. - // The A2A SDK does this internally when it creates tasks via OnTaskCreated, - // but since we use OnMessageReceived and create the task ourselves, we must - // add the message manually to maintain a consistent history. + // The A2A SDK does this internally when it creates tasks via OnTaskCreated. agentTask.History ??= []; agentTask.History.Add(originalMessage); - // Notify subscribers of the Submitted state per the A2A spec (§4.1.3). - // CreateTaskAsync persists the task with Submitted status but does not emit - // an event, so we call UpdateStatusAsync to ensure SSE subscribers see the - // full Submitted → Working state transition. + // Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false); return agentTask; @@ -179,24 +157,15 @@ void StoreContinuationToken(AgentTask agentTask, ResponseContinuationToken token async Task TransitionToWorkingAsync(string taskId, string contextId, AgentResponse response, CancellationToken cancellationToken) { // Include any intermediate progress messages from the response as a status message. - AgentMessage? progressMessage = response.Messages.Count > 0 - ? CreateMessageFromResponse(contextId, response) - : null; - + AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null; await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false); } async Task CompleteWithArtifactAsync(string taskId, AgentResponse response, CancellationToken cancellationToken) { - // Return the output as an artifact per the A2A spec (§3.7). var artifact = CreateArtifactFromResponse(response); await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false); - - await taskManager.UpdateStatusAsync( - taskId, - TaskState.Completed, - final: true, - cancellationToken: cancellationToken).ConfigureAwait(false); + await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false); } async Task OnTaskUpdatedAsync(AgentTask agentTask, CancellationToken cancellationToken) @@ -271,14 +240,14 @@ static bool TryExtractContinuationToken(AgentTask agentTask, JsonSerializerOptio if (agentTask.Metadata is not null && agentTask.Metadata.TryGetValue(ContinuationTokenMetadataKey, out var tokenElement)) { - continuationToken = (ResponseContinuationToken?)JsonSerializer.Deserialize(tokenElement, jsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); + continuationToken = (ResponseContinuationToken?)tokenElement.Deserialize(jsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); return continuationToken is not null; } continuationToken = null; return false; } -#pragma warning restore MEAI001 +#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. } private static List ExtractChatMessagesFromTaskHistory(AgentTask agentTask) From 6ec30f02be9c9642428a2c3a009f243ebfadfa1c Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 11 Feb 2026 13:12:06 +0100 Subject: [PATCH 06/22] More test --- .../AIAgentExtensionsTests.cs | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs index aa2def40f2..1efcd9eb85 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -558,6 +558,195 @@ public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync() Assert.Equal(TaskState.Failed, updatedTask.Status.State); } + /// + /// Verifies that in Task mode with a ContinuationToken, the result is an AgentTask in Working state. + /// + [Fact] + public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Working on it...")]) + { + ContinuationToken = CreateTestContinuationToken() + }; + ITaskManager taskManager = CreateAgentMockWithResponse(response) + .Object.MapA2A(responseMode: A2AResponseMode.Task); + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + + // Assert + AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.Equal(TaskState.Working, agentTask.Status.State); + Assert.NotNull(agentTask.Metadata); + Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); + } + + /// + /// Verifies that when the agent returns a ContinuationToken with no progress messages, + /// the task transitions to Working state with a null status message. + /// + [Fact] + public async Task MapA2A_WhenContinuationTokenWithNoMessages_TaskStatusHasNullMessageAsync() + { + // Arrange + AgentResponse response = new([]) + { + ContinuationToken = CreateTestContinuationToken() + }; + ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + + // Assert + AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.Equal(TaskState.Working, agentTask.Status.State); + Assert.Null(agentTask.Status.Message); + } + + /// + /// Verifies that when OnTaskUpdated is invoked on a task without a continuation token, + /// the task processes messages from its history and completes with an artifact. + /// + [Fact] + public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryAndCompletesAsync() + { + // Arrange + int callCount = 0; + Mock agentMock = CreateAgentMockWithSequentialResponses( + new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done immediately")]), + new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")]), + ref callCount); + ITaskManager taskManager = agentMock.Object.MapA2A(responseMode: A2AResponseMode.Task); + + // Act — create a completed task (no continuation token) + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + AgentTask agentTask = Assert.IsType(a2aResponse); + + // Simulate a follow-up message by adding it to history and re-submitting via OnTaskUpdated + agentTask.History ??= []; + agentTask.History.Add(new AgentMessage { MessageId = "follow-up", Role = MessageRole.User, Parts = [new TextPart { Text = "Follow up" }] }); + + // Act — invoke OnTaskUpdated without a continuation token + await InvokeOnTaskUpdatedAsync(taskManager, agentTask); + + // Assert + AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + Assert.NotNull(updatedTask); + Assert.Equal(TaskState.Completed, updatedTask.Status.State); + Assert.NotNull(updatedTask.Artifacts); + Assert.Equal(2, updatedTask.Artifacts.Count); + Artifact artifact = updatedTask.Artifacts[1]; + TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); + Assert.Equal("Follow-up done!", textPart.Text); + } + + /// + /// Verifies that when a task is cancelled, the continuation token is removed from metadata. + /// + [Fact] + public async Task MapA2A_OnTaskCancelled_RemovesContinuationTokenFromMetadataAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting...")]) + { + ContinuationToken = CreateTestContinuationToken() + }; + ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + + // Act — create a working task with a continuation token + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.NotNull(agentTask.Metadata); + Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); + + // Act — cancel the task + await taskManager.CancelTaskAsync(new TaskIdParams { Id = agentTask.Id }, CancellationToken.None); + + // Assert — continuation token should be removed from metadata + Assert.False(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); + } + + /// + /// Verifies that when the agent throws an OperationCanceledException during a poll, + /// it is re-thrown without marking the task as Failed. + /// + [Fact] + public async Task MapA2A_OnTaskUpdated_WhenOperationCancelled_DoesNotMarkFailedAsync() + { + // Arrange + int callCount = 0; + Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => + { + if (invocation == 1) + { + return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) + { + ContinuationToken = CreateTestContinuationToken() + }; + } + + throw new OperationCanceledException("Cancelled"); + }); + ITaskManager taskManager = agentMock.Object.MapA2A(); + + // Act — create the task + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + }); + AgentTask agentTask = Assert.IsType(a2aResponse); + + // Act — poll the task; agent throws OperationCanceledException + await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask)); + + // Assert — task should still be Working, not Failed + AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + Assert.NotNull(updatedTask); + Assert.Equal(TaskState.Working, updatedTask.Status.State); + } + + /// + /// Verifies that when the incoming message has a ContextId, it is used for the task + /// rather than generating a new one. + /// + [Fact] + public async Task MapA2A_WhenMessageHasContextId_UsesProvidedContextIdAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + + // Act + A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + { + Message = new AgentMessage + { + MessageId = "test-id", + ContextId = "my-context-123", + Role = MessageRole.User, + Parts = [new TextPart { Text = "Hello" }] + } + }); + + // Assert + AgentMessage agentMessage = Assert.IsType(a2aResponse); + Assert.Equal("my-context-123", agentMessage.ContextId); + } + #pragma warning restore MEAI001 private static Mock CreateAgentMock(Action optionsCallback) From 256121ebe031efd3f5acf17f82d671b7a136057d Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 11 Feb 2026 13:18:30 +0100 Subject: [PATCH 07/22] remove unsued import --- .../Converters/AdditionalPropertiesDictionaryExtensions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs index 0b88fb2e8f..e557ff4e07 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Text.Json; -using A2A; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Hosting.A2A.Converters; From 124407e831d38ede6fd8467c0d09bc350bb0a0c5 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 11 Feb 2026 13:39:48 +0100 Subject: [PATCH 08/22] fix moq override --- .../AIAgentExtensionsTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs index a3447e995f..a61c8e7b0c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -830,7 +830,10 @@ private static Mock CreateAgentMockWithCallCount( Mock agentMock = new() { CallBase = true }; agentMock.SetupGet(x => x.Name).Returns("TestAgent"); - agentMock.Setup(x => x.GetNewSessionAsync()).ReturnsAsync(new TestAgentSession()); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); agentMock .Protected() .Setup>("RunCoreAsync", From 9966c42b4108bf34bb8fd56c51f5fe2476442c7c Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 20 Feb 2026 15:16:11 +0100 Subject: [PATCH 09/22] refactoring --- .../AIAgentExtensions.cs | 409 ++++++++++-------- 1 file changed, 221 insertions(+), 188 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index b9a59dc8bd..ac611b4827 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -17,6 +17,10 @@ namespace Microsoft.Agents.AI.Hosting.A2A; /// public static class AIAgentExtensions { + // Metadata key used to store continuation tokens for long-running background operations + // in the AgentTask.Metadata dictionary, persisted by the task store. + private const string ContinuationTokenMetadataKey = "__a2a__continuationToken"; + /// /// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified . /// @@ -47,10 +51,6 @@ public static ITaskManager MapA2A( // Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent. JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions; - // Metadata key used to store continuation tokens for long-running background operations - // in the AgentTask.Metadata dictionary, persisted by the task store. - const string ContinuationTokenMetadataKey = "__a2a__continuationToken"; - // OnMessageReceived handles both message-only and task-based flows. // The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set, // so we consolidate all initial message handling here and return either @@ -58,197 +58,266 @@ public static ITaskManager MapA2A( // When the agent returns a ContinuationToken (long-running operation), a task is // created for stateful tracking. Otherwise a lightweight AgentMessage is returned. // See https://github.com/a2aproject/a2a-dotnet/issues/275 - taskManager.OnMessageReceived += OnMessageReceivedAsync; + taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, responseMode, taskManager, continuationTokenJsonOptions, ct); // Task flow for subsequent updates and cancellations - taskManager.OnTaskUpdated += OnTaskUpdatedAsync; + taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct); taskManager.OnTaskCancelled += OnTaskCancelledAsync; return taskManager; + } - async Task OnMessageReceivedAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) + /// + /// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified . + /// + /// Agent to attach A2A messaging processing capabilities to. + /// The agent card to return on query. + /// Instance of to configure for A2A messaging. New instance will be created if not passed. + /// The logger factory to use for creating instances. + /// The store to store session contents and metadata. + /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. + /// The configured . + public static ITaskManager MapA2A( + this AIAgent agent, + AgentCard agentCard, + ITaskManager? taskManager = null, + ILoggerFactory? loggerFactory = null, + AgentSessionStore? agentSessionStore = null, + A2AResponseMode responseMode = A2AResponseMode.Auto, + JsonSerializerOptions? jsonSerializerOptions = null) + { + taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, responseMode, jsonSerializerOptions); + + taskManager.OnAgentCardQuery += (context, query) => { - var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); - var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + // A2A SDK assigns the url on its own + // we can help user if they did not set Url explicitly. + if (string.IsNullOrEmpty(agentCard.Url)) + { + agentCard.Url = context.TrimEnd('/'); + } - // Only enable background responses when the mode allows task-based results. - // In Message mode, background responses are never enabled so the agent always completes synchronously. - bool allowBackground = responseMode != A2AResponseMode.Message; + return Task.FromResult(agentCard); + }; + return taskManager; + } - var options = messageSendParams.Metadata is not { Count: > 0 } - ? new AgentRunOptions { AllowBackgroundResponses = allowBackground } - : new AgentRunOptions { AllowBackgroundResponses = allowBackground, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; + private static async Task OnMessageReceivedAsync( + MessageSendParams messageSendParams, + AIHostAgent hostAgent, + A2AResponseMode responseMode, + ITaskManager taskManager, + JsonSerializerOptions continuationTokenJsonOptions, + CancellationToken cancellationToken) + { + var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); + var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); - var response = await hostAgent.RunAsync( - messageSendParams.ToChatMessages(), - session: session, - options: options, - cancellationToken: cancellationToken).ConfigureAwait(false); + // Only enable background responses when the mode allows task-based results. + // In Message mode, background responses are never enabled so the agent always completes synchronously. + bool allowBackground = responseMode != A2AResponseMode.Message; - await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + var options = messageSendParams.Metadata is not { Count: > 0 } + ? new AgentRunOptions { AllowBackgroundResponses = allowBackground } + : new AgentRunOptions { AllowBackgroundResponses = allowBackground, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; - if (responseMode is A2AResponseMode.Message || (responseMode is A2AResponseMode.Auto && response.ContinuationToken is null)) - { - return CreateMessageFromResponse(contextId, response); - } + var response = await hostAgent.RunAsync( + messageSendParams.ToChatMessages(), + session: session, + options: options, + cancellationToken: cancellationToken).ConfigureAwait(false); - var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, cancellationToken).ConfigureAwait(false); - if (response.ContinuationToken is not null) - { - StoreContinuationToken(agentTask, response.ContinuationToken); - await TransitionToWorkingAsync(agentTask.Id, contextId, response, cancellationToken).ConfigureAwait(false); - } - else - { - await CompleteWithArtifactAsync(agentTask.Id, response, cancellationToken).ConfigureAwait(false); - } + await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - return agentTask; +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + if (responseMode is A2AResponseMode.Message || (responseMode is A2AResponseMode.Auto && response.ContinuationToken is null)) + { + return CreateMessageFromResponse(contextId, response); } - AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) + var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false); + if (response.ContinuationToken is not null) { - return new AgentMessage - { - MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), - ContextId = contextId, - Role = MessageRole.Agent, - Parts = response.Messages.ToParts(), - Metadata = response.AdditionalProperties?.ToA2AMetadata() - }; + StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); + await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false); } - - // Task outputs should be returned as artifacts rather than messages: - // https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts - Artifact CreateArtifactFromResponse(AgentResponse response) => new() + else { - ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"), - Parts = response.Messages.ToParts(), - Metadata = response.AdditionalProperties?.ToA2AMetadata() - }; + await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false); + } +#pragma warning restore MEAI001 + + return agentTask; + } + + private static async Task OnTaskUpdatedAsync( + AgentTask agentTask, + AIHostAgent hostAgent, + ITaskManager taskManager, + JsonSerializerOptions continuationTokenJsonOptions, + CancellationToken cancellationToken) + { + var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N"); + var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); - async Task InitializeTaskAsync(string contextId, AgentMessage originalMessage, CancellationToken cancellationToken) + try { - AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false); + // If this task has a pending continuation token in its metadata, check on + // the background operation instead of processing new messages from history. +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + if (TryExtractContinuationToken(agentTask, continuationTokenJsonOptions, out var continuationToken)) + { + var response = await hostAgent.RunAsync( + session: session, + options: new AgentRunOptions { ContinuationToken = continuationToken }, + cancellationToken: cancellationToken).ConfigureAwait(false); - // Add the original user message to the task history. - // The A2A SDK does this internally when it creates tasks via OnTaskCreated. - agentTask.History ??= []; - agentTask.History.Add(originalMessage); + await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - // Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false); + if (response.ContinuationToken is not null) + { + StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); + await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false); + } + else + { + agentTask.Metadata!.Remove(ContinuationTokenMetadataKey); + await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false); + } +#pragma warning restore MEAI001 - return agentTask; - } + return; + } -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - void StoreContinuationToken(AgentTask agentTask, ResponseContinuationToken token) + // No pending continuation — process new user messages from task history + await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); + + var newResponse = await hostAgent.RunAsync( + ExtractChatMessagesFromTaskHistory(agentTask), + session: session, + cancellationToken: cancellationToken).ConfigureAwait(false); + + await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + await CompleteWithArtifactAsync(agentTask.Id, newResponse, taskManager, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) { - // Serialize the continuation token into the task's metadata so it survives - // across requests and is cleaned up with the task itself. - agentTask.Metadata ??= []; - agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement( - token, - continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); + throw; } -#pragma warning restore MEAI001 - - async Task TransitionToWorkingAsync(string taskId, string contextId, AgentResponse response, CancellationToken cancellationToken) + catch (Exception) { - // Include any intermediate progress messages from the response as a status message. - AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null; - await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false); + await taskManager.UpdateStatusAsync( + agentTask.Id, + TaskState.Failed, + final: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + throw; } + } + + private static Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken) + { + // Remove the continuation token from metadata if present. + // The task has already been marked as cancelled by the TaskManager. + agentTask.Metadata?.Remove(ContinuationTokenMetadataKey); + return Task.CompletedTask; + } - async Task CompleteWithArtifactAsync(string taskId, AgentResponse response, CancellationToken cancellationToken) + private static AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) => + new() { - var artifact = CreateArtifactFromResponse(response); - await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false); - await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false); - } + MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), + ContextId = contextId, + Role = MessageRole.Agent, + Parts = response.Messages.ToParts(), + Metadata = response.AdditionalProperties?.ToA2AMetadata() + }; - async Task OnTaskUpdatedAsync(AgentTask agentTask, CancellationToken cancellationToken) + // Task outputs should be returned as artifacts rather than messages: + // https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts + private static Artifact CreateArtifactFromResponse(AgentResponse response) => + new() { - var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N"); - var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"), + Parts = response.Messages.ToParts(), + Metadata = response.AdditionalProperties?.ToA2AMetadata() + }; - try - { - // If this task has a pending continuation token in its metadata, check on - // the background operation instead of processing new messages from history. - if (TryExtractContinuationToken(agentTask, continuationTokenJsonOptions, out var continuationToken)) - { - var response = await hostAgent.RunAsync( - session: session, - options: new AgentRunOptions { ContinuationToken = continuationToken }, - cancellationToken: cancellationToken).ConfigureAwait(false); - - await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - - if (response.ContinuationToken is not null) - { - StoreContinuationToken(agentTask, response.ContinuationToken); - await TransitionToWorkingAsync(agentTask.Id, contextId, response, cancellationToken).ConfigureAwait(false); - } - else - { - agentTask.Metadata!.Remove(ContinuationTokenMetadataKey); - await CompleteWithArtifactAsync(agentTask.Id, response, cancellationToken).ConfigureAwait(false); - } - - return; - } + private static async Task InitializeTaskAsync( + string contextId, + AgentMessage originalMessage, + ITaskManager taskManager, + CancellationToken cancellationToken) + { + AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false); - // No pending continuation — process new user messages from task history - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); + // Add the original user message to the task history. + // The A2A SDK does this internally when it creates tasks via OnTaskCreated. + agentTask.History ??= []; + agentTask.History.Add(originalMessage); - var newResponse = await hostAgent.RunAsync( - ExtractChatMessagesFromTaskHistory(agentTask), - session: session, - cancellationToken: cancellationToken).ConfigureAwait(false); + // Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate + await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false); - await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - await CompleteWithArtifactAsync(agentTask.Id, newResponse, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception) - { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Failed, - final: true, - cancellationToken: cancellationToken).ConfigureAwait(false); - throw; - } - } + return agentTask; + } - Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken) - { - // Remove the continuation token from metadata if present. - // The task has already been marked as cancelled by the TaskManager. - agentTask.Metadata?.Remove(ContinuationTokenMetadataKey); - return Task.CompletedTask; - } +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + private static void StoreContinuationToken( + AgentTask agentTask, + ResponseContinuationToken token, + JsonSerializerOptions continuationTokenJsonOptions) + { + // Serialize the continuation token into the task's metadata so it survives + // across requests and is cleaned up with the task itself. + agentTask.Metadata ??= []; + agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement( + token, + continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); + } +#pragma warning restore MEAI001 + + private static async Task TransitionToWorkingAsync( + string taskId, + string contextId, + AgentResponse response, + ITaskManager taskManager, + CancellationToken cancellationToken) + { + // Include any intermediate progress messages from the response as a status message. + AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null; + await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private static async Task CompleteWithArtifactAsync( + string taskId, + AgentResponse response, + ITaskManager taskManager, + CancellationToken cancellationToken) + { + var artifact = CreateArtifactFromResponse(response); + await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false); + await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false); + } #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - static bool TryExtractContinuationToken(AgentTask agentTask, JsonSerializerOptions jsonOptions, out ResponseContinuationToken? continuationToken) + private static bool TryExtractContinuationToken( + AgentTask agentTask, + JsonSerializerOptions jsonOptions, + out ResponseContinuationToken? continuationToken) + { + if (agentTask.Metadata is not null && + agentTask.Metadata.TryGetValue(ContinuationTokenMetadataKey, out var tokenElement)) { - if (agentTask.Metadata is not null && - agentTask.Metadata.TryGetValue(ContinuationTokenMetadataKey, out var tokenElement)) - { - continuationToken = (ResponseContinuationToken?)tokenElement.Deserialize(jsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); - return continuationToken is not null; - } - - continuationToken = null; - return false; + continuationToken = (ResponseContinuationToken?)tokenElement.Deserialize(jsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); + return continuationToken is not null; } -#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + continuationToken = null; + return false; } +#pragma warning restore MEAI001 private static List ExtractChatMessagesFromTaskHistory(AgentTask agentTask) { @@ -265,40 +334,4 @@ private static List ExtractChatMessagesFromTaskHistory(AgentTask ag return chatMessages; } - - /// - /// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified . - /// - /// Agent to attach A2A messaging processing capabilities to. - /// The agent card to return on query. - /// Instance of to configure for A2A messaging. New instance will be created if not passed. - /// The logger factory to use for creating instances. - /// The store to store session contents and metadata. - /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. - /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. - /// The configured . - public static ITaskManager MapA2A( - this AIAgent agent, - AgentCard agentCard, - ITaskManager? taskManager = null, - ILoggerFactory? loggerFactory = null, - AgentSessionStore? agentSessionStore = null, - A2AResponseMode responseMode = A2AResponseMode.Auto, - JsonSerializerOptions? jsonSerializerOptions = null) - { - taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, responseMode, jsonSerializerOptions); - - taskManager.OnAgentCardQuery += (context, query) => - { - // A2A SDK assigns the url on its own - // we can help user if they did not set Url explicitly. - if (string.IsNullOrEmpty(agentCard.Url)) - { - agentCard.Url = context.TrimEnd('/'); - } - - return Task.FromResult(agentCard); - }; - return taskManager; - } } From 4c3b19c032b335f5b1520521a918d8b5385e0199 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 20 Feb 2026 17:39:56 +0100 Subject: [PATCH 10/22] ontaskupdated --- .../AIAgentExtensions.cs | 66 ++++++------------- 1 file changed, 19 insertions(+), 47 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index ac611b4827..845030d7d0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -163,43 +163,33 @@ private static async Task OnTaskUpdatedAsync( try { - // If this task has a pending continuation token in its metadata, check on - // the background operation instead of processing new messages from history. -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - if (TryExtractContinuationToken(agentTask, continuationTokenJsonOptions, out var continuationToken)) - { - var response = await hostAgent.RunAsync( - session: session, - options: new AgentRunOptions { ContinuationToken = continuationToken }, - cancellationToken: cancellationToken).ConfigureAwait(false); - - await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - - if (response.ContinuationToken is not null) - { - StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); - await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false); - } - else - { - agentTask.Metadata!.Remove(ContinuationTokenMetadataKey); - await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false); - } -#pragma warning restore MEAI001 + // Discard any stale continuation token — the incoming user message supersedes + // any previous background operation. AF agents do not support resuming a + // background run while injecting new messages; we start a fresh run from the + // existing session using the full chat history (which includes the new message). + agentTask.Metadata?.Remove(ContinuationTokenMetadataKey); - return; - } - - // No pending continuation — process new user messages from task history await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); - var newResponse = await hostAgent.RunAsync( + var response = await hostAgent.RunAsync( ExtractChatMessagesFromTaskHistory(agentTask), session: session, + options: new AgentRunOptions { AllowBackgroundResponses = true }, cancellationToken: cancellationToken).ConfigureAwait(false); await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - await CompleteWithArtifactAsync(agentTask.Id, newResponse, taskManager, cancellationToken).ConfigureAwait(false); + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + if (response.ContinuationToken is not null) + { + StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); + await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false); + } + else + { + await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false); + } +#pragma warning restore MEAI001 } catch (OperationCanceledException) { @@ -301,24 +291,6 @@ private static async Task CompleteWithArtifactAsync( await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false); } -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - private static bool TryExtractContinuationToken( - AgentTask agentTask, - JsonSerializerOptions jsonOptions, - out ResponseContinuationToken? continuationToken) - { - if (agentTask.Metadata is not null && - agentTask.Metadata.TryGetValue(ContinuationTokenMetadataKey, out var tokenElement)) - { - continuationToken = (ResponseContinuationToken?)tokenElement.Deserialize(jsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); - return continuationToken is not null; - } - - continuationToken = null; - return false; - } -#pragma warning restore MEAI001 - private static List ExtractChatMessagesFromTaskHistory(AgentTask agentTask) { if (agentTask.History is not { Count: > 0 }) From febd97e1cab6587f7dcebe5875aaf5dbf8ddc8f4 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 20 Feb 2026 17:58:06 +0100 Subject: [PATCH 11/22] adjust to delegate --- .../EndpointRouteBuilderExtensions.cs | 8 +- .../A2AResponseDecisionContext.cs | 28 +++++ .../A2AResponseMode.cs | 106 ++++++++++++++++-- .../AIAgentExtensions.cs | 14 ++- .../AIAgentExtensionsTests.cs | 6 +- 5 files changed, 138 insertions(+), 24 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseDecisionContext.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs index b6f39ee6b3..95eafd03cb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using A2A; @@ -204,7 +204,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, A2AResponseMode.Auto); + => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, A2AResponseMode.Dynamic()); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -264,7 +264,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The callback to configure . /// Configured for A2A integration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager) - => endpoints.MapA2A(agent, path, configureTaskManager, A2AResponseMode.Auto); + => endpoints.MapA2A(agent, path, configureTaskManager, A2AResponseMode.Dynamic()); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -337,7 +337,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, A2AResponseMode.Auto); + => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, A2AResponseMode.Dynamic()); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseDecisionContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseDecisionContext.cs new file mode 100644 index 0000000000..8fdf760117 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseDecisionContext.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using A2A; + +namespace Microsoft.Agents.AI.Hosting.A2A; + +/// +/// Provides context for a custom A2A response mode decision. +/// Passed to the delegate supplied to . +/// +public sealed class A2AResponseDecisionContext +{ + internal A2AResponseDecisionContext(MessageSendParams messageSendParams, AgentResponse agentResponse) + { + this.MessageSendParams = messageSendParams; + this.AgentResponse = agentResponse; + } + + /// + /// Gets the parameters of the incoming A2A message that triggered this run. + /// + public MessageSendParams MessageSendParams { get; } + + /// + /// Gets the response produced by the agent for the incoming message. + /// + public AgentResponse AgentResponse { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs index 1ef70d8921..4dad72150b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs @@ -1,31 +1,113 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Threading.Tasks; + namespace Microsoft.Agents.AI.Hosting.A2A; /// /// Specifies how the A2A hosting layer determines whether to return an -/// AgentMessage or an AgentTask from -/// . +/// AgentMessage or an AgentTask from . /// -public enum A2AResponseMode +public sealed class A2AResponseMode : IEquatable { - /// - /// The decision is delegated to the agent. Background responses are enabled - /// and if the agent returns a continuation token (indicating a long-running operation), - /// an AgentTask is returned. Otherwise an AgentMessage is returned. - /// - Auto = 0, + private readonly string _value; + private readonly Func>? _decide; + + private A2AResponseMode(string value, Func>? decide = null) + { + this._value = value; + this._decide = decide; + } /// /// Always return an AgentMessage. Background responses are not enabled. - /// This is suitable for lightweight, single-shot request-response interactions. + /// Suitable for lightweight, single-shot request/response interactions. /// - Message = 1, + public static A2AResponseMode Message { get; } = new("message"); /// /// Always return an AgentTask. A task is created and tracked for every /// request, even if the agent completes immediately. Background responses are enabled /// so the agent can signal long-running operations if supported. /// - Task = 2, + public static A2AResponseMode Task { get; } = new("task"); + + /// + /// The response type is decided dynamically using the default heuristic: an + /// AgentTask is returned when the agent produces a continuation token + /// (indicating a long-running operation); otherwise an AgentMessage is returned. + /// Background responses are enabled. + /// + public static A2AResponseMode Dynamic() => new("dynamic"); + + /// + /// The response type is decided by the supplied delegate. + /// The delegate receives an with the incoming + /// message and the agent response, and returns to return an + /// AgentTask or to return an AgentMessage. + /// Background responses are enabled. + /// + /// + /// An async delegate that decides whether the response should be wrapped in an AgentTask. + /// + public static A2AResponseMode Dynamic(Func> decideAsTask) + { + ArgumentNullException.ThrowIfNull(decideAsTask); + return new("dynamic", decideAsTask); + } + + /// + /// Gets whether background (long-running) responses are enabled for this mode. + /// + internal bool AllowBackgroundResponses => + !string.Equals(this._value, "message", StringComparison.OrdinalIgnoreCase); + + /// + /// Determines whether the agent response should be returned as an AgentTask. + /// +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + internal ValueTask ShouldReturnAsTaskAsync(A2AResponseDecisionContext context) + { + if (string.Equals(this._value, "message", StringComparison.OrdinalIgnoreCase)) + { + return ValueTask.FromResult(false); + } + + if (string.Equals(this._value, "task", StringComparison.OrdinalIgnoreCase)) + { + return ValueTask.FromResult(true); + } + + // Dynamic: delegate to custom callback if provided, otherwise use the default + // heuristic of checking whether the agent returned a continuation token. + if (this._decide is not null) + { + return this._decide(context); + } + + return ValueTask.FromResult(context.AgentResponse.ContinuationToken is not null); + } +#pragma warning restore MEAI001 + + /// + public bool Equals(A2AResponseMode? other) => + other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase); + + /// + public override bool Equals(object? obj) => this.Equals(obj as A2AResponseMode); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this._value); + + /// + public override string ToString() => this._value; + + /// Determines whether two instances are equal. + public static bool operator ==(A2AResponseMode? left, A2AResponseMode? right) => + left?.Equals(right) ?? right is null; + + /// Determines whether two instances are not equal. + public static bool operator !=(A2AResponseMode? left, A2AResponseMode? right) => + !(left == right); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index 845030d7d0..37ab385f35 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -36,12 +36,14 @@ public static ITaskManager MapA2A( ITaskManager? taskManager = null, ILoggerFactory? loggerFactory = null, AgentSessionStore? agentSessionStore = null, - A2AResponseMode responseMode = A2AResponseMode.Auto, + A2AResponseMode? responseMode = null, JsonSerializerOptions? jsonSerializerOptions = null) { ArgumentNullException.ThrowIfNull(agent); ArgumentNullException.ThrowIfNull(agent.Name); + responseMode ??= A2AResponseMode.Dynamic(); + var hostAgent = new AIHostAgent( innerAgent: agent, sessionStore: agentSessionStore ?? new NoopAgentSessionStore()); @@ -84,7 +86,7 @@ public static ITaskManager MapA2A( ITaskManager? taskManager = null, ILoggerFactory? loggerFactory = null, AgentSessionStore? agentSessionStore = null, - A2AResponseMode responseMode = A2AResponseMode.Auto, + A2AResponseMode? responseMode = null, JsonSerializerOptions? jsonSerializerOptions = null) { taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, responseMode, jsonSerializerOptions); @@ -116,7 +118,7 @@ private static async Task OnMessageReceivedAsync( // Only enable background responses when the mode allows task-based results. // In Message mode, background responses are never enabled so the agent always completes synchronously. - bool allowBackground = responseMode != A2AResponseMode.Message; + bool allowBackground = responseMode.AllowBackgroundResponses; var options = messageSendParams.Metadata is not { Count: > 0 } ? new AgentRunOptions { AllowBackgroundResponses = allowBackground } @@ -130,13 +132,15 @@ private static async Task OnMessageReceivedAsync( await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - if (responseMode is A2AResponseMode.Message || (responseMode is A2AResponseMode.Auto && response.ContinuationToken is null)) + var decisionContext = new A2AResponseDecisionContext(messageSendParams, response); + var shouldReturnAsTask = await responseMode.ShouldReturnAsTaskAsync(decisionContext).ConfigureAwait(false); + if (!shouldReturnAsTask) { return CreateMessageFromResponse(contextId, response); } var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false); +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. if (response.ContinuationToken is not null) { StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs index a61c8e7b0c..f322791e92 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -229,16 +229,16 @@ public async Task MapA2A_TaskMode_WhenNoContinuationToken_ReturnsCompletedAgentT } /// - /// Verifies that when responseMode is Auto and the agent completes immediately (no ContinuationToken), + /// Verifies that when responseMode is Dynamic (default) and the agent completes immediately (no ContinuationToken), /// the result is an AgentMessage (not a task). /// [Fact] - public async Task MapA2A_AutoMode_WhenNoContinuationToken_ReturnsAgentMessageAsync() + public async Task MapA2A_DynamicMode_WhenNoContinuationToken_ReturnsAgentMessageAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]); ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(responseMode: A2AResponseMode.Auto); + .Object.MapA2A(responseMode: A2AResponseMode.Dynamic()); // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams From 5bb5939128b7308075efff0b52f7a02e6fbe826d Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 20 Feb 2026 18:08:49 +0100 Subject: [PATCH 12/22] fix encoding --- .../EndpointRouteBuilderExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs index 95eafd03cb..1ceccbb25c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using A2A; From 5f63b8eb32025fb66372736ef6b2701331e4f10d Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 23 Feb 2026 12:43:23 +0100 Subject: [PATCH 13/22] address PR comments: rework --- .../EndpointRouteBuilderExtensions.cs | 8 ++-- .../A2AResponseDecisionContext.cs | 9 +--- .../A2AResponseMode.cs | 45 ++++++++----------- .../AIAgentExtensions.cs | 11 +++-- .../AIAgentExtensionsTests.cs | 8 ++-- 5 files changed, 32 insertions(+), 49 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs index 1ceccbb25c..9fff65ebcb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using A2A; @@ -204,7 +204,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, A2AResponseMode.Dynamic()); + => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, A2AResponseMode.Message); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -264,7 +264,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The callback to configure . /// Configured for A2A integration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager) - => endpoints.MapA2A(agent, path, configureTaskManager, A2AResponseMode.Dynamic()); + => endpoints.MapA2A(agent, path, configureTaskManager, A2AResponseMode.Message); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -337,7 +337,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, A2AResponseMode.Dynamic()); + => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, A2AResponseMode.Message); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseDecisionContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseDecisionContext.cs index 8fdf760117..f81db447bc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseDecisionContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseDecisionContext.cs @@ -6,23 +6,16 @@ namespace Microsoft.Agents.AI.Hosting.A2A; /// /// Provides context for a custom A2A response mode decision. -/// Passed to the delegate supplied to . /// public sealed class A2AResponseDecisionContext { - internal A2AResponseDecisionContext(MessageSendParams messageSendParams, AgentResponse agentResponse) + internal A2AResponseDecisionContext(MessageSendParams messageSendParams) { this.MessageSendParams = messageSendParams; - this.AgentResponse = agentResponse; } /// /// Gets the parameters of the incoming A2A message that triggered this run. /// public MessageSendParams MessageSendParams { get; } - - /// - /// Gets the response produced by the agent for the incoming message. - /// - public AgentResponse AgentResponse { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs index 4dad72150b..167bde9161 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.Agents.AI.Hosting.A2A; @@ -11,10 +12,14 @@ namespace Microsoft.Agents.AI.Hosting.A2A; /// public sealed class A2AResponseMode : IEquatable { + private const string MessageValue = "message"; + private const string TaskValue = "task"; + private const string DynamicValue = "dynamic"; + private readonly string _value; - private readonly Func>? _decide; + private readonly Func>? _decide; - private A2AResponseMode(string value, Func>? decide = null) + private A2AResponseMode(string value, Func>? decide = null) { this._value = value; this._decide = decide; @@ -24,22 +29,14 @@ private A2AResponseMode(string value, FuncAgentMessage. Background responses are not enabled. /// Suitable for lightweight, single-shot request/response interactions. /// - public static A2AResponseMode Message { get; } = new("message"); + public static A2AResponseMode Message { get; } = new(MessageValue); /// /// Always return an AgentTask. A task is created and tracked for every /// request, even if the agent completes immediately. Background responses are enabled /// so the agent can signal long-running operations if supported. /// - public static A2AResponseMode Task { get; } = new("task"); - - /// - /// The response type is decided dynamically using the default heuristic: an - /// AgentTask is returned when the agent produces a continuation token - /// (indicating a long-running operation); otherwise an AgentMessage is returned. - /// Background responses are enabled. - /// - public static A2AResponseMode Dynamic() => new("dynamic"); + public static A2AResponseMode Task { get; } = new(TaskValue); /// /// The response type is decided by the supplied delegate. @@ -51,42 +48,36 @@ private A2AResponseMode(string value, Func /// An async delegate that decides whether the response should be wrapped in an AgentTask. /// - public static A2AResponseMode Dynamic(Func> decideAsTask) + public static A2AResponseMode Dynamic(Func> decideAsTask) { ArgumentNullException.ThrowIfNull(decideAsTask); - return new("dynamic", decideAsTask); + return new(DynamicValue, decideAsTask); } - /// - /// Gets whether background (long-running) responses are enabled for this mode. - /// - internal bool AllowBackgroundResponses => - !string.Equals(this._value, "message", StringComparison.OrdinalIgnoreCase); - /// /// Determines whether the agent response should be returned as an AgentTask. /// #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - internal ValueTask ShouldReturnAsTaskAsync(A2AResponseDecisionContext context) + internal ValueTask ShouldReturnAsTaskAsync(A2AResponseDecisionContext context, CancellationToken cancellationToken) { - if (string.Equals(this._value, "message", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(this._value, MessageValue, StringComparison.OrdinalIgnoreCase)) { return ValueTask.FromResult(false); } - if (string.Equals(this._value, "task", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(this._value, TaskValue, StringComparison.OrdinalIgnoreCase)) { return ValueTask.FromResult(true); } - // Dynamic: delegate to custom callback if provided, otherwise use the default - // heuristic of checking whether the agent returned a continuation token. + // Dynamic: delegate to custom callback. if (this._decide is not null) { - return this._decide(context); + return this._decide(context, cancellationToken); } - return ValueTask.FromResult(context.AgentResponse.ContinuationToken is not null); + // No delegate provided — fall back to task behavior. + return ValueTask.FromResult(true); } #pragma warning restore MEAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index 37ab385f35..d33d9c83d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -42,7 +42,7 @@ public static ITaskManager MapA2A( ArgumentNullException.ThrowIfNull(agent); ArgumentNullException.ThrowIfNull(agent.Name); - responseMode ??= A2AResponseMode.Dynamic(); + responseMode ??= A2AResponseMode.Message; var hostAgent = new AIHostAgent( innerAgent: agent, @@ -118,11 +118,12 @@ private static async Task OnMessageReceivedAsync( // Only enable background responses when the mode allows task-based results. // In Message mode, background responses are never enabled so the agent always completes synchronously. - bool allowBackground = responseMode.AllowBackgroundResponses; + var decisionContext = new A2AResponseDecisionContext(messageSendParams); + var shouldReturnAsTask = await responseMode.ShouldReturnAsTaskAsync(decisionContext, cancellationToken).ConfigureAwait(false); var options = messageSendParams.Metadata is not { Count: > 0 } - ? new AgentRunOptions { AllowBackgroundResponses = allowBackground } - : new AgentRunOptions { AllowBackgroundResponses = allowBackground, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; + ? new AgentRunOptions { AllowBackgroundResponses = shouldReturnAsTask } + : new AgentRunOptions { AllowBackgroundResponses = shouldReturnAsTask, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; var response = await hostAgent.RunAsync( messageSendParams.ToChatMessages(), @@ -132,8 +133,6 @@ private static async Task OnMessageReceivedAsync( await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - var decisionContext = new A2AResponseDecisionContext(messageSendParams, response); - var shouldReturnAsTask = await responseMode.ShouldReturnAsTaskAsync(decisionContext).ConfigureAwait(false); if (!shouldReturnAsTask) { return CreateMessageFromResponse(contextId, response); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs index f322791e92..83aaaad463 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -229,16 +229,16 @@ public async Task MapA2A_TaskMode_WhenNoContinuationToken_ReturnsCompletedAgentT } /// - /// Verifies that when responseMode is Dynamic (default) and the agent completes immediately (no ContinuationToken), - /// the result is an AgentMessage (not a task). + /// Verifies that a custom Dynamic delegate returning false produces an AgentMessage + /// even when the agent completes immediately (no ContinuationToken). /// [Fact] - public async Task MapA2A_DynamicMode_WhenNoContinuationToken_ReturnsAgentMessageAsync() + public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsAgentMessageAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]); ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(responseMode: A2AResponseMode.Dynamic()); + .Object.MapA2A(responseMode: A2AResponseMode.Dynamic((_, _) => ValueTask.FromResult(false))); // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams From 579faafb5cba79fc1c643904d68aaaf29fb4a9cb Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 23 Feb 2026 13:47:24 +0100 Subject: [PATCH 14/22] init 1 --- .../EndpointRouteBuilderExtensions.cs | 30 ++++++------- ...ionContext.cs => A2ARunDecisionContext.cs} | 6 +-- .../AIAgentExtensions.cs | 45 ++++++++----------- .../{A2AResponseMode.cs => AgentRunMode.cs} | 42 +++++++++-------- .../AIAgentExtensionsTests.cs | 12 ++--- 5 files changed, 62 insertions(+), 73 deletions(-) rename dotnet/src/Microsoft.Agents.AI.Hosting.A2A/{A2AResponseDecisionContext.cs => A2ARunDecisionContext.cs} (67%) rename dotnet/src/Microsoft.Agents.AI.Hosting.A2A/{A2AResponseMode.cs => AgentRunMode.cs} (56%) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs index 9fff65ebcb..848bc11738 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using A2A; @@ -41,7 +41,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The route group to use for A2A endpoints. /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, A2AResponseMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode responseMode) { ArgumentNullException.ThrowIfNull(agentBuilder); return endpoints.MapA2A(agentBuilder.Name, path, responseMode); @@ -65,7 +65,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The route group to use for A2A endpoints. /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, A2AResponseMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode responseMode) { ArgumentNullException.ThrowIfNull(endpoints); var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); @@ -147,7 +147,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// Agent card info to return on query. /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, A2AResponseMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode responseMode) { ArgumentNullException.ThrowIfNull(agentBuilder); return endpoints.MapA2A(agentBuilder.Name, path, agentCard, responseMode); @@ -162,7 +162,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// Agent card info to return on query. /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, A2AResponseMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode responseMode) { ArgumentNullException.ThrowIfNull(endpoints); var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); @@ -204,7 +204,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, A2AResponseMode.Message); + => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.Message); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -227,7 +227,7 @@ public static IEndpointConventionBuilder MapA2A( string path, AgentCard agentCard, Action configureTaskManager, - A2AResponseMode responseMode) + AgentRunMode responseMode) { ArgumentNullException.ThrowIfNull(endpoints); var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); @@ -252,7 +252,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The route group to use for A2A endpoints. /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, A2AResponseMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode responseMode) => endpoints.MapA2A(agent, path, _ => { }, responseMode); /// @@ -264,7 +264,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The callback to configure . /// Configured for A2A integration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager) - => endpoints.MapA2A(agent, path, configureTaskManager, A2AResponseMode.Message); + => endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.Message); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -275,14 +275,14 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The callback to configure . /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager, A2AResponseMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager, AgentRunMode responseMode) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agent); var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); - var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, responseMode: responseMode); + var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: responseMode); var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); configureTaskManager(taskManager); @@ -319,7 +319,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// Curated Registries (Catalog-Based Discovery) /// discovery mechanism. /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, A2AResponseMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode responseMode) => endpoints.MapA2A(agent, path, agentCard, _ => { }, responseMode); /// @@ -337,7 +337,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, A2AResponseMode.Message); + => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.Message); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -354,14 +354,14 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// Curated Registries (Catalog-Based Discovery) /// discovery mechanism. /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager, A2AResponseMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode responseMode) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agent); var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); - var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, responseMode: responseMode); + var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: responseMode); var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); configureTaskManager(taskManager); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseDecisionContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs similarity index 67% rename from dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseDecisionContext.cs rename to dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs index f81db447bc..6ff49f6ecb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseDecisionContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs @@ -5,11 +5,11 @@ namespace Microsoft.Agents.AI.Hosting.A2A; /// -/// Provides context for a custom A2A response mode decision. +/// Provides context for a custom A2A run mode decision. /// -public sealed class A2AResponseDecisionContext +public sealed class A2ARunDecisionContext { - internal A2AResponseDecisionContext(MessageSendParams messageSendParams) + internal A2ARunDecisionContext(MessageSendParams messageSendParams) { this.MessageSendParams = messageSendParams; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index d33d9c83d4..db79d8fcad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -28,7 +28,7 @@ public static class AIAgentExtensions /// Instance of to configure for A2A messaging. New instance will be created if not passed. /// The logger factory to use for creating instances. /// The store to store session contents and metadata. - /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Controls the run behavior. /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. /// The configured . public static ITaskManager MapA2A( @@ -36,13 +36,13 @@ public static ITaskManager MapA2A( ITaskManager? taskManager = null, ILoggerFactory? loggerFactory = null, AgentSessionStore? agentSessionStore = null, - A2AResponseMode? responseMode = null, + AgentRunMode? runMode = null, JsonSerializerOptions? jsonSerializerOptions = null) { ArgumentNullException.ThrowIfNull(agent); ArgumentNullException.ThrowIfNull(agent.Name); - responseMode ??= A2AResponseMode.Message; + runMode ??= AgentRunMode.NonBackground; var hostAgent = new AIHostAgent( innerAgent: agent, @@ -60,7 +60,7 @@ public static ITaskManager MapA2A( // When the agent returns a ContinuationToken (long-running operation), a task is // created for stateful tracking. Otherwise a lightweight AgentMessage is returned. // See https://github.com/a2aproject/a2a-dotnet/issues/275 - taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, responseMode, taskManager, continuationTokenJsonOptions, ct); + taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, runMode, taskManager, continuationTokenJsonOptions, ct); // Task flow for subsequent updates and cancellations taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct); @@ -77,7 +77,7 @@ public static ITaskManager MapA2A( /// Instance of to configure for A2A messaging. New instance will be created if not passed. /// The logger factory to use for creating instances. /// The store to store session contents and metadata. - /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Controls the run behavior. /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. /// The configured . public static ITaskManager MapA2A( @@ -86,10 +86,10 @@ public static ITaskManager MapA2A( ITaskManager? taskManager = null, ILoggerFactory? loggerFactory = null, AgentSessionStore? agentSessionStore = null, - A2AResponseMode? responseMode = null, + AgentRunMode? runMode = null, JsonSerializerOptions? jsonSerializerOptions = null) { - taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, responseMode, jsonSerializerOptions); + taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, runMode, jsonSerializerOptions); taskManager.OnAgentCardQuery += (context, query) => { @@ -108,7 +108,7 @@ public static ITaskManager MapA2A( private static async Task OnMessageReceivedAsync( MessageSendParams messageSendParams, AIHostAgent hostAgent, - A2AResponseMode responseMode, + AgentRunMode runMode, ITaskManager taskManager, JsonSerializerOptions continuationTokenJsonOptions, CancellationToken cancellationToken) @@ -116,14 +116,13 @@ private static async Task OnMessageReceivedAsync( var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); - // Only enable background responses when the mode allows task-based results. - // In Message mode, background responses are never enabled so the agent always completes synchronously. - var decisionContext = new A2AResponseDecisionContext(messageSendParams); - var shouldReturnAsTask = await responseMode.ShouldReturnAsTaskAsync(decisionContext, cancellationToken).ConfigureAwait(false); + // Decide whether to run in background based on user preferences and agent capabilities + var decisionContext = new A2ARunDecisionContext(messageSendParams); + var allowBackgroundResponses = await runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); var options = messageSendParams.Metadata is not { Count: > 0 } - ? new AgentRunOptions { AllowBackgroundResponses = shouldReturnAsTask } - : new AgentRunOptions { AllowBackgroundResponses = shouldReturnAsTask, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; + ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses } + : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; var response = await hostAgent.RunAsync( messageSendParams.ToChatMessages(), @@ -133,25 +132,17 @@ private static async Task OnMessageReceivedAsync( await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - if (!shouldReturnAsTask) +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + if (response.ContinuationToken is null) { return CreateMessageFromResponse(contextId, response); } var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false); -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - if (response.ContinuationToken is not null) - { - StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); - await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false); - } - else - { - await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false); - } -#pragma warning restore MEAI001 - + StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); + await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false); return agentTask; +#pragma warning restore MEAI001 } private static async Task OnTaskUpdatedAsync( diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs similarity index 56% rename from dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs rename to dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs index 167bde9161..49b555d92e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AResponseMode.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs @@ -7,40 +7,38 @@ namespace Microsoft.Agents.AI.Hosting.A2A; /// -/// Specifies how the A2A hosting layer determines whether to return an -/// AgentMessage or an AgentTask from . +/// Specifies how the A2A hosting layer determines whether to run in background or not. /// -public sealed class A2AResponseMode : IEquatable +public sealed class AgentRunMode : IEquatable { private const string MessageValue = "message"; private const string TaskValue = "task"; private const string DynamicValue = "dynamic"; private readonly string _value; - private readonly Func>? _decide; + private readonly Func>? _decide; - private A2AResponseMode(string value, Func>? decide = null) + private AgentRunMode(string value, Func>? decide = null) { this._value = value; this._decide = decide; } /// - /// Always return an AgentMessage. Background responses are not enabled. - /// Suitable for lightweight, single-shot request/response interactions. + /// Dissallows the background responses from the agent. Is equivalent to configuring as false. + /// In the A2A protocol terminology will make responses be returned as AgentMessage. /// - public static A2AResponseMode Message { get; } = new(MessageValue); + public static AgentRunMode NonBackground => new(MessageValue); /// - /// Always return an AgentTask. A task is created and tracked for every - /// request, even if the agent completes immediately. Background responses are enabled - /// so the agent can signal long-running operations if supported. + /// Allows the background responses from the agent. Is equivalent to configuring as true. + /// In the A2A protocol terminology will make responses be returned as AgentTask if the agent supports background responses, and as AgentMessage otherwise. /// - public static A2AResponseMode Task { get; } = new(TaskValue); + public static AgentRunMode BackgroundIfSupported => new(TaskValue); /// /// The response type is decided by the supplied delegate. - /// The delegate receives an with the incoming + /// The delegate receives an with the incoming /// message and the agent response, and returns to return an /// AgentTask or to return an AgentMessage. /// Background responses are enabled. @@ -48,7 +46,7 @@ private A2AResponseMode(string value, Func /// An async delegate that decides whether the response should be wrapped in an AgentTask. /// - public static A2AResponseMode Dynamic(Func> decideAsTask) + public static AgentRunMode Dynamic(Func> decideAsTask) { ArgumentNullException.ThrowIfNull(decideAsTask); return new(DynamicValue, decideAsTask); @@ -58,7 +56,7 @@ public static A2AResponseMode Dynamic(FuncAgentTask. /// #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - internal ValueTask ShouldReturnAsTaskAsync(A2AResponseDecisionContext context, CancellationToken cancellationToken) + internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext context, CancellationToken cancellationToken) { if (string.Equals(this._value, MessageValue, StringComparison.OrdinalIgnoreCase)) { @@ -76,17 +74,17 @@ internal ValueTask ShouldReturnAsTaskAsync(A2AResponseDecisionContext cont return this._decide(context, cancellationToken); } - // No delegate provided — fall back to task behavior. + // No delegate provided — fall back to "message" behavior. return ValueTask.FromResult(true); } #pragma warning restore MEAI001 /// - public bool Equals(A2AResponseMode? other) => + public bool Equals(AgentRunMode? other) => other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase); /// - public override bool Equals(object? obj) => this.Equals(obj as A2AResponseMode); + public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode); /// public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this._value); @@ -94,11 +92,11 @@ public bool Equals(A2AResponseMode? other) => /// public override string ToString() => this._value; - /// Determines whether two instances are equal. - public static bool operator ==(A2AResponseMode? left, A2AResponseMode? right) => + /// Determines whether two instances are equal. + public static bool operator ==(AgentRunMode? left, AgentRunMode? right) => left?.Equals(right) ?? right is null; - /// Determines whether two instances are not equal. - public static bool operator !=(A2AResponseMode? left, A2AResponseMode? right) => + /// Determines whether two instances are not equal. + public static bool operator !=(AgentRunMode? left, AgentRunMode? right) => !(left == right); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs index 83aaaad463..5a6b2db85b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -187,7 +187,7 @@ public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync() // Arrange AgentRunOptions? capturedOptions = null; ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) - .Object.MapA2A(responseMode: A2AResponseMode.Message); + .Object.MapA2A(responseMode: AgentRunMode.Message); // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams @@ -211,7 +211,7 @@ public async Task MapA2A_TaskMode_WhenNoContinuationToken_ReturnsCompletedAgentT // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done immediately")]); ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(responseMode: A2AResponseMode.Task); + .Object.MapA2A(responseMode: AgentRunMode.Task); // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams @@ -238,7 +238,7 @@ public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsAgentMessageAsync( // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]); ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(responseMode: A2AResponseMode.Dynamic((_, _) => ValueTask.FromResult(false))); + .Object.MapA2A(runMode: AgentRunMode.Dynamic((_, _) => ValueTask.FromResult(false))); // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams @@ -366,7 +366,7 @@ public async Task MapA2A_TaskMode_CompletedTask_OriginalMessageIsInHistoryAsync( // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(responseMode: A2AResponseMode.Task); + .Object.MapA2A(responseMode: AgentRunMode.Task); AgentMessage originalMessage = new() { MessageId = "user-msg-2", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] }; // Act @@ -570,7 +570,7 @@ public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskA ContinuationToken = CreateTestContinuationToken() }; ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(responseMode: A2AResponseMode.Task); + .Object.MapA2A(responseMode: AgentRunMode.Task); // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams @@ -624,7 +624,7 @@ public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryA new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done immediately")]), new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")]), ref callCount); - ITaskManager taskManager = agentMock.Object.MapA2A(responseMode: A2AResponseMode.Task); + ITaskManager taskManager = agentMock.Object.MapA2A(responseMode: AgentRunMode.Task); // Act — create a completed task (no continuation token) A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams From 3de06277aac2a3cd955352d684af7b36f0bc7af2 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 23 Feb 2026 13:52:44 +0100 Subject: [PATCH 15/22] renaming --- .../EndpointRouteBuilderExtensions.cs | 66 +++++++++---------- .../AIAgentExtensions.cs | 4 +- 2 files changed, 32 insertions(+), 38 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs index 848bc11738..4c285f55cb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs @@ -39,12 +39,12 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The to add the A2A endpoints to. /// The configuration builder for . /// The route group to use for A2A endpoints. - /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Controls the response behavior of the agent run. /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agentBuilder.Name, path, responseMode); + return endpoints.MapA2A(agentBuilder.Name, path, agentRunMode); } /// @@ -63,13 +63,13 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The to add the A2A endpoints to. /// The name of the agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. - /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Controls the response behavior of the agent run. /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(endpoints); var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, _ => { }, responseMode); + return endpoints.MapA2A(agent, path, _ => { }, agentRunMode); } /// @@ -145,12 +145,12 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The configuration builder for . /// The route group to use for A2A endpoints. /// Agent card info to return on query. - /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Controls the response behavior of the agent run. /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agentBuilder.Name, path, agentCard, responseMode); + return endpoints.MapA2A(agentBuilder.Name, path, agentCard, agentRunMode); } /// @@ -160,13 +160,13 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The name of the agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. /// Agent card info to return on query. - /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Controls the response behavior of the agent run. /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(endpoints); var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, agentCard, responseMode); + return endpoints.MapA2A(agent, path, agentCard, agentRunMode); } /// @@ -204,7 +204,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.Message); + => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.NonBackground); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -214,24 +214,18 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The route group to use for A2A endpoints. /// Agent card info to return on query. /// The callback to configure . - /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Controls the response behavior of the agent run. /// Configured for A2A integration. /// /// This method can be used to access A2A agents that support the /// Curated Registries (Catalog-Based Discovery) /// discovery mechanism. /// - public static IEndpointConventionBuilder MapA2A( - this IEndpointRouteBuilder endpoints, - string agentName, - string path, - AgentCard agentCard, - Action configureTaskManager, - AgentRunMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(endpoints); var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, responseMode); + return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, agentRunMode); } /// @@ -250,10 +244,10 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The to add the A2A endpoints to. /// The agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. - /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Controls the response behavior of the agent run. /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode responseMode) - => endpoints.MapA2A(agent, path, _ => { }, responseMode); + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode agentRunMode) + => endpoints.MapA2A(agent, path, _ => { }, agentRunMode); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -264,7 +258,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The callback to configure . /// Configured for A2A integration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager) - => endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.Message); + => endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.NonBackground); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -273,16 +267,16 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. /// The callback to configure . - /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Controls the response behavior of the agent run. /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager, AgentRunMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agent); var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); - var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: responseMode); + var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode); var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); configureTaskManager(taskManager); @@ -312,15 +306,15 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. /// Agent card info to return on query. - /// Controls whether the A2A response is an AgentMessage, an AgentTask, or determined automatically by the agent. + /// Controls the response behavior of the agent run. /// Configured for A2A integration. /// /// This method can be used to access A2A agents that support the /// Curated Registries (Catalog-Based Discovery) /// discovery mechanism. /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode responseMode) - => endpoints.MapA2A(agent, path, agentCard, _ => { }, responseMode); + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode agentRunMode) + => endpoints.MapA2A(agent, path, agentCard, _ => { }, agentRunMode); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -337,7 +331,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.Message); + => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.NonBackground); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -347,21 +341,21 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The route group to use for A2A endpoints. /// Agent card info to return on query. /// The callback to configure . - /// The mode for the Agent to respond with A2A Task or Message with. + /// Controls the response behavior of the agent run. /// Configured for A2A integration. /// /// This method can be used to access A2A agents that support the /// Curated Registries (Catalog-Based Discovery) /// discovery mechanism. /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode responseMode) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agent); var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); - var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: responseMode); + var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: agentRunMode); var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); configureTaskManager(taskManager); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index db79d8fcad..de6c81c6fb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -28,7 +28,7 @@ public static class AIAgentExtensions /// Instance of to configure for A2A messaging. New instance will be created if not passed. /// The logger factory to use for creating instances. /// The store to store session contents and metadata. - /// Controls the run behavior. + /// Controls the response behavior of the agent run. /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. /// The configured . public static ITaskManager MapA2A( @@ -77,7 +77,7 @@ public static ITaskManager MapA2A( /// Instance of to configure for A2A messaging. New instance will be created if not passed. /// The logger factory to use for creating instances. /// The store to store session contents and metadata. - /// Controls the run behavior. + /// Controls the response behavior of the agent run. /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. /// The configured . public static ITaskManager MapA2A( From ea428fbc2e7f8e226fcf5d0a18515210753422c8 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 23 Feb 2026 14:00:36 +0100 Subject: [PATCH 16/22] fix tests --- .../AIAgentExtensionsTests.cs | 78 +++++++++++-------- 1 file changed, 45 insertions(+), 33 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs index 5a6b2db85b..5464f05f2f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -38,7 +38,7 @@ public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropert // Assert Assert.NotNull(capturedOptions); - Assert.True(capturedOptions.AllowBackgroundResponses); + Assert.False(capturedOptions.AllowBackgroundResponses); Assert.Null(capturedOptions.AdditionalProperties); } @@ -91,7 +91,7 @@ public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditi // Assert Assert.NotNull(capturedOptions); - Assert.True(capturedOptions.AllowBackgroundResponses); + Assert.False(capturedOptions.AllowBackgroundResponses); Assert.Null(capturedOptions.AdditionalProperties); } @@ -187,7 +187,7 @@ public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync() // Arrange AgentRunOptions? capturedOptions = null; ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) - .Object.MapA2A(responseMode: AgentRunMode.Message); + .Object.MapA2A(runMode: AgentRunMode.NonBackground); // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams @@ -202,16 +202,16 @@ public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync() } /// - /// Verifies that when responseMode is Task and the agent completes immediately (no ContinuationToken), - /// the result is an AgentTask in Completed state. + /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken), + /// the result is an AgentMessage because the response type is determined solely by ContinuationToken presence. /// [Fact] - public async Task MapA2A_TaskMode_WhenNoContinuationToken_ReturnsCompletedAgentTaskAsync() + public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageAsync() { // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done immediately")]); - ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(responseMode: AgentRunMode.Task); + AgentRunOptions? capturedOptions = null; + ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) + .Object.MapA2A(runMode: AgentRunMode.BackgroundIfSupported); // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams @@ -220,12 +220,9 @@ public async Task MapA2A_TaskMode_WhenNoContinuationToken_ReturnsCompletedAgentT }); // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Completed, agentTask.Status.State); - Assert.NotNull(agentTask.Artifacts); - Artifact artifact = Assert.Single(agentTask.Artifacts); - TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); - Assert.Equal("Done immediately", textPart.Text); + Assert.IsType(a2aResponse); + Assert.NotNull(capturedOptions); + Assert.True(capturedOptions.AllowBackgroundResponses); } /// @@ -357,17 +354,17 @@ public async Task MapA2A_WhenTaskIsCreated_OriginalMessageIsInHistoryAsync() } /// - /// Verifies that in Task mode when the agent completes immediately, the original user message - /// is added to the completed task's history. + /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken), + /// the returned AgentMessage preserves the original context ID. /// [Fact] - public async Task MapA2A_TaskMode_CompletedTask_OriginalMessageIsInHistoryAsync() + public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageWithContextIdAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(responseMode: AgentRunMode.Task); - AgentMessage originalMessage = new() { MessageId = "user-msg-2", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] }; + .Object.MapA2A(runMode: AgentRunMode.BackgroundIfSupported); + AgentMessage originalMessage = new() { MessageId = "user-msg-2", ContextId = "ctx-123", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] }; // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams @@ -376,9 +373,8 @@ public async Task MapA2A_TaskMode_CompletedTask_OriginalMessageIsInHistoryAsync( }); // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.History); - Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-2" && m.Role == MessageRole.User); + AgentMessage agentMessage = Assert.IsType(a2aResponse); + Assert.Equal("ctx-123", agentMessage.ContextId); } /// @@ -570,7 +566,7 @@ public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskA ContinuationToken = CreateTestContinuationToken() }; ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(responseMode: AgentRunMode.Task); + .Object.MapA2A(runMode: AgentRunMode.BackgroundIfSupported); // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams @@ -612,32 +608,48 @@ public async Task MapA2A_WhenContinuationTokenWithNoMessages_TaskStatusHasNullMe } /// - /// Verifies that when OnTaskUpdated is invoked on a task without a continuation token, - /// the task processes messages from its history and completes with an artifact. + /// Verifies that when OnTaskUpdated is invoked on a completed task with a follow-up message + /// and no continuation token in metadata, the task processes history and completes with a new artifact. /// [Fact] public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryAndCompletesAsync() { // Arrange int callCount = 0; - Mock agentMock = CreateAgentMockWithSequentialResponses( - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done immediately")]), - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")]), - ref callCount); - ITaskManager taskManager = agentMock.Object.MapA2A(responseMode: AgentRunMode.Task); + Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => + { + return invocation switch + { + // First call: create a task with ContinuationToken + 1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) + { + ContinuationToken = CreateTestContinuationToken() + }, + // Second call (via OnTaskUpdated): complete the background operation + 2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]), + // Third call (follow-up via OnTaskUpdated): complete follow-up + _ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")]) + }; + }); + ITaskManager taskManager = agentMock.Object.MapA2A(); - // Act — create a completed task (no continuation token) + // Act — create a working task (with continuation token) A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams { Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } }); AgentTask agentTask = Assert.IsType(a2aResponse); + // Act — first OnTaskUpdated: completes the background operation + await InvokeOnTaskUpdatedAsync(taskManager, agentTask); + agentTask = (await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None))!; + Assert.Equal(TaskState.Completed, agentTask.Status.State); + // Simulate a follow-up message by adding it to history and re-submitting via OnTaskUpdated agentTask.History ??= []; agentTask.History.Add(new AgentMessage { MessageId = "follow-up", Role = MessageRole.User, Parts = [new TextPart { Text = "Follow up" }] }); - // Act — invoke OnTaskUpdated without a continuation token + // Act — invoke OnTaskUpdated without a continuation token in metadata await InvokeOnTaskUpdatedAsync(taskManager, agentTask); // Assert From a2b47ac52c48ddc0464d34758f9f082dbe37ea3e Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 23 Feb 2026 14:01:14 +0100 Subject: [PATCH 17/22] fix comment --- .../src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index de6c81c6fb..6c1e6da4a9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -158,8 +158,8 @@ private static async Task OnTaskUpdatedAsync( try { // Discard any stale continuation token — the incoming user message supersedes - // any previous background operation. AF agents do not support resuming a - // background run while injecting new messages; we start a fresh run from the + // any previous background operation. AF agents don't support updating existing + // background responses (long-running operations); we start a fresh run from the // existing session using the full chat history (which includes the new message). agentTask.Metadata?.Remove(ContinuationTokenMetadataKey); From d84213f7268956204d6deb1f126d2d822905c845 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 23 Feb 2026 14:09:23 +0100 Subject: [PATCH 18/22] runmode rename --- .../AIAgentExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs index 5464f05f2f..1740426d54 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -178,7 +178,7 @@ public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMe } /// - /// Verifies that when responseMode is Message, the result is always an AgentMessage even when + /// Verifies that when runMode is Message, the result is always an AgentMessage even when /// the agent would otherwise support background responses. /// [Fact] From 3aba021f6425d8d914f53674dc227f1c28e97973 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 23 Feb 2026 14:46:55 +0100 Subject: [PATCH 19/22] rename --- .../AgentRunMode.cs | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs index 49b555d92e..63ace8f08a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs @@ -16,12 +16,12 @@ public sealed class AgentRunMode : IEquatable private const string DynamicValue = "dynamic"; private readonly string _value; - private readonly Func>? _decide; + private readonly Func>? _runInBackground; - private AgentRunMode(string value, Func>? decide = null) + private AgentRunMode(string value, Func>? runInBackground = null) { this._value = value; - this._decide = decide; + this._runInBackground = runInBackground; } /// @@ -37,19 +37,21 @@ private AgentRunMode(string value, Func new(TaskValue); /// - /// The response type is decided by the supplied delegate. + /// The agent run mode is decided by the supplied delegate. /// The delegate receives an with the incoming - /// message and the agent response, and returns to return an - /// AgentTask or to return an AgentMessage. - /// Background responses are enabled. + /// message and returns a boolean specifying whether to run the agent in background mode. + /// indicates that the agent should run in background mode and return an + /// AgentTask if the agent supports background mode; otherwise, it returns an AgentMessage + /// if the mode is not supported. indicates that the agent should run in + /// non-background mode and return an AgentMessage. /// - /// + /// /// An async delegate that decides whether the response should be wrapped in an AgentTask. /// - public static AgentRunMode Dynamic(Func> decideAsTask) + public static AgentRunMode Dynamic(Func> runInBackground) { - ArgumentNullException.ThrowIfNull(decideAsTask); - return new(DynamicValue, decideAsTask); + ArgumentNullException.ThrowIfNull(runInBackground); + return new(DynamicValue, runInBackground); } /// @@ -69,9 +71,9 @@ internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext contex } // Dynamic: delegate to custom callback. - if (this._decide is not null) + if (this._runInBackground is not null) { - return this._decide(context, cancellationToken); + return this._runInBackground(context, cancellationToken); } // No delegate provided — fall back to "message" behavior. From da94d01a58b1c6414c926c49bbeb39ea0f775f0a Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 24 Feb 2026 13:29:34 +0100 Subject: [PATCH 20/22] rename --- .../EndpointRouteBuilderExtensions.cs | 6 +++--- .../AIAgentExtensions.cs | 2 +- .../Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs | 6 +++--- .../AIAgentExtensionsTests.cs | 10 +++++----- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs index 4c285f55cb..7aea88dc40 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs @@ -204,7 +204,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.NonBackground); + => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -258,7 +258,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The callback to configure . /// Configured for A2A integration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager) - => endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.NonBackground); + => endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.DisallowBackground); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -331,7 +331,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.NonBackground); + => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index 6c1e6da4a9..d4792acea3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -42,7 +42,7 @@ public static ITaskManager MapA2A( ArgumentNullException.ThrowIfNull(agent); ArgumentNullException.ThrowIfNull(agent.Name); - runMode ??= AgentRunMode.NonBackground; + runMode ??= AgentRunMode.DisallowBackground; var hostAgent = new AIHostAgent( innerAgent: agent, diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs index 63ace8f08a..39d48e793e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs @@ -28,13 +28,13 @@ private AgentRunMode(string value, Func as false. /// In the A2A protocol terminology will make responses be returned as AgentMessage. /// - public static AgentRunMode NonBackground => new(MessageValue); + public static AgentRunMode DisallowBackground => new(MessageValue); /// /// Allows the background responses from the agent. Is equivalent to configuring as true. /// In the A2A protocol terminology will make responses be returned as AgentTask if the agent supports background responses, and as AgentMessage otherwise. /// - public static AgentRunMode BackgroundIfSupported => new(TaskValue); + public static AgentRunMode AllowBackgroundIfSupported => new(TaskValue); /// /// The agent run mode is decided by the supplied delegate. @@ -48,7 +48,7 @@ private AgentRunMode(string value, Func /// An async delegate that decides whether the response should be wrapped in an AgentTask. /// - public static AgentRunMode Dynamic(Func> runInBackground) + public static AgentRunMode AllowBackgroundWhen(Func> runInBackground) { ArgumentNullException.ThrowIfNull(runInBackground); return new(DynamicValue, runInBackground); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs index 1740426d54..87de6e52cd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -187,7 +187,7 @@ public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync() // Arrange AgentRunOptions? capturedOptions = null; ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) - .Object.MapA2A(runMode: AgentRunMode.NonBackground); + .Object.MapA2A(runMode: AgentRunMode.DisallowBackground); // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams @@ -211,7 +211,7 @@ public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_Retur // Arrange AgentRunOptions? capturedOptions = null; ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) - .Object.MapA2A(runMode: AgentRunMode.BackgroundIfSupported); + .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams @@ -235,7 +235,7 @@ public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsAgentMessageAsync( // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]); ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(runMode: AgentRunMode.Dynamic((_, _) => ValueTask.FromResult(false))); + .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false))); // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams @@ -363,7 +363,7 @@ public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_Retur // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(runMode: AgentRunMode.BackgroundIfSupported); + .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); AgentMessage originalMessage = new() { MessageId = "user-msg-2", ContextId = "ctx-123", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] }; // Act @@ -566,7 +566,7 @@ public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskA ContinuationToken = CreateTestContinuationToken() }; ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(runMode: AgentRunMode.BackgroundIfSupported); + .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); // Act A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams From 732900ed534d4f6eb821498ab6b6f3036122b813 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 24 Feb 2026 13:39:32 +0100 Subject: [PATCH 21/22] use exxperimental api, allow experimental on project level --- .../EndpointRouteBuilderExtensions.cs | 3 +++ .../Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj | 6 ++++++ .../Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs | 9 +++------ .../src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs | 5 +++-- .../Microsoft.Agents.AI.Hosting.A2A.csproj | 2 ++ 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs index 7aea88dc40..af3ff093ee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using A2A; using A2A.AspNetCore; using Microsoft.Agents.AI; @@ -10,12 +11,14 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.AspNetCore.Builder; /// /// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder. /// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions { /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj index 093c5e0cfb..4829b56b9e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj @@ -8,6 +8,12 @@ + + true + true + true + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index d4792acea3..c8640c1e2e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -9,12 +10,14 @@ using Microsoft.Agents.AI.Hosting.A2A.Converters; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Hosting.A2A; /// /// Provides extension methods for attaching A2A (Agent2Agent) messaging capabilities to an . /// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] public static class AIAgentExtensions { // Metadata key used to store continuation tokens for long-running background operations @@ -132,7 +135,6 @@ private static async Task OnMessageReceivedAsync( await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. if (response.ContinuationToken is null) { return CreateMessageFromResponse(contextId, response); @@ -142,7 +144,6 @@ private static async Task OnMessageReceivedAsync( StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false); return agentTask; -#pragma warning restore MEAI001 } private static async Task OnTaskUpdatedAsync( @@ -173,7 +174,6 @@ private static async Task OnTaskUpdatedAsync( await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. if (response.ContinuationToken is not null) { StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); @@ -183,7 +183,6 @@ private static async Task OnTaskUpdatedAsync( { await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false); } -#pragma warning restore MEAI001 } catch (OperationCanceledException) { @@ -247,7 +246,6 @@ private static async Task InitializeTaskAsync( return agentTask; } -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. private static void StoreContinuationToken( AgentTask agentTask, ResponseContinuationToken token, @@ -260,7 +258,6 @@ private static void StoreContinuationToken( token, continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); } -#pragma warning restore MEAI001 private static async Task TransitionToWorkingAsync( string taskId, diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs index 39d48e793e..087df96aae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs @@ -1,14 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Hosting.A2A; /// /// Specifies how the A2A hosting layer determines whether to run in background or not. /// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] public sealed class AgentRunMode : IEquatable { private const string MessageValue = "message"; @@ -57,7 +60,6 @@ public static AgentRunMode AllowBackgroundWhen(Func /// Determines whether the agent response should be returned as an AgentTask. /// -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext context, CancellationToken cancellationToken) { if (string.Equals(this._value, MessageValue, StringComparison.OrdinalIgnoreCase)) @@ -79,7 +81,6 @@ internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext contex // No delegate provided — fall back to "message" behavior. return ValueTask.FromResult(true); } -#pragma warning restore MEAI001 /// public bool Equals(AgentRunMode? other) => diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj index a0d66cc1d5..3c805ee7a4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj @@ -10,6 +10,8 @@ true + true + true From a59ae7bd17b11c875c14ae61ffe6eea40a8d79e9 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 24 Feb 2026 14:07:55 +0100 Subject: [PATCH 22/22] throw on refereceTaskIds --- .../Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index c8640c1e2e..31c520755f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -116,6 +116,15 @@ private static async Task OnMessageReceivedAsync( JsonSerializerOptions continuationTokenJsonOptions, CancellationToken cancellationToken) { + // AIAgent does not support resuming from arbitrary prior tasks. + // Throw explicitly so the client gets a clear error rather than a response + // that silently ignores the referenced task context. + // Follow-ups on the *same* task are handled via OnTaskUpdated instead. + if (messageSendParams.Message.ReferenceTaskIds is { Count: > 0 }) + { + throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context. Use OnTaskUpdated for follow-ups on the same task."); + } + var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);