mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
feat: Implement return-to-previous routing in handoff workflow
- Also obsoletes HandoffsWorkflowBuilder => HandoffWorkflowBuilder (no "s")
This commit is contained in:
@@ -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;
|
||||
@@ -8,10 +9,21 @@ using Microsoft.Shared.Diagnostics;
|
||||
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
/// <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}<agent_id>`,
|
||||
@@ -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);
|
||||
@@ -63,10 +76,10 @@ public sealed class HandoffsWorkflowBuilder
|
||||
/// <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>
|
||||
@@ -75,10 +88,10 @@ public sealed class HandoffsWorkflowBuilder
|
||||
/// </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>
|
||||
@@ -86,10 +99,10 @@ public sealed class HandoffsWorkflowBuilder
|
||||
/// </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>
|
||||
@@ -97,10 +110,21 @@ public sealed class HandoffsWorkflowBuilder
|
||||
/// <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>
|
||||
@@ -110,7 +134,7 @@ public sealed class HandoffsWorkflowBuilder
|
||||
/// <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);
|
||||
@@ -125,7 +149,7 @@ public sealed class HandoffsWorkflowBuilder
|
||||
this.WithHandoff(from, target);
|
||||
}
|
||||
|
||||
return this;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -138,7 +162,7 @@ public sealed class HandoffsWorkflowBuilder
|
||||
/// 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);
|
||||
@@ -153,7 +177,7 @@ public sealed class HandoffsWorkflowBuilder
|
||||
this.WithHandoff(source, to, handoffReason);
|
||||
}
|
||||
|
||||
return this;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -166,7 +190,7 @@ public sealed class HandoffsWorkflowBuilder
|
||||
/// 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);
|
||||
@@ -196,7 +220,7 @@ public sealed class HandoffsWorkflowBuilder
|
||||
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>
|
||||
@@ -206,8 +230,8 @@ public sealed class HandoffsWorkflowBuilder
|
||||
/// <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,
|
||||
@@ -215,11 +239,31 @@ public sealed class HandoffsWorkflowBuilder
|
||||
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)
|
||||
|
||||
@@ -42,7 +42,7 @@ internal sealed class HandoffMessagesFilter
|
||||
|
||||
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)
|
||||
@@ -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(
|
||||
@@ -199,9 +200,10 @@ internal sealed class HandoffAgentExecutor(
|
||||
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);
|
||||
|
||||
@@ -267,7 +269,11 @@ internal sealed class HandoffAgentExecutor(
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
/// <summary>Tracks the current agent ID across turns when return-to-previous routing is enabled.</summary>
|
||||
internal sealed class HandoffsCurrentAgentTracker
|
||||
{
|
||||
public string? CurrentAgentId { get; set; }
|
||||
}
|
||||
@@ -1,20 +1,31 @@
|
||||
// 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(HandoffsCurrentAgentTracker? tracker = null) : 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 (tracker is not null && handoff.CurrentAgentId is not null)
|
||||
{
|
||||
tracker.CurrentAgentId = handoff.CurrentAgentId;
|
||||
}
|
||||
|
||||
await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public ValueTask ResetAsync() => default;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ using Microsoft.Extensions.AI;
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
/// <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(HandoffsCurrentAgentTracker? tracker = null) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
internal const string ExecutorId = "HandoffStart";
|
||||
|
||||
@@ -22,7 +22,7 @@ internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId,
|
||||
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);
|
||||
=> context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages, tracker?.CurrentAgentId), cancellationToken: cancellationToken);
|
||||
|
||||
public new ValueTask ResetAsync() => base.ResetAsync();
|
||||
}
|
||||
|
||||
@@ -680,6 +680,196 @@ public class AgentWorkflowBuilderTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_ReturnToPrevious_DisabledByDefault_SecondTurnRoutesViaCoordinatorAsync()
|
||||
{
|
||||
int coordinatorCallCount = 0;
|
||||
|
||||
var coordinator = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
coordinatorCallCount++;
|
||||
if (coordinatorCallCount == 1)
|
||||
{
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
|
||||
}
|
||||
return new(new ChatMessage(ChatRole.Assistant, "coordinator responded on turn 2"));
|
||||
}), name: "coordinator");
|
||||
|
||||
var specialist = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
new(new ChatMessage(ChatRole.Assistant, "specialist responded"))),
|
||||
name: "specialist", description: "The specialist agent");
|
||||
|
||||
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator)
|
||||
.WithHandoff(coordinator, specialist)
|
||||
.Build();
|
||||
|
||||
var environment = InProcessExecution.Lockstep;
|
||||
string sessionId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Turn 1: coordinator hands off to specialist
|
||||
(_, List<ChatMessage>? turn1Result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], environment, sessionId);
|
||||
Assert.Equal(1, coordinatorCallCount);
|
||||
|
||||
// Turn 2: without ReturnToPrevious, coordinator should be invoked again
|
||||
Assert.NotNull(turn1Result);
|
||||
turn1Result.Add(new ChatMessage(ChatRole.User, "my id is 12345"));
|
||||
_ = await RunWorkflowAsync(workflow, turn1Result, environment, sessionId);
|
||||
Assert.Equal(2, coordinatorCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_ReturnToPrevious_Enabled_SecondTurnRoutesDirectlyToSpecialistAsync()
|
||||
{
|
||||
int coordinatorCallCount = 0;
|
||||
int specialistCallCount = 0;
|
||||
|
||||
var coordinator = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
coordinatorCallCount++;
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
|
||||
}), name: "coordinator");
|
||||
|
||||
var specialist = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
specialistCallCount++;
|
||||
return new(new ChatMessage(ChatRole.Assistant, "specialist responded"));
|
||||
}), name: "specialist", description: "The specialist agent");
|
||||
|
||||
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator)
|
||||
.WithHandoff(coordinator, specialist)
|
||||
.EnableReturnToPrevious()
|
||||
.Build();
|
||||
|
||||
var environment = InProcessExecution.Lockstep;
|
||||
string sessionId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Turn 1: coordinator hands off to specialist
|
||||
(_, List<ChatMessage>? turn1Result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], environment, sessionId);
|
||||
Assert.Equal(1, coordinatorCallCount);
|
||||
Assert.Equal(1, specialistCallCount);
|
||||
|
||||
// Turn 2: with ReturnToPrevious, specialist should be invoked directly, coordinator should NOT be called again
|
||||
Assert.NotNull(turn1Result);
|
||||
turn1Result.Add(new ChatMessage(ChatRole.User, "my id is 12345"));
|
||||
_ = await RunWorkflowAsync(workflow, turn1Result, environment, sessionId);
|
||||
Assert.Equal(1, coordinatorCallCount); // coordinator NOT called again
|
||||
Assert.Equal(2, specialistCallCount); // specialist called again
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_ReturnToPrevious_Enabled_BeforeAnyHandoff_RoutesViaInitialAgentAsync()
|
||||
{
|
||||
int coordinatorCallCount = 0;
|
||||
|
||||
var coordinator = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
coordinatorCallCount++;
|
||||
return new(new ChatMessage(ChatRole.Assistant, "coordinator responded"));
|
||||
}), name: "coordinator");
|
||||
|
||||
var specialist = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
Assert.Fail("Specialist should not be invoked.");
|
||||
return new();
|
||||
}), name: "specialist", description: "The specialist agent");
|
||||
|
||||
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator)
|
||||
.WithHandoff(coordinator, specialist)
|
||||
.EnableReturnToPrevious()
|
||||
.Build();
|
||||
|
||||
var environment = InProcessExecution.Lockstep;
|
||||
string sessionId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// First turn with no prior handoff: should route to initial (coordinator) agent
|
||||
_ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")], environment, sessionId);
|
||||
Assert.Equal(1, coordinatorCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoffs_ReturnToPrevious_Enabled_AfterHandoffBackToCoordinator_NextTurnRoutesViaCoordinatorAsync()
|
||||
{
|
||||
int coordinatorCallCount = 0;
|
||||
int specialistCallCount = 0;
|
||||
|
||||
var coordinator = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
coordinatorCallCount++;
|
||||
if (coordinatorCallCount == 1)
|
||||
{
|
||||
// First call: hand off to specialist
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
|
||||
}
|
||||
// Subsequent calls: respond without handoff
|
||||
return new(new ChatMessage(ChatRole.Assistant, "coordinator responded"));
|
||||
}), name: "coordinator");
|
||||
|
||||
var specialist = new ChatClientAgent(new MockChatClient((messages, options) =>
|
||||
{
|
||||
specialistCallCount++;
|
||||
// Specialist hands back to coordinator
|
||||
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
|
||||
Assert.NotNull(transferFuncName);
|
||||
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)]));
|
||||
}), name: "specialist", description: "The specialist agent");
|
||||
|
||||
var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator)
|
||||
.WithHandoff(coordinator, specialist)
|
||||
.WithHandoff(specialist, coordinator)
|
||||
.EnableReturnToPrevious()
|
||||
.Build();
|
||||
|
||||
var environment = InProcessExecution.Lockstep;
|
||||
string sessionId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Turn 1: coordinator → specialist → coordinator (specialist hands back)
|
||||
(_, List<ChatMessage>? turn1Result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], environment, sessionId);
|
||||
Assert.Equal(2, coordinatorCallCount); // called twice: initial handoff + receiving handback
|
||||
Assert.Equal(1, specialistCallCount); // specialist called once, then handed back
|
||||
|
||||
// Turn 2: after handoff back to coordinator, should route to coordinator (not specialist)
|
||||
Assert.NotNull(turn1Result);
|
||||
turn1Result.Add(new ChatMessage(ChatRole.User, "never mind"));
|
||||
_ = await RunWorkflowAsync(workflow, turn1Result, environment, sessionId);
|
||||
Assert.Equal(3, coordinatorCallCount); // coordinator called again on turn 2
|
||||
Assert.Equal(1, specialistCallCount); // specialist NOT called
|
||||
}
|
||||
|
||||
private static async Task<(string UpdateText, List<ChatMessage>? Result)> RunWorkflowAsync(
|
||||
Workflow workflow, List<ChatMessage> input, InProcessExecutionEnvironment environment, string? sessionId = null)
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
|
||||
await using StreamingRun run = await environment.RunStreamingAsync(workflow, input, sessionId);
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
|
||||
|
||||
WorkflowOutputEvent? output = null;
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
|
||||
{
|
||||
if (evt is AgentResponseUpdateEvent executorComplete)
|
||||
{
|
||||
sb.Append(executorComplete.Data);
|
||||
}
|
||||
else if (evt is WorkflowOutputEvent e)
|
||||
{
|
||||
output = e;
|
||||
break;
|
||||
}
|
||||
else if (evt is WorkflowErrorEvent errorEvent)
|
||||
{
|
||||
Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}");
|
||||
}
|
||||
}
|
||||
|
||||
return (sb.ToString(), output?.As<List<ChatMessage>>());
|
||||
}
|
||||
|
||||
private static async Task<(string UpdateText, List<ChatMessage>? Result)> RunWorkflowAsync(
|
||||
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
|
||||
{
|
||||
|
||||
@@ -794,7 +794,7 @@ public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase
|
||||
{
|
||||
// Arrange
|
||||
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
|
||||
Workflow handoffWorkflow = new HandoffsWorkflowBuilder(agent).Build();
|
||||
Workflow handoffWorkflow = new HandoffWorkflowBuilder(agent).Build();
|
||||
return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user