Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
T
945647a065 .NET: feat: Bring Handoff Orchestration to parity with Python (#6138)
* feat: implement autonomous mode and termination conditions in handoff workflow

* fixup: format

* feat: enhance autonomous mode with per-agent configurations and add unit tests

* fixup: remove empty file

---------

Co-authored-by: Jacob Alber <jalber@lokitoth.com>
2026-05-28 18:04:15 +00:00

511 lines
24 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class HandoffAgentExecutorOptions
{
public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentResponseEvents, bool? emitAgentResponseUpdateEvents, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
{
this.HandoffInstructions = handoffInstructions;
this.EmitAgentResponseEvents = emitAgentResponseEvents;
this.EmitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
this.ToolCallFilteringBehavior = toolCallFilteringBehavior;
}
public string? HandoffInstructions { get; set; }
public bool EmitAgentResponseEvents { get; set; }
public bool? EmitAgentResponseUpdateEvents { get; set; }
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
// Termination condition. When provided, evaluated after the agent responds and no handoff was
// requested. If it returns true, the outgoing HandoffState is stamped with IsTerminated = true
// so the per-agent routing switch routes the turn to HandoffEndExecutor instead of continuing.
public Func<IReadOnlyList<ChatMessage>, ValueTask<bool>>? TerminationCondition { get; set; }
}
internal static class HandoffWorkflowBuilderDefaults
{
public const string DefaultAutonomousContinuationPrompt = "User did not respond. Continue assisting autonomously.";
public const int DefaultAutonomousTurnLimit = 50;
}
internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId)
{
public AgentResponse Response => agentResponse;
public string? HandoffTargetId => handoffTargetId;
[MemberNotNullWhen(true, nameof(HandoffTargetId))]
public bool IsHandoffRequested => this.HandoffTargetId != null;
}
internal record HandoffAgentHostState(
HandoffState? IncomingState,
int ConversationBookmark)
{
[MemberNotNullWhen(true, nameof(IncomingState))]
[JsonIgnore]
public bool IsTakingTurn => this.IncomingState != null;
}
internal sealed record StateRef<TState>(string Key, string? ScopeName)
{
public ValueTask InvokeWithStateAsync(Func<TState?, IWorkflowContext, CancellationToken, ValueTask<TState?>> invocation,
IWorkflowContext context,
CancellationToken cancellationToken)
=> context.InvokeWithStateAsync(invocation, this.Key, this.ScopeName, cancellationToken);
public ValueTask InvokeWithStateAsync(Func<TState?, IWorkflowContext, CancellationToken, ValueTask> invocation,
IWorkflowContext context,
CancellationToken cancellationToken)
=> context.InvokeWithStateAsync<TState>(
async (state, ctx, ct) =>
{
await invocation(state, ctx, ct).ConfigureAwait(false);
return state;
}, this.Key, this.ScopeName, cancellationToken);
}
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
internal sealed class HandoffAgentExecutor :
StatefulExecutor<HandoffAgentHostState, HandoffState>
{
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
public static string IdFor(AIAgent agent) => agent.GetDescriptiveId();
private readonly AIAgent _agent;
private readonly ChatClientAgentRunOptions? _agentOptions;
private readonly HandoffAgentExecutorOptions _options;
private readonly HashSet<string> _handoffFunctionNames = [];
private readonly Dictionary<string, string> _handoffFunctionToAgentId = [];
private readonly StateRef<HandoffSharedState> _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey,
HandoffConstants.HandoffSharedStateScope);
internal const string AgentSessionKey = nameof(AgentSession);
private AgentSession? _session;
private static HandoffAgentHostState InitialStateFactory() => new(null, 0);
public HandoffAgentExecutor(AIAgent agent, HashSet<HandoffTarget> handoffs, HandoffAgentExecutorOptions options)
: base(IdFor(agent), InitialStateFactory)
{
this._agent = agent;
this._options = options;
this._agentOptions = CreateAgentHandoffContext(this._options.HandoffInstructions, handoffs, this._handoffFunctionNames, this._handoffFunctionToAgentId);
}
private static ChatClientAgentRunOptions? CreateAgentHandoffContext(string? handoffInstructions, HashSet<HandoffTarget> handoffs, HashSet<string> functionNames, Dictionary<string, string> functionToAgentId)
{
ChatClientAgentRunOptions? result = null;
if (handoffs.Count != 0)
{
result = new()
{
ChatOptions = new()
{
AllowMultipleToolCalls = false,
Instructions = handoffInstructions,
Tools = [],
},
};
int index = 0;
foreach (HandoffTarget handoff in handoffs)
{
index++;
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
functionNames.Add(handoffFunc.Name);
functionToAgentId[handoffFunc.Name] = handoff.Target.Id;
result.ChatOptions.Tools.Add(handoffFunc);
}
}
return result;
}
private AIContentExternalHandler<ToolApprovalRequestContent, ToolApprovalResponseContent>? _userInputHandler;
private AIContentExternalHandler<FunctionCallContent, FunctionResultContent>? _functionCallHandler;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder))
.SendsMessage<HandoffState>();
}
private ProtocolBuilder ConfigureUserInputHandling(ProtocolBuilder protocolBuilder)
{
this._userInputHandler = new AIContentExternalHandler<ToolApprovalRequestContent, ToolApprovalResponseContent>(
ref protocolBuilder,
portId: $"{this.Id}_UserInput",
intercepted: false,
handler: this.HandleUserInputResponseAsync);
this._functionCallHandler = new AIContentExternalHandler<FunctionCallContent, FunctionResultContent>(
ref protocolBuilder,
portId: $"{this.Id}_FunctionCall",
intercepted: false, // TODO: Use this instead of manual function handling for handoff?
handler: this.HandleFunctionResultAsync);
return protocolBuilder;
}
private ValueTask HandleUserInputResponseAsync(
ToolApprovalResponseContent response,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (!this._userInputHandler!.MarkRequestAsHandled(response.RequestId))
{
throw new InvalidOperationException($"No pending ToolApprovalRequest found with id '{response.RequestId}'.");
}
// Merge the external response with any already-buffered regular messages so mixed-content
// resumes can be processed in one invocation.
return this.InvokeWithStateAsync((state, ctx, ct) =>
{
if (!state.IsTakingTurn)
{
throw new InvalidOperationException("Cannot process user responses when not taking a turn in Handoff Orchestration.");
}
ChatMessage userMessage = new(ChatRole.User, [response])
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
};
return this.ContinueTurnAsync(state, [userMessage], ctx, ct);
}, context, skipCache: false, cancellationToken);
}
private ValueTask HandleFunctionResultAsync(
FunctionResultContent result,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (!this._functionCallHandler!.MarkRequestAsHandled(result.CallId))
{
throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'.");
}
// Merge the external response with any already-buffered regular messages so mixed-content
// resumes can be processed in one invocation.
return this.InvokeWithStateAsync((state, ctx, ct) =>
{
if (!state.IsTakingTurn)
{
throw new InvalidOperationException("Cannot process user responses in when not taking a turn in Handoff Orchestration.");
}
ChatMessage toolMessage = new(ChatRole.Tool, [result])
{
AuthorName = this._agent.Name ?? this._agent.Id,
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
};
return this.ContinueTurnAsync(state, [toolMessage], ctx, ct);
}, context, skipCache: false, cancellationToken);
}
private async ValueTask<HandoffAgentHostState?> ContinueTurnAsync(HandoffAgentHostState state, List<ChatMessage> incomingMessages, IWorkflowContext context, CancellationToken cancellationToken, bool skipAddIncoming = false)
{
if (!state.IsTakingTurn)
{
throw new InvalidOperationException("Cannot process user responses in when not taking a turn in Handoff Orchestration.");
}
// If a handoff was invoked by a previous agent, filter out the handoff function call and tool result messages
// before sending to the underlying agent. These are internal workflow mechanics that confuse the target model
// into ignoring the original user question.
//
// This will not filter out tool responses and approval responses that are part of this agent's turn, which is
// the expected behavior since those are part of the agent's reasoning process.
HandoffMessagesFilter handoffMessagesFilter = new(this._options.ToolCallFilteringBehavior);
List<ChatMessage> messagesForAgent = (state.IncomingState.RequestedHandoffTargetAgentId is not null
? handoffMessagesFilter.FilterMessages(incomingMessages)
: incomingMessages)
.CopyWithAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
bool emitUpdateEvents = state.IncomingState!.ShouldEmitStreamingEvents(this._options.EmitAgentResponseUpdateEvents);
AgentInvocationResult result = await this.InvokeAgentAsync(messagesForAgent, context, emitUpdateEvents, cancellationToken)
.ConfigureAwait(false);
if (this.HasOutstandingRequests && result.IsHandoffRequested)
{
throw new InvalidOperationException("Cannot request a handoff while holding pending requests.");
}
int newConversationBookmark = state.ConversationBookmark;
List<ChatMessage>? conversationSnapshot = null;
await this._sharedStateRef.InvokeWithStateAsync(
(sharedState, ctx, ct) =>
{
if (sharedState == null)
{
throw new InvalidOperationException("Handoff Orchestration shared state was not properly initialized.");
}
if (!skipAddIncoming)
{
sharedState.Conversation.AddMessages(incomingMessages);
}
if (result.IsHandoffRequested)
{
int preHandoffMessageCount = result.Response.Messages.Count - 1;
newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages.Take(preHandoffMessageCount));
// The following message contains the Handoff FunctionCallResult which should be added to the conversation history with
// the caveat that we need to get it back next time _this_ agent is invoked because we need to feed the FunctionCallResult
// back to the agent. So ignore the bookmark update.
ChatMessage handoffCallResultMessage = result.Response.Messages[preHandoffMessageCount];
if (handoffCallResultMessage.Role != ChatRole.Tool)
{
throw new InvalidOperationException("The last message in a handoff response must be a Tool message containing the Handoff FunctionCallResult.");
}
if (handoffCallResultMessage.Contents.Count != 1 ||
handoffCallResultMessage.Contents[0] is not FunctionResultContent)
{
throw new InvalidOperationException("The Tool message in a handoff response must contain exactly one content item of type FunctionResultContent.");
}
_ = sharedState.Conversation.AddMessage(handoffCallResultMessage);
// Reset this agent's autonomous-turn counter when it chooses to hand off, so that
// if control returns to this agent later in the turn (e.g. via another handoff),
// its autonomous loop starts fresh rather than carrying over prior iterations.
sharedState.AutonomousTurnsByAgent[this._agent.Id] = 0;
}
else
{
newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages);
}
// Snapshot the conversation for termination evaluation while we still hold shared state access.
// Termination is only relevant when no handoff was requested — a requested handoff always
// routes to the target agent regardless of termination.
if (this._options.TerminationCondition is not null && !result.IsHandoffRequested)
{
conversationSnapshot = sharedState.Conversation.CloneHistory();
}
return new ValueTask();
},
context,
cancellationToken).ConfigureAwait(false);
// We send on the HandoffState even if handoff is not requested because we might be terminating the processing, but this only
// happens if we have no outstanding requests.
if (this.HasOutstandingRequests)
{
return state with { ConversationBookmark = newConversationBookmark };
}
// Evaluate the termination condition (when configured and no handoff was requested) and stamp
// the result onto the outgoing HandoffState so the per-agent routing switch can route the turn
// to HandoffEndExecutor instead of dispatching another handoff or autonomous continuation.
bool isTerminated = false;
if (conversationSnapshot is not null)
{
isTerminated = await this._options.TerminationCondition!(conversationSnapshot).ConfigureAwait(false);
}
HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id, isTerminated);
await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false);
// Reset the turn-local state; keep the conversation bookmark and the agent session so the
// next invocation (handoff back, autonomous loop-back, or new user turn) resumes cleanly.
return state with { IncomingState = null, ConversationBookmark = newConversationBookmark };
}
public override ValueTask HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
return this.InvokeWithStateAsync(InvokeContinueTurnAsync, context, skipCache: false, cancellationToken);
async ValueTask<HandoffAgentHostState?> InvokeContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken)
{
// Check that we are not getting this message while in the middle of a turn
if (state.IsTakingTurn)
{
throw new InvalidOperationException("Cannot have multiple simultaneous conversations in Handoff Orchestration.");
}
IEnumerable<ChatMessage> newConversationMessages = [];
int newConversationBookmark = 0;
await this._sharedStateRef.InvokeWithStateAsync(
(sharedState, ctx, ct) =>
{
if (sharedState == null)
{
throw new InvalidOperationException("Handoff Orchestration shared state was not properly initialized.");
}
(newConversationMessages, newConversationBookmark) = sharedState.Conversation.CollectNewMessages(state.ConversationBookmark);
return new ValueTask();
},
context,
cancellationToken).ConfigureAwait(false);
state = state with { IncomingState = message, ConversationBookmark = newConversationBookmark };
return await this.ContinueTurnAsync(state, newConversationMessages.ToList(), context, cancellationToken, skipAddIncoming: true)
.ConfigureAwait(false);
}
}
private const string UserInputRequestStateKey = nameof(_userInputHandler);
private const string FunctionCallRequestStateKey = nameof(_functionCallHandler);
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task functionCallRequestsTask = this._functionCallHandler?.OnCheckpointingAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task agentSessionTask = CheckpointAgentSessionAsync();
Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask();
await Task.WhenAll(userInputRequestsTask, functionCallRequestsTask, agentSessionTask, baseTask).ConfigureAwait(false);
async Task CheckpointAgentSessionAsync()
{
JsonElement? sessionState = this._session is not null ? await this._agent.SerializeSessionAsync(this._session, cancellationToken: cancellationToken).ConfigureAwait(false) : null;
await context.QueueStateUpdateAsync(AgentSessionKey, sessionState, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Task userInputRestoreTask = this._userInputHandler?.OnCheckpointRestoredAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task functionCallRestoreTask = this._functionCallHandler?.OnCheckpointRestoredAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task agentSessionTask = RestoreAgentSessionAsync();
await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask, agentSessionTask).ConfigureAwait(false);
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
async Task RestoreAgentSessionAsync()
{
JsonElement? sessionState = await context.ReadStateAsync<JsonElement?>(AgentSessionKey, cancellationToken: cancellationToken).ConfigureAwait(false);
if (sessionState.HasValue)
{
this._session = await this._agent.DeserializeSessionAsync(sessionState.Value, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
private bool HasOutstandingRequests => (this._userInputHandler?.HasPendingRequests == true)
|| (this._functionCallHandler?.HasPendingRequests == true);
private async ValueTask<AgentInvocationResult> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default)
{
AgentResponse response;
AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler);
string? requestedHandoff = null;
List<AgentResponseUpdate> updates = [];
List<FunctionCallContent> candidateRequests = [];
this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
IAsyncEnumerable<AgentResponseUpdate> agentStream =
this._agent.RunStreamingAsync(messages, this._session, this._agentOptions, cancellationToken);
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter);
bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest)
{
bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name);
if (isHandoffRequest)
{
candidateRequests.Add(candidateHandoffRequest);
}
return !isHandoffRequest;
}
}
if (candidateRequests.Count > 1)
{
string message = $"Duplicate handoff requests in single turn ([{string.Join(", ", candidateRequests.Select(request => request.Name))}]). Using last ({candidateRequests.Last().Name})";
await context.AddEventAsync(new WorkflowWarningEvent(message), cancellationToken).ConfigureAwait(false);
}
if (candidateRequests.Count > 0)
{
FunctionCallContent handoffRequest = candidateRequests[candidateRequests.Count - 1];
requestedHandoff = handoffRequest.Name;
await AddUpdateAsync(
new AgentResponseUpdate
{
AgentId = this._agent.Id,
AuthorName = this._agent.Name ?? this._agent.Id,
Contents = [CreateHandoffResult(handoffRequest.CallId)],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Tool,
},
cancellationToken
)
.ConfigureAwait(false);
}
response = updates.ToAgentResponse();
if (this._options.EmitAgentResponseEvents)
{
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
}
await collector.SubmitAsync(context, cancellationToken).ConfigureAwait(false);
return new(response, LookupHandoffTarget(requestedHandoff));
ValueTask AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
{
updates.Add(update);
return emitUpdateEvents ? context.YieldOutputAsync(update, cancellationToken) : default;
}
string? LookupHandoffTarget(string? requestedHandoff)
=> requestedHandoff != null
? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null
: null;
}
internal static FunctionResultContent CreateHandoffResult(string requestCallId) => new(requestCallId, "Transferred.");
}