feat: add autonomous workflow mode to .NET handoff

This commit is contained in:
copilot-swe-agent[bot]
2026-05-12 00:29:25 +00:00
committed by GitHub
Unverified
parent 0987ce3d31
commit 05c29347ff
3 changed files with 282 additions and 2 deletions
@@ -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);