Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -142,6 +145,34 @@ public TBuilder EnableReturnToPrevious()
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 incoming turn.
/// The counter resets at the beginning of each new turn (each incoming <see cref="HandoffState"/>).
/// 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>
Expand Down Expand Up @@ -247,7 +278,10 @@ private Dictionary<string, ExecutorBinding> 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -30,6 +40,23 @@ public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentRe
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 per incoming turn.
/// The counter is reset at the start of every new <see cref="HandoffState"/> turn.
/// </summary>
public int AutonomousModeTurnLimit { get; set; }
}

internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId)
Expand Down Expand Up @@ -74,6 +101,12 @@ public ValueTask InvokeWithStateAsync(Func<TState?, IWorkflowContext, Cancellati
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;

Expand All @@ -87,6 +120,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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,189 @@ public async Task Test_HandoffAgentExecutor_PreservesExistingInstructionsAndTool
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_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<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);
Expand Down