Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ private static Workflow BuildConcurrentCore(
return builder.Build();
}

/// <summary>Creates a new <see cref="HandoffsWorkflowBuilder"/> using <paramref name="initialAgent"/> as the starting agent in the workflow.</summary>
/// <summary>Creates a new <see cref="HandoffWorkflowBuilder"/> using <paramref name="initialAgent"/> as the starting agent in the workflow.</summary>
/// <param name="initialAgent">The agent that will receive inputs provided to the workflow.</param>
/// <returns>The builder for creating a workflow based on handoffs.</returns>
/// <remarks>
Expand All @@ -154,7 +154,7 @@ private static Workflow BuildConcurrentCore(
/// The <see cref="AIAgent"/> must be capable of understanding those <see cref="AgentRunOptions"/> provided. If the agent
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
/// </remarks>
public static HandoffsWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
{
Throw.IfNull(initialAgent);
return new(initialAgent);
Expand Down
86 changes: 65 additions & 21 deletions dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Specialized;
Expand All @@ -8,10 +9,21 @@

namespace Microsoft.Agents.AI.Workflows;

/// <inheritdoc/>
[Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")]
public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore<HandoffsWorkflowBuilder>(initialAgent)
{
}

/// <inheritdoc/>
public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore<HandoffWorkflowBuilder>(initialAgent)
Comment thread
lokitoth marked this conversation as resolved.
{
}

/// <summary>
/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow.
/// </summary>
public sealed class HandoffsWorkflowBuilder
public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
{
/// <summary>
/// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}&lt;agent_id&gt;`,
Expand All @@ -26,12 +38,13 @@ public sealed class HandoffsWorkflowBuilder
private bool _emitAgentResponseEvents;
private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
private bool _returnToPrevious;

/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
/// </summary>
/// <param name="initialAgent">The first agent to be invoked (prior to any handoff).</param>
internal HandoffsWorkflowBuilder(AIAgent initialAgent)
internal HandoffWorkflowBuilderCore(AIAgent initialAgent)
{
this._initialAgent = initialAgent;
this._allAgents.Add(initialAgent);
Expand Down Expand Up @@ -63,10 +76,10 @@ in your conversation with the user.
/// <see cref="FunctionPrefix"/> constant.
/// </remarks>
/// <param name="instructions">The instructions to provide, or <see langword="null"/> to restore the default instructions.</param>
public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
public TBuilder WithHandoffInstructions(string? instructions)
{
this.HandoffInstructions = instructions ?? DefaultHandoffInstructions;
return this;
return (TBuilder)this;
}

/// <summary>
Expand All @@ -75,32 +88,43 @@ public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
/// </summary>
/// <param name="emitAgentResponseUpdateEvents"></param>
/// <returns></returns>
public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
public TBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
{
this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
return this;
return (TBuilder)this;
}

/// <summary>
/// Sets a value indicating whether aggregated agent response events should be emitted during execution.
/// </summary>
/// <param name="emitAgentResponseEvents"></param>
/// <returns></returns>
public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
public TBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
{
this._emitAgentResponseEvents = emitAgentResponseEvents;
return this;
return (TBuilder)this;
}

/// <summary>
/// Sets the behavior for filtering <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents from
/// <see cref="ChatMessage"/>s flowing through the handoff workflow. Defaults to <see cref="HandoffToolCallFilteringBehavior.HandoffOnly"/>.
/// </summary>
/// <param name="behavior">The filtering behavior to apply.</param>
public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
public TBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
{
this._toolCallFilteringBehavior = behavior;
return this;
return (TBuilder)this;
}

/// <summary>
/// Configures the workflow so that subsequent user turns route directly back to the specialist agent
/// that handled the previous turn, rather than always routing through the initial (coordinator) agent.
/// </summary>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
public TBuilder EnableReturnToPrevious()
{
this._returnToPrevious = true;
return (TBuilder)this;
}

/// <summary>
Expand All @@ -110,7 +134,7 @@ public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilt
/// <param name="to">The target agents to add as handoff targets for the source agent.</param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
/// <remarks>The handoff reason for each target in <paramref name="to"/> is derived from that agent's description or name.</remarks>
public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable<AIAgent> to)
public TBuilder WithHandoffs(AIAgent from, IEnumerable<AIAgent> to)
{
Throw.IfNull(from);
Throw.IfNull(to);
Expand All @@ -125,7 +149,7 @@ public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable<AIAgent> t
this.WithHandoff(from, target);
}

return this;
return (TBuilder)this;
}

/// <summary>
Expand All @@ -138,7 +162,7 @@ public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable<AIAgent> t
/// If <see langword="null"/>, the reason is derived from <paramref name="to"/>'s description or name.
/// </param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
public HandoffsWorkflowBuilder WithHandoffs(IEnumerable<AIAgent> from, AIAgent to, string? handoffReason = null)
public TBuilder WithHandoffs(IEnumerable<AIAgent> from, AIAgent to, string? handoffReason = null)
{
Throw.IfNull(from);
Throw.IfNull(to);
Expand All @@ -153,7 +177,7 @@ public HandoffsWorkflowBuilder WithHandoffs(IEnumerable<AIAgent> from, AIAgent t
this.WithHandoff(source, to, handoffReason);
}

return this;
return (TBuilder)this;
}

/// <summary>
Expand All @@ -166,7 +190,7 @@ public HandoffsWorkflowBuilder WithHandoffs(IEnumerable<AIAgent> from, AIAgent t
/// If <see langword="null"/>, the reason is derived from <paramref name="to"/>'s description or name.
/// </param>
/// <returns>The updated <see cref="HandoffsWorkflowBuilder"/> instance.</returns>
public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null)
public TBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null)
{
Throw.IfNull(from);
Throw.IfNull(to);
Expand Down Expand Up @@ -196,7 +220,7 @@ public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? han
Throw.InvalidOperationException($"A handoff from agent '{from.Name ?? from.Id}' to agent '{to.Name ?? to.Id}' has already been registered.");
}

return this;
return (TBuilder)this;
}

/// <summary>
Expand All @@ -206,20 +230,40 @@ public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? han
/// <returns>The workflow built based on the handoffs in the builder.</returns>
public Workflow Build()
{
HandoffsStartExecutor start = new();
HandoffsEndExecutor end = new();
HandoffsStartExecutor start = new(this._returnToPrevious);
HandoffsEndExecutor end = new(this._returnToPrevious);
WorkflowBuilder builder = new(start);

HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
this._emitAgentResponseEvents,
this._emitAgentResponseUpdateEvents,
this._toolCallFilteringBehavior);

// Create an AgentExecutor for each again.
// Create an AgentExecutor for each agent.
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));

// Connect the start executor to the initial agent.
builder.AddEdge(start, executors[this._initialAgent.Id]);
// Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled).
if (this._returnToPrevious)
{
string initialAgentId = this._initialAgent.Id;
builder.AddSwitch(start, sb =>
{
foreach (var agent in this._allAgents)
{
if (agent.Id != initialAgentId)
{
string agentId = agent.Id;
sb.AddCase<HandoffState>(state => state?.CurrentAgentId == agentId, executors[agentId]);
}
}

sb.WithDefault(executors[initialAgentId]);
});
}
else
{
builder.AddEdge(start, executors[this._initialAgent.Id]);
}

// Initialize each executor with its handoff targets to the other executors.
foreach (var agent in this._allAgents)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public HandoffMessagesFilter(HandoffToolCallFilteringBehavior filteringBehavior)

internal static bool IsHandoffFunctionName(string name)
{
return name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
}

public IEnumerable<ChatMessage> FilterMessages(List<ChatMessage> messages)
Expand Down Expand Up @@ -173,6 +173,7 @@ internal sealed class HandoffAgentExecutor(

private readonly AIAgent _agent = agent;
private readonly HashSet<string> _handoffFunctionNames = [];
private readonly Dictionary<string, string> _handoffFunctionToAgentId = [];
private ChatClientAgentRunOptions? _agentOptions;

public void Initialize(
Expand All @@ -199,9 +200,10 @@ public void Initialize(
foreach (HandoffTarget handoff in handoffs)
{
index++;
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffsWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);

this._handoffFunctionNames.Add(handoffFunc.Name);
this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id;

this._agentOptions.ChatOptions.Tools.Add(handoffFunc);

Expand Down Expand Up @@ -267,7 +269,11 @@ await AddUpdateAsync(

roleChanges.ResetUserToAssistantForChangedRoles();

return new(message.TurnToken, requestedHandoff, allMessages);
string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId)
? targetAgentId
: this._agent.Id;

return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId);

async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed record class HandoffState(
TurnToken TurnToken,
string? InvokedHandoff,
List<ChatMessage> Messages);
List<ChatMessage> Messages,
string? CurrentAgentId = null);
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;

namespace Microsoft.Agents.AI.Workflows.Specialized;

/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
internal sealed class HandoffsEndExecutor() : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
{
public const string ExecutorId = "HandoffEnd";

protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>((handoff, context, cancellationToken) =>
context.YieldOutputAsync(handoff.Messages, cancellationToken)))
this.HandleAsync(handoff, context, cancellationToken)))
.YieldsOutput<List<ChatMessage>>();

private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken)
{
if (returnToPrevious)
{
await context.QueueStateUpdateAsync<string?>(HandoffConstants.CurrentAgentTrackerKey,
handoff.CurrentAgentId,
HandoffConstants.CurrentAgentTrackerScope,
cancellationToken)
.ConfigureAwait(false);
}

await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false);
}

public ValueTask ResetAsync() => default;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@

namespace Microsoft.Agents.AI.Workflows.Specialized;

internal static class HandoffConstants
{
internal const string CurrentAgentTrackerKey = "LastAgentId";
internal const string CurrentAgentTrackerScope = "HandoffOrchestration";
}

/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
{
internal const string ExecutorId = "HandoffStart";

Expand All @@ -22,7 +28,25 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui
base.ConfigureProtocol(protocolBuilder).SendsMessage<HandoffState>();

protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken);
{
if (returnToPrevious)
{
return context.InvokeWithStateAsync(
async (string? currentAgentId, IWorkflowContext context, CancellationToken cancellationToken) =>
{
HandoffState handoffState = new(new(emitEvents), null, messages, currentAgentId);
await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false);

return currentAgentId;
},
HandoffConstants.CurrentAgentTrackerKey,
HandoffConstants.CurrentAgentTrackerScope,
cancellationToken);
}

HandoffState handoff = new(new(emitEvents), null, messages);
return context.SendMessageAsync(handoff, cancellationToken);
}

public new ValueTask ResetAsync() => base.ResetAsync();
}
Loading
Loading