diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index a531f650fa..04f0bcdd03 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -70,7 +70,7 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(TextSearchProvider.TextSearchProviderState))] [JsonSerializable(typeof(ChatHistoryMemoryProvider.State))] - // Harness types + // TodoProvider types [JsonSerializable(typeof(TodoState))] [JsonSerializable(typeof(TodoItem))] [JsonSerializable(typeof(TodoItemInput))] @@ -78,6 +78,9 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(List), TypeInfoPropertyName = "TodoItemList")] [JsonSerializable(typeof(List), TypeInfoPropertyName = "TodoItemInputList")] + // AgentModeProvider types + [JsonSerializable(typeof(AgentModeState))] + [ExcludeFromCodeCoverage] internal sealed partial class JsonContext : JsonSerializerContext; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs new file mode 100644 index 0000000000..1a0ee68941 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// An that tracks the agent's operating mode (e.g., "plan" or "execute") +/// in the session state and provides tools for querying and switching modes. +/// +/// +/// +/// The enables agents to operate in distinct modes during long-running +/// complex tasks. The current mode is persisted in the session's +/// and is included in the instructions provided to the agent on each invocation. +/// +/// +/// This provider exposes the following tools to the agent: +/// +/// SetMode — Switch the agent's operating mode. +/// GetMode — Retrieve the agent's current operating mode. +/// +/// +/// +/// Public helper methods and allow external code +/// to programmatically read and change the mode. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentModeProvider : AIContextProvider +{ + /// + /// The "plan" mode, indicating the agent is planning work. + /// + public const string PlanMode = "plan"; + + /// + /// The "execute" mode, indicating the agent is executing work. + /// + public const string ExecuteMode = "execute"; + + private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; + + /// + /// Initializes a new instance of the class. + /// + public AgentModeProvider() + { + this._sessionState = new ProviderSessionState( + _ => new AgentModeState(), + this.GetType().Name, + AgentJsonUtilities.DefaultOptions); + } + + /// + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; + + /// + /// Gets the current operating mode from the session state. + /// + /// The agent session to read the mode from. + /// The current mode string. + public string GetMode(AgentSession? session) + { + return this._sessionState.GetOrInitializeState(session).CurrentMode; + } + + /// + /// Sets the operating mode in the session state. + /// + /// The agent session to update the mode in. + /// The new mode to set. + public void SetMode(AgentSession? session, string mode) + { + if (mode != PlanMode && mode != ExecuteMode) + { + throw new ArgumentException($"Invalid mode: {mode}. Supported modes are \"{PlanMode}\" and \"{ExecuteMode}\".", nameof(mode)); + } + + AgentModeState state = this._sessionState.GetOrInitializeState(session); + state.CurrentMode = mode; + this._sessionState.SaveState(session, state); + } + + /// + protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + AgentModeState state = this._sessionState.GetOrInitializeState(context.Session); + + string instructions = $""" + You are currently operating in "{state.CurrentMode}" mode. + Available modes: + - "plan": Use this mode when analyzing requirements, breaking down tasks, and creating plans. + - "execute": Use this mode when implementing changes, writing code, and carrying out planned work. + Use the SetMode tool to switch between modes as your work progresses. Only use SetMode if the user explicitly instructs you to change modes. + Use the GetMode tool to check your current operating mode. + """; + + return new ValueTask(new AIContext + { + Instructions = instructions, + Tools = this.CreateTools(state, context.Session), + }); + } + + private AITool[] CreateTools(AgentModeState state, AgentSession? session) + { + var serializerOptions = AgentJsonUtilities.DefaultOptions; + + return + [ + AIFunctionFactory.Create( + (string mode) => + { + if (mode != PlanMode && mode != ExecuteMode) + { + throw new ArgumentException($"Invalid mode: {mode}. Supported modes are \"{PlanMode}\" and \"{ExecuteMode}\".", nameof(mode)); + } + + state.CurrentMode = mode; + this._sessionState.SaveState(session, state); + return $"Mode changed to \"{mode}\"."; + }, + new AIFunctionFactoryOptions + { + Name = "SetMode", + Description = "Switch the agent's operating mode. Supported modes: \"plan\" and \"execute\".", + SerializerOptions = serializerOptions, + }), + + AIFunctionFactory.Create( + () => state.CurrentMode, + new AIFunctionFactoryOptions + { + Name = "GetMode", + Description = "Get the agent's current operating mode.", + SerializerOptions = serializerOptions, + }), + ]; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeState.cs new file mode 100644 index 0000000000..e16c9c9289 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeState.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the state of the agent's operating mode, stored in the session's . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class AgentModeState +{ + /// + /// Gets or sets the current operating mode of the agent. + /// + [JsonPropertyName("currentMode")] + public string CurrentMode { get; set; } = AgentModeProvider.PlanMode; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/AgentMode/AgentModeProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/AgentMode/AgentModeProviderTests.cs new file mode 100644 index 0000000000..59393d4430 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/AgentMode/AgentModeProviderTests.cs @@ -0,0 +1,341 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class AgentModeProviderTests +{ + #region ProvideAIContextAsync Tests + + /// + /// Verify that the provider returns tools and instructions. + /// + [Fact] + public async Task ProvideAIContextAsync_ReturnsToolsAndInstructionsAsync() + { + // Arrange + var provider = new AgentModeProvider(); + var agent = new Mock().Object; + var session = new ChatClientAgentSession(); +#pragma warning disable MAAI001 + var context = new AIContextProvider.InvokingContext(agent, session, new AIContext()); +#pragma warning restore MAAI001 + + // Act + AIContext result = await provider.InvokingAsync(context); + + // Assert + Assert.NotNull(result.Instructions); + Assert.NotNull(result.Tools); + Assert.Equal(2, result.Tools!.Count()); + } + + /// + /// Verify that the instructions include the current mode. + /// + [Fact] + public async Task ProvideAIContextAsync_InstructionsIncludeCurrentModeAsync() + { + // Arrange + var provider = new AgentModeProvider(); + var agent = new Mock().Object; + var session = new ChatClientAgentSession(); +#pragma warning disable MAAI001 + var context = new AIContextProvider.InvokingContext(agent, session, new AIContext()); +#pragma warning restore MAAI001 + + // Act + AIContext result = await provider.InvokingAsync(context); + + // Assert + Assert.Contains("plan", result.Instructions); + } + + #endregion + + #region SetMode Tool Tests + + /// + /// Verify that SetMode changes the mode. + /// + [Fact] + public async Task SetMode_ChangesModeAsync() + { + // Arrange + var (tools, state) = await CreateToolsWithStateAsync(); + AIFunction setMode = GetTool(tools, "SetMode"); + + // Act + await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" }); + + // Assert + Assert.Equal("execute", state.CurrentMode); + } + + /// + /// Verify that SetMode returns a confirmation message. + /// + [Fact] + public async Task SetMode_ReturnsConfirmationAsync() + { + // Arrange + var (tools, _) = await CreateToolsWithStateAsync(); + AIFunction setMode = GetTool(tools, "SetMode"); + + // Act + object? result = await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" }); + + // Assert + Assert.Equal("Mode changed to \"execute\".", GetStringResult(result)); + } + + /// + /// Verify that SetMode with an unsupported value throws and does not persist the mode. + /// + [Fact] + public async Task SetMode_InvalidMode_ThrowsAsync() + { + // Arrange + var (tools, provider, session) = await CreateToolsWithProviderAndSessionAsync(); + AIFunction setMode = GetTool(tools, "SetMode"); + AIFunction getMode = GetTool(tools, "GetMode"); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "foo" })); + + // Verify mode was not changed from default + object? currentMode = await getMode.InvokeAsync(new AIFunctionArguments()); + Assert.Equal(AgentModeProvider.PlanMode, GetStringResult(currentMode)); + } + + #endregion + + #region GetMode Tool Tests + + /// + /// Verify that GetMode returns the default mode. + /// + [Fact] + public async Task GetMode_ReturnsDefaultModeAsync() + { + // Arrange + var (tools, _) = await CreateToolsWithStateAsync(); + AIFunction getMode = GetTool(tools, "GetMode"); + + // Act + object? result = await getMode.InvokeAsync(new AIFunctionArguments()); + + // Assert + Assert.Equal("plan", GetStringResult(result)); + } + + /// + /// Verify that GetMode returns the mode after SetMode. + /// + [Fact] + public async Task GetMode_ReturnsUpdatedModeAfterSetAsync() + { + // Arrange + var (tools, _) = await CreateToolsWithStateAsync(); + AIFunction setMode = GetTool(tools, "SetMode"); + AIFunction getMode = GetTool(tools, "GetMode"); + + // Act + await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" }); + object? result = await getMode.InvokeAsync(new AIFunctionArguments()); + + // Assert + Assert.Equal("execute", GetStringResult(result)); + } + + #endregion + + #region Public Helper Method Tests + + /// + /// Verify that the public GetMode helper returns the default mode. + /// + [Fact] + public void PublicGetMode_ReturnsDefaultMode() + { + // Arrange + var provider = new AgentModeProvider(); + var session = new ChatClientAgentSession(); + + // Act + string mode = provider.GetMode(session); + + // Assert + Assert.Equal(AgentModeProvider.PlanMode, mode); + } + + /// + /// Verify that the public SetMode helper changes the mode. + /// + [Fact] + public void PublicSetMode_ChangesMode() + { + // Arrange + var provider = new AgentModeProvider(); + var session = new ChatClientAgentSession(); + + // Act + provider.SetMode(session, AgentModeProvider.ExecuteMode); + string mode = provider.GetMode(session); + + // Assert + Assert.Equal(AgentModeProvider.ExecuteMode, mode); + } + + /// + /// Verify that the public SetMode helper throws for an unsupported value and does not persist the mode. + /// + [Fact] + public void PublicSetMode_InvalidMode_Throws() + { + // Arrange + var provider = new AgentModeProvider(); + var session = new ChatClientAgentSession(); + + // Act & Assert + Assert.Throws(() => provider.SetMode(session, "foo")); + + // Verify mode was not changed from default + string mode = provider.GetMode(session); + Assert.Equal(AgentModeProvider.PlanMode, mode); + } + + /// + /// Verify that public helper changes are reflected in tool results. + /// + [Fact] + public async Task PublicSetMode_ReflectedInToolResultsAsync() + { + // Arrange + var provider = new AgentModeProvider(); + var agent = new Mock().Object; + var session = new ChatClientAgentSession(); + + // Set mode via public helper + provider.SetMode(session, AgentModeProvider.ExecuteMode); + +#pragma warning disable MAAI001 + var context = new AIContextProvider.InvokingContext(agent, session, new AIContext()); +#pragma warning restore MAAI001 + + // Act + AIContext result = await provider.InvokingAsync(context); + AIFunction getMode = GetTool(result.Tools!, "GetMode"); + object? modeResult = await getMode.InvokeAsync(new AIFunctionArguments()); + + // Assert + Assert.Equal("execute", GetStringResult(modeResult)); + Assert.Contains("execute", result.Instructions); + } + + #endregion + + #region State Persistence Tests + + /// + /// Verify that state persists across invocations. + /// + [Fact] + public async Task State_PersistsAcrossInvocationsAsync() + { + // Arrange + var provider = new AgentModeProvider(); + var agent = new Mock().Object; + var session = new ChatClientAgentSession(); +#pragma warning disable MAAI001 + var context = new AIContextProvider.InvokingContext(agent, session, new AIContext()); +#pragma warning restore MAAI001 + + // Act — first invocation changes mode + AIContext result1 = await provider.InvokingAsync(context); + AIFunction setMode = GetTool(result1.Tools!, "SetMode"); + await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" }); + + // Second invocation should see the updated mode + AIContext result2 = await provider.InvokingAsync(context); + AIFunction getMode = GetTool(result2.Tools!, "GetMode"); + object? modeResult = await getMode.InvokeAsync(new AIFunctionArguments()); + + // Assert + Assert.Equal("execute", GetStringResult(modeResult)); + Assert.Contains("execute", result2.Instructions); + } + + #endregion + + #region Constants Tests + + /// + /// Verify that mode constants have expected values. + /// + [Fact] + public void ModeConstants_HaveExpectedValues() + { + // Assert + Assert.Equal("plan", AgentModeProvider.PlanMode); + Assert.Equal("execute", AgentModeProvider.ExecuteMode); + } + + #endregion + + #region Helper Methods + + private static async Task<(IEnumerable Tools, AgentModeState State)> CreateToolsWithStateAsync() + { + var provider = new AgentModeProvider(); + var agent = new Mock().Object; + var session = new ChatClientAgentSession(); +#pragma warning disable MAAI001 + var context = new AIContextProvider.InvokingContext(agent, session, new AIContext()); +#pragma warning restore MAAI001 + + AIContext result = await provider.InvokingAsync(context); + + // Retrieve the state from the session to verify mutations + session.StateBag.TryGetValue("AgentModeProvider", out var state, AgentJsonUtilities.DefaultOptions); + + return (result.Tools!, state!); + } + + private static async Task<(IEnumerable Tools, AgentModeProvider Provider, AgentSession Session)> CreateToolsWithProviderAndSessionAsync() + { + var provider = new AgentModeProvider(); + var agent = new Mock().Object; + var session = new ChatClientAgentSession(); +#pragma warning disable MAAI001 + var context = new AIContextProvider.InvokingContext(agent, session, new AIContext()); +#pragma warning restore MAAI001 + + AIContext result = await provider.InvokingAsync(context); + return (result.Tools!, provider, session); + } + + private static AIFunction GetTool(IEnumerable tools, string name) + { + return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name); + } + + private static string GetStringResult(object? result) + { + var element = Assert.IsType(result); + return element.GetString()!; + } + + #endregion +}