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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 30 additions & 38 deletions dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ internal sealed class A2AAgentHandler : IAgentHandler
/// Initializes a new instance of the <see cref="A2AAgentHandler"/> class.
/// </summary>
/// <param name="hostAgent">The hosted agent that provides the execution logic.</param>
/// <param name="runMode">Controls whether the agent runs in background mode.</param>
/// <param name="runMode">Controls which A2A artifact the agent response is returned as.</param>
public A2AAgentHandler(
AIHostAgent hostAgent,
AgentRunMode runMode)
Expand Down Expand Up @@ -80,20 +80,20 @@ public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue
/// <param name="eventQueue">The queue the response events are written to.</param>
/// <param name="aggregateTaskUpdates">
/// <see langword="true"/> to run the agent to completion before emitting a single completed task;
/// <see langword="false"/> to emit task updates as they are produced. Ignored when the server disallows
/// background responses, because a message response is always aggregated.
/// <see langword="false"/> to emit task updates as they are produced. Ignored when the server is configured
/// through <see cref="AgentRunMode"/> to return a message, because a message response is always aggregated.
/// </param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to cancel the operation.</param>
/// <remarks>
/// The response shape is decided by two independent inputs:
/// <list type="number">
/// <item><description>
/// Whether the server allows background responses. This is configured per agent registration, for example:
/// Which A2A artifact the server returns. This is configured per agent registration, for example:
/// <code>
/// builder.AddA2AServer(agent, (A2AServerRegistrationOptions options) =>
/// options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported);
/// options.AgentRunMode = AgentRunMode.ReturnTask);
/// </code>
/// Use <c>AgentRunMode.DisallowBackground</c> to always respond with a message instead of a task.
/// Use <c>AgentRunMode.ReturnMessage</c> to always respond with a message instead of a task.
/// </description></item>
/// <item><description>
/// Whether the client asked for an immediate response. In the A2A protocol this is the
Expand All @@ -104,17 +104,20 @@ public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue
/// The resulting combinations are:
/// <list type="bullet">
/// <item><description>
/// Server allows background responses and <c>ReturnImmediately = true</c>: returns the initial task, then the
/// rest of the updates piece by piece.
/// Server is configured through <see cref="AgentRunMode"/> to return a task and <c>ReturnImmediately = true</c>:
/// returns the initial task, then the rest of the updates piece by piece.
/// </description></item>
/// <item><description>
/// Server allows background responses and <c>ReturnImmediately = false</c>: returns a single completed task.
/// Server is configured through <see cref="AgentRunMode"/> to return a task and <c>ReturnImmediately = false</c>:
/// returns a single completed task.
/// </description></item>
/// <item><description>
/// Server disallows background responses and <c>ReturnImmediately = true</c>: returns a message.
/// Server is configured through <see cref="AgentRunMode"/> to return a message and <c>ReturnImmediately = true</c>:
/// returns a message.
/// </description></item>
/// <item><description>
/// Server disallows background responses and <c>ReturnImmediately = false</c>: returns a message.
/// Server is configured through <see cref="AgentRunMode"/> to return a message and <c>ReturnImmediately = false</c>:
/// returns a message.
/// </description></item>
/// </list>
/// </remarks>
Expand All @@ -133,11 +136,11 @@ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue

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

var options = CreateRunOptions(context);

// Decide whether to run in background based on user preferences and agent capabilities
// Decide which A2A artifact to return based on the configured run mode.
var decisionContext = new A2ARunDecisionContext(context);
var returnTask = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
var returnTask = await this._runMode.ShouldReturnTaskAsync(decisionContext, cancellationToken).ConfigureAwait(false);

var options = CreateRunOptions(context);

var updates = this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken);

Expand All @@ -148,20 +151,20 @@ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
if (aggregateTaskUpdates)
{
// The server allows background responses, but the non-streaming client request has
// The server is configured through AgentRunMode to return a task, but the non-streaming client request has
// ReturnImmediately disabled, so collect all updates and return a completed task.
await AggregateTaskUpdatesAsync(updates, taskUpdater, eventQueue, cancellationToken).ConfigureAwait(false);
}
else
{
// The server allows background responses and this is either a streaming request or a
// The server is configured through AgentRunMode to return a task and this is either a streaming request or a
// non-streaming request with ReturnImmediately enabled, so emit task updates as they arrive.
await StreamTaskUpdatesAsync(updates, taskUpdater, cancellationToken).ConfigureAwait(false);
}
}
else
{
// The server disallows background responses, so return one aggregated message regardless
// The server is configured through AgentRunMode to return a message, so return one aggregated message regardless
// of the client request's ReturnImmediately value.
await StreamMessageUpdatesAsync(contextId, updates, eventQueue, cancellationToken).ConfigureAwait(false);
}
Expand All @@ -179,10 +182,7 @@ private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue

List<ChatMessage> chatMessages = ExtractChatMessagesFromTaskHistory(context.Task);

var decisionContext = new A2ARunDecisionContext(context);
var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);

var options = CreateRunOptions(context, allowBackgroundResponses);
var options = CreateRunOptions(context);

AgentResponse response;
try
Expand Down Expand Up @@ -233,13 +233,8 @@ private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue
/// <c>MessageSendParams.metadata</c> and <c>MessageSendParams.configuration</c> to the hosted agent.
/// </summary>
/// <param name="context">The A2A request context of the incoming request.</param>
/// <param name="allowBackgroundResponses">
/// The value to assign to <see cref="AgentRunOptions.AllowBackgroundResponses"/>. Defaults to <see langword="null"/>, which leaves it unset.
/// </param>
/// <returns>
/// The run options to invoke the agent with, or <see langword="null"/> when there is nothing to forward.
/// </returns>
private static AgentRunOptions? CreateRunOptions(RequestContext context, bool? allowBackgroundResponses = null)
/// <returns>The run options to invoke the agent with.</returns>
private static AgentRunOptions CreateRunOptions(RequestContext context)
{
AdditionalPropertiesDictionary? additionalProperties = context.Metadata is { Count: > 0 }
? context.Metadata.ToAdditionalProperties()
Expand All @@ -252,14 +247,8 @@ private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue
(additionalProperties ??= [])[ConfigurationPropertyKey] = configuration;
}

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

return new AgentRunOptions
{
AllowBackgroundResponses = allowBackgroundResponses,
AdditionalProperties = additionalProperties
};
}
Expand Down Expand Up @@ -294,7 +283,8 @@ private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask? a
/// Emits a task and streams the agent updates into it as artifacts as they are produced.
/// </summary>
/// <remarks>
/// Handles the case where the server allows background responses and the response is delivered incrementally:
/// Handles the case where the server is configured through <see cref="AgentRunMode"/> to return a task and the
/// response is delivered incrementally:
/// either a streaming (<c>message/stream</c>) request, or a non-streaming request with
/// <c>ReturnImmediately = true</c>. In the latter case the caller receives the initial task immediately and
/// obtains the remaining updates by polling the task.
Expand Down Expand Up @@ -342,7 +332,8 @@ private static async Task StreamTaskUpdatesAsync(IAsyncEnumerable<AgentResponseU
/// Consumes the agent updates without emitting them and then returns a single completed task.
/// </summary>
/// <remarks>
/// Handles the case where the server allows background responses and a non-streaming client sent
/// Handles the case where the server is configured through <see cref="AgentRunMode"/> to return a task and a
/// non-streaming client sent
/// <c>ReturnImmediately = false</c>, meaning it wants the final result in the response rather than a task
/// it has to poll. No task event is emitted until the agent stream finishes, because the server returns on the
/// first task event; emitting early would hand the caller an in-progress task instead of a completed one.
Expand Down Expand Up @@ -384,7 +375,8 @@ await eventQueue.AddArtifactAsync(
/// Consumes the agent updates and emits the aggregated result as a single message.
/// </summary>
/// <remarks>
/// Handles the case where the server disallows background responses, which applies regardless of the client's
/// Handles the case where the server is configured through <see cref="AgentRunMode"/> to return a message, which
/// applies regardless of the client's
/// <c>ReturnImmediately</c> value: a message is not a long-running entity, so there is nothing to return early
/// or poll for and the full agent run is always aggregated into one message. An empty message is emitted when
/// the agent produces no messages.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public sealed class A2AServerRegistrationOptions
/// Gets or sets the agent run mode that controls how the agent responds to A2A requests.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, defaults to <see cref="AgentRunMode.DisallowBackground"/>.
/// When <see langword="null"/>, defaults to <see cref="AgentRunMode.ReturnMessage"/>.
/// </remarks>
public AgentRunMode? AgentRunMode { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAge
if (agentHandler is null)
{
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
var runMode = options?.AgentRunMode ?? AgentRunMode.ReturnMessage;

// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
Expand Down
53 changes: 26 additions & 27 deletions dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
namespace Microsoft.Agents.AI.Hosting.A2A;

/// <summary>
/// Specifies how the A2A hosting layer determines whether to run <see cref="AIAgent"/> in background or not.
/// Specifies which A2A protocol artifact the hosting layer returns for a run of an <see cref="AIAgent"/>:
/// an <c>AgentMessage</c> or an <c>AgentTask</c>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public sealed class AgentRunMode : IEquatable<AgentRunMode>
Expand All @@ -20,48 +21,46 @@ public sealed class AgentRunMode : IEquatable<AgentRunMode>
private const string DynamicValue = "dynamic";

private readonly string _value;
private readonly Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? _runInBackground;
private readonly Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? _returnTask;

private AgentRunMode(string value, Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? runInBackground = null)
private AgentRunMode(string value, Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? returnTask = null)
{
this._value = value;
this._runInBackground = runInBackground;
this._returnTask = returnTask;
}

/// <summary>
/// Disallows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>false</c>.
/// In the A2A protocol terminology will make responses be returned as <c>AgentMessage</c>.
/// Returns the agent response as an <c>AgentMessage</c>. The updates produced by the agent are aggregated
/// into a single message.
/// </summary>
public static AgentRunMode DisallowBackground => new(MessageValue);
public static AgentRunMode ReturnMessage => new(MessageValue);

/// <summary>
/// Allows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>true</c>.
/// In the A2A protocol terminology will make responses be returned as <c>AgentTask</c> if the agent supports background responses, and as <c>AgentMessage</c> otherwise.
/// Returns the agent response as an <c>AgentTask</c>, allowing the caller to track its lifecycle and to
/// receive the result incrementally.
/// </summary>
public static AgentRunMode AllowBackgroundIfSupported => new(TaskValue);
public static AgentRunMode ReturnTask => new(TaskValue);

/// <summary>
/// The agent run mode is decided by the supplied <paramref name="runInBackground"/> delegate.
/// The delegate receives an <see cref="A2ARunDecisionContext"/> with the incoming
/// message and returns a boolean specifying whether to run the agent in background mode.
/// <see langword="true"/> indicates that the agent should run in background mode and return an
/// <c>AgentTask</c> if the agent supports background mode; otherwise, it returns an <c>AgentMessage</c>
/// if the mode is not supported. <see langword="false"/> indicates that the agent should run in
/// non-background mode and return an <c>AgentMessage</c>.
/// Defers the choice between an <c>AgentMessage</c> and an <c>AgentTask</c> to the supplied
/// <paramref name="returnTask"/> delegate, which is invoked for each new-message request. The delegate receives
/// an <see cref="A2ARunDecisionContext"/> describing the incoming request and returns <see langword="true"/> to
/// return an <c>AgentTask</c>, or <see langword="false"/> to return an <c>AgentMessage</c>. Continuations of an
/// existing task remain task responses and do not invoke the delegate.
/// </summary>
/// <param name="runInBackground">
/// An async delegate that decides whether the response should be wrapped in an <c>AgentTask</c>.
/// <param name="returnTask">
/// An async delegate that decides whether a new-message response is returned as an <c>AgentTask</c>.
/// </param>
public static AgentRunMode AllowBackgroundWhen(Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>> runInBackground)
public static AgentRunMode ReturnTaskWhen(Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>> returnTask)
{
ArgumentNullException.ThrowIfNull(runInBackground);
return new(DynamicValue, runInBackground);
ArgumentNullException.ThrowIfNull(returnTask);
return new(DynamicValue, returnTask);
}

/// <summary>
/// Determines whether the agent response should be returned as an <c>AgentTask</c>.
/// </summary>
internal ValueTask<bool> ShouldRunInBackgroundAsync(A2ARunDecisionContext context, CancellationToken cancellationToken)
internal ValueTask<bool> ShouldReturnTaskAsync(A2ARunDecisionContext context, CancellationToken cancellationToken)
{
if (string.Equals(this._value, MessageValue, StringComparison.OrdinalIgnoreCase))
{
Expand All @@ -74,9 +73,9 @@ internal ValueTask<bool> ShouldRunInBackgroundAsync(A2ARunDecisionContext contex
}

// Dynamic: delegate to custom callback.
if (this._runInBackground is not null)
if (this._returnTask is not null)
{
return this._runInBackground(context, cancellationToken);
return this._returnTask(context, cancellationToken);
}

// No delegate provided — fall back to "message" behavior.
Expand All @@ -87,15 +86,15 @@ internal ValueTask<bool> ShouldRunInBackgroundAsync(A2ARunDecisionContext contex
public bool Equals(AgentRunMode? other) =>
other is not null
&& string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase)
&& ReferenceEquals(this._runInBackground, other._runInBackground);
&& ReferenceEquals(this._returnTask, other._returnTask);

/// <inheritdoc/>
public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode);

/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(
StringComparer.OrdinalIgnoreCase.GetHashCode(this._value),
RuntimeHelpers.GetHashCode(this._runInBackground));
RuntimeHelpers.GetHashCode(this._returnTask));

/// <inheritdoc/>
public override string ToString() => this._value;
Expand Down
Loading
Loading