mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add A2A input-request content for human-in-the-loop scenarios (#5743)
* .NET: Add A2A input-request content for human-in-the-loop scenarios Adds first-class support for handling user input requests from A2A agents when they return an `input-required` task state. - Add `A2AInputRequestContent` (wraps the requested `AIContent`) and `A2AInputResponseContent` (wraps the user's `AIContent` reply), with `CreateResponse` helper overloads on the request type. - Surface input requests on `AgentResponse` / `AgentResponseUpdate` via `AgentTask` and `TaskStatusUpdateEvent` mappings. - Link follow-up messages containing `A2AInputResponseContent` to the existing task via `TaskId` instead of `ReferenceTaskIds`. - Add `A2AAgent_HumanInTheLoop` sample and register it in the solution and parent README. - Add unit tests for the new types, extensions, and `A2AAgent` paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unnecessary using directive flagged by CI format check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address feedback * Guard against null TaskId when sending A2AInputResponseContent Throw InvalidOperationException if TaskId is missing when the message contains A2AInputResponseContent, preventing silent no-op responses. Also adds tests for both RunAsync and RunStreamingAsync paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Leave Contents null for non-InputRequired status updates Remove unnecessary '?? []' fallback so Contents stays null when there are no input requests, matching the other update mapping patterns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use consistent GUID format for request IDs Use ToString("N") to match message ID format used elsewhere in the A2A component. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove Debug build exclusion for the HumanInTheLoop sample so it participates in normal solution validation. * Add missing using Microsoft.Extensions.AI to A2AAgent_HumanInTheLoop The sample uses ChatMessage, TextContent, and ChatRole types from Microsoft.Extensions.AI but was missing the using directive, causing CS0246 build errors on all CI jobs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * change the way user input requests are handled based on pr review comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
parent
939d4d0153
commit
dfc3079d68
@@ -93,26 +93,26 @@ public sealed class A2AAgent : AIAgent
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(messages);
|
||||
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
|
||||
|
||||
A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name);
|
||||
|
||||
if (GetContinuationToken(messages, options) is { } token)
|
||||
if (GetContinuationToken(inputMessages, options) is { } token)
|
||||
{
|
||||
AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = token.TaskId }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
|
||||
|
||||
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
|
||||
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id, agentTask.Status.State);
|
||||
|
||||
return this.ConvertToAgentResponse(agentTask);
|
||||
}
|
||||
|
||||
SendMessageRequest sendParams = new()
|
||||
{
|
||||
Message = CreateA2AMessage(typedSession, messages),
|
||||
Message = CreateA2AMessage(typedSession, inputMessages),
|
||||
Metadata = options?.AdditionalProperties?.ToA2AMetadata(),
|
||||
Configuration = new SendMessageConfiguration { ReturnImmediately = options?.AllowBackgroundResponses is true }
|
||||
};
|
||||
@@ -134,7 +134,7 @@ public sealed class A2AAgent : AIAgent
|
||||
{
|
||||
var agentTask = a2aResponse.Task!;
|
||||
|
||||
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
|
||||
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id, agentTask.Status.State);
|
||||
|
||||
return this.ConvertToAgentResponse(agentTask);
|
||||
}
|
||||
@@ -145,7 +145,7 @@ public sealed class A2AAgent : AIAgent
|
||||
/// <inheritdoc/>
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(messages);
|
||||
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
|
||||
|
||||
A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -153,7 +153,7 @@ public sealed class A2AAgent : AIAgent
|
||||
|
||||
ConfiguredCancelableAsyncEnumerable<StreamResponse> streamEvents;
|
||||
|
||||
if (GetContinuationToken(messages, options) is { } token)
|
||||
if (GetContinuationToken(inputMessages, options) is { } token)
|
||||
{
|
||||
streamEvents = this.SubscribeToTaskWithFallbackAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -161,7 +161,7 @@ public sealed class A2AAgent : AIAgent
|
||||
{
|
||||
SendMessageRequest sendParams = new()
|
||||
{
|
||||
Message = CreateA2AMessage(typedSession, messages),
|
||||
Message = CreateA2AMessage(typedSession, inputMessages),
|
||||
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
@@ -172,6 +172,7 @@ public sealed class A2AAgent : AIAgent
|
||||
|
||||
string? contextId = null;
|
||||
string? taskId = null;
|
||||
TaskState? taskState = null;
|
||||
|
||||
await foreach (var streamResponse in streamEvents)
|
||||
{
|
||||
@@ -187,6 +188,7 @@ public sealed class A2AAgent : AIAgent
|
||||
var task = streamResponse.Task!;
|
||||
contextId = task.ContextId;
|
||||
taskId = task.Id;
|
||||
taskState = task.Status.State;
|
||||
yield return this.ConvertToAgentResponseUpdate(task);
|
||||
break;
|
||||
|
||||
@@ -194,6 +196,7 @@ public sealed class A2AAgent : AIAgent
|
||||
var statusUpdate = streamResponse.StatusUpdate!;
|
||||
contextId = statusUpdate.ContextId;
|
||||
taskId = statusUpdate.TaskId;
|
||||
taskState = statusUpdate.Status.State;
|
||||
yield return this.ConvertToAgentResponseUpdate(statusUpdate);
|
||||
break;
|
||||
|
||||
@@ -209,7 +212,7 @@ public sealed class A2AAgent : AIAgent
|
||||
}
|
||||
}
|
||||
|
||||
UpdateSession(typedSession, contextId, taskId);
|
||||
UpdateSession(typedSession, contextId, taskId, taskState);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -317,7 +320,7 @@ public sealed class A2AAgent : AIAgent
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null)
|
||||
private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null, TaskState? taskState = null)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
@@ -335,9 +338,10 @@ public sealed class A2AAgent : AIAgent
|
||||
// Assign a server-generated context Id to the session if it's not already set.
|
||||
session.ContextId ??= contextId;
|
||||
session.TaskId = taskId;
|
||||
session.TaskState = taskState;
|
||||
}
|
||||
|
||||
private static Message CreateA2AMessage(A2AAgentSession typedSession, IEnumerable<ChatMessage> messages)
|
||||
private static Message CreateA2AMessage(A2AAgentSession typedSession, IReadOnlyCollection<ChatMessage> messages)
|
||||
{
|
||||
var a2aMessage = messages.ToA2AMessage();
|
||||
|
||||
@@ -345,9 +349,19 @@ public sealed class A2AAgent : AIAgent
|
||||
// See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#group-related-interactions
|
||||
a2aMessage.ContextId = typedSession.ContextId;
|
||||
|
||||
// Link the message as a follow-up to an existing task, if any.
|
||||
// See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#task-refinements
|
||||
a2aMessage.ReferenceTaskIds = typedSession.TaskId is null ? null : [typedSession.TaskId];
|
||||
if (typedSession.TaskState == TaskState.InputRequired)
|
||||
{
|
||||
// If the session indicates the task is waiting for user input,
|
||||
// link the response to the existing task so it is treated as input
|
||||
// for that task.
|
||||
a2aMessage.TaskId = typedSession.TaskId;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Link the message as a follow-up to an existing task, if any.
|
||||
// See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#task-refinements
|
||||
a2aMessage.ReferenceTaskIds = typedSession.TaskId is not null ? [typedSession.TaskId] : null;
|
||||
}
|
||||
|
||||
return a2aMessage;
|
||||
}
|
||||
@@ -444,6 +458,7 @@ public sealed class A2AAgent : AIAgent
|
||||
Role = ChatRole.Assistant,
|
||||
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
|
||||
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
|
||||
Contents = statusUpdateEvent.Status.GetUserInputRequests(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using TaskState = A2A.TaskState;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A;
|
||||
|
||||
/// <summary>
|
||||
@@ -18,10 +20,11 @@ public sealed class A2AAgentSession : AgentSession
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
internal A2AAgentSession(string? contextId, string? taskId, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
|
||||
internal A2AAgentSession(string? contextId, string? taskId, TaskState? taskState, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
|
||||
{
|
||||
this.ContextId = contextId;
|
||||
this.TaskId = taskId;
|
||||
this.TaskState = taskState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -36,6 +39,12 @@ public sealed class A2AAgentSession : AgentSession
|
||||
[JsonPropertyName("taskId")]
|
||||
public string? TaskId { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the state of the task the agent is currently working on.
|
||||
/// </summary>
|
||||
[JsonPropertyName("taskState")]
|
||||
public TaskState? TaskState { get; internal set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
@@ -57,5 +66,5 @@ public sealed class A2AAgentSession : AgentSession
|
||||
|
||||
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
|
||||
private string DebuggerDisplay =>
|
||||
$"ContextId = {this.ContextId}, TaskId = {this.TaskId}, StateBag Count = {this.StateBag.Count}";
|
||||
$"ContextId = {this.ContextId}, TaskId = {this.TaskId}, TaskState = {this.TaskState}, StateBag Count = {this.StateBag.Count}";
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ internal static class A2AAIContentExtensions
|
||||
/// <summary>
|
||||
/// Converts a collection of <see cref="AIContent"/> to a list of <see cref="Part"/> objects.
|
||||
/// </summary>
|
||||
/// <param name="contents">The collection of AI contents to convert.</param>"
|
||||
/// <param name="contents">The collection of AI contents to convert.</param>
|
||||
/// <returns>The list of A2A <see cref="Part"/> objects.</returns>
|
||||
internal static List<Part>? ToParts(this IEnumerable<AIContent> contents)
|
||||
{
|
||||
@@ -21,8 +21,7 @@ internal static class A2AAIContentExtensions
|
||||
|
||||
foreach (var content in contents)
|
||||
{
|
||||
var part = content.ToPart();
|
||||
if (part is not null)
|
||||
if (content.ToPart() is { } part)
|
||||
{
|
||||
(parts ??= []).Add(part);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ internal static class A2AAgentTaskExtensions
|
||||
|
||||
List<ChatMessage>? messages = null;
|
||||
|
||||
if (agentTask?.Artifacts is { Count: > 0 })
|
||||
if (agentTask.Artifacts is { Count: > 0 })
|
||||
{
|
||||
foreach (var artifact in agentTask.Artifacts)
|
||||
{
|
||||
@@ -25,6 +25,14 @@ internal static class A2AAgentTaskExtensions
|
||||
}
|
||||
}
|
||||
|
||||
if (agentTask.Status?.GetUserInputRequests() is { } userInputRequests)
|
||||
{
|
||||
(messages ??= []).Add(new(ChatRole.Assistant, userInputRequests)
|
||||
{
|
||||
RawRepresentation = agentTask.Status,
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@@ -42,6 +50,11 @@ internal static class A2AAgentTaskExtensions
|
||||
}
|
||||
}
|
||||
|
||||
if (agentTask.Status?.GetUserInputRequests() is { } userInputRequests)
|
||||
{
|
||||
(aiContents ??= []).AddRange(userInputRequests);
|
||||
}
|
||||
|
||||
return aiContents;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for the <see cref="TaskStatus"/> class.
|
||||
/// </summary>
|
||||
internal static class AgentTaskStatusExtensions
|
||||
{
|
||||
internal static IList<AIContent>? GetUserInputRequests(this TaskStatus status)
|
||||
{
|
||||
_ = Throw.IfNull(status);
|
||||
|
||||
List<AIContent>? contents = null;
|
||||
|
||||
if (status.Message is null || status.State is not TaskState.InputRequired)
|
||||
{
|
||||
return contents;
|
||||
}
|
||||
|
||||
foreach (var part in status.Message.Parts)
|
||||
{
|
||||
var aiContent = part.ToAIContent();
|
||||
aiContent.RawRepresentation = part;
|
||||
aiContent.AdditionalProperties = part.Metadata.ToAdditionalProperties();
|
||||
(contents ??= []).Add(aiContent);
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user