diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs index 00e030448f6..794cbd00994 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs @@ -54,6 +54,9 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl private bool _emitAgentResponseUpdateEvents; private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly; private bool _returnToPrevious; + private bool _autonomousMode; + private string? _autonomousModePrompt; + private int? _autonomousModeTurnLimit; /// /// Initializes a new instance of the class with no handoff relationships. @@ -142,6 +145,34 @@ public TBuilder EnableReturnToPrevious() return (TBuilder)this; } + /// + /// Enables autonomous mode for all agents in the workflow. + /// + /// + /// In autonomous mode, when an agent responds without requesting a handoff, it is immediately + /// re-invoked with a synthetic user message (the ) rather than + /// returning control to the user. The agent continues iterating until it requests a handoff + /// or the is reached. After the turn limit is exceeded, control + /// is returned to the user as in the default human-in-the-loop behavior. + /// + /// + /// The message to inject as a user turn when re-invoking an agent in autonomous mode. + /// If , a default prompt is used. + /// + /// + /// The maximum number of autonomous continuation turns per agent per incoming turn. + /// The counter resets at the beginning of each new turn (each incoming ). + /// If , the default limit is used. + /// + /// The updated builder instance. + public TBuilder EnableAutonomousMode(string? prompt = null, int? turnLimit = null) + { + this._autonomousMode = true; + this._autonomousModePrompt = prompt; + this._autonomousModeTurnLimit = turnLimit; + return (TBuilder)this; + } + /// /// Adds handoff relationships from a source agent to one or more target agents. /// @@ -247,7 +278,10 @@ private Dictionary CreateExecutorBindings(WorkflowBuild 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. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 576c749a908..80799bb242b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -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,23 @@ public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentRe public bool? EmitAgentResponseUpdateEvents { get; set; } public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly; + + /// + /// 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. + /// + public bool AutonomousMode { get; set; } + + /// + /// Gets or sets the prompt to inject as a user message when continuing in autonomous mode. + /// + public string AutonomousModePrompt { get; set; } + + /// + /// Gets or sets the maximum number of autonomous turns per incoming turn. + /// The counter is reset at the start of every new turn. + /// + public int AutonomousModeTurnLimit { get; set; } } internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId) @@ -74,6 +101,12 @@ public ValueTask InvokeWithStateAsync(Func { + /// The default prompt injected as a user message when operating in autonomous mode and no handoff has been requested. + internal const string DefaultAutonomousModePrompt = "User did not respond. Continue assisting autonomously."; + + /// The default maximum number of autonomous turns before control is returned to the user. + internal const int DefaultAutonomousModeTurnLimit = 50; + private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create( ([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema; @@ -87,6 +120,8 @@ internal sealed class HandoffAgentExecutor : private readonly HashSet _handoffFunctionNames = []; private readonly Dictionary _handoffFunctionToAgentId = []; + private int _autonomousModeTurnCount; + private readonly StateRef _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey, HandoffConstants.HandoffSharedStateScope); @@ -277,6 +312,38 @@ await this._sharedStateRef.InvokeWithStateAsync( // 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) + { + 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); + + // Increment only after successfully adding the autonomous message to shared state. + // This ensures the counter remains accurate if the state write throws an exception. + this._autonomousModeTurnCount++; + + return await this.ContinueTurnAsync( + state with { ConversationBookmark = autonomousBookmark }, + [autonomousMessage], + context, + cancellationToken, + skipAddIncoming: true).ConfigureAwait(false); + } + HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id); await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false); @@ -321,6 +388,11 @@ await this._sharedStateRef.InvokeWithStateAsync( state = state with { IncomingState = message, ConversationBookmark = newConversationBookmark }; + // Reset the autonomous turn counter at the start of each new HandoffState turn so that + // the limit is applied fresh for every incoming message, regardless of how the previous + // turn ended (e.g. outstanding external requests that prevented an earlier reset). + this._autonomousModeTurnCount = 0; + return await this.ContinueTurnAsync(state, newConversationMessages.ToList(), context, cancellationToken, skipAddIncoming: true) .ConfigureAwait(false); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs index 70f802399d6..cf98e014345 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -201,6 +201,189 @@ public async Task Test_HandoffAgentExecutor_PreservesExistingInstructionsAndTool Func 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() + .Subject; + sentState.RequestedHandoffTargetAgentId.Should().BeNull(); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public async Task Test_HandoffAgentExecutor_AutonomousMode_InvokesAgentExactlyOnePlusTurnLimitTimes(int turnLimit) + { + // Arrange: agent with many prepared turns; no handoff ever requested; autonomous mode ON + // We prepare (turnLimit + 2) turns to detect off-by-one errors. TestReplayAgent stops + // incrementing Turn when prepared messages are exhausted, so preparing exactly (turnLimit + 1) + // turns would fail to detect if the implementation invokes the agent one extra time. + int totalTurns = turnLimit + 2; + 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() + .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> 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() + .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(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);