Skip to content
Open
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 @@ -43,7 +43,7 @@ public override async ValueTask<bool> TryHandleAsync(string input, AgentSession
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
{
string current = this._modeProvider.GetMode(session);
string current = await this._modeProvider.GetModeAsync(session).ConfigureAwait(false);
await ux.WriteInfoLineAsync($"Current mode: {current}").ConfigureAwait(false);
return true;
}
Expand All @@ -52,7 +52,7 @@ public override async ValueTask<bool> TryHandleAsync(string input, AgentSession

try
{
this._modeProvider.SetMode(session, newMode);
await this._modeProvider.SetModeAsync(session, newMode).ConfigureAwait(false);
ux.CurrentMode = newMode;
await ux.WriteInfoLineAsync($"Switched to {newMode} mode.", ModeColors.Get(newMode, this._modeColors)).ConfigureAwait(false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ internal async Task OnUserInputAsync(string text)
{
if (await handler.TryHandleAsync(text, this._session, this._ux).ConfigureAwait(false))
{
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
this._ux.CurrentMode = this._modeProvider is null ? null : await this._modeProvider.GetModeAsync(this._session).ConfigureAwait(false);
return;
}
}
Expand Down Expand Up @@ -136,7 +136,7 @@ internal async Task StartAgentTurnAsync(IList<ChatMessage> messages)
{
if (messages.Count == 0)
{
this.CompleteTurn();
await this.CompleteTurnAsync().ConfigureAwait(false);
return;
}

Expand All @@ -158,10 +158,10 @@ private async Task RunAgentLoopAsync(IList<ChatMessage> messages)
var runOptions = new AgentRunOptions();
foreach (var observer in this._observers)
{
observer.ConfigureRunOptions(runOptions, this._agent, this._session);
await observer.ConfigureRunOptionsAsync(runOptions, this._agent, this._session).ConfigureAwait(false);
}

this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
this._ux.CurrentMode = this._modeProvider is null ? null : await this._modeProvider.GetModeAsync(this._session).ConfigureAwait(false);
this._ux.BeginStreaming();
this._ux.BeginStreamingOutput();

Expand All @@ -171,7 +171,7 @@ private async Task RunAgentLoopAsync(IList<ChatMessage> messages)
{
if (this._modeProvider is not null)
{
string currentMode = this._modeProvider.GetMode(this._session);
string currentMode = await this._modeProvider.GetModeAsync(this._session).ConfigureAwait(false);
if (currentMode != this._ux.CurrentMode)
{
this._ux.CurrentMode = currentMode;
Expand Down Expand Up @@ -261,13 +261,13 @@ private async Task RunAgentLoopAsync(IList<ChatMessage> messages)
nextMessages = drained.Count > 0 ? [.. drained] : null;
}

this.CompleteTurn();
await this.CompleteTurnAsync().ConfigureAwait(false);
}

private void CompleteTurn()
private async Task CompleteTurnAsync()
{
this._ux.EndStreaming();
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
this._ux.CurrentMode = this._modeProvider is null ? null : await this._modeProvider.GetModeAsync(this._session).ConfigureAwait(false);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ public static async Task RunAgentAsync(AIAgent agent, string userPrompt, Harness
? await options.SessionFactory(agent)
: await agent.CreateSessionAsync();

string? initialMode = modeProvider is null ? null : await modeProvider.GetModeAsync(session);

using var component = new HarnessAppComponent(
placeholder: userPrompt,
initialMode: modeProvider?.GetMode(session),
initialMode: initialMode,
inputEnabled: messageInjector is not null,
runnerFactory: ux => new HarnessAgentRunner(
agent: agent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ public abstract class ConsoleObserver
/// <param name="options">The run options to configure.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
public virtual void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
{
}
public virtual ValueTask ConfigureRunOptionsAsync(AgentRunOptions options, AIAgent agent, AgentSession session) => default;

/// <summary>
/// Called for each <see cref="AgentResponseUpdate"/> in the response stream, regardless of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ public PlanningOutputObserver(AgentModeProvider modeProvider, string planModeNam
}

/// <inheritdoc/>
public override void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
public override async ValueTask ConfigureRunOptionsAsync(AgentRunOptions options, AIAgent agent, AgentSession session)
{
if (this.IsPlanningMode(this._modeProvider.GetMode(session)))
if (this.IsPlanningMode(await this._modeProvider.GetModeAsync(session).ConfigureAwait(false)))
{
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
}
Expand Down Expand Up @@ -205,7 +205,7 @@ private ChoiceFollowUpQuestion BuildApprovalAction(PlanningQuestion question, Ag

if (selection == ApproveOption)
{
this._modeProvider.SetMode(session, this._executionModeName);
await this._modeProvider.SetModeAsync(session, this._executionModeName).ConfigureAwait(false);
await ux.WriteInfoLineAsync(
$"✅ Switched to {this._executionModeName} mode.",
ModeColors.Get(this._executionModeName, this._modeColors)).ConfigureAwait(false);
Expand Down
155 changes: 123 additions & 32 deletions dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Agents.AI;

Expand All @@ -34,12 +34,15 @@ namespace Microsoft.Agents.AI;
/// </list>
/// </para>
/// <para>
/// Public helper methods <see cref="GetMode"/> and <see cref="SetMode"/> allow external code
/// Public helper methods <see cref="GetModeAsync"/> and <see cref="SetModeAsync"/> allow external code
/// to programmatically read and change the mode.
/// </para>
/// <para>
/// All operations are thread-safe; concurrent reads and mutations on the same session are serialized
/// using a per-session lock to prevent lost updates or inconsistent reads.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentModeProvider : AIContextProvider
public sealed class AgentModeProvider : AIContextProvider, IDisposable
{
private const string DefaultInstructions =
"""
Expand Down Expand Up @@ -102,6 +105,8 @@ 4. Mark tasks as completed as you finish them.
private readonly string? _instructions;
private readonly HashSet<string> _validModeNames;
private readonly string _modeNamesDisplay;
private readonly ConditionalWeakTable<AgentSession, SemaphoreSlim> _sessionLocks = new();
private readonly SemaphoreSlim _nullSessionLock = new(1, 1);
private IReadOnlyList<string>? _stateKeys;

/// <summary>
Expand Down Expand Up @@ -159,65 +164,115 @@ public AgentModeProvider(AgentModeProviderOptions? options = null)
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];

/// <inheritdoc />
public void Dispose()
{
this._nullSessionLock.Dispose();
}

/// <summary>
/// Gets the current operating mode from the session state.
/// </summary>
/// <param name="session">The agent session to read the mode from.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>The current mode string.</returns>
public string GetMode(AgentSession? session)
/// <exception cref="ArgumentNullException"><paramref name="session"/> is <see langword="null"/>.</exception>
public async Task<string> GetModeAsync(AgentSession session, CancellationToken cancellationToken = default)
{
return this._sessionState.GetOrInitializeState(session).CurrentMode;
_ = Throw.IfNull(session);

SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return this._sessionState.GetOrInitializeState(session).CurrentMode;
}
finally
{
sessionLock.Release();
}
}

/// <summary>
/// Sets the operating mode in the session state.
/// </summary>
/// <param name="session">The agent session to update the mode in.</param>
/// <param name="mode">The new mode to set.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous operation.</returns>
/// <exception cref="ArgumentNullException"><paramref name="session"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="mode"/> is not a configured mode.</exception>
public void SetMode(AgentSession? session, string mode)
public async Task SetModeAsync(AgentSession session, string mode, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(session);

this.ValidateMode(mode);

AgentModeState state = this._sessionState.GetOrInitializeState(session);
string previousMode = state.CurrentMode;
state.CurrentMode = mode;
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
AgentModeState state = this._sessionState.GetOrInitializeState(session);
string previousMode = state.CurrentMode;
state.CurrentMode = mode;

if (!string.Equals(previousMode, mode, StringComparison.Ordinal))
if (!string.Equals(previousMode, mode, StringComparison.Ordinal))
{
state.PreviousModeForNotification = previousMode;
}

this._sessionState.SaveState(session, state);
}
finally
{
state.PreviousModeForNotification = previousMode;
sessionLock.Release();
}

this._sessionState.SaveState(session, state);
}

/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
AgentModeState state = this._sessionState.GetOrInitializeState(context.Session);
string currentMode;
string? previousModeForNotification;

string instructions = this.BuildInstructions(state.CurrentMode);
SemaphoreSlim sessionLock = this.GetSessionLock(context.Session);
await sessionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
AgentModeState state = this._sessionState.GetOrInitializeState(context.Session);
currentMode = state.CurrentMode;
previousModeForNotification = state.PreviousModeForNotification;

// If the mode was changed externally (e.g., via /mode command), clear the pending
// notification flag now that we are about to surface it to the agent.
if (previousModeForNotification != null)
{
state.PreviousModeForNotification = null;
this._sessionState.SaveState(context.Session, state);
}
}
finally
{
sessionLock.Release();
}

var aiContext = new AIContext
{
Instructions = instructions,
Tools = this.CreateTools(state, context.Session),
Instructions = this.BuildInstructions(currentMode),
Tools = this.CreateTools(context.Session),
};

// If the mode was changed externally (e.g., via /mode command), inject a notification message
// so the agent clearly sees the change rather than relying solely on the system instructions.
if (state.PreviousModeForNotification != null)
if (previousModeForNotification != null)
{
string previousMode = state.PreviousModeForNotification;
state.PreviousModeForNotification = null;

aiContext.Messages =
[
new ChatMessage(ChatRole.User, $"[Mode changed: The operating mode has been switched from \"{previousMode}\" to \"{state.CurrentMode}\". You must now adjust your behavior to match the \"{state.CurrentMode}\" mode.]"),
new ChatMessage(ChatRole.User, $"[Mode changed: The operating mode has been switched from \"{previousModeForNotification}\" to \"{currentMode}\". You must now adjust your behavior to match the \"{currentMode}\" mode.]"),
];
}

return new ValueTask<AIContext>(aiContext);
return aiContext;
}

private string BuildInstructions(string currentMode)
Expand All @@ -227,7 +282,7 @@ private string BuildInstructions(string currentMode)
{
modesListBuilder.AppendLine($"#### {mode.Name}");
modesListBuilder.AppendLine();
modesListBuilder.AppendLine(mode.Description.TrimEnd());
modesListBuilder.AppendLine(mode.Instructions.TrimEnd());
modesListBuilder.AppendLine();
}

Expand All @@ -247,19 +302,43 @@ private void ValidateMode(string mode)
}
}

private AITool[] CreateTools(AgentModeState state, AgentSession? session)
/// <summary>
/// Returns the per-session semaphore used to serialize all mode operations.
/// </summary>
private SemaphoreSlim GetSessionLock(AgentSession? session)
{
if (session is null)
{
return this._nullSessionLock;
}

return this._sessionLocks.GetValue(session, _ => new SemaphoreSlim(1, 1));
}

private AITool[] CreateTools(AgentSession? session)
{
var serializerOptions = AgentJsonUtilities.DefaultOptions;

return
[
AIFunctionFactory.Create(
(string mode) =>
async (string mode) =>
{
this.ValidateMode(mode);

state.CurrentMode = mode;
this._sessionState.SaveState(session, state);
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
AgentModeState state = this._sessionState.GetOrInitializeState(session);
state.CurrentMode = mode;
this._sessionState.SaveState(session, state);
}
finally
{
sessionLock.Release();
}

return $"Mode changed to \"{mode}\".";
},
new AIFunctionFactoryOptions
Expand All @@ -270,7 +349,19 @@ private AITool[] CreateTools(AgentModeState state, AgentSession? session)
}),

AIFunctionFactory.Create(
() => state.CurrentMode,
async () =>
{
SemaphoreSlim sessionLock = this.GetSessionLock(session);
await sessionLock.WaitAsync().ConfigureAwait(false);
try
{
return this._sessionState.GetOrInitializeState(session).CurrentMode;
}
finally
{
sessionLock.Release();
}
},
new AIFunctionFactoryOptions
{
Name = "mode_get",
Expand Down
Loading
Loading