mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
feat: add autonomous workflow mode to .NET handoff
This commit is contained in:
committed by
GitHub
Unverified
parent
0987ce3d31
commit
05c29347ff
@@ -54,6 +54,9 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
private bool _emitAgentResponseUpdateEvents;
|
||||
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
private bool _returnToPrevious;
|
||||
private bool _autonomousMode;
|
||||
private string? _autonomousModePrompt;
|
||||
private int? _autonomousModeTurnLimit;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
|
||||
@@ -142,6 +145,33 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables autonomous mode for all agents in the workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In autonomous mode, when an agent responds without requesting a handoff, it is immediately
|
||||
/// re-invoked with a synthetic user message (the <paramref name="prompt"/>) rather than
|
||||
/// returning control to the user. The agent continues iterating until it requests a handoff
|
||||
/// or the <paramref name="turnLimit"/> is reached. After the turn limit is exceeded, control
|
||||
/// is returned to the user as in the default human-in-the-loop behavior.
|
||||
/// </remarks>
|
||||
/// <param name="prompt">
|
||||
/// The message to inject as a user turn when re-invoking an agent in autonomous mode.
|
||||
/// If <see langword="null"/>, a default prompt is used.
|
||||
/// </param>
|
||||
/// <param name="turnLimit">
|
||||
/// The maximum number of autonomous continuation turns per agent per user message.
|
||||
/// If <see langword="null"/>, the default limit is used.
|
||||
/// </param>
|
||||
/// <returns>The updated builder instance.</returns>
|
||||
public TBuilder EnableAutonomousMode(string? prompt = null, int? turnLimit = null)
|
||||
{
|
||||
this._autonomousMode = true;
|
||||
this._autonomousModePrompt = prompt;
|
||||
this._autonomousModeTurnLimit = turnLimit;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds handoff relationships from a source agent to one or more target agents.
|
||||
/// </summary>
|
||||
@@ -247,7 +277,10 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
|
||||
this._emitAgentResponseEvents,
|
||||
this._emitAgentResponseUpdateEvents,
|
||||
this._toolCallFilteringBehavior);
|
||||
this._toolCallFilteringBehavior,
|
||||
autonomousMode: this._autonomousMode,
|
||||
autonomousModePrompt: this._autonomousModePrompt,
|
||||
autonomousModeTurnLimit: this._autonomousModeTurnLimit);
|
||||
|
||||
// There are two types of ids being used in this method, and it is critical that we are clear about
|
||||
// which one we are using, and where.
|
||||
|
||||
@@ -15,12 +15,22 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
internal sealed class HandoffAgentExecutorOptions
|
||||
{
|
||||
public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentResponseEvents, bool? emitAgentResponseUpdateEvents, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
|
||||
public HandoffAgentExecutorOptions(
|
||||
string? handoffInstructions,
|
||||
bool emitAgentResponseEvents,
|
||||
bool? emitAgentResponseUpdateEvents,
|
||||
HandoffToolCallFilteringBehavior toolCallFilteringBehavior,
|
||||
bool autonomousMode = false,
|
||||
string? autonomousModePrompt = null,
|
||||
int? autonomousModeTurnLimit = null)
|
||||
{
|
||||
this.HandoffInstructions = handoffInstructions;
|
||||
this.EmitAgentResponseEvents = emitAgentResponseEvents;
|
||||
this.EmitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
|
||||
this.ToolCallFilteringBehavior = toolCallFilteringBehavior;
|
||||
this.AutonomousMode = autonomousMode;
|
||||
this.AutonomousModePrompt = autonomousModePrompt ?? HandoffAgentExecutor.DefaultAutonomousModePrompt;
|
||||
this.AutonomousModeTurnLimit = autonomousModeTurnLimit ?? HandoffAgentExecutor.DefaultAutonomousModeTurnLimit;
|
||||
}
|
||||
|
||||
public string? HandoffInstructions { get; set; }
|
||||
@@ -30,6 +40,22 @@ internal sealed class HandoffAgentExecutorOptions
|
||||
public bool? EmitAgentResponseUpdateEvents { get; set; }
|
||||
|
||||
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the agent operates in autonomous mode.
|
||||
/// In autonomous mode, the agent continues responding without user input until a handoff is requested or the turn limit is reached.
|
||||
/// </summary>
|
||||
public bool AutonomousMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the prompt to inject as a user message when continuing in autonomous mode.
|
||||
/// </summary>
|
||||
public string AutonomousModePrompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of autonomous turns before control is returned to the user.
|
||||
/// </summary>
|
||||
public int AutonomousModeTurnLimit { get; set; }
|
||||
}
|
||||
|
||||
internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId)
|
||||
@@ -74,6 +100,12 @@ internal sealed record StateRef<TState>(string Key, string? ScopeName)
|
||||
internal sealed class HandoffAgentExecutor :
|
||||
StatefulExecutor<HandoffAgentHostState, HandoffState>
|
||||
{
|
||||
/// <summary>The default prompt injected as a user message when operating in autonomous mode and no handoff has been requested.</summary>
|
||||
internal const string DefaultAutonomousModePrompt = "User did not respond. Continue assisting autonomously.";
|
||||
|
||||
/// <summary>The default maximum number of autonomous turns before control is returned to the user.</summary>
|
||||
internal const int DefaultAutonomousModeTurnLimit = 50;
|
||||
|
||||
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
|
||||
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
|
||||
|
||||
@@ -87,6 +119,8 @@ internal sealed class HandoffAgentExecutor :
|
||||
private readonly HashSet<string> _handoffFunctionNames = [];
|
||||
private readonly Dictionary<string, string> _handoffFunctionToAgentId = [];
|
||||
|
||||
private int _autonomousModeTurnCount;
|
||||
|
||||
private readonly StateRef<HandoffSharedState> _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey,
|
||||
HandoffConstants.HandoffSharedStateScope);
|
||||
|
||||
@@ -277,6 +311,39 @@ internal sealed class HandoffAgentExecutor :
|
||||
// happens if we have no outstanding requests.
|
||||
if (!this.HasOutstandingRequests)
|
||||
{
|
||||
// In autonomous mode, if no handoff was requested and we haven't hit the turn limit, continue the agent's
|
||||
// turn by injecting a synthetic user message instead of returning control to the user.
|
||||
if (this._options.AutonomousMode && !result.IsHandoffRequested && this._autonomousModeTurnCount < this._options.AutonomousModeTurnLimit)
|
||||
{
|
||||
this._autonomousModeTurnCount++;
|
||||
|
||||
ChatMessage autonomousMessage = new(ChatRole.User, this._options.AutonomousModePrompt)
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
|
||||
int autonomousBookmark = newConversationBookmark;
|
||||
await this._sharedStateRef.InvokeWithStateAsync(
|
||||
(sharedState, ctx, ct) =>
|
||||
{
|
||||
autonomousBookmark = sharedState!.Conversation.AddMessage(autonomousMessage);
|
||||
return new ValueTask();
|
||||
},
|
||||
context,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await this.ContinueTurnAsync(
|
||||
state with { ConversationBookmark = autonomousBookmark },
|
||||
[autonomousMessage],
|
||||
context,
|
||||
cancellationToken,
|
||||
skipAddIncoming: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Reset the counter when ending the turn (handoff requested or turn limit reached).
|
||||
this._autonomousModeTurnCount = 0;
|
||||
|
||||
HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id);
|
||||
|
||||
await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -201,6 +201,186 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase
|
||||
Func<Task> runStreamingAsync = async () => await executor.HandleAsync(state, testContext);
|
||||
await runStreamingAsync.Should().NotThrowAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_HandoffAgentExecutor_AutonomousMode_Disabled_DoesNotContinueWithoutHandoff()
|
||||
{
|
||||
// Arrange: agent with 3 prepared turns; autonomous mode OFF
|
||||
TestRunContext testContext = await PrepareHandoffSharedStateAsync();
|
||||
TestReplayAgent agent = new(
|
||||
[
|
||||
TestReplayAgent.ToChatMessages("Turn 0 response"),
|
||||
TestReplayAgent.ToChatMessages("Turn 1 response"),
|
||||
TestReplayAgent.ToChatMessages("Turn 2 response"),
|
||||
], TestAgentId, TestAgentName);
|
||||
|
||||
HandoffAgentExecutorOptions options = new("",
|
||||
emitAgentResponseEvents: false,
|
||||
emitAgentResponseUpdateEvents: false,
|
||||
HandoffToolCallFilteringBehavior.None,
|
||||
autonomousMode: false);
|
||||
|
||||
HandoffAgentExecutor executor = new(agent, [], options);
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
HandoffState message = new(new(false), null);
|
||||
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert: without autonomous mode, the agent is called exactly once
|
||||
agent.Turn.Should().Be(1);
|
||||
HandoffState sentState = testContext.QueuedMessages[executor.Id].Should().ContainSingle()
|
||||
.Which.Message.Should().BeOfType<HandoffState>()
|
||||
.Subject;
|
||||
sentState.RequestedHandoffTargetAgentId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
public async Task Test_HandoffAgentExecutor_AutonomousMode_InvokesAgentUpToTurnLimitPlusOne(int turnLimit)
|
||||
{
|
||||
// Arrange: agent with many prepared turns; no handoff ever requested; autonomous mode ON
|
||||
int totalTurns = turnLimit + 2; // More turns prepared than the limit to detect over-invocation
|
||||
TestReplayAgent agent = new(
|
||||
Enumerable.Range(0, totalTurns)
|
||||
.Select(i => TestReplayAgent.ToChatMessages($"Turn {i} response"))
|
||||
.ToList(),
|
||||
TestAgentId, TestAgentName);
|
||||
|
||||
TestRunContext testContext = await PrepareHandoffSharedStateAsync();
|
||||
|
||||
HandoffAgentExecutorOptions options = new("",
|
||||
emitAgentResponseEvents: false,
|
||||
emitAgentResponseUpdateEvents: false,
|
||||
HandoffToolCallFilteringBehavior.None,
|
||||
autonomousMode: true,
|
||||
autonomousModeTurnLimit: turnLimit);
|
||||
|
||||
HandoffAgentExecutor executor = new(agent, [], options);
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
HandoffState message = new(new(false), null);
|
||||
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert: agent is called once for the initial turn plus once per autonomous turn
|
||||
int expectedInvocations = 1 + turnLimit;
|
||||
agent.Turn.Should().Be(expectedInvocations);
|
||||
|
||||
// The final HandoffState should have no requested handoff (turn limit exhausted)
|
||||
HandoffState sentState = testContext.QueuedMessages[executor.Id].Should().ContainSingle()
|
||||
.Which.Message.Should().BeOfType<HandoffState>()
|
||||
.Subject;
|
||||
sentState.RequestedHandoffTargetAgentId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_HandoffAgentExecutor_AutonomousMode_HandoffDuringAutonomousTurn_RoutesToTarget()
|
||||
{
|
||||
// Arrange: agent returns a plain response on turn 0, then a handoff on turn 1 (the first autonomous turn)
|
||||
TestEchoAgent targetAgent = new("target-agent", "Target Agent");
|
||||
|
||||
string handoffFunctionName = $"{HandoffWorkflowBuilder.FunctionPrefix}1"; // first (only) handoff target
|
||||
string handoffCallId = Guid.NewGuid().ToString("N");
|
||||
|
||||
List<List<ChatMessage>> agentTurns =
|
||||
[
|
||||
TestReplayAgent.ToChatMessages("Initial response — no handoff yet"),
|
||||
[new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(handoffCallId, handoffFunctionName)])
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
}],
|
||||
];
|
||||
|
||||
TestReplayAgent agent = new(agentTurns, TestAgentId, TestAgentName);
|
||||
|
||||
TestRunContext testContext = await PrepareHandoffSharedStateAsync();
|
||||
|
||||
HandoffTarget handoffTarget = new(targetAgent);
|
||||
HandoffAgentExecutorOptions options = new("",
|
||||
emitAgentResponseEvents: false,
|
||||
emitAgentResponseUpdateEvents: false,
|
||||
HandoffToolCallFilteringBehavior.None,
|
||||
autonomousMode: true,
|
||||
autonomousModeTurnLimit: 5);
|
||||
|
||||
HandoffAgentExecutor executor = new(agent, [handoffTarget], options);
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
HandoffState message = new(new(false), null);
|
||||
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert: agent was called twice (initial + 1 autonomous turn that triggered handoff)
|
||||
agent.Turn.Should().Be(2);
|
||||
|
||||
// The final HandoffState should name the target agent
|
||||
HandoffState sentState = testContext.QueuedMessages[executor.Id].Should().ContainSingle()
|
||||
.Which.Message.Should().BeOfType<HandoffState>()
|
||||
.Subject;
|
||||
sentState.RequestedHandoffTargetAgentId.Should().Be(targetAgent.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_HandoffAgentExecutor_AutonomousMode_AddsAutonomousPromptToConversation()
|
||||
{
|
||||
// Arrange: one turn without handoff, turn limit = 1 → one autonomous invocation
|
||||
TestRunContext testContext = await PrepareHandoffSharedStateAsync();
|
||||
TestReplayAgent agent = new(
|
||||
[
|
||||
TestReplayAgent.ToChatMessages("First response"),
|
||||
TestReplayAgent.ToChatMessages("Second response (autonomous)"),
|
||||
], TestAgentId, TestAgentName);
|
||||
|
||||
const string CustomPrompt = "Continue your work autonomously.";
|
||||
|
||||
HandoffAgentExecutorOptions options = new("",
|
||||
emitAgentResponseEvents: false,
|
||||
emitAgentResponseUpdateEvents: false,
|
||||
HandoffToolCallFilteringBehavior.None,
|
||||
autonomousMode: true,
|
||||
autonomousModePrompt: CustomPrompt,
|
||||
autonomousModeTurnLimit: 1);
|
||||
|
||||
HandoffAgentExecutor executor = new(agent, [], options);
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
HandoffState message = new(new(false), null);
|
||||
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert: the autonomous prompt was added to the shared conversation as a user message
|
||||
HandoffSharedState? sharedState = await testContext
|
||||
.BindWorkflowContext(nameof(HandoffStartExecutor))
|
||||
.ReadStateAsync<HandoffSharedState>(HandoffConstants.HandoffSharedStateKey,
|
||||
HandoffConstants.HandoffSharedStateScope);
|
||||
|
||||
sharedState.Should().NotBeNull();
|
||||
sharedState!.Conversation.History.Should().Contain(
|
||||
m => m.Role == ChatRole.User && m.Text == CustomPrompt,
|
||||
because: "the autonomous mode prompt should be injected as a user message");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_HandoffWorkflowBuilder_EnableAutonomousMode_SetsOptionsOnExecutors()
|
||||
{
|
||||
// Arrange
|
||||
TestEchoAgent initialAgent = new("initial", "Initial");
|
||||
TestEchoAgent targetAgent = new("target", "Target");
|
||||
|
||||
// Act – build a workflow with autonomous mode enabled and verify no exception is thrown
|
||||
Workflow workflow = new HandoffWorkflowBuilder(initialAgent)
|
||||
.WithHandoff(initialAgent, targetAgent)
|
||||
.EnableAutonomousMode(prompt: "Keep going.", turnLimit: 10)
|
||||
.Build();
|
||||
|
||||
// Assert: the workflow was built without error and contains the expected executors
|
||||
workflow.Should().NotBeNull();
|
||||
workflow.ExecutorBindings.Should().ContainKey(HandoffAgentExecutor.IdFor(initialAgent));
|
||||
workflow.ExecutorBindings.Should().ContainKey(HandoffAgentExecutor.IdFor(targetAgent));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record Challenge(string Value);
|
||||
|
||||
Reference in New Issue
Block a user