From 0987ce3d31555f6fca554df1b7abfb162cb12e8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 00:17:07 +0000 Subject: [PATCH 1/5] Initial plan From 05c29347ff185ab336c70b5fd68ab4cad13d7af8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 00:29:25 +0000 Subject: [PATCH 2/5] feat: add autonomous workflow mode to .NET handoff --- .../HandoffWorkflowBuilder.cs | 35 +++- .../Specialized/HandoffAgentExecutor.cs | 69 ++++++- .../HandoffAgentExecutorTests.cs | 180 ++++++++++++++++++ 3 files changed, 282 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs index 00e030448f6..584ad928df2 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,33 @@ 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 user message. + /// 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 +277,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..e7e8468806a 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,22 @@ 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 before control is returned to the user. + /// + public int AutonomousModeTurnLimit { get; set; } } internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId) @@ -74,6 +100,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 +119,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 +311,39 @@ 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) + { + 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); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs index 70f802399d6..21ebb46d2ca 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -201,6 +201,186 @@ 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_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() + .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); From 0681e046eac1044eb71334ba48320fbcf7f8f868 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 00:32:36 +0000 Subject: [PATCH 3/5] fix: reset autonomous turn counter at start of each new HandoffState turn --- .../HandoffWorkflowBuilder.cs | 3 ++- .../Specialized/HandoffAgentExecutor.cs | 11 ++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs index 584ad928df2..794cbd00994 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs @@ -160,7 +160,8 @@ public TBuilder EnableReturnToPrevious() /// If , a default prompt is used. /// /// - /// The maximum number of autonomous continuation turns per agent per user message. + /// 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. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index e7e8468806a..4370e54b41e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -53,7 +53,8 @@ public HandoffAgentExecutorOptions( public string AutonomousModePrompt { get; set; } /// - /// Gets or sets the maximum number of autonomous turns before control is returned to the user. + /// 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; } } @@ -342,6 +343,9 @@ await this._sharedStateRef.InvokeWithStateAsync( } // Reset the counter when ending the turn (handoff requested or turn limit reached). + // This also covers the case where the turn is interrupted by outstanding requests: + // the counter is reset at the start of the next HandleAsync call, but we still + // clean up here on a normal turn exit for clarity. this._autonomousModeTurnCount = 0; HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id); @@ -388,6 +392,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); } From a47da4de3de1f717b1451ad31a21f40a77f2e326 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 00:35:10 +0000 Subject: [PATCH 4/5] refactor: remove redundant autonomous turn counter reset from ContinueTurnAsync --- .../Specialized/HandoffAgentExecutor.cs | 6 ------ .../HandoffAgentExecutorTests.cs | 6 +++++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 4370e54b41e..3ba6603b73c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -342,12 +342,6 @@ await this._sharedStateRef.InvokeWithStateAsync( skipAddIncoming: true).ConfigureAwait(false); } - // Reset the counter when ending the turn (handoff requested or turn limit reached). - // This also covers the case where the turn is interrupted by outstanding requests: - // the counter is reset at the start of the next HandleAsync call, but we still - // clean up here on a normal turn exit for clarity. - this._autonomousModeTurnCount = 0; - HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id); await context.SendMessageAsync(outgoingState, cancellationToken).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 21ebb46d2ca..9ce9918d163 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -242,7 +242,11 @@ public async Task Test_HandoffAgentExecutor_AutonomousMode_Disabled_DoesNotConti 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 + // We prepare (turnLimit + 2) turns so that if the implementation over-invokes by one, + // TestReplayAgent.Turn will be (turnLimit + 2) rather than the expected (turnLimit + 1). + // TestReplayAgent does NOT increment Turn when it runs out of prepared messages, so preparing + // exactly (turnLimit + 1) would make an extra invocation silently undetectable. + int totalTurns = turnLimit + 2; TestReplayAgent agent = new( Enumerable.Range(0, totalTurns) .Select(i => TestReplayAgent.ToChatMessages($"Turn {i} response")) From 021d9f2dc4c0261cfac0135d573be7a054f7d29a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 00:38:26 +0000 Subject: [PATCH 5/5] fix: improve autonomous mode comments per code review --- .../Specialized/HandoffAgentExecutor.cs | 6 ++++-- .../HandoffAgentExecutorTests.cs | 9 ++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 3ba6603b73c..80799bb242b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -316,8 +316,6 @@ await this._sharedStateRef.InvokeWithStateAsync( // 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, @@ -334,6 +332,10 @@ await this._sharedStateRef.InvokeWithStateAsync( 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], diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs index 9ce9918d163..cf98e014345 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -239,13 +239,12 @@ public async Task Test_HandoffAgentExecutor_AutonomousMode_Disabled_DoesNotConti [InlineData(1)] [InlineData(2)] [InlineData(3)] - public async Task Test_HandoffAgentExecutor_AutonomousMode_InvokesAgentUpToTurnLimitPlusOne(int turnLimit) + 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 so that if the implementation over-invokes by one, - // TestReplayAgent.Turn will be (turnLimit + 2) rather than the expected (turnLimit + 1). - // TestReplayAgent does NOT increment Turn when it runs out of prepared messages, so preparing - // exactly (turnLimit + 1) would make an extra invocation silently undetectable. + // 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)