From 673f3d92141a5c35f999f72734f59c7559c0d548 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:07:55 +0100 Subject: [PATCH 01/13] .NET: Add a TODO AIContextProvider (#5233) * Add a TODO AIContextProvider * Add unit tests * Address PR comments * Address PR comments * Fix test after removing one tool --- .../Microsoft.Agents.AI/AgentJsonUtilities.cs | 9 + .../Harness/Todo/TodoItem.cs | 38 ++ .../Harness/Todo/TodoItemInput.cs | 26 + .../Harness/Todo/TodoProvider.cs | 204 ++++++++ .../Harness/Todo/TodoState.cs | 28 ++ .../Harness/Todo/TodoProviderTests.cs | 445 ++++++++++++++++++ 6 files changed, 750 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoItem.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoItemInput.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoState.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index 96ec6dbecb..a531f650fa 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.Encodings.Web; using System.Text.Json; @@ -69,6 +70,14 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(TextSearchProvider.TextSearchProviderState))] [JsonSerializable(typeof(ChatHistoryMemoryProvider.State))] + // Harness types + [JsonSerializable(typeof(TodoState))] + [JsonSerializable(typeof(TodoItem))] + [JsonSerializable(typeof(TodoItemInput))] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "IntList")] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "TodoItemList")] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "TodoItemInputList")] + [ExcludeFromCodeCoverage] internal sealed partial class JsonContext : JsonSerializerContext; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoItem.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoItem.cs new file mode 100644 index 0000000000..b9540ffcbd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoItem.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents a single todo item managed by the . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class TodoItem +{ + /// + /// Gets or sets the unique identifier for this todo item. + /// + [JsonPropertyName("id")] + public int Id { get; set; } + + /// + /// Gets or sets the title of this todo item. + /// + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets an optional description providing additional details about this todo item. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// Gets or sets a value indicating whether this todo item has been completed. + /// + [JsonPropertyName("isComplete")] + public bool IsComplete { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoItemInput.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoItemInput.cs new file mode 100644 index 0000000000..aa15a3e436 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoItemInput.cs @@ -0,0 +1,26 @@ +// 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 input for creating a new todo item via the . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class TodoItemInput +{ + /// + /// Gets or sets the title of the todo item to create. + /// + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets an optional description providing additional details about the todo item. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs new file mode 100644 index 0000000000..e39db5e197 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// An that provides todo management tools and instructions +/// to an agent for tracking work items during long-running complex tasks. +/// +/// +/// +/// The enables agents to create, complete, remove, and query todo items +/// as part of their planning and execution workflow. Todo state is stored in the session's +/// and persists across agent invocations within the same session. +/// +/// +/// This provider exposes the following tools to the agent: +/// +/// AddTodos — Add one or more todo items, each with a title and optional description. +/// CompleteTodos — Mark one or more todo items as complete by their IDs. +/// RemoveTodos — Remove one or more todo items by their IDs. +/// GetRemainingTodos — Retrieve only incomplete todo items. +/// GetAllTodos — Retrieve all todo items (complete and incomplete). +/// +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class TodoProvider : AIContextProvider +{ + private const string DefaultInstructions = + """ + You have access to a todo list for tracking work items. + While planning, make sure that you break down complex tasks into manageable todo items and add them to the list. + Ask questions from the user where clarification is needed to create effective todos. + If the user provides feedback on your plan, adjust your todos accordingly by adding new items or removing irrelevant ones. + During execution, use the todo list to keep track of what needs to be done, mark items as complete when finished, and remove any items that are no longer needed. + When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant items or adding new ones as needed. + + Use these tools to manage your tasks: + - Use AddTodos to break down complex work into trackable items (supports adding one or many at once). + - Use CompleteTodos to mark items as done when finished (supports one or many at once). + - Use GetRemainingTodos to check what work is still pending. + - Use GetAllTodos to review the full list including completed items. + - Use RemoveTodos to remove items that are no longer needed (supports one or many at once). + """; + + private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; + + /// + /// Initializes a new instance of the class. + /// + public TodoProvider() + { + this._sessionState = new ProviderSessionState( + _ => new TodoState(), + this.GetType().Name, + AgentJsonUtilities.DefaultOptions); + } + + /// + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; + + /// + /// Gets all todo items from the session state. + /// + /// The agent session to read todos from. + /// A read-only list of all todo items. + public IReadOnlyList GetAllTodos(AgentSession? session) + { + return this._sessionState.GetOrInitializeState(session).Items; + } + + /// + /// Gets the remaining (incomplete) todo items from the session state. + /// + /// The agent session to read todos from. + /// A list of incomplete todo items. + public List GetRemainingTodos(AgentSession? session) + { + return this._sessionState.GetOrInitializeState(session).Items.Where(t => !t.IsComplete).ToList(); + } + + /// + protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + TodoState state = this._sessionState.GetOrInitializeState(context.Session); + + return new ValueTask(new AIContext + { + Instructions = DefaultInstructions, + Tools = this.CreateTools(state, context.Session), + }); + } + + // Note: These tool delegates mutate shared session state without synchronization. + // This is safe because FunctionInvokingChatClient serializes tool calls within a single run. + private AITool[] CreateTools(TodoState state, AgentSession? session) + { + var serializerOptions = AgentJsonUtilities.DefaultOptions; + + return + [ + AIFunctionFactory.Create( + (List todos) => + { + var created = new List(); + foreach (var input in todos) + { + var item = new TodoItem + { + Id = state.NextId++, + Title = input.Title, + Description = input.Description, + }; + state.Items.Add(item); + created.Add(item); + } + + this._sessionState.SaveState(session, state); + return created; + }, + new AIFunctionFactoryOptions + { + Name = "AddTodos", + Description = "Add one or more todo items. Each item has a title and an optional description. Returns the list of created todo items.", + SerializerOptions = serializerOptions, + }), + + AIFunctionFactory.Create( + (List ids) => + { + var idSet = new HashSet(ids); + int completed = 0; + foreach (TodoItem item in state.Items) + { + if (!item.IsComplete && idSet.Contains(item.Id)) + { + item.IsComplete = true; + completed++; + } + } + + if (completed > 0) + { + this._sessionState.SaveState(session, state); + } + + return completed; + }, + new AIFunctionFactoryOptions + { + Name = "CompleteTodos", + Description = "Mark one or more todo items as complete by their IDs. Returns the number of items that were found and marked complete.", + SerializerOptions = serializerOptions, + }), + + AIFunctionFactory.Create( + (List ids) => + { + var idSet = new HashSet(ids); + int removed = state.Items.RemoveAll(t => idSet.Contains(t.Id)); + + if (removed > 0) + { + this._sessionState.SaveState(session, state); + } + + return removed; + }, + new AIFunctionFactoryOptions + { + Name = "RemoveTodos", + Description = "Remove one or more todo items by their IDs. Returns the number of items that were found and removed.", + SerializerOptions = serializerOptions, + }), + + AIFunctionFactory.Create( + () => state.Items.Where(t => !t.IsComplete).ToList(), + new AIFunctionFactoryOptions + { + Name = "GetRemainingTodos", + Description = "Retrieve the list of incomplete todo items.", + SerializerOptions = serializerOptions, + }), + + AIFunctionFactory.Create( + () => state.Items, + new AIFunctionFactoryOptions + { + Name = "GetAllTodos", + Description = "Retrieve the full list of todo items, both complete and incomplete.", + SerializerOptions = serializerOptions, + }), + ]; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoState.cs new file mode 100644 index 0000000000..5b62d6d1eb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoState.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the state of the todo list managed by the , +/// stored in the session's . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class TodoState +{ + /// + /// Gets the list of todo items. + /// + [JsonPropertyName("items")] + public List Items { get; set; } = []; + + /// + /// Gets or sets the next ID to assign to a new todo item. + /// + [JsonPropertyName("nextId")] + public int NextId { get; set; } = 1; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs new file mode 100644 index 0000000000..726de7e50a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs @@ -0,0 +1,445 @@ +// Copyright (c) Microsoft. All rights reserved. + +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 TodoProviderTests +{ + #region ProvideAIContextAsync Tests + + /// + /// Verify that the provider returns tools and instructions. + /// + [Fact] + public async Task ProvideAIContextAsync_ReturnsToolsAndInstructionsAsync() + { + // Arrange + var provider = new TodoProvider(); + 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(5, result.Tools!.Count()); + } + + #endregion + + #region AddTodos Tests + + /// + /// Verify that AddTodos creates a new todo item when given a single item. + /// + [Fact] + public async Task AddTodos_CreatesSingleItemAsync() + { + // Arrange + var (tools, state) = await CreateToolsWithStateAsync(); + AIFunction addTodos = GetTool(tools, "AddTodos"); + + // Act + await addTodos.InvokeAsync(new AIFunctionArguments() + { + ["todos"] = new List { new() { Title = "Test todo", Description = "A test description" } }, + }); + + // Assert + Assert.Single(state.Items); + Assert.Equal("Test todo", state.Items[0].Title); + Assert.Equal("A test description", state.Items[0].Description); + Assert.False(state.Items[0].IsComplete); + Assert.Equal(1, state.Items[0].Id); + } + + /// + /// Verify that AddTodos creates multiple items with incrementing IDs. + /// + [Fact] + public async Task AddTodos_CreatesMultipleItemsWithIncrementingIdsAsync() + { + // Arrange + var (tools, state) = await CreateToolsWithStateAsync(); + AIFunction addTodos = GetTool(tools, "AddTodos"); + + // Act + await addTodos.InvokeAsync(new AIFunctionArguments() + { + ["todos"] = new List + { + new() { Title = "First", Description = null }, + new() { Title = "Second", Description = null }, + new() { Title = "Third", Description = "With description" }, + }, + }); + + // Assert + Assert.Equal(3, state.Items.Count); + Assert.Equal(1, state.Items[0].Id); + Assert.Equal("First", state.Items[0].Title); + Assert.Equal(2, state.Items[1].Id); + Assert.Equal("Second", state.Items[1].Title); + Assert.Equal(3, state.Items[2].Id); + Assert.Equal("Third", state.Items[2].Title); + Assert.Equal("With description", state.Items[2].Description); + } + + #endregion + + #region CompleteTodos Tests + + /// + /// Verify that CompleteTodos marks an item as complete. + /// + [Fact] + public async Task CompleteTodos_MarksItemCompleteAsync() + { + // Arrange + var (tools, state) = await CreateToolsWithStateAsync(); + AIFunction addTodos = GetTool(tools, "AddTodos"); + AIFunction completeTodos = GetTool(tools, "CompleteTodos"); + await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Test", Description = null } } }); + + // Act + object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } }); + + // Assert + Assert.True(state.Items[0].IsComplete); + Assert.Equal(1, GetIntResult(result)); + } + + /// + /// Verify that CompleteTodos marks multiple items as complete. + /// + [Fact] + public async Task CompleteTodos_MarksMultipleItemsCompleteAsync() + { + // Arrange + var (tools, state) = await CreateToolsWithStateAsync(); + AIFunction addTodos = GetTool(tools, "AddTodos"); + AIFunction completeTodos = GetTool(tools, "CompleteTodos"); + await addTodos.InvokeAsync(new AIFunctionArguments() + { + ["todos"] = new List { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } }, + }); + + // Act + object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1, 3 } }); + + // Assert + Assert.True(state.Items[0].IsComplete); + Assert.False(state.Items[1].IsComplete); + Assert.True(state.Items[2].IsComplete); + Assert.Equal(2, GetIntResult(result)); + } + + /// + /// Verify that CompleteTodos returns zero for non-existent IDs. + /// + [Fact] + public async Task CompleteTodos_ReturnsZeroForMissingIdsAsync() + { + // Arrange + var (tools, _) = await CreateToolsWithStateAsync(); + AIFunction completeTodos = GetTool(tools, "CompleteTodos"); + + // Act + object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 999 } }); + + // Assert + Assert.Equal(0, GetIntResult(result)); + } + + #endregion + + #region RemoveTodos Tests + + /// + /// Verify that RemoveTodos removes an item. + /// + [Fact] + public async Task RemoveTodos_RemovesItemAsync() + { + // Arrange + var (tools, state) = await CreateToolsWithStateAsync(); + AIFunction addTodos = GetTool(tools, "AddTodos"); + AIFunction removeTodos = GetTool(tools, "RemoveTodos"); + await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Test", Description = null } } }); + + // Act + object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } }); + + // Assert + Assert.Empty(state.Items); + Assert.Equal(1, GetIntResult(result)); + } + + /// + /// Verify that RemoveTodos removes multiple items. + /// + [Fact] + public async Task RemoveTodos_RemovesMultipleItemsAsync() + { + // Arrange + var (tools, state) = await CreateToolsWithStateAsync(); + AIFunction addTodos = GetTool(tools, "AddTodos"); + AIFunction removeTodos = GetTool(tools, "RemoveTodos"); + await addTodos.InvokeAsync(new AIFunctionArguments() + { + ["todos"] = new List { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } }, + }); + + // Act + object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1, 3 } }); + + // Assert + Assert.Single(state.Items); + Assert.Equal("Second", state.Items[0].Title); + Assert.Equal(2, GetIntResult(result)); + } + + /// + /// Verify that RemoveTodos returns zero for non-existent IDs. + /// + [Fact] + public async Task RemoveTodos_ReturnsZeroForMissingIdsAsync() + { + // Arrange + var (tools, _) = await CreateToolsWithStateAsync(); + AIFunction removeTodos = GetTool(tools, "RemoveTodos"); + + // Act + object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 999 } }); + + // Assert + Assert.Equal(0, GetIntResult(result)); + } + + #endregion + + #region GetRemainingTodos Tests + + /// + /// Verify that GetRemainingTodos returns only incomplete items. + /// + [Fact] + public async Task GetRemainingTodos_ReturnsOnlyIncompleteAsync() + { + // Arrange + var (tools, _) = await CreateToolsWithStateAsync(); + AIFunction addTodos = GetTool(tools, "AddTodos"); + AIFunction completeTodos = GetTool(tools, "CompleteTodos"); + AIFunction getRemainingTodos = GetTool(tools, "GetRemainingTodos"); + await addTodos.InvokeAsync(new AIFunctionArguments() + { + ["todos"] = new List { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } }, + }); + await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } }); + + // Act + object? result = await getRemainingTodos.InvokeAsync(new AIFunctionArguments()); + + // Assert + var remaining = GetArrayResult(result); + Assert.Single(remaining); + Assert.Equal("Pending", remaining[0].GetProperty("title").GetString()); + } + + #endregion + + #region GetAllTodos Tests + + /// + /// Verify that GetAllTodos returns all items. + /// + [Fact] + public async Task GetAllTodos_ReturnsAllItemsAsync() + { + // Arrange + var (tools, _) = await CreateToolsWithStateAsync(); + AIFunction addTodos = GetTool(tools, "AddTodos"); + AIFunction completeTodos = GetTool(tools, "CompleteTodos"); + AIFunction getAllTodos = GetTool(tools, "GetAllTodos"); + await addTodos.InvokeAsync(new AIFunctionArguments() + { + ["todos"] = new List { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } }, + }); + await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } }); + + // Act + object? result = await getAllTodos.InvokeAsync(new AIFunctionArguments()); + + // Assert + var all = GetArrayResult(result); + Assert.Equal(2, all.Count); + } + + #endregion + + #region State Persistence Tests + + /// + /// Verify that state persists in the session StateBag. + /// + [Fact] + public async Task State_PersistsInSessionStateBagAsync() + { + // Arrange + var provider = new TodoProvider(); + 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 adds a todo + AIContext result1 = await provider.InvokingAsync(context); + AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "AddTodos"); + await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Persisted", Description = null } } }); + + // Second invocation should see the same state + AIContext result2 = await provider.InvokingAsync(context); + AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "GetAllTodos"); + object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments()); + + // Assert + var all = GetArrayResult(allResult); + Assert.Single(all); + Assert.Equal("Persisted", all[0].GetProperty("title").GetString()); + } + + #endregion + + #region Public Helper Method Tests + + /// + /// Verify that GetAllTodos returns all items after adding via tools. + /// + [Fact] + public async Task PublicGetAllTodos_ReturnsAllItemsAsync() + { + // Arrange + var provider = new TodoProvider(); + 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); + AIFunction addTodos = GetTool(result.Tools!, "AddTodos"); + await addTodos.InvokeAsync(new AIFunctionArguments() + { + ["todos"] = new List { new() { Title = "First", Description = null }, new() { Title = "Second", Description = null } }, + }); + + // Act + var todos = provider.GetAllTodos(session); + + // Assert + Assert.Equal(2, todos.Count); + Assert.Equal("First", todos[0].Title); + Assert.Equal("Second", todos[1].Title); + } + + /// + /// Verify that GetRemainingTodos returns only incomplete items. + /// + [Fact] + public async Task PublicGetRemainingTodos_ReturnsOnlyIncompleteAsync() + { + // Arrange + var provider = new TodoProvider(); + 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); + AIFunction addTodos = GetTool(result.Tools!, "AddTodos"); + AIFunction completeTodos = GetTool(result.Tools!, "CompleteTodos"); + await addTodos.InvokeAsync(new AIFunctionArguments() + { + ["todos"] = new List { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } }, + }); + await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 1 } }); + + // Act + var remaining = provider.GetRemainingTodos(session); + + // Assert + Assert.Single(remaining); + Assert.Equal("Pending", remaining[0].Title); + } + + /// + /// Verify that GetAllTodos returns empty list for a new session. + /// + [Fact] + public void PublicGetAllTodos_ReturnsEmptyForNewSession() + { + // Arrange + var provider = new TodoProvider(); + var session = new ChatClientAgentSession(); + + // Act + var todos = provider.GetAllTodos(session); + + // Assert + Assert.Empty(todos); + } + + #endregion + + #region Helper Methods + + private static async Task<(IEnumerable Tools, TodoState State)> CreateToolsWithStateAsync() + { + var provider = new TodoProvider(); + 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("TodoProvider", out var state, AgentJsonUtilities.DefaultOptions); + + return (result.Tools!, state!); + } + + private static AIFunction GetTool(IEnumerable tools, string name) + { + return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name); + } + + private static int GetIntResult(object? result) + { + var element = Assert.IsType(result); + return element.GetInt32(); + } + + private static List GetArrayResult(object? result) + { + var element = Assert.IsType(result); + return element.EnumerateArray().ToList(); + } + + #endregion +} From b4c853ec1b98dff38ea6148eef1822633a721c32 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:17:59 +0100 Subject: [PATCH 02/13] .NET: Add a ModeProvider for managing agent modes (#5247) * Add a ModeProvider for managing agent modes * Fix typo * Fix typo * Fix typo * Address PR comments --- .../Microsoft.Agents.AI/AgentJsonUtilities.cs | 5 +- .../Harness/AgentMode/AgentModeProvider.cs | 148 ++++++++ .../Harness/AgentMode/AgentModeState.cs | 20 + .../AgentMode/AgentModeProviderTests.cs | 341 ++++++++++++++++++ 4 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeState.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/AgentMode/AgentModeProviderTests.cs 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 +} From 9d89353818b4bbc7b23e1dce06e1b3fe175bb50b Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:58:28 +0100 Subject: [PATCH 03/13] .NET: Add sample to show how to build a harness (#5268) * Add sample to show how to build a harness * Improve sample * Sample max output tokens and model * Fix encoding * Fix model name in readme * Address PR comments --- dotnet/agent-framework-dotnet.slnx | 5 + .../Harness_Shared_Console/HarnessConsole.cs | 246 ++++++++++++++++ .../Harness_Shared_Console.csproj | 14 + .../Harness/Harness_Shared_Console/Spinner.cs | 77 +++++ .../ToolCallFormatter.cs | 251 ++++++++++++++++ .../Harness_Step01_Research.csproj | 20 ++ .../Harness_Step01_Research/Program.cs | 94 ++++++ .../Harness/Harness_Step01_Research/README.md | 52 ++++ .../WebBrowsingTools.cs | 269 ++++++++++++++++++ dotnet/samples/02-agents/Harness/README.md | 9 + dotnet/samples/02-agents/README.md | 1 + 11 files changed, 1038 insertions(+) create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Spinner.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTools.cs create mode 100644 dotnet/samples/02-agents/Harness/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 24b596509e..fe6cd8ff3f 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -110,6 +110,11 @@ + + + + + diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs new file mode 100644 index 0000000000..9ed9f5bb04 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console; + +/// +/// Provides a reusable interactive console loop for running an +/// with streaming output, tool call display, spinner, and mode-aware prompts. +/// +public static class HarnessConsole +{ + /// + /// Runs an interactive console session with the specified agent. + /// Supports streaming output, tool call display, spinner animation, + /// and the /todos command. + /// + /// The agent to interact with. + /// The title displayed in the console header. + /// A short prompt to the user, displayed below the title. + public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt) + { + var todoProvider = agent.GetService(); + var modeProvider = agent.GetService(); + + System.Console.WriteLine($"=== {title} ==="); + System.Console.WriteLine(userPrompt); + System.Console.WriteLine("Commands: /todos (show todo list), /mode [plan|execute] (show or switch mode), exit (quit)"); + System.Console.WriteLine(); + + AgentSession session = await agent.CreateSessionAsync(); + + WritePrompt(modeProvider, session); + string? userInput = System.Console.ReadLine(); + + while (!string.IsNullOrWhiteSpace(userInput) && !userInput.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + if (userInput.Equals("/todos", StringComparison.OrdinalIgnoreCase)) + { + PrintTodos(todoProvider, session); + } + else if (userInput.StartsWith("/mode", StringComparison.OrdinalIgnoreCase)) + { + HandleModeCommand(modeProvider, session, userInput); + } + else + { + await StreamAgentResponseAsync(agent, session, modeProvider, userInput); + } + + WritePrompt(modeProvider, session); + userInput = System.Console.ReadLine(); + } + + System.Console.ResetColor(); + System.Console.WriteLine("Goodbye!"); + } + + private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession session, AgentModeProvider? modeProvider, string userInput) + { + string mode = modeProvider?.GetMode(session) ?? "unknown"; + System.Console.ForegroundColor = GetModeColor(mode); + System.Console.Write($"\n[{mode}] Agent: "); + + var spinner = new Spinner(); + spinner.Start(); + bool hasTextOutput = false; + bool hasReceivedAnyText = false; + + try + { + await foreach (var update in agent.RunStreamingAsync(userInput, session)) + { + foreach (var content in update.Contents) + { + if (content is FunctionCallContent functionCall) + { + await spinner.StopAsync(); + System.Console.ForegroundColor = ConsoleColor.DarkYellow; + System.Console.Write(hasTextOutput ? "\n\n 🔧 Calling tool: " : "\n 🔧 Calling tool: "); + System.Console.Write($"{ToolCallFormatter.Format(functionCall)}..."); + System.Console.ForegroundColor = GetModeColor(mode); + hasTextOutput = false; + spinner.Start(); + } + else if (content is ToolCallContent toolCall) + { + await spinner.StopAsync(); + System.Console.ForegroundColor = ConsoleColor.DarkYellow; + System.Console.Write(hasTextOutput ? "\n\n 🔧 Calling tool: " : "\n 🔧 Calling tool: "); + System.Console.Write($"{toolCall}..."); + System.Console.ForegroundColor = GetModeColor(mode); + hasTextOutput = false; + spinner.Start(); + } + else if (content is ErrorContent errorContent) + { + await spinner.StopAsync(); + System.Console.ForegroundColor = ConsoleColor.Red; + System.Console.Write($"\n ❌ Error: {errorContent.Message}"); + if (errorContent.ErrorCode is not null) + { + System.Console.Write($" (code: {errorContent.ErrorCode})"); + } + + System.Console.ForegroundColor = GetModeColor(mode); + } + } + + if (string.IsNullOrEmpty(update.Text)) + { + continue; + } + + await spinner.StopAsync(); + + if (!hasTextOutput) + { + System.Console.Write("\n"); + hasTextOutput = true; + hasReceivedAnyText = true; + } + + string currentMode = modeProvider?.GetMode(session) ?? "unknown"; + if (currentMode != mode) + { + mode = currentMode; + System.Console.ForegroundColor = GetModeColor(mode); + } + + System.Console.Write(update.Text); + } + } + catch (Exception ex) + { + await spinner.StopAsync(); + System.Console.ForegroundColor = ConsoleColor.Red; + System.Console.Write($"\n ❌ Stream error: {ex.GetType().Name}: {ex.Message}"); + } + + await spinner.StopAsync(); + + if (!hasReceivedAnyText) + { + System.Console.ForegroundColor = ConsoleColor.DarkYellow; + System.Console.Write("\n (no text response from agent)"); + } + + System.Console.ResetColor(); + System.Console.WriteLine(); + System.Console.WriteLine(); + } + + private static void HandleModeCommand(AgentModeProvider? modeProvider, AgentSession session, string input) + { + if (modeProvider is null) + { + System.Console.WriteLine("AgentModeProvider is not available."); + return; + } + + string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length < 2) + { + string current = modeProvider.GetMode(session); + System.Console.WriteLine($"\n Current mode: {current}\n"); + return; + } + + string newMode = parts[1]; + + // Normalize to known mode values for case-insensitive matching. + if (string.Equals(newMode, AgentModeProvider.PlanMode, StringComparison.OrdinalIgnoreCase)) + { + newMode = AgentModeProvider.PlanMode; + } + else if (string.Equals(newMode, AgentModeProvider.ExecuteMode, StringComparison.OrdinalIgnoreCase)) + { + newMode = AgentModeProvider.ExecuteMode; + } + + try + { + modeProvider.SetMode(session, newMode); + System.Console.ForegroundColor = GetModeColor(newMode); + System.Console.WriteLine($"\n Switched to {newMode} mode.\n"); + System.Console.ResetColor(); + } + catch (ArgumentException ex) + { + System.Console.ForegroundColor = ConsoleColor.Red; + System.Console.WriteLine($"\n {ex.Message}\n"); + System.Console.ResetColor(); + } + } + + private static void WritePrompt(AgentModeProvider? modeProvider, AgentSession session) + { + string mode = modeProvider?.GetMode(session) ?? "unknown"; + System.Console.ForegroundColor = GetModeColor(mode); + System.Console.Write($"[{mode}] You: "); + System.Console.ResetColor(); + } + + private static void PrintTodos(TodoProvider? todoProvider, AgentSession session) + { + if (todoProvider is null) + { + System.Console.WriteLine("TodoProvider is not available."); + return; + } + + var todos = todoProvider.GetAllTodos(session); + if (todos.Count == 0) + { + System.Console.WriteLine("\n No todos yet.\n"); + return; + } + + System.Console.WriteLine(); + System.Console.WriteLine(" ── Todo List ──"); + foreach (var item in todos) + { + string status = item.IsComplete ? "✓" : "○"; + System.Console.ForegroundColor = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White; + System.Console.Write($" [{status}] #{item.Id} {item.Title}"); + if (!string.IsNullOrWhiteSpace(item.Description)) + { + System.Console.Write($" — {item.Description}"); + } + + System.Console.WriteLine(); + } + + System.Console.ResetColor(); + System.Console.WriteLine(); + } + + private static ConsoleColor GetModeColor(string mode) => mode switch + { + AgentModeProvider.PlanMode => ConsoleColor.Cyan, + AgentModeProvider.ExecuteMode => ConsoleColor.Green, + _ => ConsoleColor.Gray, + }; +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj new file mode 100644 index 0000000000..7483be77bf --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Spinner.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Spinner.cs new file mode 100644 index 0000000000..336bee0d9d --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Spinner.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Harness.Shared.Console; + +/// +/// A restartable spinner that can be started and stopped multiple times. +/// +internal sealed class Spinner : IDisposable +{ + private static readonly string[] s_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + + private CancellationTokenSource? _cts; + private Task? _task; + + public void Start() + { + if (this._task is not null) + { + return; + } + + this._cts = new CancellationTokenSource(); + this._task = RunAsync(this._cts.Token); + } + + public async Task StopAsync() + { + if (this._cts is null || this._task is null) + { + return; + } + + this._cts.Cancel(); + await this._task; + this._cts.Dispose(); + this._cts = null; + this._task = null; + } + + public void Dispose() + { + if (this._cts is not null && this._task is not null) + { + this._cts.Cancel(); + + // Block briefly to let the spinner task clean up. + // This prevents the background task from writing to the console after disposal. +#pragma warning disable VSTHRD002 // Synchronous wait in Dispose is acceptable here — the spinner task completes quickly on cancellation. + this._task.Wait(); +#pragma warning restore VSTHRD002 + } + + this._cts?.Dispose(); + this._cts = null; + this._task = null; + } + + private static async Task RunAsync(CancellationToken cancellationToken) + { + int i = 0; + try + { + while (!cancellationToken.IsCancellationRequested) + { + System.Console.Write(s_frames[i % s_frames.Length]); + await Task.Delay(80, cancellationToken); + System.Console.Write("\b \b"); + i++; + } + } + catch (OperationCanceledException) + { + // Clear the last spinner frame left on screen. + System.Console.Write("\b \b"); + } + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs new file mode 100644 index 0000000000..9eb9ca5090 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console; + +/// +/// Formats instances into human-readable strings +/// for console display. +/// +public static class ToolCallFormatter +{ + /// + /// Returns a formatted string for the given tool call, with human-readable + /// details for known tools (todos, mode, sub-agents, web tools). + /// + /// The function call content to format. + /// A formatted string describing the tool call. + public static string Format(FunctionCallContent call) + { + string? detail = call.Name switch + { + // Todo tools + "AddTodos" => FormatAddTodos(call), + "CompleteTodos" => FormatIdList(call, "ids", "Complete"), + "RemoveTodos" => FormatIdList(call, "ids", "Remove"), + "GetRemainingTodos" => null, + "GetAllTodos" => null, + + // Mode tools + "SetMode" => FormatStringArg(call, "mode"), + "GetMode" => null, + + // Sub-agent tools + "StartSubTask" => FormatStartSubTask(call), + "WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"), + "GetSubTaskResults" => FormatSingleId(call, "taskId"), + "GetAllTasks" => null, + "ContinueTask" => FormatContinueTask(call), + "ClearCompletedTask" => FormatSingleId(call, "taskId"), + + // External tools + "web_search" => FormatStringArg(call, "query"), + "DownloadUri" => FormatStringArg(call, "uri"), + + _ => FormatFallback(call), + }; + + return detail is not null ? $"{call.Name} {detail}" : call.Name; + } + + private static string? FormatAddTodos(FunctionCallContent call) + { + if (call.Arguments?.TryGetValue("todos", out object? todosObj) != true || todosObj is null) + { + return null; + } + + var titles = new List(); + + if (todosObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in jsonArray.EnumerateArray()) + { + string? title = item.TryGetProperty("title", out JsonElement titleElement) + ? titleElement.GetString() + : null; + + if (!string.IsNullOrEmpty(title)) + { + titles.Add(title); + } + } + } + + if (titles.Count == 0) + { + return null; + } + + var sb = new StringBuilder(); + sb.Append($"({titles.Count} item{(titles.Count == 1 ? "" : "s")})"); + foreach (string title in titles) + { + sb.Append($"\n • {title}"); + } + + return sb.ToString(); + } + + private static string? FormatIdList(FunctionCallContent call, string paramName, string verb) + { + List? ids = GetIntList(call, paramName); + if (ids is null || ids.Count == 0) + { + return null; + } + + return $"({verb} #{string.Join(", #", ids)})"; + } + + private static string? FormatSingleId(FunctionCallContent call, string paramName) + { + int? id = GetInt(call, paramName); + return id.HasValue ? $"(task #{id.Value})" : null; + } + + private static string? FormatStartSubTask(FunctionCallContent call) + { + string? agentName = GetString(call, "agentName"); + string? description = GetString(call, "description"); + + if (agentName is null && description is null) + { + return null; + } + + var sb = new StringBuilder("("); + if (agentName is not null) + { + sb.Append($"agent: {agentName}"); + } + + if (description is not null) + { + if (agentName is not null) + { + sb.Append(", "); + } + + sb.Append($"\"{Truncate(description, 60)}\""); + } + + sb.Append(')'); + return sb.ToString(); + } + + private static string? FormatContinueTask(FunctionCallContent call) + { + int? taskId = GetInt(call, "taskId"); + string? text = GetString(call, "text"); + + if (!taskId.HasValue) + { + return null; + } + + return text is not null + ? $"(task #{taskId.Value}, \"{Truncate(text, 50)}\")" + : $"(task #{taskId.Value})"; + } + + private static string? FormatStringArg(FunctionCallContent call, string paramName) + { + string? value = GetString(call, paramName); + return value is not null ? $"({value})" : null; + } + + private static string? FormatFallback(FunctionCallContent call) + { + if (call.Arguments is null || call.Arguments.Count == 0) + { + return null; + } + + var parts = new List(); + foreach (var kvp in call.Arguments) + { + string? stringValue = kvp.Value switch + { + JsonElement je => je.ValueKind switch + { + JsonValueKind.String => je.GetString(), + JsonValueKind.Number => je.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => null, + }, + not null => kvp.Value.ToString(), + _ => null, + }; + + if (stringValue is not null) + { + parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}"); + } + } + + return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null; + } + + private static string? GetString(FunctionCallContent call, string paramName) + { + if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null) + { + return null; + } + + return value switch + { + JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(), + string s => s, + _ => value.ToString(), + }; + } + + private static int? GetInt(FunctionCallContent call, string paramName) + { + if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null) + { + return null; + } + + return value switch + { + JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(), + int i => i, + _ => int.TryParse(value.ToString(), out int parsed) ? parsed : null, + }; + } + + private static List? GetIntList(FunctionCallContent call, string paramName) + { + if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null) + { + return null; + } + + var result = new List(); + + if (value is JsonElement je && je.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in je.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Number) + { + result.Add(item.GetInt32()); + } + } + } + + return result.Count > 0 ? result : null; + } + + private static string Truncate(string text, int maxLength) + { + return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…"); + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj new file mode 100644 index 0000000000..da636bc25a --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs new file mode 100644 index 0000000000..c0a03448b9 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use a ChatClientAgent with the Harness AIContextProviders +// (TodoProvider and AgentModeProvider) for interactive research tasks with web search +// capabilities powered by Azure AI Foundry. +// The agent plans research tasks, creates a todo list, gets user approval, +// and then executes each step — all within an interactive conversation loop. +// +// Special commands: +// /todos — Display the current todo list without invoking the agent. +// exit — End the session. + +#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage. + +using Azure.AI.Projects; +using Azure.Identity; +using Harness.Shared.Console; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; +using Microsoft.Extensions.AI; +using SampleApp; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4"; + +// Create the Azure AI Project client and get an IChatClient with stored output disabled +// so that chat history is managed locally by the agent framework. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); +IChatClient chatClient = aiProjectClient + .GetProjectOpenAIClient() + .GetProjectResponsesClient() + .AsIChatClient(deploymentName); + +// Create web browsing tools for downloading and converting HTML pages to markdown. +var webBrowsingTools = new WebBrowsingTools(); + +// Create a ChatClientAgent with the Harness providers (TodoProvider and AgentModeProvider) +// and research-focused instructions including the mandatory planning workflow. +var instructions = + """ + You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing. Don't rely on your own knowledge — use the tools available to you to find up-to-date information. + + **Mandatory planning workflow** + + For every new substantive user request, including short factual questions, you must begin in plan mode and follow this sequence: + + 1. Analyze the request. + 2. Ask for clarifications where needed. + 1. When asking for clarification and you have specific options in mind, present them to the user with numbers, so they can respond with the number instead of having to retype the entire response. + 2. Always also allow the user to respond with free-form text in case they want to provide information or context that you didn't specifically ask for. + 3. Create one or more todo items. + 4. Present the plan to the user. + 5. Ask for approval to switch to execute mode and process the plan. + 6. When approval is granted, always switch to execute mode, execute the plan and complete the todos. + + Explain your reasoning and thought process as you work through the tasks. + Explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process. + Don't call many tools in a row without providing some explanation in between to help the user understand what you're doing and why. + Do not answer the underlying question before the plan has been presented and approved. + This rule applies even when the answer seems obvious or the task seems small. + For short requests, use a brief micro-plan rather than skipping planning. + + The only exceptions are: + - greetings, + - pure acknowledgments, + - clarification questions needed to form the plan, + - meta-discussion about the workflow itself. + + When the task is complete, switch back to plan mode for the next request, even if the next request is just a short question. + """; + +AIAgent agent = new ChatClientAgent( + chatClient, + new ChatClientAgentOptions + { + Name = "ResearchAgent", + Description = "A research assistant that plans and executes research tasks.", + AIContextProviders = [new TodoProvider(), new AgentModeProvider()], + ChatOptions = new ChatOptions + { + // Set a high token limit for long research tasks with many tool calls and long outputs. + // This matches gpt-5.4's max output tokens, and should be adjusted depending on the model used and expected response length. + MaxOutputTokens = 128_000, + Instructions = instructions, + Reasoning = new() { Effort = ReasoningEffort.High }, + Tools = [FoundryAITool.CreateWebSearchTool(), .. webBrowsingTools.Tools], + }, + }); + +// Run the interactive console session using the shared HarnessConsole helper. +await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started."); diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md new file mode 100644 index 0000000000..6db0718c0f --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md @@ -0,0 +1,52 @@ +# What this sample demonstrates + +This sample demonstrates how to use a `ChatClientAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Azure AI Foundry. + +Key features showcased: + +- **ChatClientAgent** — configured directly with Harness providers for planning and task management +- **Web Search** — the agent can search the web for current information via `FoundryAITool.CreateWebSearchTool()` +- **TodoProvider** — the agent creates and manages a todo list to track research questions +- **AgentModeProvider** — the agent switches between "plan" mode (breaking down the topic) and "execute" mode (answering each research question) +- **Interactive conversation** — you can review the agent's plan, provide feedback, and approve before execution begins +- **Streaming output** — responses are streamed token-by-token for a natural experience +- **`/todos` command** — view the current todo list at any time without invoking the agent +- **Mode-based coloring** — console output is colored based on the agent's current mode (cyan for plan, green for execute) + +## Prerequisites + +Before running this sample, ensure you have: + +1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`) +2. Azure CLI installed and authenticated (`az login`) + +## Environment Variables + +Set the following environment variables: + +```bash +# Required: Your Azure AI Foundry project endpoint +export AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project-name" + +# Optional: Model deployment name (defaults to gpt-5.4) +export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4" +``` + +## Running the Sample + +```bash +cd dotnet +dotnet run --project samples/02-agents/Harness/Harness_Step01_Research +``` + +## What to Expect + +The sample starts an interactive conversation loop. You can: + +1. **Enter a research topic** — the agent will analyze it and create a plan with todos +2. **Review and adjust** — provide feedback on the plan, ask for changes, or approve it +3. **Type `/todos`** — to see the current todo list at any time +4. **Watch execution** — once approved, tell the agent to proceed and it will work through each todo +5. **Type `exit`** — to end the session + +The prompt and agent output are colored by the current mode: **cyan** during planning, **green** during execution. diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTools.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTools.cs new file mode 100644 index 0000000000..0c7e7d826e --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTools.cs @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using System.Net; +using System.Text.RegularExpressions; +using Microsoft.Extensions.AI; + +namespace SampleApp; + +/// +/// Provides a web browsing tool that downloads HTML pages and converts them to markdown. +/// +internal sealed partial class WebBrowsingTools +{ + private static readonly HttpClient s_httpClient = new(); + + /// + /// Gets the web browsing tools. + /// + public IList Tools { get; } = + [ + AIFunctionFactory.Create(DownloadUriAsync), + ]; + + [Description("Download the html from the given url as markdown")] + private static async Task DownloadUriAsync( + [Description("The URL to download")] string uri, + CancellationToken cancellationToken = default) + { + if (!Uri.TryCreate(uri, UriKind.Absolute, out Uri? parsedUri)) + { + return $"Error: '{uri}' is not a valid URL."; + } + + try + { + string html = await s_httpClient.GetStringAsync(parsedUri, cancellationToken); + return HtmlToMarkdownConverter.Convert(html); + } + catch (HttpRequestException ex) + { + return $"Error downloading {uri}: {ex.Message}"; + } + } + + /// + /// A simple HTML to Markdown converter using regex-based transformations. + /// Handles the most common HTML elements without requiring external dependencies. + /// + private static partial class HtmlToMarkdownConverter + { + public static string Convert(string html) + { + // Extract body content if present, otherwise use the full HTML. + var bodyMatch = BodyRegex().Match(html); + string content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html; + + // Remove script, style, and head blocks. + content = ScriptRegex().Replace(content, string.Empty); + content = StyleRegex().Replace(content, string.Empty); + content = HeadRegex().Replace(content, string.Empty); + content = CommentRegex().Replace(content, string.Empty); + + // Convert block elements before inline elements. + content = ConvertHeadings(content); + content = ConvertCodeBlocks(content); + content = ConvertBlockquotes(content); + content = ConvertLists(content); + content = ConvertHorizontalRules(content); + + // Convert inline elements. + content = ConvertLinks(content); + content = ConvertImages(content); + content = ConvertBold(content); + content = ConvertItalic(content); + content = ConvertInlineCode(content); + + // Convert structural elements. + content = ConvertParagraphs(content); + content = ConvertLineBreaks(content); + + // Strip remaining HTML tags. + content = StripTagsRegex().Replace(content, string.Empty); + + // Decode HTML entities. + content = WebUtility.HtmlDecode(content); + + // Clean up excessive whitespace. + content = ExcessiveNewlinesRegex().Replace(content, "\n\n"); + + return content.Trim(); + } + + private static string ConvertHeadings(string html) + { + html = H1Regex().Replace(html, m => $"\n# {StripInnerTags(m.Groups[1].Value).Trim()}\n"); + html = H2Regex().Replace(html, m => $"\n## {StripInnerTags(m.Groups[1].Value).Trim()}\n"); + html = H3Regex().Replace(html, m => $"\n### {StripInnerTags(m.Groups[1].Value).Trim()}\n"); + html = H4Regex().Replace(html, m => $"\n#### {StripInnerTags(m.Groups[1].Value).Trim()}\n"); + html = H5Regex().Replace(html, m => $"\n##### {StripInnerTags(m.Groups[1].Value).Trim()}\n"); + html = H6Regex().Replace(html, m => $"\n###### {StripInnerTags(m.Groups[1].Value).Trim()}\n"); + return html; + } + + private static string ConvertLinks(string html) => + LinkRegex().Replace(html, m => + { + string href = m.Groups[1].Value; + string text = StripInnerTags(m.Groups[2].Value).Trim(); + + // Skip javascript and data links. + if (href.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) || + href.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + return text; + } + + return string.IsNullOrWhiteSpace(text) ? string.Empty : $"[{text}]({href})"; + }); + + private static string ConvertImages(string html) => + ImageRegex().Replace(html, m => + { + string src = m.Groups[1].Value; + string alt = m.Groups[2].Value; + + // Truncate data URIs. + if (src.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + src = src.Split(',')[0] + "..."; + } + + return $"![{alt}]({src})"; + }); + + private static string ConvertBold(string html) => + BoldRegex().Replace(html, m => $"**{m.Groups[2].Value}**"); + + private static string ConvertItalic(string html) => + ItalicRegex().Replace(html, m => $"*{m.Groups[2].Value}*"); + + private static string ConvertInlineCode(string html) => + InlineCodeRegex().Replace(html, m => $"`{m.Groups[1].Value}`"); + + private static string ConvertCodeBlocks(string html) => + CodeBlockRegex().Replace(html, m => $"\n```\n{StripInnerTags(m.Groups[1].Value).Trim()}\n```\n"); + + private static string ConvertBlockquotes(string html) => + BlockquoteRegex().Replace(html, m => + { + string inner = StripInnerTags(m.Groups[1].Value).Trim(); + // Prefix each line with "> ". + string quoted = string.Join("\n", inner.Split('\n').Select(line => $"> {line.Trim()}")); + return $"\n{quoted}\n"; + }); + + private static string ConvertLists(string html) + { + // Unordered lists. + html = UlRegex().Replace(html, m => + { + string items = LiRegex().Replace(m.Groups[1].Value, li => $"- {StripInnerTags(li.Groups[1].Value).Trim()}\n"); + return $"\n{items}"; + }); + + // Ordered lists. + html = OlRegex().Replace(html, m => + { + int index = 1; + string items = LiRegex().Replace(m.Groups[1].Value, li => $"{index++}. {StripInnerTags(li.Groups[1].Value).Trim()}\n"); + return $"\n{items}"; + }); + + return html; + } + + private static string ConvertHorizontalRules(string html) => + HrRegex().Replace(html, "\n---\n"); + + private static string ConvertParagraphs(string html) => + ParagraphRegex().Replace(html, m => $"\n\n{m.Groups[1].Value}\n\n"); + + private static string ConvertLineBreaks(string html) => + BrRegex().Replace(html, "\n"); + + private static string StripInnerTags(string html) => + StripTagsRegex().Replace(html, string.Empty); + + // Source-generated regex patterns for performance and AOT compatibility. + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex BodyRegex(); + + [GeneratedRegex(@"]*>.*?", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex ScriptRegex(); + + [GeneratedRegex(@"]*>.*?", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex StyleRegex(); + + [GeneratedRegex(@"]*>.*?", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex HeadRegex(); + + [GeneratedRegex(@"", RegexOptions.Singleline)] + private static partial Regex CommentRegex(); + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex H1Regex(); + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex H2Regex(); + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex H3Regex(); + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex H4Regex(); + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex H5Regex(); + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex H6Regex(); + + [GeneratedRegex(@"]*href=[""']([^""']*)[""'][^>]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex LinkRegex(); + + [GeneratedRegex(@"]*src=[""']([^""']*)[""'][^>]*?(?:alt=[""']([^""']*)[""'])?[^>]*/?>", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex ImageRegex(); + + [GeneratedRegex(@"<(strong|b)\b[^>]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex BoldRegex(); + + [GeneratedRegex(@"<(em|i)\b[^>]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex ItalicRegex(); + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex InlineCodeRegex(); + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex CodeBlockRegex(); + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex BlockquoteRegex(); + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex UlRegex(); + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex OlRegex(); + + [GeneratedRegex(@"]*>(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex LiRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase)] + private static partial Regex HrRegex(); + + [GeneratedRegex(@"]*>(.*?)

", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex ParagraphRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase)] + private static partial Regex BrRegex(); + + [GeneratedRegex(@"<[^>]+>")] + private static partial Regex StripTagsRegex(); + + [GeneratedRegex(@"\n{3,}")] + private static partial Regex ExcessiveNewlinesRegex(); + } +} diff --git a/dotnet/samples/02-agents/Harness/README.md b/dotnet/samples/02-agents/Harness/README.md new file mode 100644 index 0000000000..da54967258 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/README.md @@ -0,0 +1,9 @@ +# Harness Agent Samples + +Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Agents.AI/Harness/) — reusable providers that add planning, task management, and mode tracking to any `ChatClientAgent`. + +## Samples + +| Sample | Description | +| --- | --- | +| [Harness_Step01_Research](./Harness_Step01_Research/README.md) | Using a ChatClientAgent with TodoProvider and AgentModeProvider for research, showcasing planning mode and todo management | diff --git a/dotnet/samples/02-agents/README.md b/dotnet/samples/02-agents/README.md index 5ff0db416d..93abd34d17 100644 --- a/dotnet/samples/02-agents/README.md +++ b/dotnet/samples/02-agents/README.md @@ -16,6 +16,7 @@ The getting started samples demonstrate the fundamental concepts and functionali | [Agent With Anthropic](./AgentWithAnthropic/README.md) | Getting started with agents using Anthropic Claude | | [Model Context Protocol](./ModelContextProtocol/README.md) | Getting started with Model Context Protocol | | [Agent Skills](./AgentSkills/README.md) | Getting started with Agent Skills | +| [Agent Harness with built-in tools](./Harness/README.md) | Demonstrating how to build an Agent Harness with built-in planning, todo, and mode management tooling | | [Declarative Agents](./DeclarativeAgents) | Loading and executing AI agents from YAML configuration files | | [AG-UI](./AGUI/README.md) | Getting started with AG-UI (Agent UI Protocol) servers and clients | | [Dev UI](./DevUI/README.md) | Interactive web interface for testing and debugging AI agents during development | From d0ac1d83bcd7925ccf14cf8dc39efd3618904839 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:44:45 +0100 Subject: [PATCH 04/13] .NET: Add context window size compaction strategy for harness (#5304) * Add context window size compaction strategy for harness * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address PR comments --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Harness_Shared_Console/HarnessConsole.cs | 75 +++++- .../Harness_Step01_Research.csproj | 2 +- .../Harness_Step01_Research/Program.cs | 38 ++- .../Harness/Harness_Step01_Research/README.md | 6 +- .../ContextWindowCompactionStrategy.cs | 148 ++++++++++++ .../ContextWindowCompactionStrategyTests.cs | 219 ++++++++++++++++++ 6 files changed, 468 insertions(+), 20 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/ContextWindowCompactionStrategy.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ContextWindowCompactionStrategyTests.cs diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs index 9ed9f5bb04..c47ed3cf25 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs @@ -19,7 +19,9 @@ public static class HarnessConsole /// The agent to interact with. /// The title displayed in the console header. /// A short prompt to the user, displayed below the title. - public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt) + /// Optional max context window size in tokens. When set, usage is displayed as a percentage. + /// Optional max output tokens. Used with to show input/output budget breakdown. + public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, int? maxContextWindowTokens = null, int? maxOutputTokens = null) { var todoProvider = agent.GetService(); var modeProvider = agent.GetService(); @@ -46,7 +48,7 @@ public static async Task RunAgentAsync(AIAgent agent, string title, string userP } else { - await StreamAgentResponseAsync(agent, session, modeProvider, userInput); + await StreamAgentResponseAsync(agent, session, modeProvider, userInput, maxContextWindowTokens, maxOutputTokens); } WritePrompt(modeProvider, session); @@ -57,7 +59,7 @@ public static async Task RunAgentAsync(AIAgent agent, string title, string userP System.Console.WriteLine("Goodbye!"); } - private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession session, AgentModeProvider? modeProvider, string userInput) + private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession session, AgentModeProvider? modeProvider, string userInput, int? maxContextWindowTokens, int? maxOutputTokens) { string mode = modeProvider?.GetMode(session) ?? "unknown"; System.Console.ForegroundColor = GetModeColor(mode); @@ -106,6 +108,37 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s System.Console.ForegroundColor = GetModeColor(mode); } + else if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text)) + { + await spinner.StopAsync(); + + if (!hasTextOutput) + { + System.Console.Write("\n"); + hasTextOutput = true; + hasReceivedAnyText = true; + } + + System.Console.ForegroundColor = ConsoleColor.DarkMagenta; + System.Console.Write(reasoning.Text); + System.Console.ForegroundColor = GetModeColor(mode); + } + else if (content is UsageContent usage) + { + await spinner.StopAsync(); + System.Console.ForegroundColor = ConsoleColor.DarkGray; + System.Console.Write("\n\n 📊 Tokens"); + if (usage.Details is not null) + { + WriteUsageBreakdown(usage.Details, maxContextWindowTokens, maxOutputTokens); + } + else + { + System.Console.Write(" —"); + } + System.Console.ForegroundColor = GetModeColor(mode); + hasTextOutput = false; + } } if (string.IsNullOrEmpty(update.Text)) @@ -136,7 +169,7 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s { await spinner.StopAsync(); System.Console.ForegroundColor = ConsoleColor.Red; - System.Console.Write($"\n ❌ Stream error: {ex.GetType().Name}: {ex.Message}"); + System.Console.Write($"\n ❌ Stream error: {ex.GetType().Name}:\n{ex}"); } await spinner.StopAsync(); @@ -190,7 +223,7 @@ private static void HandleModeCommand(AgentModeProvider? modeProvider, AgentSess catch (ArgumentException ex) { System.Console.ForegroundColor = ConsoleColor.Red; - System.Console.WriteLine($"\n {ex.Message}\n"); + System.Console.WriteLine($"\n {ex}\n"); System.Console.ResetColor(); } } @@ -237,6 +270,38 @@ private static void PrintTodos(TodoProvider? todoProvider, AgentSession session) System.Console.WriteLine(); } + private static void WriteUsageBreakdown(UsageDetails details, int? maxContextWindowTokens, int? maxOutputTokens) + { + int? inputBudget = (maxContextWindowTokens is not null && maxOutputTokens is not null) + ? maxContextWindowTokens.Value - maxOutputTokens.Value + : null; + + System.Console.Write(" — input: "); + WriteTokenCount(details.InputTokenCount, inputBudget); + + System.Console.Write(" | output: "); + WriteTokenCount(details.OutputTokenCount, maxOutputTokens); + + System.Console.Write(" | total: "); + WriteTokenCount(details.TotalTokenCount, maxContextWindowTokens); + } + + private static void WriteTokenCount(long? count, int? budget) + { + if (count is null) + { + System.Console.Write("—"); + return; + } + + System.Console.Write($"{count.Value:N0}"); + if (budget is not null && budget.Value > 0) + { + double pct = (double)count.Value / budget.Value * 100; + System.Console.Write($"/{budget.Value:N0} ({pct:F1}%)"); + } + } + private static ConsoleColor GetModeColor(string mode) => mode switch { AgentModeProvider.PlanMode => ConsoleColor.Cyan, diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj index da636bc25a..b28ff5bf42 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs index c0a03448b9..fdd4d8abd1 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -11,28 +11,40 @@ // exit — End the session. #pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage. +#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments. -using Azure.AI.Projects; +using System.ClientModel.Primitives; using Azure.Identity; using Harness.Shared.Console; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Foundry; +using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Responses; using SampleApp; -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4"; -// Create the Azure AI Project client and get an IChatClient with stored output disabled +// Create a compaction strategy based on the model's context window. +// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens. +// Defaults: tool result eviction at 50% of input budget, truncation at 80%. +var compactionStrategy = new ContextWindowCompactionStrategy( + maxContextWindowTokens: 1_050_000, + maxOutputTokens: 128_000); + +// Create an OpenAIClient that communicates with the Foundry responses service and get an IChatClient with stored output disabled // so that chat history is managed locally by the agent framework. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); -IChatClient chatClient = aiProjectClient - .GetProjectOpenAIClient() - .GetProjectResponsesClient() - .AsIChatClient(deploymentName); +OpenAIClientOptions clientOptions = new() { Endpoint = new Uri(endpoint) }; +IChatClient chatClient = new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions) + .GetResponsesClient() + .AsIChatClientWithStoredOutputDisabled(deploymentName) + .AsBuilder() + .UseAIContextProviders(new CompactionProvider(compactionStrategy)) + .Build(); // Create web browsing tools for downloading and converting HTML pages to markdown. var webBrowsingTools = new WebBrowsingTools(); @@ -79,6 +91,10 @@ This rule applies even when the answer seems obvious or the task seems small. Name = "ResearchAgent", Description = "A research assistant that plans and executes research tasks.", AIContextProviders = [new TodoProvider(), new AgentModeProvider()], + ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions + { + ChatReducer = compactionStrategy.AsChatReducer(), + }), ChatOptions = new ChatOptions { // Set a high token limit for long research tasks with many tool calls and long outputs. @@ -86,9 +102,9 @@ This rule applies even when the answer seems obvious or the task seems small. MaxOutputTokens = 128_000, Instructions = instructions, Reasoning = new() { Effort = ReasoningEffort.High }, - Tools = [FoundryAITool.CreateWebSearchTool(), .. webBrowsingTools.Tools], + Tools = [ResponseTool.CreateWebSearchTool().AsAITool(), .. webBrowsingTools.Tools], }, }); // Run the interactive console session using the shared HarnessConsole helper. -await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started."); +await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started.", maxContextWindowTokens: 1_050_000, maxOutputTokens: 128_000); diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md index 6db0718c0f..270acf409b 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md @@ -5,7 +5,7 @@ This sample demonstrates how to use a `ChatClientAgent` with the Harness `AICont Key features showcased: - **ChatClientAgent** — configured directly with Harness providers for planning and task management -- **Web Search** — the agent can search the web for current information via `FoundryAITool.CreateWebSearchTool()` +- **Web Search** — the agent can search the web for current information via `ResponseTool.CreateWebSearchTool()` - **TodoProvider** — the agent creates and manages a todo list to track research questions - **AgentModeProvider** — the agent switches between "plan" mode (breaking down the topic) and "execute" mode (answering each research question) - **Interactive conversation** — you can review the agent's plan, provide feedback, and approve before execution begins @@ -25,8 +25,8 @@ Before running this sample, ensure you have: Set the following environment variables: ```bash -# Required: Your Azure AI Foundry project endpoint -export AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project-name" +# Required: Your Azure AI Foundry OpenAI endpoint +export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/" # Optional: Model deployment name (defaults to gpt-5.4) export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4" diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ContextWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ContextWindowCompactionStrategy.cs new file mode 100644 index 0000000000..5e177a372d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ContextWindowCompactionStrategy.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A compaction strategy that derives token thresholds from a model's context window size +/// and maximum output tokens, applying a two-phase compaction pipeline: +/// +/// Tool result eviction () — collapses old tool call groups +/// into concise summaries when the token count exceeds the . +/// Truncation () — removes the oldest non-system message groups +/// when the token count exceeds the . +/// +/// +/// +/// +/// The input budget is defined as maxContextWindowTokens - maxOutputTokens, representing +/// the maximum number of tokens available for the conversation input (including system messages, tools, and history). +/// +/// +/// This strategy is a convenience wrapper around that automates +/// threshold calculation from model specifications. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class ContextWindowCompactionStrategy : CompactionStrategy +{ + /// + /// The default fraction of the input budget at which tool result eviction triggers. + /// + public const double DefaultToolEvictionThreshold = 0.5; + + /// + /// The default fraction of the input budget at which truncation triggers. + /// + public const double DefaultTruncationThreshold = 0.8; + + private readonly PipelineCompactionStrategy _pipeline; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4). + /// + /// + /// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4). + /// + /// + /// The fraction of the input budget (0.0, 1.0] at which tool result eviction triggers. + /// Defaults to (0.5). + /// + /// + /// The fraction of the input budget (0.0, 1.0] at which truncation triggers. + /// Defaults to (0.8). + /// Must be greater than or equal to . + /// + /// + /// is not positive, or + /// is negative or greater than or equal to , or + /// or is not in (0.0, 1.0], or + /// is less than . + /// + public ContextWindowCompactionStrategy( + int maxContextWindowTokens, + int maxOutputTokens, + double toolEvictionThreshold = DefaultToolEvictionThreshold, + double truncationThreshold = DefaultTruncationThreshold) + : base(CompactionTriggers.Always) + { + Throw.IfLessThanOrEqual(maxContextWindowTokens, 0); + Throw.IfLessThan(maxOutputTokens, 0); + Throw.IfGreaterThanOrEqual(maxOutputTokens, maxContextWindowTokens); + + ValidateThreshold(toolEvictionThreshold, nameof(toolEvictionThreshold)); + ValidateThreshold(truncationThreshold, nameof(truncationThreshold)); + + if (truncationThreshold < toolEvictionThreshold) + { + throw new ArgumentOutOfRangeException(nameof(truncationThreshold), truncationThreshold, + $"Truncation threshold ({truncationThreshold}) must be greater than or equal to tool eviction threshold ({toolEvictionThreshold})."); + } + + this.MaxContextWindowTokens = maxContextWindowTokens; + this.MaxOutputTokens = maxOutputTokens; + this.InputBudgetTokens = maxContextWindowTokens - maxOutputTokens; + this.ToolEvictionThreshold = toolEvictionThreshold; + this.TruncationThreshold = truncationThreshold; + + int toolEvictionTokens = (int)(this.InputBudgetTokens * toolEvictionThreshold); + int truncationTokens = (int)(this.InputBudgetTokens * truncationThreshold); + + this._pipeline = new PipelineCompactionStrategy( + new ToolResultCompactionStrategy( + trigger: CompactionTriggers.TokensExceed(toolEvictionTokens), + minimumPreservedGroups: 2), + new TruncationCompactionStrategy( + trigger: CompactionTriggers.TokensExceed(truncationTokens), + minimumPreservedGroups: 2)); + } + + /// + /// Gets the maximum context window size in tokens. + /// + public int MaxContextWindowTokens { get; } + + /// + /// Gets the maximum output tokens per response. + /// + public int MaxOutputTokens { get; } + + /// + /// Gets the computed input budget in tokens ( minus ). + /// + public int InputBudgetTokens { get; } + + /// + /// Gets the fraction of the input budget at which tool result eviction triggers. + /// + public double ToolEvictionThreshold { get; } + + /// + /// Gets the fraction of the input budget at which truncation triggers. + /// + public double TruncationThreshold { get; } + + /// + protected override async ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken) + { + return await this._pipeline.CompactAsync(index, logger, cancellationToken).ConfigureAwait(false); + } + + private static void ValidateThreshold(double value, string paramName) + { + if (value is <= 0.0 or > 1.0) + { + throw new ArgumentOutOfRangeException(paramName, value, "Threshold must be in the range (0.0, 1.0]."); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ContextWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ContextWindowCompactionStrategyTests.cs new file mode 100644 index 0000000000..af74110b6a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ContextWindowCompactionStrategyTests.cs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.Compaction; + +/// +/// Contains tests for the class. +/// +public class ContextWindowCompactionStrategyTests +{ + [Fact] + public void Constructor_ValidParameters_SetsProperties() + { + // Arrange & Act + var strategy = new ContextWindowCompactionStrategy( + maxContextWindowTokens: 1_050_000, + maxOutputTokens: 128_000); + + // Assert + Assert.Equal(1_050_000, strategy.MaxContextWindowTokens); + Assert.Equal(128_000, strategy.MaxOutputTokens); + Assert.Equal(922_000, strategy.InputBudgetTokens); + Assert.Equal(ContextWindowCompactionStrategy.DefaultToolEvictionThreshold, strategy.ToolEvictionThreshold); + Assert.Equal(ContextWindowCompactionStrategy.DefaultTruncationThreshold, strategy.TruncationThreshold); + } + + [Fact] + public void Constructor_CustomThresholds_SetsProperties() + { + // Arrange & Act + var strategy = new ContextWindowCompactionStrategy( + maxContextWindowTokens: 1_000_000, + maxOutputTokens: 100_000, + toolEvictionThreshold: 0.3, + truncationThreshold: 0.6); + + // Assert + Assert.Equal(900_000, strategy.InputBudgetTokens); + Assert.Equal(0.3, strategy.ToolEvictionThreshold); + Assert.Equal(0.6, strategy.TruncationThreshold); + } + + [Theory] + [InlineData(0, 100)] // maxContextWindowTokens <= 0 + [InlineData(-1, 100)] // maxContextWindowTokens negative + public void Constructor_InvalidContextWindow_Throws(int contextWindow, int maxOutput) + { + // Act & Assert + Assert.Throws(() => + new ContextWindowCompactionStrategy(contextWindow, maxOutput)); + } + + [Theory] + [InlineData(1000, -1)] // maxOutputTokens negative + [InlineData(1000, 1000)] // maxOutputTokens == contextWindow + [InlineData(1000, 1001)] // maxOutputTokens > contextWindow + public void Constructor_InvalidOutputTokens_Throws(int contextWindow, int maxOutput) + { + // Act & Assert + Assert.Throws(() => + new ContextWindowCompactionStrategy(contextWindow, maxOutput)); + } + + [Theory] + [InlineData(0.0)] // Zero threshold + [InlineData(-0.1)] // Negative threshold + [InlineData(1.1)] // Over 1.0 + public void Constructor_InvalidToolEvictionThreshold_Throws(double threshold) + { + // Act & Assert + Assert.Throws(() => + new ContextWindowCompactionStrategy(1000, 100, toolEvictionThreshold: threshold)); + } + + [Theory] + [InlineData(0.0)] // Zero threshold + [InlineData(-0.1)] // Negative threshold + [InlineData(1.1)] // Over 1.0 + public void Constructor_InvalidTruncationThreshold_Throws(double threshold) + { + // Act & Assert + Assert.Throws(() => + new ContextWindowCompactionStrategy(1000, 100, truncationThreshold: threshold)); + } + + [Fact] + public void Constructor_TruncationBelowToolEviction_Throws() + { + // Act & Assert + Assert.Throws(() => + new ContextWindowCompactionStrategy(1000, 100, toolEvictionThreshold: 0.8, truncationThreshold: 0.5)); + } + + [Fact] + public async Task CompactAsync_BelowToolEvictionThreshold_NoCompactionAsync() + { + // Arrange — input budget = 900 tokens, tool eviction at 450, truncation at 720 + // A few short messages should be well below any threshold. + var strategy = new ContextWindowCompactionStrategy( + maxContextWindowTokens: 1000, + maxOutputTokens: 100); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi there!"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.False(result); + Assert.Equal(2, index.IncludedGroupCount); + } + + [Fact] + public async Task CompactAsync_AboveTruncationThreshold_TruncatesOldestAsync() + { + // Arrange — use a budget of 5 tokens with truncation at 80% = 4 token threshold. + // Even the shortest messages will exceed this, ensuring truncation fires. + var strategy = new ContextWindowCompactionStrategy( + maxContextWindowTokens: 10, + maxOutputTokens: 5, + toolEvictionThreshold: 0.5, + truncationThreshold: 0.8); + + // Verify internal budget calculation + Assert.Equal(5, strategy.InputBudgetTokens); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "First user message"), + new ChatMessage(ChatRole.Assistant, "First response"), + new ChatMessage(ChatRole.User, "Second user message"), + new ChatMessage(ChatRole.Assistant, "Second response"), + ]); + + int groupsBefore = index.IncludedGroupCount; + int tokensBefore = index.IncludedTokenCount; + + // Verify tokens actually exceed the truncation threshold (80% of 5 = 4) + Assert.True(tokensBefore > 4, $"Expected tokens > 4 but got {tokensBefore}"); + Assert.True(groupsBefore > 1, $"Expected groups > 1 but got {groupsBefore}"); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — with tokens well above a 4-token threshold, truncation should fire + Assert.True(result, $"Expected compaction to occur. Tokens before: {tokensBefore}, groups before: {groupsBefore}, NonSystemGroups: {index.IncludedNonSystemGroupCount}"); + Assert.True(index.IncludedGroupCount < groupsBefore); + } + + [Fact] + public async Task CompactAsync_ToolCallsAboveEvictionThreshold_CollapsesToolCallsAsync() + { + // Arrange — very small budget so tool eviction fires. + // Input budget = 5, tool eviction at 50% = 2 token threshold. + var strategy = new ContextWindowCompactionStrategy( + maxContextWindowTokens: 10, + maxOutputTokens: 5, + toolEvictionThreshold: 0.5, + truncationThreshold: 0.9); + + // Build messages with a tool call group: assistant with FunctionCallContent + tool result + var assistantMessage = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_data", arguments: new Dictionary { ["query"] = "test" })]); + var toolResultMessage = new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Here is a long result with many words to ensure we exceed the token threshold")]); + var userMessage = new ChatMessage(ChatRole.User, "What did you find?"); + var assistantResponse = new ChatMessage(ChatRole.Assistant, "Based on the results I found information."); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + assistantMessage, + toolResultMessage, + userMessage, + assistantResponse, + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — compaction should succeed for tool calls above the eviction threshold. + // Do not assert on IncludedTokenCount because tool-result compaction preserves content + // in summary form and tokenization can make the count stay the same or increase. + Assert.True(result); + } + + [Fact] + public void Constructor_EqualThresholds_Succeeds() + { + // Arrange & Act — truncation == tool eviction should be valid + var strategy = new ContextWindowCompactionStrategy( + maxContextWindowTokens: 1000, + maxOutputTokens: 100, + toolEvictionThreshold: 0.7, + truncationThreshold: 0.7); + + // Assert + Assert.Equal(0.7, strategy.ToolEvictionThreshold); + Assert.Equal(0.7, strategy.TruncationThreshold); + } + + [Fact] + public void Constructor_ZeroMaxOutputTokens_FullBudget() + { + // Arrange & Act + var strategy = new ContextWindowCompactionStrategy( + maxContextWindowTokens: 1_000_000, + maxOutputTokens: 0); + + // Assert — entire context window is the input budget + Assert.Equal(1_000_000, strategy.InputBudgetTokens); + } +} From 8dca006edd986e38ddb83c005da5ddc226c49f6c Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:34:31 +0100 Subject: [PATCH 05/13] .NET: Add a file memory provider (#5315) * Add a file memory provider * Address PR comments * Fix review comments. * Add additional unit tests * Addressing PR comments. --- dotnet/Directory.Packages.props | 1 + .../ToolCallFormatter.cs | 37 ++ .../Harness_Step01_Research/Program.cs | 2 +- .../Microsoft.Agents.AI/AgentJsonUtilities.cs | 9 + .../Harness/FileMemory/AgentFileStore.cs | 128 ++++ .../Harness/FileMemory/FileListEntry.cs | 27 + .../Harness/FileMemory/FileMemoryProvider.cs | 303 ++++++++++ .../Harness/FileMemory/FileMemoryState.cs | 21 + .../Harness/FileMemory/FileSearchMatch.cs | 26 + .../Harness/FileMemory/FileSearchResult.cs | 33 ++ .../FileMemory/InMemoryAgentFileStore.cs | 188 ++++++ .../Microsoft.Agents.AI.csproj | 1 + .../FileMemory/FileMemoryProviderTests.cs | 555 ++++++++++++++++++ .../FileMemory/InMemoryAgentFileStoreTests.cs | 523 +++++++++++++++++ 14 files changed, 1853 insertions(+), 1 deletion(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/AgentFileStore.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileListEntry.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryState.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchMatch.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchResult.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/InMemoryAgentFileStore.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/InMemoryAgentFileStoreTests.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 4e32c2198f..4881564407 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -75,6 +75,7 @@ + diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs index 9eb9ca5090..e3430ce10d 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs @@ -41,6 +41,13 @@ public static string Format(FunctionCallContent call) "ContinueTask" => FormatContinueTask(call), "ClearCompletedTask" => FormatSingleId(call, "taskId"), + // File memory tools + "FileMemory_SaveFile" => FormatSaveFile(call), + "FileMemory_ReadFile" => FormatStringArg(call, "fileName"), + "FileMemory_DeleteFile" => FormatStringArg(call, "fileName"), + "FileMemory_ListFiles" => null, + "FileMemory_SearchFiles" => FormatSearchFiles(call), + // External tools "web_search" => FormatStringArg(call, "query"), "DownloadUri" => FormatStringArg(call, "uri"), @@ -152,6 +159,36 @@ public static string Format(FunctionCallContent call) : $"(task #{taskId.Value})"; } + private static string? FormatSaveFile(FunctionCallContent call) + { + string? fileName = GetString(call, "fileName"); + string? description = GetString(call, "description"); + + if (fileName is null) + { + return null; + } + + return string.IsNullOrEmpty(description) + ? $"({fileName})" + : $"({fileName}, with description)"; + } + + private static string? FormatSearchFiles(FunctionCallContent call) + { + string? pattern = GetString(call, "regexPattern"); + string? filePattern = GetString(call, "filePattern"); + + if (pattern is null) + { + return null; + } + + return string.IsNullOrEmpty(filePattern) + ? $"(/{pattern}/)" + : $"(/{pattern}/ in {filePattern})"; + } + private static string? FormatStringArg(FunctionCallContent call, string paramName) { string? value = GetString(call, paramName); diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs index fdd4d8abd1..3494b986c9 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -90,7 +90,7 @@ This rule applies even when the answer seems obvious or the task seems small. { Name = "ResearchAgent", Description = "A research assistant that plans and executes research tasks.", - AIContextProviders = [new TodoProvider(), new AgentModeProvider()], + AIContextProviders = [new TodoProvider(), new AgentModeProvider(), new FileMemoryProvider(new InMemoryAgentFileStore())], ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions { ChatReducer = compactionStrategy.AsChatReducer(), diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index 04f0bcdd03..cbcffcc679 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -81,6 +81,15 @@ private static JsonSerializerOptions CreateDefaultOptions() // AgentModeProvider types [JsonSerializable(typeof(AgentModeState))] + // FileMemoryProvider types + [JsonSerializable(typeof(FileMemoryState))] + [JsonSerializable(typeof(FileSearchResult))] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "FileSearchResultList")] + [JsonSerializable(typeof(FileSearchMatch))] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "FileSearchMatchList")] + [JsonSerializable(typeof(FileListEntry))] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "FileListEntryList")] + [ExcludeFromCodeCoverage] internal sealed partial class JsonContext : JsonSerializerContext; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/AgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/AgentFileStore.cs new file mode 100644 index 0000000000..ad8bbe4390 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/AgentFileStore.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Provides an abstract base class for file storage operations. +/// +/// +/// +/// All paths are relative to an implementation-defined root. Implementations may map these +/// paths to a local file system, in-memory store, remote blob storage, or other mechanisms. +/// +/// +/// Paths use forward slashes as separators and must not escape the root (e.g., via .. segments). +/// It is up to each implementation to ensure that this is enforced. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentFileStore +{ + /// + /// Writes content to a file, creating or overwriting it. + /// + /// The relative path of the file to write. + /// The content to write to the file. + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + public abstract Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default); + + /// + /// Reads the content of a file. + /// + /// The relative path of the file to read. + /// A token to cancel the operation. + /// The file content, or if the file does not exist. + public abstract Task ReadFileAsync(string path, CancellationToken cancellationToken = default); + + /// + /// Deletes a file. + /// + /// The relative path of the file to delete. + /// A token to cancel the operation. + /// if the file was deleted; if it did not exist. + public abstract Task DeleteFileAsync(string path, CancellationToken cancellationToken = default); + + /// + /// Lists files in a directory. + /// + /// The relative path of the directory to list. Use an empty string for the root. + /// A token to cancel the operation. + /// A list of file names in the specified directory (direct children only). + public abstract Task> ListFilesAsync(string directory, CancellationToken cancellationToken = default); + + /// + /// Checks whether a file exists. + /// + /// The relative path of the file to check. + /// A token to cancel the operation. + /// if the file exists; otherwise, . + public abstract Task FileExistsAsync(string path, CancellationToken cancellationToken = default); + + /// + /// Searches for files whose content matches a regular expression pattern. + /// + /// The relative path of the directory to search. Use an empty string for the root. + /// + /// A regular expression pattern to match against file contents. The pattern is matched case-insensitively. + /// For example, "error|warning" matches lines containing "error" or "warning". + /// + /// + /// An optional glob pattern to filter which files are searched (e.g., "*.md", "research*"). + /// When , all files in the directory are searched. + /// Uses standard glob syntax from . + /// + /// A token to cancel the operation. + /// A list of search results with matching file names, snippets, and matching lines. + public abstract Task> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default); + + /// + /// Ensures a directory exists, creating it if necessary. + /// + /// The relative path of the directory to create. + /// A token to cancel the operation. + /// A task representing the asynchronous operation. + public abstract Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default); + + /// + /// Creates a for the specified glob pattern. Use the returned instance + /// to test multiple file names without allocating a new matcher for each one. + /// + /// + /// The glob pattern to match against (e.g., "*.md", "research*"). + /// + /// A configured with the specified pattern. + protected static Matcher CreateGlobMatcher(string filePattern) + { + var matcher = new Matcher(System.StringComparison.OrdinalIgnoreCase); + matcher.AddInclude(filePattern); + return matcher; + } + + /// + /// Determines whether a file name matches a pre-built glob . + /// + /// The file name to test (not a full path — just the name). + /// + /// A pre-built to test against. + /// When , this method returns for any file name. + /// + /// if the file name matches the pattern or if the matcher is ; otherwise, . + protected static bool MatchesGlob(string fileName, Matcher? matcher) + { + if (matcher is null) + { + return true; + } + + PatternMatchingResult result = matcher.Match(fileName); + return result.HasMatches; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileListEntry.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileListEntry.cs new file mode 100644 index 0000000000..430b437516 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileListEntry.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents a file entry returned by the list files tool, +/// containing the file name and an optional description. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileListEntry +{ + /// + /// Gets or sets the name of the file. + /// + [JsonPropertyName("fileName")] + public string FileName { get; set; } = string.Empty; + + /// + /// Gets or sets the description of the file, or if no description is available. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs new file mode 100644 index 0000000000..664ea7ed11 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs @@ -0,0 +1,303 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An that provides file-based memory tools to an agent +/// for storing, retrieving, modifying, listing, deleting, and searching files. +/// +/// +/// +/// The enables agents to persist information across interactions +/// using a file-based storage model. Each memory is stored as an individual file with a meaningful name. +/// For large files, a companion description file (suffixed with _description.md) can be stored +/// alongside the main file to provide a summary. +/// +/// +/// File access is mediated through a abstraction, allowing pluggable +/// backends (in-memory, local file system, remote blob storage, etc.). +/// +/// +/// This provider exposes the following tools to the agent: +/// +/// SaveFile — Save a memory file with the given name, content, and an optional description. +/// ReadFile — Read the content of a file by name. +/// DeleteFile — Delete a file by name. +/// ListFiles — List all files with their descriptions (if available). +/// SearchFiles — Search file contents using a regular expression pattern. +/// +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileMemoryProvider : AIContextProvider +{ + private const string DescriptionSuffix = "_description.md"; + + private const string DefaultInstructions = + """ + You have access to a file-based memory system via the FileMemory_* tools for storing and retrieving information across interactions. + Use FileMemory_SaveFile to store one memory per file with a clear, descriptive file name (e.g., "projectarchitecture.md", "userpreferences.md"). + For large files, include a description when saving to provide a summary that helps with discovery. + Before starting new tasks, use FileMemory_ListFiles and FileMemory_SearchFiles to check for relevant existing memories. + Use FileMemory_ReadFile to retrieve file contents and FileMemory_DeleteFile to remove outdated memories. + Keep memories up-to-date by overwriting files when information changes. + When you receive large amounts of data (e.g., downloaded web pages, API responses, research results), + save them to files if they will be required later, so that they are not lost when older context is compacted or truncated. + This ensures important data remains accessible across long-running sessions. + """; + + private readonly AgentFileStore _fileStore; + private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; + private AITool[]? _tools; + + /// + /// Initializes a new instance of the class. + /// + /// The file store implementation used for storage operations. + /// + /// An optional function that initializes the for a new session. + /// Use this to customize the working folder (e.g., per-user or per-session subfolders). + /// When , the default initializer creates state with an empty working folder. + /// + /// Thrown when is . + public FileMemoryProvider(AgentFileStore fileStore, Func? stateInitializer = null) + { + Throw.IfNull(fileStore); + + this._fileStore = fileStore; + this._sessionState = new ProviderSessionState( + stateInitializer ?? (_ => new FileMemoryState()), + this.GetType().Name, + AgentJsonUtilities.DefaultOptions); + } + + /// + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; + + /// + protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + FileMemoryState state = this._sessionState.GetOrInitializeState(context.Session); + + // Ensure the working folder exists in the store. + if (!string.IsNullOrEmpty(state.WorkingFolder)) + { + await this._fileStore.CreateDirectoryAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false); + } + + return new AIContext + { + Instructions = DefaultInstructions, + Tools = this._tools ??= this.CreateTools(), + }; + } + + /// + /// Save a memory file with the given name and content. + /// Overwrites the file if it already exists. + /// Include a description for large files to provide a summary that helps with discovery. + /// + /// The name of the file to save. + /// The content to write to the file. + /// An optional description of the file contents for discovery. Leave empty or omit to skip. + /// A token to cancel the operation. + /// A confirmation message. + [Description("Save a memory file with the given name and content. Overwrites the file if it already exists. Include a description for large files to provide a summary that helps with discovery.")] + private async Task SaveFileAsync(string fileName, string content, string? description = null, CancellationToken cancellationToken = default) + { + FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); + string path = ResolvePath(state.WorkingFolder, fileName); + await this._fileStore.WriteFileAsync(path, content, cancellationToken).ConfigureAwait(false); + + string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName)); + + if (!string.IsNullOrWhiteSpace(description)) + { + await this._fileStore.WriteFileAsync(descPath, description, cancellationToken).ConfigureAwait(false); + } + else + { + // Remove any stale description file when no description is provided. + await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false); + } + + return string.IsNullOrWhiteSpace(description) + ? $"File '{fileName}' saved." + : $"File '{fileName}' saved with description."; + } + + /// + /// Read the content of a memory file by name. + /// Returns the file content or a message indicating the file was not found. + /// + /// The name of the file to read. + /// A token to cancel the operation. + /// The file content or a not-found message. + [Description("Read the content of a memory file by name. Returns the file content or a message indicating the file was not found.")] + private async Task ReadFileAsync(string fileName, CancellationToken cancellationToken = default) + { + FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); + string path = ResolvePath(state.WorkingFolder, fileName); + string? content = await this._fileStore.ReadFileAsync(path, cancellationToken).ConfigureAwait(false); + return content ?? $"File '{fileName}' not found."; + } + + /// + /// Delete a memory file by name. Also removes its companion description file if one exists. + /// + /// The name of the file to delete. + /// A token to cancel the operation. + /// A confirmation or not-found message. + [Description("Delete a memory file by name. Also removes its companion description file if one exists.")] + private async Task DeleteFileAsync(string fileName, CancellationToken cancellationToken = default) + { + FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); + string path = ResolvePath(state.WorkingFolder, fileName); + bool deleted = await this._fileStore.DeleteFileAsync(path, cancellationToken).ConfigureAwait(false); + + // Also delete companion description file if it exists. + string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName)); + await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false); + + return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found."; + } + + /// + /// List all memory files with their descriptions (if available). Description files are not shown separately. + /// + /// A token to cancel the operation. + /// A list of file entries with names and optional descriptions. + [Description("List all memory files with their descriptions (if available). Description files are not shown separately.")] + private async Task> ListFilesAsync(CancellationToken cancellationToken = default) + { + FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); + IReadOnlyList fileNames = await this._fileStore.ListFilesAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false); + + var descriptionFileSet = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string file in fileNames) + { + if (file.EndsWith(DescriptionSuffix, StringComparison.OrdinalIgnoreCase)) + { + descriptionFileSet.Add(file); + } + } + + var entries = new List(); + foreach (string file in fileNames) + { + if (descriptionFileSet.Contains(file)) + { + continue; + } + + string? fileDescription = null; + string descFileName = GetDescriptionFileName(file); + + if (descriptionFileSet.Contains(descFileName)) + { + string descPath = CombinePaths(state.WorkingFolder, descFileName); + fileDescription = await this._fileStore.ReadFileAsync(descPath, cancellationToken).ConfigureAwait(false); + } + + entries.Add(new FileListEntry { FileName = file, Description = fileDescription }); + } + + return entries; + } + + /// + /// Search memory file contents using a regular expression pattern (case-insensitive). + /// Optionally filter which files to search using a glob pattern. + /// Returns matching file names, content snippets, and matching lines with line numbers. + /// + /// A regular expression pattern to match against file contents (case-insensitive). + /// An optional glob pattern to filter which files to search (e.g., "*.md", "research*"). Leave empty or omit to search all files. + /// A token to cancel the operation. + /// A list of search results with matching file names, snippets, and matching lines. + [Description("Search memory file contents using a regular expression pattern (case-insensitive). Optionally filter which files to search using a glob pattern (e.g., \"*.md\", \"research*\"). Returns matching file names, content snippets, and matching lines with line numbers.")] + private async Task> SearchFilesAsync(string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default) + { + FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); + string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern; + IReadOnlyList results = await this._fileStore.SearchFilesAsync(state.WorkingFolder, regexPattern, pattern, cancellationToken).ConfigureAwait(false); + return new List(results); + } + + private AITool[] CreateTools() + { + var serializerOptions = AgentJsonUtilities.DefaultOptions; + + return + [ + AIFunctionFactory.Create(this.SaveFileAsync, new AIFunctionFactoryOptions { Name = "FileMemory_SaveFile", SerializerOptions = serializerOptions }), + AIFunctionFactory.Create(this.ReadFileAsync, new AIFunctionFactoryOptions { Name = "FileMemory_ReadFile", SerializerOptions = serializerOptions }), + AIFunctionFactory.Create(this.DeleteFileAsync, new AIFunctionFactoryOptions { Name = "FileMemory_DeleteFile", SerializerOptions = serializerOptions }), + AIFunctionFactory.Create(this.ListFilesAsync, new AIFunctionFactoryOptions { Name = "FileMemory_ListFiles", SerializerOptions = serializerOptions }), + AIFunctionFactory.Create(this.SearchFilesAsync, new AIFunctionFactoryOptions { Name = "FileMemory_SearchFiles", SerializerOptions = serializerOptions }), + ]; + } + + private static string GetDescriptionFileName(string fileName) + { + int extIndex = fileName.LastIndexOf('.'); + if (extIndex > 0) + { +#pragma warning disable CA1845 // Use span-based 'string.Concat' — not available on all target frameworks + return fileName.Substring(0, extIndex) + DescriptionSuffix; +#pragma warning restore CA1845 + } + + return fileName + DescriptionSuffix; + } + + private static string ResolvePath(string workingFolder, string fileName) + { + // Prevent path traversal by rejecting rooted paths and '.'/'..' segments. + string normalized = fileName.Replace('\\', '/'); + + if (Path.IsPathRooted(fileName) || + fileName.StartsWith("/", StringComparison.Ordinal) || + fileName.StartsWith("\\", StringComparison.Ordinal) || + (normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':')) + { + throw new ArgumentException($"Invalid file name: '{fileName}'. File names must be relative and must not start with '/', '\\', or a drive root.", nameof(fileName)); + } + + foreach (string segment in normalized.Split('/')) + { + if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal)) + { + throw new ArgumentException($"Invalid file name: '{fileName}'. File names must not contain '.' or '..' segments.", nameof(fileName)); + } + } + + return CombinePaths(workingFolder, fileName); + } + + private static string CombinePaths(string basePath, string relativePath) + { + if (string.IsNullOrEmpty(basePath)) + { + return relativePath; + } + + if (string.IsNullOrEmpty(relativePath)) + { + return basePath; + } + + return basePath.TrimEnd('/') + "/" + relativePath.TrimStart('/'); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryState.cs new file mode 100644 index 0000000000..fc32da0c7b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryState.cs @@ -0,0 +1,21 @@ +// 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 , +/// stored in the session's . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileMemoryState +{ + /// + /// Gets or sets the working folder path for this session, relative to the store root. + /// + [JsonPropertyName("workingFolder")] + public string WorkingFolder { get; set; } = string.Empty; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchMatch.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchMatch.cs new file mode 100644 index 0000000000..0bf2d102d3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchMatch.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents a match found within a file during a search operation. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileSearchMatch +{ + /// + /// Gets or sets the 1-based line number where the match was found. + /// + [JsonPropertyName("lineNumber")] + public int LineNumber { get; set; } + + /// + /// Gets or sets the content of the matching line. + /// + [JsonPropertyName("line")] + public string Line { get; set; } = string.Empty; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchResult.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchResult.cs new file mode 100644 index 0000000000..162bb36e73 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchResult.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents a result from searching files, containing the file name, a content snippet, and matching lines. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileSearchResult +{ + /// + /// Gets or sets the name of the file that matched the search. + /// + [JsonPropertyName("fileName")] + public string FileName { get; set; } = string.Empty; + + /// + /// Gets or sets a snippet of content from the file around the first match. + /// + [JsonPropertyName("snippet")] + public string Snippet { get; set; } = string.Empty; + + /// + /// Gets or sets the lines where matches were found. + /// + [JsonPropertyName("matchingLines")] + public List MatchingLines { get; set; } = []; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/InMemoryAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/InMemoryAgentFileStore.cs new file mode 100644 index 0000000000..670ef992ab --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/InMemoryAgentFileStore.cs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// An in-memory implementation of that stores files in a dictionary. +/// +/// +/// This implementation is suitable for testing and lightweight scenarios where persistence is not required. +/// Directory concepts are simulated using path prefixes — no explicit directory structure is maintained. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class InMemoryAgentFileStore : AgentFileStore +{ + private readonly ConcurrentDictionary _files = new(StringComparer.OrdinalIgnoreCase); + + /// + public override Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default) + { + path = NormalizePath(path); + this._files[path] = content; + return Task.CompletedTask; + } + + /// + public override Task ReadFileAsync(string path, CancellationToken cancellationToken = default) + { + path = NormalizePath(path); + this._files.TryGetValue(path, out string? content); + return Task.FromResult(content); + } + + /// + public override Task DeleteFileAsync(string path, CancellationToken cancellationToken = default) + { + path = NormalizePath(path); + return Task.FromResult(this._files.TryRemove(path, out _)); + } + + /// + public override Task> ListFilesAsync(string directory, CancellationToken cancellationToken = default) + { + string prefix = NormalizePath(directory); + if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal)) + { + prefix += "/"; + } + + var files = this._files.Keys + .Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Select(k => k.Substring(prefix.Length)) + .Where(k => k.IndexOf("/", StringComparison.Ordinal) < 0) + .ToList(); + + return Task.FromResult>(files); + } + + /// + public override Task FileExistsAsync(string path, CancellationToken cancellationToken = default) + { + path = NormalizePath(path); + return Task.FromResult(this._files.ContainsKey(path)); + } + + /// + public override Task> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default) + { + // Normalize the directory prefix for path matching. + string prefix = NormalizePath(directory); + if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal)) + { + prefix += "/"; + } + + // Compile the regex with a timeout to guard against catastrophic backtracking (ReDoS). + var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5)); + Matcher? matcher = filePattern is not null ? CreateGlobMatcher(filePattern) : null; + var results = new List(); + + foreach (var kvp in this._files) + { + // Only consider files within the target directory (by path prefix). + if (!kvp.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // Exclude files in subdirectories (direct children only). + string relativeName = kvp.Key.Substring(prefix.Length); + if (relativeName.IndexOf("/", StringComparison.Ordinal) >= 0) + { + continue; + } + + // Apply the optional glob filter on the file name. + if (!MatchesGlob(relativeName, matcher)) + { + continue; + } + + // Search each line for regex matches, tracking line numbers and building a snippet. + string fileContent = kvp.Value; + string[] lines = fileContent.Split('\n'); + var matchingLines = new List(); + string? firstSnippet = null; + int lineStartOffset = 0; + + for (int i = 0; i < lines.Length; i++) + { + Match match = regex.Match(lines[i]); + if (match.Success) + { + matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i].TrimEnd('\r') }); + + // Build a context snippet around the first match (±50 chars). + if (firstSnippet is null) + { + int charIndex = lineStartOffset + match.Index; + int snippetStart = Math.Max(0, charIndex - 50); + int snippetEnd = Math.Min(fileContent.Length, charIndex + match.Value.Length + 50); + firstSnippet = fileContent.Substring(snippetStart, snippetEnd - snippetStart); + } + } + + // Advance the offset past this line (including the '\n' separator). + lineStartOffset += lines[i].Length + 1; + } + + if (matchingLines.Count > 0) + { + results.Add(new FileSearchResult + { + FileName = relativeName, + Snippet = firstSnippet!, + MatchingLines = matchingLines, + }); + } + } + + return Task.FromResult>(results); + } + + /// + public override Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default) + { + // No-op: directories are implicit from file paths in the in-memory store. + return Task.CompletedTask; + } + + private static string NormalizePath(string path) + { + string normalized = path.Replace('\\', '/').Trim('/'); + + if (Path.IsPathRooted(path) || + path.StartsWith("/", StringComparison.Ordinal) || + path.StartsWith("\\", StringComparison.Ordinal) || + (normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':')) + { + throw new ArgumentException( + $"Invalid path: '{path}'. Paths must be relative and must not start with '/', '\\', or a drive root.", + nameof(path)); + } + + foreach (string segment in normalized.Split('/')) + { + if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Invalid path: '{path}'. Paths must not contain '.' or '..' segments.", + nameof(path)); + } + } + + return normalized; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index 10e92850d5..98901cfbfd 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -26,6 +26,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs new file mode 100644 index 0000000000..83b2bf3d1d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs @@ -0,0 +1,555 @@ +// 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.Harness.FileMemory; + +public class FileMemoryProviderTests +{ + #region Constructor Validation + + [Fact] + public void Constructor_NullFileStore_Throws() + { + Assert.Throws(() => new FileMemoryProvider(null!)); + } + + [Fact] + public void Constructor_WithDefaults_Succeeds() + { + // Act + var provider = new FileMemoryProvider(new InMemoryAgentFileStore()); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void Constructor_WithStateInitializer_Succeeds() + { + // Act + var provider = new FileMemoryProvider( + new InMemoryAgentFileStore(), + _ => new FileMemoryState { WorkingFolder = "custom" }); + + // Assert + Assert.NotNull(provider); + } + + #endregion + + #region ProvideAIContextAsync Tests + + [Fact] + public async Task ProvideAIContextAsync_ReturnsToolsAsync() + { + // Arrange + var (tools, _, session) = await CreateToolsAsync(); + + // Assert - 5 tools: SaveFile, ReadFile, DeleteFile, ListFiles, SearchFiles + Assert.Equal(5, tools.Count()); + } + + [Fact] + public async Task ProvideAIContextAsync_ReturnsInstructionsAsync() + { + // Arrange + var provider = new FileMemoryProvider(new InMemoryAgentFileStore()); + 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.Contains("file-based memory", result.Instructions); + Assert.Contains("compacted", result.Instructions); + } + + #endregion + + #region SaveFile Tests + + [Fact] + public async Task SaveFile_CreatesFileAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var (tools, _, session) = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + + // Act + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["content"] = "Test content", + ["description"] = "", + }, session); + + // Assert + var content = await store.ReadFileAsync("notes.md"); + Assert.Equal("Test content", content); + } + + [Fact] + public async Task SaveFile_WithDescription_CreatesBothFilesAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var (tools, _, session) = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + + // Act + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "research.md", + ["content"] = "Long research content...", + ["description"] = "Summary of research findings", + }, session); + + // Assert + var content = await store.ReadFileAsync("research.md"); + Assert.Equal("Long research content...", content); + var desc = await store.ReadFileAsync("research_description.md"); + Assert.Equal("Summary of research findings", desc); + } + + [Fact] + public async Task SaveFile_WithoutDescription_DeletesStaleDescriptionAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var (tools, _, session) = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + + // Save with description first. + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["content"] = "Original", + ["description"] = "Old description", + }, session); + Assert.NotNull(await store.ReadFileAsync("notes_description.md")); + + // Act — overwrite without description. + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["content"] = "Updated", + }, session); + + // Assert — stale description file is removed. + Assert.Equal("Updated", await store.ReadFileAsync("notes.md")); + Assert.Null(await store.ReadFileAsync("notes_description.md")); + } + + [Fact] + public async Task SaveFile_WithCustomState_CreatesInSubfolderAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var (tools, state, session) = await CreateToolsAsync(store, _ => new FileMemoryState { WorkingFolder = "session123" }); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + + // Act + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["content"] = "Session content", + ["description"] = "", + }, session); + + // Assert + Assert.Equal("session123", state.WorkingFolder); + var content = await store.ReadFileAsync("session123/notes.md"); + Assert.Equal("Session content", content); + } + + #endregion + + #region ReadFile Tests + + [Fact] + public async Task ReadFile_ExistingFile_ReturnsContentAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Stored content"); + var (tools, _, session) = await CreateToolsAsync(store); + var readFile = GetTool(tools, "FileMemory_ReadFile"); + + // Act + var result = await InvokeWithRunContextAsync(readFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + }, session); + + // Assert + var text = Assert.IsType(result).GetString(); + Assert.Equal("Stored content", text); + } + + [Fact] + public async Task ReadFile_NonExistent_ReturnsNotFoundMessageAsync() + { + // Arrange + var (tools, _, session) = await CreateToolsAsync(); + var readFile = GetTool(tools, "FileMemory_ReadFile"); + + // Act + var result = await InvokeWithRunContextAsync(readFile, new AIFunctionArguments + { + ["fileName"] = "nonexistent.md", + }, session); + + // Assert + var text = Assert.IsType(result).GetString(); + Assert.Contains("not found", text); + } + + #endregion + + #region DeleteFile Tests + + [Fact] + public async Task DeleteFile_ExistingFile_DeletesAndReturnsConfirmationAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Content"); + var (tools, _, session) = await CreateToolsAsync(store); + var deleteFile = GetTool(tools, "FileMemory_DeleteFile"); + + // Act + var result = await InvokeWithRunContextAsync(deleteFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + }, session); + + // Assert + var text = Assert.IsType(result).GetString(); + Assert.Contains("deleted", text); + Assert.False(await store.FileExistsAsync("notes.md")); + } + + [Fact] + public async Task DeleteFile_AlsoDeletesDescriptionFileAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Content"); + await store.WriteFileAsync("notes_description.md", "Description"); + var (tools, _, session) = await CreateToolsAsync(store); + var deleteFile = GetTool(tools, "FileMemory_DeleteFile"); + + // Act + await InvokeWithRunContextAsync(deleteFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + }, session); + + // Assert + Assert.False(await store.FileExistsAsync("notes.md")); + Assert.False(await store.FileExistsAsync("notes_description.md")); + } + + #endregion + + #region ListFiles Tests + + [Fact] + public async Task ListFiles_ReturnsFilesWithDescriptionsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Content"); + await store.WriteFileAsync("notes_description.md", "A description"); + await store.WriteFileAsync("other.md", "Other content"); + var (tools, _, session) = await CreateToolsAsync(store); + var listFiles = GetTool(tools, "FileMemory_ListFiles"); + + // Act + var result = await InvokeWithRunContextAsync(listFiles, new AIFunctionArguments(), session); + + // Assert + var entries = Assert.IsType(result).EnumerateArray().ToList(); + Assert.Equal(2, entries.Count); + + var notesEntry = entries.First(e => e.GetProperty("fileName").GetString() == "notes.md"); + Assert.Equal("A description", notesEntry.GetProperty("description").GetString()); + + var otherEntry = entries.First(e => e.GetProperty("fileName").GetString() == "other.md"); + Assert.False(otherEntry.TryGetProperty("description", out _)); + } + + [Fact] + public async Task ListFiles_HidesDescriptionFilesAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Content"); + await store.WriteFileAsync("notes_description.md", "Desc"); + var (tools, _, session) = await CreateToolsAsync(store); + var listFiles = GetTool(tools, "FileMemory_ListFiles"); + + // Act + var result = await InvokeWithRunContextAsync(listFiles, new AIFunctionArguments(), session); + + // Assert + var entries = Assert.IsType(result).EnumerateArray().ToList(); + Assert.Single(entries); + Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString()); + } + + #endregion + + #region SearchFiles Tests + + [Fact] + public async Task SearchFiles_FindsMatchingContentAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Important research findings about AI"); + var (tools, _, session) = await CreateToolsAsync(store); + var searchFiles = GetTool(tools, "FileMemory_SearchFiles"); + + // Act + var result = await InvokeWithRunContextAsync(searchFiles, new AIFunctionArguments + { + ["regexPattern"] = "research findings", + ["filePattern"] = "", + }, session); + + // Assert + var entries = Assert.IsType(result).EnumerateArray().ToList(); + Assert.Single(entries); + Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString()); + Assert.True(entries[0].TryGetProperty("matchingLines", out var matchingLines)); + Assert.True(matchingLines.GetArrayLength() > 0); + } + + [Fact] + public async Task SearchFiles_WithFilePattern_FiltersResultsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Important data"); + await store.WriteFileAsync("data.txt", "Important data"); + var (tools, _, session) = await CreateToolsAsync(store); + var searchFiles = GetTool(tools, "FileMemory_SearchFiles"); + + // Act + var result = await InvokeWithRunContextAsync(searchFiles, new AIFunctionArguments + { + ["regexPattern"] = "Important", + ["filePattern"] = "*.md", + }, session); + + // Assert + var entries = Assert.IsType(result).EnumerateArray().ToList(); + Assert.Single(entries); + Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString()); + } + + #endregion + + #region State Initializer Tests + + [Fact] + public async Task CustomStateInitializer_SetsWorkingFolderAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var (_, state, _) = await CreateToolsAsync(store, _ => new FileMemoryState { WorkingFolder = "user42" }); + + // Assert + Assert.Equal("user42", state.WorkingFolder); + } + + [Fact] + public async Task DefaultStateInitializer_UsesEmptyWorkingFolderAsync() + { + // Arrange + var (_, state, _) = await CreateToolsAsync(); + + // Assert + Assert.Equal(string.Empty, state.WorkingFolder); + } + + [Fact] + public async Task State_PersistsAcrossInvocationsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var provider = new FileMemoryProvider(store, _ => new FileMemoryState { WorkingFolder = "persistent" }); + 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 initializes state + await provider.InvokingAsync(context); + session.StateBag.TryGetValue("FileMemoryProvider", out var state1, AgentJsonUtilities.DefaultOptions); + + // Second invocation should reuse the same folder + await provider.InvokingAsync(context); + session.StateBag.TryGetValue("FileMemoryProvider", out var state2, AgentJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(state1); + Assert.NotNull(state2); + Assert.Equal(state1!.WorkingFolder, state2!.WorkingFolder); + } + + #endregion + + #region Path Traversal Protection + + [Fact] + public async Task SaveFile_PathTraversal_ThrowsAsync() + { + // Arrange + var (tools, _, session) = await CreateToolsAsync(); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "../escape.md", + ["content"] = "Content", + ["description"] = "", + }, session)); + } + + [Fact] + public async Task SaveFile_AbsolutePath_ThrowsAsync() + { + // Arrange + var (tools, _, session) = await CreateToolsAsync(); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "/etc/passwd", + ["content"] = "Content", + ["description"] = "", + }, session)); + } + + [Fact] + public async Task SaveFile_DriveRootedPath_ThrowsAsync() + { + // Arrange + var (tools, _, session) = await CreateToolsAsync(); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "C:\\temp\\file.md", + ["content"] = "Content", + }, session)); + } + + [Fact] + public async Task SaveFile_DoubleDotsInFileName_AllowedAsync() + { + // Arrange — "notes..md" is not a path traversal attempt. + var store = new InMemoryAgentFileStore(); + var (tools, _, session) = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + + // Act + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes..md", + ["content"] = "Content", + }, session); + + // Assert + Assert.Equal("Content", await store.ReadFileAsync("notes..md")); + } + + #endregion + + #region Helper Methods + + private static FileMemoryProvider CreateProvider(InMemoryAgentFileStore? store = null, Func? stateInitializer = null) + { + return new FileMemoryProvider(store ?? new InMemoryAgentFileStore(), stateInitializer); + } + + private static async Task<(IEnumerable Tools, FileMemoryState State, AgentSession Session)> CreateToolsAsync(InMemoryAgentFileStore? store = null, Func? stateInitializer = null) + { + var provider = CreateProvider(store, stateInitializer); + 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); + + session.StateBag.TryGetValue("FileMemoryProvider", out var state, AgentJsonUtilities.DefaultOptions); + + return (result.Tools!, state!, session); + } + + private static AIFunction GetTool(IEnumerable tools, string name) + { + return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name); + } + + /// + /// Invokes a tool within a mock so that + /// the tool methods can access the session via AIAgent.CurrentRunContext?.Session. + /// + /// The tool to invoke. + /// The arguments to pass to the tool. + /// + /// An optional session to use in the run context. When provided, ensures the tool executes + /// against the same session whose state was initialized during . + /// When , a new session is created. + /// + private static async Task InvokeWithRunContextAsync(AIFunction tool, AIFunctionArguments arguments, AgentSession? session = null) + { + var agent = new Mock().Object; + session ??= new ChatClientAgentSession(); + var messages = new List(); + + // Set up the ambient run context so tool methods can access the session. + var runContext = new AgentRunContext(agent, session, messages, null); + + // Use reflection to set the protected static CurrentRunContext property. + var property = typeof(AIAgent).GetProperty("CurrentRunContext", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + var setter = property!.GetSetMethod(true)!; + var previousContext = AIAgent.CurrentRunContext; + try + { + setter.Invoke(null, [runContext]); + return await tool.InvokeAsync(arguments); + } + finally + { + setter.Invoke(null, [previousContext]); + } + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/InMemoryAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/InMemoryAgentFileStoreTests.cs new file mode 100644 index 0000000000..a6a513017c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/InMemoryAgentFileStoreTests.cs @@ -0,0 +1,523 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory; + +public class InMemoryAgentFileStoreTests +{ + [Fact] + public async Task WriteAndReadFile_ReturnsContentAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act + await store.WriteFileAsync("notes.md", "Hello world"); + var content = await store.ReadFileAsync("notes.md"); + + // Assert + Assert.Equal("Hello world", content); + } + + [Fact] + public async Task ReadFile_NonExistent_ReturnsNullAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act + var content = await store.ReadFileAsync("nonexistent.md"); + + // Assert + Assert.Null(content); + } + + [Fact] + public async Task WriteFile_OverwritesExistingAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Original"); + + // Act + await store.WriteFileAsync("notes.md", "Updated"); + var content = await store.ReadFileAsync("notes.md"); + + // Assert + Assert.Equal("Updated", content); + } + + [Fact] + public async Task DeleteFile_ExistingFile_ReturnsTrueAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Content"); + + // Act + var deleted = await store.DeleteFileAsync("notes.md"); + + // Assert + Assert.True(deleted); + Assert.Null(await store.ReadFileAsync("notes.md")); + } + + [Fact] + public async Task DeleteFile_NonExistent_ReturnsFalseAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act + var deleted = await store.DeleteFileAsync("nonexistent.md"); + + // Assert + Assert.False(deleted); + } + + [Fact] + public async Task ListFiles_ReturnsDirectChildrenAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("folder/file1.md", "Content 1"); + await store.WriteFileAsync("folder/file2.md", "Content 2"); + await store.WriteFileAsync("folder/sub/file3.md", "Content 3"); + await store.WriteFileAsync("other/file4.md", "Content 4"); + + // Act + var files = await store.ListFilesAsync("folder"); + + // Assert + Assert.Equal(2, files.Count); + Assert.Contains("file1.md", files); + Assert.Contains("file2.md", files); + } + + [Fact] + public async Task ListFiles_EmptyDirectory_ReturnsEmptyAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act + var files = await store.ListFilesAsync("empty"); + + // Assert + Assert.Empty(files); + } + + [Fact] + public async Task ListFiles_RootDirectory_ReturnsRootFilesAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("root.md", "Content"); + await store.WriteFileAsync("folder/nested.md", "Content"); + + // Act + var files = await store.ListFilesAsync(""); + + // Assert + Assert.Single(files); + Assert.Equal("root.md", files[0]); + } + + [Fact] + public async Task ListFiles_IncludesDescriptionFilesAsync() + { + // Arrange — the store is dumb; it returns all files including _description.md + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("folder/notes.md", "Content"); + await store.WriteFileAsync("folder/notes_description.md", "Desc"); + + // Act + var files = await store.ListFilesAsync("folder"); + + // Assert + Assert.Equal(2, files.Count); + Assert.Contains("notes.md", files); + Assert.Contains("notes_description.md", files); + } + + [Fact] + public async Task FileExists_ExistingFile_ReturnsTrueAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Content"); + + // Act & Assert + Assert.True(await store.FileExistsAsync("notes.md")); + } + + [Fact] + public async Task FileExists_NonExistent_ReturnsFalseAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act & Assert + Assert.False(await store.FileExistsAsync("nonexistent.md")); + } + + [Fact] + public async Task SearchFiles_FindsMatchingContentAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("folder/notes.md", "The quick brown fox jumps over the lazy dog"); + await store.WriteFileAsync("folder/other.md", "No match here"); + + // Act + var results = await store.SearchFilesAsync("folder", "brown fox"); + + // Assert + Assert.Single(results); + Assert.Equal("notes.md", results[0].FileName); + Assert.Contains("brown fox", results[0].Snippet); + } + + [Fact] + public async Task SearchFiles_ReturnsMatchingLineNumbersAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("folder/notes.md", "Line one\nLine two with match\nLine three\nLine four with match"); + + // Act + var results = await store.SearchFilesAsync("folder", "match"); + + // Assert + Assert.Single(results); + Assert.Equal(2, results[0].MatchingLines.Count); + Assert.Equal(2, results[0].MatchingLines[0].LineNumber); + Assert.Equal("Line two with match", results[0].MatchingLines[0].Line); + Assert.Equal(4, results[0].MatchingLines[1].LineNumber); + Assert.Equal("Line four with match", results[0].MatchingLines[1].Line); + } + + [Fact] + public async Task SearchFiles_CaseInsensitiveAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("folder/notes.md", "Important Data Here"); + + // Act + var results = await store.SearchFilesAsync("folder", "important data"); + + // Assert + Assert.Single(results); + } + + [Fact] + public async Task SearchFiles_SupportsRegexPatternAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("folder/notes.md", "Error: something went wrong\nWarning: check this\nInfo: all good"); + + // Act + var results = await store.SearchFilesAsync("folder", "error|warning"); + + // Assert + Assert.Single(results); + Assert.Equal(2, results[0].MatchingLines.Count); + Assert.Equal(1, results[0].MatchingLines[0].LineNumber); + Assert.Equal(2, results[0].MatchingLines[1].LineNumber); + } + + [Fact] + public async Task SearchFiles_SupportsRegexWithSpecialCharactersAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("folder/code.cs", "var x = 42;\nvar y = 100;\nconst z = 7;"); + + // Act — regex matching lines starting with "var" + var results = await store.SearchFilesAsync("folder", @"^var\b"); + + // Assert + Assert.Single(results); + Assert.Equal(2, results[0].MatchingLines.Count); + } + + [Fact] + public async Task SearchFiles_WithGlobPattern_FiltersFilesAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("folder/notes.md", "Important data"); + await store.WriteFileAsync("folder/data.txt", "Important data"); + await store.WriteFileAsync("folder/code.cs", "Important data"); + + // Act — only search markdown files + var results = await store.SearchFilesAsync("folder", "Important", filePattern: "*.md"); + + // Assert + Assert.Single(results); + Assert.Equal("notes.md", results[0].FileName); + } + + [Fact] + public async Task SearchFiles_WithGlobPattern_MultipleExtensionsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("folder/notes.md", "match here"); + await store.WriteFileAsync("folder/data.txt", "match here"); + await store.WriteFileAsync("folder/code.cs", "match here"); + + // Act — search both md and txt files + var resultsMd = await store.SearchFilesAsync("folder", "match", filePattern: "*.md"); + var resultsTxt = await store.SearchFilesAsync("folder", "match", filePattern: "*.txt"); + + // Assert + Assert.Single(resultsMd); + Assert.Equal("notes.md", resultsMd[0].FileName); + Assert.Single(resultsTxt); + Assert.Equal("data.txt", resultsTxt[0].FileName); + } + + [Fact] + public async Task SearchFiles_WithGlobPattern_PrefixMatchAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("folder/research_ai.md", "findings"); + await store.WriteFileAsync("folder/research_ml.md", "findings"); + await store.WriteFileAsync("folder/notes.md", "findings"); + + // Act + var results = await store.SearchFilesAsync("folder", "findings", filePattern: "research*"); + + // Assert + Assert.Equal(2, results.Count); + Assert.All(results, r => Assert.StartsWith("research", r.FileName)); + } + + [Fact] + public async Task SearchFiles_WithNullGlobPattern_SearchesAllFilesAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("folder/notes.md", "match"); + await store.WriteFileAsync("folder/data.txt", "match"); + + // Act + var results = await store.SearchFilesAsync("folder", "match", filePattern: null); + + // Assert + Assert.Equal(2, results.Count); + } + + [Fact] + public async Task SearchFiles_NoMatch_ReturnsEmptyAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("folder/notes.md", "Some content"); + + // Act + var results = await store.SearchFilesAsync("folder", "nonexistent query"); + + // Assert + Assert.Empty(results); + } + + [Fact] + public async Task SearchFiles_IgnoresSubdirectoryFilesAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("folder/notes.md", "Match here"); + await store.WriteFileAsync("folder/sub/deep.md", "Match here too"); + + // Act + var results = await store.SearchFilesAsync("folder", "Match"); + + // Assert + Assert.Single(results); + Assert.Equal("notes.md", results[0].FileName); + } + + [Fact] + public async Task SearchFiles_Snippet_IncludesSurroundingContextAsync() + { + // Arrange — place the match in the middle of a long line so ±50 chars are available. + var store = new InMemoryAgentFileStore(); + string padding = new('A', 60); + string content = $"{padding}MATCH_HERE{padding}"; + await store.WriteFileAsync("folder/file.md", content); + + // Act + var results = await store.SearchFilesAsync("folder", "MATCH_HERE"); + + // Assert — snippet should contain the match and surrounding context (up to ±50 chars). + Assert.Single(results); + string snippet = results[0].Snippet; + Assert.Contains("MATCH_HERE", snippet); + Assert.True(snippet.Length <= 50 + "MATCH_HERE".Length + 50, "Snippet should be at most ±50 chars around the match."); + Assert.True(snippet.Length > "MATCH_HERE".Length, "Snippet should include surrounding context."); + } + + [Fact] + public async Task SearchFiles_Snippet_MatchNearStartOfFileAsync() + { + // Arrange — match is at the very beginning, so no leading context is available. + var store = new InMemoryAgentFileStore(); + string trailing = new('B', 80); + string content = $"MATCH{trailing}"; + await store.WriteFileAsync("folder/file.md", content); + + // Act + var results = await store.SearchFilesAsync("folder", "MATCH"); + + // Assert — snippet should start at the beginning of the file. + Assert.Single(results); + Assert.StartsWith("MATCH", results[0].Snippet); + Assert.True(results[0].Snippet.Length <= "MATCH".Length + 50); + } + + [Fact] + public async Task SearchFiles_Snippet_MatchNearEndOfFileAsync() + { + // Arrange — match is at the very end, so no trailing context is available. + var store = new InMemoryAgentFileStore(); + string leading = new('C', 80); + string content = $"{leading}MATCH"; + await store.WriteFileAsync("folder/file.md", content); + + // Act + var results = await store.SearchFilesAsync("folder", "MATCH"); + + // Assert — snippet should end at the end of the file. + Assert.Single(results); + Assert.EndsWith("MATCH", results[0].Snippet); + Assert.True(results[0].Snippet.Length <= 50 + "MATCH".Length); + } + + [Fact] + public async Task SearchFiles_Snippet_UsesFirstMatchPositionAsync() + { + // Arrange — "target" appears on lines 1 and 3, but the regex only matches line 3 + // because we require the word "UNIQUE" which only appears on line 3. + var store = new InMemoryAgentFileStore(); + const string Content = "Line one has some text\nLine two is filler\nLine three has UNIQUE_MARKER here"; + await store.WriteFileAsync("folder/file.md", Content); + + // Act + var results = await store.SearchFilesAsync("folder", "UNIQUE_MARKER"); + + // Assert — snippet should be from around line 3, not line 1. + Assert.Single(results); + Assert.Contains("UNIQUE_MARKER", results[0].Snippet); + Assert.Contains("Line three", results[0].Snippet); + } + + [Fact] + public async Task SearchFiles_Snippet_CorrectForMultiLineMatchAsync() + { + // Arrange — match is on the second line with enough distance from line 1 + // that the ±50 char snippet window does not reach the start of the file. + var store = new InMemoryAgentFileStore(); + string line1 = new('X', 100); + string line2 = new string('Y', 60) + "FIND_ME" + new string('Z', 60); + string line3 = new('W', 100); + string content = $"{line1}\n{line2}\n{line3}"; + await store.WriteFileAsync("folder/file.md", content); + + // Act + var results = await store.SearchFilesAsync("folder", "FIND_ME"); + + // Assert — snippet should contain the match from line 2. + Assert.Single(results); + Assert.Contains("FIND_ME", results[0].Snippet); + + // The match is at offset 101 (line1=100 + '\n') + 60 = 161. + // snippetStart = 161 - 50 = 111, which is well past line 1 (ends at offset 100). + // So line 1 content should not appear in the snippet. + Assert.DoesNotContain("XXXX", results[0].Snippet); + } + + [Fact] + public async Task PathNormalization_HandlesBackslashesAndTrailingSlashesAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act + await store.WriteFileAsync("folder\\file.md", "Content"); + var content = await store.ReadFileAsync("folder/file.md"); + + // Assert + Assert.Equal("Content", content); + } + + [Fact] + public async Task WriteFile_PathTraversal_ThrowsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act & Assert + await Assert.ThrowsAsync(() => store.WriteFileAsync("../escape.md", "Content")); + } + + [Fact] + public async Task ReadFile_PathTraversal_ThrowsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act & Assert + await Assert.ThrowsAsync(() => store.ReadFileAsync("folder/../../escape.md")); + } + + [Fact] + public async Task WriteFile_AbsolutePath_ThrowsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act & Assert + await Assert.ThrowsAsync(() => store.WriteFileAsync("/etc/passwd", "Content")); + } + + [Fact] + public async Task WriteFile_DoubleDotsInFileName_AllowedAsync() + { + // Arrange — "notes..md" contains ".." as a substring but not as a path segment. + var store = new InMemoryAgentFileStore(); + + // Act + await store.WriteFileAsync("notes..md", "Content"); + var content = await store.ReadFileAsync("notes..md"); + + // Assert + Assert.Equal("Content", content); + } + + [Fact] + public async Task WriteFile_DriveRootedPath_ThrowsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act & Assert + await Assert.ThrowsAsync(() => store.WriteFileAsync("C:\\temp\\file.md", "Content")); + } + + [Fact] + public async Task ListFiles_PathTraversal_ThrowsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + + // Act & Assert + await Assert.ThrowsAsync(() => store.ListFilesAsync("../other")); + } +} From 7f661e85241bfc458cf4a5041e2fcd7b4fdd8d63 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:39:17 +0100 Subject: [PATCH 06/13] .NET: Harness: Improve prompts and add FileSystem store (#5365) * Harness: Improve prompts and add FileSystem store * Address PR comments --- .../Harness_Step01_Research/Program.cs | 70 +++- .../Harness/AgentMode/AgentModeProvider.cs | 4 +- .../FileMemory/FileSystemAgentFileStore.cs | 305 ++++++++++++++++ .../FileSystemAgentFileStoreTests.cs | 333 ++++++++++++++++++ 4 files changed, 699 insertions(+), 13 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSystemAgentFileStore.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileSystemAgentFileStoreTests.cs diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs index 3494b986c9..9ac2e570bd 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -26,23 +26,28 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4"; +const int MaxContextWindowTokens = 1_050_000; +const int MaxOutputTokens = 128_000; + // Create a compaction strategy based on the model's context window. // gpt-5.4: 1,050,000 token context window, 128,000 max output tokens. // Defaults: tool result eviction at 50% of input budget, truncation at 80%. var compactionStrategy = new ContextWindowCompactionStrategy( - maxContextWindowTokens: 1_050_000, - maxOutputTokens: 128_000); + maxContextWindowTokens: MaxContextWindowTokens, + maxOutputTokens: MaxOutputTokens); // Create an OpenAIClient that communicates with the Foundry responses service and get an IChatClient with stored output disabled // so that chat history is managed locally by the agent framework. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -OpenAIClientOptions clientOptions = new() { Endpoint = new Uri(endpoint) }; +OpenAIClientOptions clientOptions = new() { Endpoint = new Uri(endpoint), RetryPolicy = new ClientRetryPolicy(3) }; IChatClient chatClient = new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions) .GetResponsesClient() .AsIChatClientWithStoredOutputDisabled(deploymentName) .AsBuilder() + .UseFunctionInvocation() + .UsePerServiceCallChatHistoryPersistence() .UseAIContextProviders(new CompactionProvider(compactionStrategy)) .Build(); @@ -53,7 +58,7 @@ // and research-focused instructions including the mandatory planning workflow. var instructions = """ - You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing. Don't rely on your own knowledge — use the tools available to you to find up-to-date information. + You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing. Use your knowledge to form good search queries and hypotheses, but always verify claims with the tools available to you rather than relying on memory alone. **Mandatory planning workflow** @@ -64,13 +69,17 @@ 1. Analyze the request. 1. When asking for clarification and you have specific options in mind, present them to the user with numbers, so they can respond with the number instead of having to retype the entire response. 2. Always also allow the user to respond with free-form text in case they want to provide information or context that you didn't specifically ask for. 3. Create one or more todo items. - 4. Present the plan to the user. - 5. Ask for approval to switch to execute mode and process the plan. - 6. When approval is granted, always switch to execute mode, execute the plan and complete the todos. + 4. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes. + 5. Present the plan to the user. + 6. Ask for approval to switch to execute mode and process the plan. + 7. When approval is granted, always switch to execute mode, execute the plan and complete the todos. + 8. In execute mode, work autonomously — use your best judgement to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns. + 9. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going. + 10. Continue working, thinking and calling tools until you have the research result for the user. Explain your reasoning and thought process as you work through the tasks. Explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process. - Don't call many tools in a row without providing some explanation in between to help the user understand what you're doing and why. + When calling many tools in a row, provide an explanation to the user after each 4 tool calls (or fewer) to help the user understand what you're doing and why. Do not answer the underlying question before the plan has been presented and approved. This rule applies even when the answer seems obvious or the task seems small. For short requests, use a brief micro-plan rather than skipping planning. @@ -79,9 +88,39 @@ This rule applies even when the answer seems obvious or the task seems small. - greetings, - pure acknowledgments, - clarification questions needed to form the plan, + - follow-up questions about results you have already presented, - meta-discussion about the workflow itself. When the task is complete, switch back to plan mode for the next request, even if the next request is just a short question. + + **Todo management** + + Mark each todo complete as you finish it so the list stays current. + If a todo turns out to be unnecessary or is blocked, remove it and briefly explain why. + + **Research quality** + + Consult multiple sources when possible and cross-reference key claims. + When sources disagree, note the discrepancy and explain which source you consider more reliable and why. + If a web page fails to load or a search returns irrelevant results, try alternative search queries or sources before moving on. + Track your sources — you will need them when presenting results. + + **Presenting results** + + When presenting your final findings: + - Use clear sections with headings for each major topic or sub-question. + - Cite your sources inline (e.g., "According to [source name](URL), ..."). + - End with a brief summary of key takeaways. + - Save the final research report to file memory so it survives compaction and can be referenced later. + + **File memory** + + When you download web pages or receive large amounts of data, save them to file memory using the FileMemory_SaveFile tool. + This ensures the data remains accessible even if older context is compacted or truncated during long research sessions. + Use descriptive file names (e.g., "openai_pricing_page.md") and include a brief description for large files. + Also save intermediate notes and findings as you go — this helps with long multi-step research where early findings inform later steps. + Before starting new research, check file memory with FileMemory_ListFiles and FileMemory_SearchFiles for relevant prior downloads. + When a temporary file is no longer needed, delete it to keep file memory tidy. """; AIAgent agent = new ChatClientAgent( @@ -90,7 +129,16 @@ This rule applies even when the answer seems obvious or the task seems small. { Name = "ResearchAgent", Description = "A research assistant that plans and executes research tasks.", - AIContextProviders = [new TodoProvider(), new AgentModeProvider(), new FileMemoryProvider(new InMemoryAgentFileStore())], + AIContextProviders = + [ + new TodoProvider(), + new AgentModeProvider(), + new FileMemoryProvider( + new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")), + (_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() }) + ], + RequirePerServiceCallChatHistoryPersistence = true, + UseProvidedChatClientAsIs = true, ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions { ChatReducer = compactionStrategy.AsChatReducer(), @@ -101,10 +149,10 @@ This rule applies even when the answer seems obvious or the task seems small. // This matches gpt-5.4's max output tokens, and should be adjusted depending on the model used and expected response length. MaxOutputTokens = 128_000, Instructions = instructions, - Reasoning = new() { Effort = ReasoningEffort.High }, + Reasoning = new() { Effort = ReasoningEffort.Medium }, Tools = [ResponseTool.CreateWebSearchTool().AsAITool(), .. webBrowsingTools.Tools], }, }); // Run the interactive console session using the shared HarnessConsole helper. -await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started.", maxContextWindowTokens: 1_050_000, maxOutputTokens: 128_000); +await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started.", maxContextWindowTokens: MaxContextWindowTokens, maxOutputTokens: MaxOutputTokens); diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs index 1a0ee68941..80f634b3b6 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs @@ -97,8 +97,8 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co 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. + - "plan": Use this mode when analyzing requirements, breaking down tasks, and creating plans. This is the interactive mode — ask clarifying questions, discuss options, and get user approval before proceeding. + - "execute": Use this mode when carrying out approved plans. Work autonomously using your best judgement — do not ask the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, useful result when the user returns. If you encounter ambiguity, choose the most reasonable option and note your choice. 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. """; diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSystemAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSystemAgentFileStore.cs new file mode 100644 index 0000000000..7133cc2925 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSystemAgentFileStore.cs @@ -0,0 +1,305 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A file-system-backed implementation of that stores files on disk +/// under a configurable root directory. +/// +/// +/// +/// All paths passed to this store are resolved relative to the root directory provided +/// at construction time. Lexical path traversal attempts (for example, via .. segments +/// or absolute paths) are rejected with an . +/// +/// +/// The root directory is created automatically if it does not already exist. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileSystemAgentFileStore : AgentFileStore +{ + /// + /// The canonical full path of the root directory, always ending with a directory separator. + /// + private readonly string _rootPath; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The root directory under which all files are stored. Created if it does not exist. + /// + public FileSystemAgentFileStore(string rootDirectory) + { + _ = Throw.IfNullOrWhitespace(rootDirectory); + + // Canonicalize the root and ensure it ends with a separator for prefix comparison. + string fullRoot = Path.GetFullPath(rootDirectory); + if (!fullRoot.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) && + !fullRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + { + fullRoot += Path.DirectorySeparatorChar; + } + + this._rootPath = fullRoot; + Directory.CreateDirectory(fullRoot); + } + + /// + public override async Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default) + { + string fullPath = this.ResolveSafePath(path); + + // Ensure the parent directory exists. + string? parentDir = Path.GetDirectoryName(fullPath); + if (parentDir is not null) + { + Directory.CreateDirectory(parentDir); + } + +#if NET8_0_OR_GREATER + await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8, cancellationToken).ConfigureAwait(false); +#else + using var writer = new StreamWriter(fullPath, false, Encoding.UTF8); + await writer.WriteAsync(content).ConfigureAwait(false); +#endif + } + + /// + public override async Task ReadFileAsync(string path, CancellationToken cancellationToken = default) + { + string fullPath = this.ResolveSafePath(path); + + if (!File.Exists(fullPath)) + { + return null; + } + +#if NET8_0_OR_GREATER + return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); +#else + using var reader = new StreamReader(fullPath, Encoding.UTF8); + return await reader.ReadToEndAsync().ConfigureAwait(false); +#endif + } + + /// + public override Task DeleteFileAsync(string path, CancellationToken cancellationToken = default) + { + string fullPath = this.ResolveSafePath(path); + + if (!File.Exists(fullPath)) + { + return Task.FromResult(false); + } + + File.Delete(fullPath); + return Task.FromResult(true); + } + + /// + public override Task> ListFilesAsync(string directory, CancellationToken cancellationToken = default) + { + string fullDir = this.ResolveSafeDirectoryPath(directory); + + if (!Directory.Exists(fullDir)) + { + return Task.FromResult>([]); + } + + var files = Directory.GetFiles(fullDir) + .Select(Path.GetFileName) + .Where(name => name is not null) + .ToList(); + + return Task.FromResult>(files!); + } + + /// + public override Task FileExistsAsync(string path, CancellationToken cancellationToken = default) + { + string fullPath = this.ResolveSafePath(path); + return Task.FromResult(File.Exists(fullPath)); + } + + /// + public override async Task> SearchFilesAsync( + string directory, + string regexPattern, + string? filePattern = null, + CancellationToken cancellationToken = default) + { + string fullDir = this.ResolveSafeDirectoryPath(directory); + + if (!Directory.Exists(fullDir)) + { + return []; + } + + // Compile the regex with a timeout to guard against catastrophic backtracking (ReDoS). + var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5)); + Matcher? matcher = filePattern is not null ? CreateGlobMatcher(filePattern) : null; + var results = new List(); + + foreach (string filePath in Directory.GetFiles(fullDir)) + { + string? fileName = Path.GetFileName(filePath); + if (fileName is null) + { + continue; + } + + // Apply the optional glob filter on the file name. + if (!MatchesGlob(fileName, matcher)) + { + continue; + } + + // Read file content. +#if NET8_0_OR_GREATER + string fileContent = await File.ReadAllTextAsync(filePath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); +#else + string fileContent; + using (var reader = new StreamReader(filePath, Encoding.UTF8)) + { + fileContent = await reader.ReadToEndAsync().ConfigureAwait(false); + } +#endif + + // Search each line for regex matches, tracking line numbers and building a snippet. + string[] lines = fileContent.Split('\n'); + var matchingLines = new List(); + string? firstSnippet = null; + int lineStartOffset = 0; + + for (int i = 0; i < lines.Length; i++) + { + Match match = regex.Match(lines[i]); + if (match.Success) + { + matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i].TrimEnd('\r') }); + + // Build a context snippet around the first match (±50 chars). + if (firstSnippet is null) + { + int charIndex = lineStartOffset + match.Index; + int snippetStart = Math.Max(0, charIndex - 50); + int snippetEnd = Math.Min(fileContent.Length, charIndex + match.Value.Length + 50); + firstSnippet = fileContent.Substring(snippetStart, snippetEnd - snippetStart); + } + } + + // Advance the offset past this line (including the '\n' separator). + lineStartOffset += lines[i].Length + 1; + } + + if (matchingLines.Count > 0) + { + results.Add(new FileSearchResult + { + FileName = fileName, + Snippet = firstSnippet!, + MatchingLines = matchingLines, + }); + } + } + + return results; + } + + /// + public override Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default) + { + string fullPath = this.ResolveSafeDirectoryPath(path); + Directory.CreateDirectory(fullPath); + return Task.CompletedTask; + } + + /// + /// Resolves a relative file path to a safe absolute path under the root directory. + /// Rejects paths that would escape the root via traversal or rooted paths. + /// + private string ResolveSafePath(string relativePath) + { + ValidateRelativePath(relativePath); + + // Normalize separators before combining to prevent backslashes from becoming + // literal filename characters on Unix. + string normalized = relativePath.Replace('\\', '/').Replace('/', Path.DirectorySeparatorChar); + string combined = Path.Combine(this._rootPath, normalized); + string fullPath = Path.GetFullPath(combined); + + if (!fullPath.StartsWith(this._rootPath, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Invalid path: '{relativePath}'. The resolved path escapes the root directory.", + nameof(relativePath)); + } + + return fullPath; + } + + /// + /// Resolves a relative directory path to a safe absolute path under the root directory. + /// An empty string resolves to the root directory itself. + /// + private string ResolveSafeDirectoryPath(string relativeDirectory) + { + if (string.IsNullOrEmpty(relativeDirectory)) + { + return this._rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + return this.ResolveSafePath(relativeDirectory); + } + + /// + /// Validates that a relative path does not contain rooted paths or traversal segments. + /// + private static void ValidateRelativePath(string path) + { + string normalized = path.Replace('\\', '/'); + + if (Path.IsPathRooted(path) || + path.StartsWith("/", StringComparison.Ordinal) || + path.StartsWith("\\", StringComparison.Ordinal) || + (normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':')) + { + throw new ArgumentException( + $"Invalid path: '{path}'. Paths must be relative and must not start with '/', '\\', or a drive root.", + nameof(path)); + } + + foreach (string segment in normalized.Split('/')) + { + if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Invalid path: '{path}'. Paths must not contain '.' or '..' segments.", + nameof(path)); + } + } + + if (normalized.StartsWith("/", StringComparison.Ordinal) || + normalized.EndsWith("/", StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Invalid path: '{path}'. Paths must not start or end with a directory separator.", + nameof(path)); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileSystemAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileSystemAgentFileStoreTests.cs new file mode 100644 index 0000000000..c19ab28b4d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileSystemAgentFileStoreTests.cs @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory; + +public sealed class FileSystemAgentFileStoreTests : IDisposable +{ + private readonly string _rootDir; + private readonly FileSystemAgentFileStore _store; + + public FileSystemAgentFileStoreTests() + { + this._rootDir = Path.Combine(Path.GetTempPath(), "FileSystemAgentFileStoreTests_" + Guid.NewGuid().ToString("N")); + this._store = new FileSystemAgentFileStore(this._rootDir); + } + + public void Dispose() + { + if (Directory.Exists(this._rootDir)) + { + Directory.Delete(this._rootDir, recursive: true); + } + } + + #region Constructor + + [Fact] + public void Constructor_CreatesRootDirectory() + { + // Assert + Assert.True(Directory.Exists(this._rootDir)); + } + + [Fact] + public void Constructor_NullRootDirectory_Throws() + { + // Act & Assert + Assert.Throws(() => new FileSystemAgentFileStore(null!)); + } + + [Fact] + public void Constructor_EmptyRootDirectory_Throws() + { + // Act & Assert + Assert.Throws(() => new FileSystemAgentFileStore("")); + } + + [Fact] + public void Constructor_WhitespaceRootDirectory_Throws() + { + // Act & Assert + Assert.Throws(() => new FileSystemAgentFileStore(" ")); + } + + #endregion + + #region Path Traversal Rejection + + [Fact] + public async Task WriteFileAsync_DotDotSegment_ThrowsAsync() + { + // Act & Assert + await Assert.ThrowsAsync(() => this._store.WriteFileAsync("../escape.txt", "content")); + } + + [Fact] + public async Task ReadFileAsync_AbsolutePath_ThrowsAsync() + { + // Act & Assert + await Assert.ThrowsAsync(() => this._store.ReadFileAsync("/etc/passwd")); + } + + [Fact] + public async Task DeleteFileAsync_DriveRootedPath_ThrowsAsync() + { + // Act & Assert + await Assert.ThrowsAsync(() => this._store.DeleteFileAsync("C:\\temp\\file.txt")); + } + + [Fact] + public async Task WriteFileAsync_DotSegment_ThrowsAsync() + { + // Act & Assert + await Assert.ThrowsAsync(() => this._store.WriteFileAsync("./file.txt", "content")); + } + + [Fact] + public async Task WriteFileAsync_DoubleDotsInFileName_AllowedAsync() + { + // Arrange — "notes..md" contains ".." but is not a ".." segment + await this._store.WriteFileAsync("notes..md", "content"); + + // Act + string? result = await this._store.ReadFileAsync("notes..md"); + + // Assert + Assert.Equal("content", result); + } + + [Fact] + public async Task WriteFileAsync_TrailingSlash_ThrowsAsync() + { + // Act & Assert + await Assert.ThrowsAsync(() => this._store.WriteFileAsync("subdir/", "content")); + } + + #endregion + + #region Write and Read + + [Fact] + public async Task WriteAndReadAsync_RoundTripsAsync() + { + // Arrange + await this._store.WriteFileAsync("test.txt", "hello world"); + + // Act + string? content = await this._store.ReadFileAsync("test.txt"); + + // Assert + Assert.Equal("hello world", content); + } + + [Fact] + public async Task WriteFileAsync_OverwritesExistingAsync() + { + // Arrange + await this._store.WriteFileAsync("test.txt", "first"); + await this._store.WriteFileAsync("test.txt", "second"); + + // Act + string? content = await this._store.ReadFileAsync("test.txt"); + + // Assert + Assert.Equal("second", content); + } + + [Fact] + public async Task ReadFileAsync_NonExistent_ReturnsNullAsync() + { + // Act + string? content = await this._store.ReadFileAsync("missing.txt"); + + // Assert + Assert.Null(content); + } + + #endregion + + #region Delete + + [Fact] + public async Task DeleteFileAsync_ExistingFile_ReturnsTrueAsync() + { + // Arrange + await this._store.WriteFileAsync("delete-me.txt", "content"); + + // Act + bool deleted = await this._store.DeleteFileAsync("delete-me.txt"); + + // Assert + Assert.True(deleted); + Assert.Null(await this._store.ReadFileAsync("delete-me.txt")); + } + + [Fact] + public async Task DeleteFileAsync_NonExistent_ReturnsFalseAsync() + { + // Act + bool deleted = await this._store.DeleteFileAsync("nope.txt"); + + // Assert + Assert.False(deleted); + } + + #endregion + + #region FileExists + + [Fact] + public async Task FileExistsAsync_ExistingFile_ReturnsTrueAsync() + { + // Arrange + await this._store.WriteFileAsync("exists.txt", "content"); + + // Act & Assert + Assert.True(await this._store.FileExistsAsync("exists.txt")); + } + + [Fact] + public async Task FileExistsAsync_NonExistent_ReturnsFalseAsync() + { + // Act & Assert + Assert.False(await this._store.FileExistsAsync("missing.txt")); + } + + #endregion + + #region ListFiles + + [Fact] + public async Task ListFilesAsync_ReturnsDirectChildrenOnlyAsync() + { + // Arrange + await this._store.WriteFileAsync("root.txt", "content"); + await this._store.WriteFileAsync("sub/nested.txt", "content"); + + // Act + var files = await this._store.ListFilesAsync(""); + + // Assert + Assert.Single(files); + Assert.Equal("root.txt", files[0]); + } + + [Fact] + public async Task ListFilesAsync_SubDirectory_ReturnsChildrenAsync() + { + // Arrange + await this._store.WriteFileAsync("sub/a.txt", "content"); + await this._store.WriteFileAsync("sub/b.txt", "content"); + await this._store.WriteFileAsync("other.txt", "content"); + + // Act + var files = await this._store.ListFilesAsync("sub"); + + // Assert + Assert.Equal(2, files.Count); + Assert.Contains("a.txt", files); + Assert.Contains("b.txt", files); + } + + [Fact] + public async Task ListFilesAsync_NonExistentDirectory_ReturnsEmptyAsync() + { + // Act + var files = await this._store.ListFilesAsync("no-such-dir"); + + // Assert + Assert.Empty(files); + } + + #endregion + + #region CreateDirectory + + [Fact] + public async Task CreateDirectoryAsync_CreatesOnDiskAsync() + { + // Act + await this._store.CreateDirectoryAsync("new-dir"); + + // Assert + Assert.True(Directory.Exists(Path.Combine(this._rootDir, "new-dir"))); + } + + #endregion + + #region SearchFiles + + [Fact] + public async Task SearchFilesAsync_FindsMatchAsync() + { + // Arrange + await this._store.WriteFileAsync("doc.md", "This has an error on line one.\nLine two is fine."); + + // Act + var results = await this._store.SearchFilesAsync("", "error"); + + // Assert + Assert.Single(results); + Assert.Equal("doc.md", results[0].FileName); + Assert.Single(results[0].MatchingLines); + Assert.Equal(1, results[0].MatchingLines[0].LineNumber); + Assert.Contains("error", results[0].Snippet); + } + + [Fact] + public async Task SearchFilesAsync_GlobFilter_ExcludesNonMatchingAsync() + { + // Arrange + await this._store.WriteFileAsync("notes.md", "important info"); + await this._store.WriteFileAsync("data.txt", "important info"); + + // Act + var results = await this._store.SearchFilesAsync("", "important", "*.md"); + + // Assert + Assert.Single(results); + Assert.Equal("notes.md", results[0].FileName); + } + + [Fact] + public async Task SearchFilesAsync_NoMatch_ReturnsEmptyAsync() + { + // Arrange + await this._store.WriteFileAsync("doc.md", "nothing here"); + + // Act + var results = await this._store.SearchFilesAsync("", "missing-pattern"); + + // Assert + Assert.Empty(results); + } + + [Fact] + public async Task SearchFilesAsync_NonExistentDirectory_ReturnsEmptyAsync() + { + // Act + var results = await this._store.SearchFilesAsync("no-dir", "anything"); + + // Assert + Assert.Empty(results); + } + + [Fact] + public async Task SearchFilesAsync_RegexTimeout_ThrowsOnBadPatternAsync() + { + // Arrange — write a file with content that triggers catastrophic backtracking. + // The pattern (a+)+$ with a string of 'a's followed by 'b' forces exponential backtracking. + await this._store.WriteFileAsync("trap.txt", new string('a', 30) + "b"); + + // Act & Assert — a known ReDoS pattern with backtracking + await Assert.ThrowsAsync(() => + this._store.SearchFilesAsync("", "(a+)+$")); + } + + #endregion +} From 53274fde850e69680725fe184485895fba1460d0 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:28:37 +0100 Subject: [PATCH 07/13] .NET: Harness: Improve path validation (#5404) * Harness: Improve path validation * Address PR comments --- .../Harness/FileMemory/AgentFileStore.cs | 35 ---- .../Harness/FileMemory/FileMemoryProvider.cs | 25 +-- .../FileMemory/FileSystemAgentFileStore.cs | 50 +---- .../FileMemory/InMemoryAgentFileStore.cs | 44 +---- .../Harness/FileMemory/StorePaths.cs | 109 +++++++++++ .../FileSystemAgentFileStoreTests.cs | 10 +- .../Harness/FileMemory/StorePathsTests.cs | 177 ++++++++++++++++++ 7 files changed, 313 insertions(+), 137 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/AgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/AgentFileStore.cs index ad8bbe4390..85b33a4f35 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/AgentFileStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/AgentFileStore.cs @@ -90,39 +90,4 @@ public abstract class AgentFileStore /// A token to cancel the operation. /// A task representing the asynchronous operation. public abstract Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default); - - /// - /// Creates a for the specified glob pattern. Use the returned instance - /// to test multiple file names without allocating a new matcher for each one. - /// - /// - /// The glob pattern to match against (e.g., "*.md", "research*"). - /// - /// A configured with the specified pattern. - protected static Matcher CreateGlobMatcher(string filePattern) - { - var matcher = new Matcher(System.StringComparison.OrdinalIgnoreCase); - matcher.AddInclude(filePattern); - return matcher; - } - - /// - /// Determines whether a file name matches a pre-built glob . - /// - /// The file name to test (not a full path — just the name). - /// - /// A pre-built to test against. - /// When , this method returns for any file name. - /// - /// if the file name matches the pattern or if the matcher is ; otherwise, . - protected static bool MatchesGlob(string fileName, Matcher? matcher) - { - if (matcher is null) - { - return true; - } - - PatternMatchingResult result = matcher.Match(fileName); - return result.HasMatches; - } } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs index 664ea7ed11..49aaf6e79a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -264,26 +263,12 @@ private static string GetDescriptionFileName(string fileName) private static string ResolvePath(string workingFolder, string fileName) { - // Prevent path traversal by rejecting rooted paths and '.'/'..' segments. - string normalized = fileName.Replace('\\', '/'); + // Validate and normalize the file name (rejects rooted, traversal, empty, etc.). + // Only fileName needs validation — workingFolder is developer-provided and trusted. + string normalizedFileName = StorePaths.NormalizeRelativePath(fileName); - if (Path.IsPathRooted(fileName) || - fileName.StartsWith("/", StringComparison.Ordinal) || - fileName.StartsWith("\\", StringComparison.Ordinal) || - (normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':')) - { - throw new ArgumentException($"Invalid file name: '{fileName}'. File names must be relative and must not start with '/', '\\', or a drive root.", nameof(fileName)); - } - - foreach (string segment in normalized.Split('/')) - { - if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal)) - { - throw new ArgumentException($"Invalid file name: '{fileName}'. File names must not contain '.' or '..' segments.", nameof(fileName)); - } - } - - return CombinePaths(workingFolder, fileName); + string normalizedWorkingFolder = workingFolder.Replace('\\', '/'); + return CombinePaths(normalizedWorkingFolder, normalizedFileName); } private static string CombinePaths(string basePath, string relativePath) diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSystemAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSystemAgentFileStore.cs index 7133cc2925..b26d0eea5d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSystemAgentFileStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSystemAgentFileStore.cs @@ -152,7 +152,7 @@ public override async Task> SearchFilesAsync( // Compile the regex with a timeout to guard against catastrophic backtracking (ReDoS). var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5)); - Matcher? matcher = filePattern is not null ? CreateGlobMatcher(filePattern) : null; + Matcher? matcher = filePattern is not null ? StorePaths.CreateGlobMatcher(filePattern) : null; var results = new List(); foreach (string filePath in Directory.GetFiles(fullDir)) @@ -164,7 +164,7 @@ public override async Task> SearchFilesAsync( } // Apply the optional glob filter on the file name. - if (!MatchesGlob(fileName, matcher)) + if (!StorePaths.MatchesGlob(fileName, matcher)) { continue; } @@ -235,12 +235,12 @@ public override Task CreateDirectoryAsync(string path, CancellationToken cancell /// private string ResolveSafePath(string relativePath) { - ValidateRelativePath(relativePath); + // Normalize and validate the relative path (rejects rooted, traversal, etc.). + string normalized = StorePaths.NormalizeRelativePath(relativePath); - // Normalize separators before combining to prevent backslashes from becoming - // literal filename characters on Unix. - string normalized = relativePath.Replace('\\', '/').Replace('/', Path.DirectorySeparatorChar); - string combined = Path.Combine(this._rootPath, normalized); + // Convert to OS-native separators before combining. + string nativePath = normalized.Replace('/', Path.DirectorySeparatorChar); + string combined = Path.Combine(this._rootPath, nativePath); string fullPath = Path.GetFullPath(combined); if (!fullPath.StartsWith(this._rootPath, StringComparison.Ordinal)) @@ -266,40 +266,4 @@ private string ResolveSafeDirectoryPath(string relativeDirectory) return this.ResolveSafePath(relativeDirectory); } - - /// - /// Validates that a relative path does not contain rooted paths or traversal segments. - /// - private static void ValidateRelativePath(string path) - { - string normalized = path.Replace('\\', '/'); - - if (Path.IsPathRooted(path) || - path.StartsWith("/", StringComparison.Ordinal) || - path.StartsWith("\\", StringComparison.Ordinal) || - (normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':')) - { - throw new ArgumentException( - $"Invalid path: '{path}'. Paths must be relative and must not start with '/', '\\', or a drive root.", - nameof(path)); - } - - foreach (string segment in normalized.Split('/')) - { - if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal)) - { - throw new ArgumentException( - $"Invalid path: '{path}'. Paths must not contain '.' or '..' segments.", - nameof(path)); - } - } - - if (normalized.StartsWith("/", StringComparison.Ordinal) || - normalized.EndsWith("/", StringComparison.Ordinal)) - { - throw new ArgumentException( - $"Invalid path: '{path}'. Paths must not start or end with a directory separator.", - nameof(path)); - } - } } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/InMemoryAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/InMemoryAgentFileStore.cs index 670ef992ab..206a38db8a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/InMemoryAgentFileStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/InMemoryAgentFileStore.cs @@ -4,7 +4,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading; @@ -29,7 +28,7 @@ public sealed class InMemoryAgentFileStore : AgentFileStore /// public override Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default) { - path = NormalizePath(path); + path = StorePaths.NormalizeRelativePath(path); this._files[path] = content; return Task.CompletedTask; } @@ -37,7 +36,7 @@ public override Task WriteFileAsync(string path, string content, CancellationTok /// public override Task ReadFileAsync(string path, CancellationToken cancellationToken = default) { - path = NormalizePath(path); + path = StorePaths.NormalizeRelativePath(path); this._files.TryGetValue(path, out string? content); return Task.FromResult(content); } @@ -45,14 +44,14 @@ public override Task WriteFileAsync(string path, string content, CancellationTok /// public override Task DeleteFileAsync(string path, CancellationToken cancellationToken = default) { - path = NormalizePath(path); + path = StorePaths.NormalizeRelativePath(path); return Task.FromResult(this._files.TryRemove(path, out _)); } /// public override Task> ListFilesAsync(string directory, CancellationToken cancellationToken = default) { - string prefix = NormalizePath(directory); + string prefix = StorePaths.NormalizeRelativePath(directory, isDirectory: true); if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal)) { prefix += "/"; @@ -70,7 +69,7 @@ public override Task> ListFilesAsync(string directory, Can /// public override Task FileExistsAsync(string path, CancellationToken cancellationToken = default) { - path = NormalizePath(path); + path = StorePaths.NormalizeRelativePath(path); return Task.FromResult(this._files.ContainsKey(path)); } @@ -78,7 +77,7 @@ public override Task FileExistsAsync(string path, CancellationToken cancel public override Task> SearchFilesAsync(string directory, string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default) { // Normalize the directory prefix for path matching. - string prefix = NormalizePath(directory); + string prefix = StorePaths.NormalizeRelativePath(directory, isDirectory: true); if (prefix.Length > 0 && !prefix.EndsWith("/", StringComparison.Ordinal)) { prefix += "/"; @@ -86,7 +85,7 @@ public override Task> SearchFilesAsync(string di // Compile the regex with a timeout to guard against catastrophic backtracking (ReDoS). var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5)); - Matcher? matcher = filePattern is not null ? CreateGlobMatcher(filePattern) : null; + Matcher? matcher = filePattern is not null ? StorePaths.CreateGlobMatcher(filePattern) : null; var results = new List(); foreach (var kvp in this._files) @@ -105,7 +104,7 @@ public override Task> SearchFilesAsync(string di } // Apply the optional glob filter on the file name. - if (!MatchesGlob(relativeName, matcher)) + if (!StorePaths.MatchesGlob(relativeName, matcher)) { continue; } @@ -158,31 +157,4 @@ public override Task CreateDirectoryAsync(string path, CancellationToken cancell // No-op: directories are implicit from file paths in the in-memory store. return Task.CompletedTask; } - - private static string NormalizePath(string path) - { - string normalized = path.Replace('\\', '/').Trim('/'); - - if (Path.IsPathRooted(path) || - path.StartsWith("/", StringComparison.Ordinal) || - path.StartsWith("\\", StringComparison.Ordinal) || - (normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':')) - { - throw new ArgumentException( - $"Invalid path: '{path}'. Paths must be relative and must not start with '/', '\\', or a drive root.", - nameof(path)); - } - - foreach (string segment in normalized.Split('/')) - { - if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal)) - { - throw new ArgumentException( - $"Invalid path: '{path}'. Paths must not contain '.' or '..' segments.", - nameof(path)); - } - } - - return normalized; - } } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs new file mode 100644 index 0000000000..4714993c45 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Extensions.FileSystemGlobbing; + +namespace Microsoft.Agents.AI; + +/// +/// Internal helper for normalizing and validating relative store paths and matching glob patterns. +/// Shared across implementations and . +/// +internal static class StorePaths +{ + /// + /// Normalizes a relative path by replacing backslashes with forward slashes, trimming leading + /// and trailing separators, and collapsing consecutive separators. Also validates that the path + /// does not contain rooted paths, drive roots, or ./.. traversal segments. + /// + /// The relative path to normalize. + /// + /// When , the path represents a directory and an empty result (meaning root) is allowed. + /// When (default), the path represents a file and an empty result is rejected. + /// + /// The normalized forward-slash path. + /// + /// Thrown when is rooted, starts with a drive letter, contains + /// . or .. segments, or is empty when is . + /// + internal static string NormalizeRelativePath(string path, bool isDirectory = false) + { + string normalized = path.Replace('\\', '/').Trim('/'); + + if (Path.IsPathRooted(path) || + path.StartsWith("/", StringComparison.Ordinal) || + path.StartsWith("\\", StringComparison.Ordinal) || + (normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':')) + { + throw new ArgumentException( + $"Invalid path: '{path}'. Paths must be relative and must not start with '/', '\\', or a drive root.", + nameof(path)); + } + + // Split, validate segments, and filter out empty segments to collapse consecutive separators. + string[] segments = normalized.Split('/'); + var cleanSegments = new List(segments.Length); + foreach (string segment in segments) + { + if (segment.Length == 0) + { + continue; + } + + if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Invalid path: '{path}'. Paths must not contain '.' or '..' segments.", + nameof(path)); + } + + cleanSegments.Add(segment); + } + + string result = string.Join("/", cleanSegments); + + if (!isDirectory && result.Length == 0) + { + throw new ArgumentException("A file path must not be empty.", nameof(path)); + } + + return result; + } + + /// + /// Creates a for the specified glob pattern. Use the returned instance + /// to test multiple file names without allocating a new matcher for each one. + /// + /// + /// The glob pattern to match against (e.g., "*.md", "research*"). + /// + /// A configured with the specified pattern. + internal static Matcher CreateGlobMatcher(string filePattern) + { + var matcher = new Matcher(StringComparison.OrdinalIgnoreCase); + matcher.AddInclude(filePattern); + return matcher; + } + + /// + /// Determines whether a file name matches a pre-built glob . + /// + /// The file name to test (not a full path — just the name). + /// + /// A pre-built to test against. + /// When , this method returns for any file name. + /// + /// if the file name matches the pattern or if the matcher is ; otherwise, . + internal static bool MatchesGlob(string fileName, Matcher? matcher) + { + if (matcher is null) + { + return true; + } + + PatternMatchingResult result = matcher.Match(fileName); + return result.HasMatches; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileSystemAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileSystemAgentFileStoreTests.cs index c19ab28b4d..82341d9a47 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileSystemAgentFileStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileSystemAgentFileStoreTests.cs @@ -102,10 +102,14 @@ public async Task WriteFileAsync_DoubleDotsInFileName_AllowedAsync() } [Fact] - public async Task WriteFileAsync_TrailingSlash_ThrowsAsync() + public async Task WriteFileAsync_TrailingSlash_NormalizesAsync() { - // Act & Assert - await Assert.ThrowsAsync(() => this._store.WriteFileAsync("subdir/", "content")); + // Act — trailing slash is trimmed during normalization. + await this._store.WriteFileAsync("subdir/", "content"); + + // Assert — the file is accessible via the normalized name. + string? result = await this._store.ReadFileAsync("subdir"); + Assert.Equal("content", result); } #endregion diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs new file mode 100644 index 0000000000..20a45a3ac4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.FileSystemGlobbing; + +namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory; + +public class StorePathsTests +{ + #region NormalizeRelativePath — valid paths + + [Theory] + [InlineData("file.md", "file.md")] + [InlineData("folder/file.md", "folder/file.md")] + [InlineData("a/b/c.txt", "a/b/c.txt")] + public void NormalizeRelativePath_ValidPath_ReturnsNormalized(string input, string expected) + { + // Act + string result = StorePaths.NormalizeRelativePath(input); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("folder\\file.md", "folder/file.md")] + [InlineData("a\\b\\c.txt", "a/b/c.txt")] + public void NormalizeRelativePath_Backslashes_NormalizesToForwardSlash(string input, string expected) + { + // Act + string result = StorePaths.NormalizeRelativePath(input); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("folder//file.md", "folder/file.md")] + [InlineData("a///b////c.txt", "a/b/c.txt")] + public void NormalizeRelativePath_ConsecutiveSeparators_Collapsed(string input, string expected) + { + // Act + string result = StorePaths.NormalizeRelativePath(input); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void NormalizeRelativePath_TrailingSlash_Trimmed() + { + // Act + string result = StorePaths.NormalizeRelativePath("file.md/"); + + // Assert + Assert.Equal("file.md", result); + } + + [Theory] + [InlineData("/file.md")] + [InlineData("/folder/file.md/")] + public void NormalizeRelativePath_LeadingSlash_Throws(string input) + { + // Act & Assert — leading slash is treated as a rooted path. + Assert.Throws(() => StorePaths.NormalizeRelativePath(input)); + } + + #endregion + + #region NormalizeRelativePath — rejected paths + + [Theory] + [InlineData("../file.md")] + [InlineData("folder/../file.md")] + [InlineData("./file.md")] + [InlineData("folder/./file.md")] + public void NormalizeRelativePath_TraversalSegments_Throws(string input) + { + // Act & Assert + Assert.Throws(() => StorePaths.NormalizeRelativePath(input)); + } + + [Theory] + [InlineData("C:\\file.md")] + [InlineData("C:/file.md")] + [InlineData("D:file.md")] + public void NormalizeRelativePath_DriveRoot_Throws(string input) + { + // Act & Assert + Assert.Throws(() => StorePaths.NormalizeRelativePath(input)); + } + + [Fact] + public void NormalizeRelativePath_EmptyFile_Throws() + { + // Act & Assert + Assert.Throws(() => StorePaths.NormalizeRelativePath("")); + } + + [Fact] + public void NormalizeRelativePath_WhitespaceOnlyFile_DoesNotThrowAsTraversal() + { + // Act — whitespace characters are not path separators, so " " becomes a valid segment. + string result = StorePaths.NormalizeRelativePath(" "); + + // Assert + Assert.Equal(" ", result); + } + + #endregion + + #region NormalizeRelativePath — directory mode + + [Fact] + public void NormalizeRelativePath_EmptyDirectory_ReturnsEmpty() + { + // Act + string result = StorePaths.NormalizeRelativePath("", isDirectory: true); + + // Assert + Assert.Equal("", result); + } + + [Theory] + [InlineData("folder", "folder")] + [InlineData("a/b", "a/b")] + [InlineData("a\\b/", "a/b")] + public void NormalizeRelativePath_DirectoryMode_NormalizesPath(string input, string expected) + { + // Act + string result = StorePaths.NormalizeRelativePath(input, isDirectory: true); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void NormalizeRelativePath_DirectoryTraversal_Throws() + { + // Act & Assert + Assert.Throws(() => StorePaths.NormalizeRelativePath("../folder", isDirectory: true)); + } + + #endregion + + #region CreateGlobMatcher and MatchesGlob + + [Theory] + [InlineData("*.md", "notes.md", true)] + [InlineData("*.md", "notes.txt", false)] + [InlineData("research*", "research_results.md", true)] + [InlineData("research*", "notes.md", false)] + [InlineData("*.md", "NOTES.MD", true)] // case-insensitive + public void MatchesGlob_WithMatcher_MatchesCorrectly(string pattern, string fileName, bool expected) + { + // Arrange + Matcher matcher = StorePaths.CreateGlobMatcher(pattern); + + // Act + bool result = StorePaths.MatchesGlob(fileName, matcher); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void MatchesGlob_NullMatcher_ReturnsTrue() + { + // Act + bool result = StorePaths.MatchesGlob("anything.txt", null); + + // Assert + Assert.True(result); + } + + #endregion +} From e4595be0c2cd9e07b4783b7f15cbcf4500418032 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:59:09 +0100 Subject: [PATCH 08/13] .NET: Add always approve helpers, improve sample and fix bug (#5451) * Add always approve helpers, improve sample and fix bug * Address PR comments --- .../Harness_Shared_Console/HarnessConsole.cs | 83 +- .../Harness_Step01_Research/Program.cs | 115 +- ...WebBrowsingTools.cs => WebBrowsingTool.cs} | 27 +- .../Microsoft.Agents.AI/AgentJsonUtilities.cs | 5 + ...viceCallChatHistoryPersistingChatClient.cs | 2 +- ...lwaysApproveToolApprovalResponseContent.cs | 67 + .../Harness/ToolApproval/ToolApprovalAgent.cs | 781 +++++++++ .../ToolApprovalAgentBuilderExtensions.cs | 37 + .../ToolApprovalRequestContentExtensions.cs | 65 + .../Harness/ToolApproval/ToolApprovalRule.cs | 45 + .../Harness/ToolApproval/ToolApprovalState.cs | 53 + ...ApproveToolApprovalResponseContentTests.cs | 288 +++ ...ToolApprovalAgentBuilderExtensionsTests.cs | 75 + .../ToolApproval/ToolApprovalAgentTests.cs | 1538 +++++++++++++++++ .../ToolApproval/ToolApprovalRuleTests.cs | 154 ++ 15 files changed, 3270 insertions(+), 65 deletions(-) rename dotnet/samples/02-agents/Harness/Harness_Step01_Research/{WebBrowsingTools.cs => WebBrowsingTool.cs} (93%) create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentBuilderExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRequestContentExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRule.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalState.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContentTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentBuilderExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalRuleTests.cs diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs index c47ed3cf25..9e46751001 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs @@ -61,6 +61,21 @@ public static async Task RunAgentAsync(AIAgent agent, string title, string userP private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession session, AgentModeProvider? modeProvider, string userInput, int? maxContextWindowTokens, int? maxOutputTokens) { + // Initial user input + var approvalRequests = await StreamAndCollectApprovalsAsync(agent.RunStreamingAsync(userInput, session), modeProvider, session, maxContextWindowTokens, maxOutputTokens); + var messagesToSend = PromptForApprovals(approvalRequests); + + // Loop while there are approval responses to send back + while (messagesToSend is not null) + { + approvalRequests = await StreamAndCollectApprovalsAsync(agent.RunStreamingAsync(messagesToSend, session), modeProvider, session, maxContextWindowTokens, maxOutputTokens); + messagesToSend = PromptForApprovals(approvalRequests); + } + } + + private static async Task> StreamAndCollectApprovalsAsync(IAsyncEnumerable updates, AgentModeProvider? modeProvider, AgentSession session, int? maxContextWindowTokens, int? maxOutputTokens) + { + var approvalRequests = new List(); string mode = modeProvider?.GetMode(session) ?? "unknown"; System.Console.ForegroundColor = GetModeColor(mode); System.Console.Write($"\n[{mode}] Agent: "); @@ -72,7 +87,7 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s try { - await foreach (var update in agent.RunStreamingAsync(userInput, session)) + await foreach (var update in updates) { foreach (var content in update.Contents) { @@ -96,6 +111,17 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s hasTextOutput = false; spinner.Start(); } + else if (content is ToolApprovalRequestContent approvalRequest) + { + await spinner.StopAsync(); + approvalRequests.Add(approvalRequest); + string toolName = approvalRequest.ToolCall is FunctionCallContent fc ? ToolCallFormatter.Format(fc) : approvalRequest.ToolCall?.ToString() ?? "unknown"; + System.Console.ForegroundColor = ConsoleColor.Yellow; + System.Console.Write(hasTextOutput ? "\n\n ⚠️ Approval needed: " : "\n ⚠️ Approval needed: "); + System.Console.Write(toolName); + System.Console.ForegroundColor = GetModeColor(mode); + hasTextOutput = false; + } else if (content is ErrorContent errorContent) { await spinner.StopAsync(); @@ -174,7 +200,7 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s await spinner.StopAsync(); - if (!hasReceivedAnyText) + if (!hasReceivedAnyText && approvalRequests.Count == 0) { System.Console.ForegroundColor = ConsoleColor.DarkYellow; System.Console.Write("\n (no text response from agent)"); @@ -183,6 +209,59 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s System.Console.ResetColor(); System.Console.WriteLine(); System.Console.WriteLine(); + + return approvalRequests; + } + + /// + /// Prompts the user for approval of each tool approval request. + /// Returns a list of messages to send back to the agent, or if there are no requests. + /// + private static List? PromptForApprovals(List approvalRequests) + { + if (approvalRequests.Count == 0) + { + return null; + } + + var responses = new List(); + foreach (var request in approvalRequests) + { + string toolName = request.ToolCall is FunctionCallContent fc ? ToolCallFormatter.Format(fc) : request.ToolCall?.ToString() ?? "unknown"; + + System.Console.ForegroundColor = ConsoleColor.Yellow; + System.Console.WriteLine($"\n 🔐 Tool approval required: {toolName}"); + System.Console.ResetColor(); + System.Console.WriteLine(" 1) Approve this call"); + System.Console.WriteLine(" 2) Always approve this tool (any arguments)"); + System.Console.WriteLine(" 3) Always approve this tool with these arguments"); + System.Console.WriteLine(" 4) Deny"); + System.Console.Write(" Choice [1-4]: "); + + string? choice = System.Console.ReadLine()?.Trim(); + AIContent response = choice switch + { + "2" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"), + "3" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"), + "4" => request.CreateResponse(approved: false, reason: "User denied"), + _ => request.CreateResponse(approved: true, reason: "User approved"), + }; + + string action = choice switch + { + "2" => "✅ Always approved (any args)", + "3" => "✅ Always approved (these args)", + "4" => "❌ Denied", + _ => "✅ Approved", + }; + System.Console.ForegroundColor = ConsoleColor.DarkGray; + System.Console.WriteLine($" {action}"); + System.Console.ResetColor(); + + responses.Add(response); + } + + return [new ChatMessage(ChatRole.User, responses)]; } private static void HandleModeCommand(AgentModeProvider? modeProvider, AgentSession session, string input) diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs index 9ac2e570bd..e1cc2b6ff7 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -29,31 +29,6 @@ const int MaxContextWindowTokens = 1_050_000; const int MaxOutputTokens = 128_000; -// Create a compaction strategy based on the model's context window. -// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens. -// Defaults: tool result eviction at 50% of input budget, truncation at 80%. -var compactionStrategy = new ContextWindowCompactionStrategy( - maxContextWindowTokens: MaxContextWindowTokens, - maxOutputTokens: MaxOutputTokens); - -// Create an OpenAIClient that communicates with the Foundry responses service and get an IChatClient with stored output disabled -// so that chat history is managed locally by the agent framework. -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -OpenAIClientOptions clientOptions = new() { Endpoint = new Uri(endpoint), RetryPolicy = new ClientRetryPolicy(3) }; -IChatClient chatClient = new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions) - .GetResponsesClient() - .AsIChatClientWithStoredOutputDisabled(deploymentName) - .AsBuilder() - .UseFunctionInvocation() - .UsePerServiceCallChatHistoryPersistence() - .UseAIContextProviders(new CompactionProvider(compactionStrategy)) - .Build(); - -// Create web browsing tools for downloading and converting HTML pages to markdown. -var webBrowsingTools = new WebBrowsingTools(); - // Create a ChatClientAgent with the Harness providers (TodoProvider and AgentModeProvider) // and research-focused instructions including the mandatory planning workflow. var instructions = @@ -123,36 +98,70 @@ Also save intermediate notes and findings as you go — this helps with long mul When a temporary file is no longer needed, delete it to keep file memory tidy. """; -AIAgent agent = new ChatClientAgent( - chatClient, - new ChatClientAgentOptions - { - Name = "ResearchAgent", - Description = "A research assistant that plans and executes research tasks.", - AIContextProviders = - [ - new TodoProvider(), - new AgentModeProvider(), - new FileMemoryProvider( - new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")), - (_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() }) - ], - RequirePerServiceCallChatHistoryPersistence = true, - UseProvidedChatClientAsIs = true, - ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions +// Create a compaction strategy based on the model's context window. +// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens. +// Defaults: tool result eviction at 50% of input budget, truncation at 80%. +var compactionStrategy = new ContextWindowCompactionStrategy( + maxContextWindowTokens: MaxContextWindowTokens, + maxOutputTokens: MaxOutputTokens); + +AIAgent agent = + // Create an OpenAIClient that communicates with the Foundry responses service. + new OpenAIClient( + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), + new OpenAIClientOptions() { - ChatReducer = compactionStrategy.AsChatReducer(), - }), - ChatOptions = new ChatOptions + Endpoint = new Uri(endpoint), + RetryPolicy = new ClientRetryPolicy(3) // Enable retries to improve resiliency. + }) + .GetResponsesClient() + .AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves. + + // Build a ChatClient Pipeline + .AsBuilder() + .UseFunctionInvocation() // We are building our own stack from scratch so we need to include Function Invocation ourselves. + .UsePerServiceCallChatHistoryPersistence() // Save chat history updates to the session after each service call, rather than only at the end of the run. + .UseAIContextProviders(new CompactionProvider(compactionStrategy)) // Add Compaction before each service call to responses so that long function invocation loops don't overflow the context. + + // Build our agent on top of the ChatClient Pipeline + .BuildAIAgent( + new ChatClientAgentOptions { - // Set a high token limit for long research tasks with many tool calls and long outputs. - // This matches gpt-5.4's max output tokens, and should be adjusted depending on the model used and expected response length. - MaxOutputTokens = 128_000, - Instructions = instructions, - Reasoning = new() { Effort = ReasoningEffort.Medium }, - Tools = [ResponseTool.CreateWebSearchTool().AsAITool(), .. webBrowsingTools.Tools], - }, - }); + Name = "ResearchAgent", + Description = "A research assistant that plans and executes research tasks.", + UseProvidedChatClientAsIs = true, // Since we built our own stack from scratch we need to tell the agent not to also add defaults like Function Invocation. + RequirePerServiceCallChatHistoryPersistence = true, // Since we are added the per service call persistence ChatClient, we need to tell the agent to not also store chat history at the end of the run. + ChatHistoryProvider = new InMemoryChatHistoryProvider( // Store chat history in memory in the session object. Will persist if the session is persisted. + new InMemoryChatHistoryProviderOptions + { + ChatReducer = compactionStrategy.AsChatReducer(), // Run compaction on the InMemory chat history when it gets too large. + }), + AIContextProviders = + [ + new TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session. + new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session. + new FileMemoryProvider( // Add an AIContextProvider that can store memories in files under a session specific working folder. + new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")), + (_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() }) + ], + ChatOptions = new ChatOptions + { + Instructions = instructions, + Tools = + [ + ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service. + new WebBrowsingTool(), // Add a local web browsing tool that converts html to markdown. + ], + MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs. + Reasoning = new() { Effort = ReasoningEffort.Medium }, + }, + }) + .AsBuilder() + .UseToolApproval() // Add the ability to auto approve tools once a user has said they don't want to be asked again. Approval rules are tied to the session. + .Build(); // Run the interactive console session using the shared HarnessConsole helper. await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started.", maxContextWindowTokens: MaxContextWindowTokens, maxOutputTokens: MaxOutputTokens); diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTools.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs similarity index 93% rename from dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTools.cs rename to dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs index 0c7e7d826e..8f35070bf6 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTools.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs @@ -2,25 +2,34 @@ using System.ComponentModel; using System.Net; +using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.Extensions.AI; namespace SampleApp; /// -/// Provides a web browsing tool that downloads HTML pages and converts them to markdown. +/// An AI function that downloads HTML pages and converts them to markdown. /// -internal sealed partial class WebBrowsingTools +internal sealed partial class WebBrowsingTool : AIFunction { private static readonly HttpClient s_httpClient = new(); + private readonly AIFunction _inner = AIFunctionFactory.Create(DownloadUriAsync); - /// - /// Gets the web browsing tools. - /// - public IList Tools { get; } = - [ - AIFunctionFactory.Create(DownloadUriAsync), - ]; + /// + public override string Name => this._inner.Name; + + /// + public override string Description => this._inner.Description; + + /// + public override JsonElement JsonSchema => this._inner.JsonSchema; + + /// + protected override ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, + CancellationToken cancellationToken) => + this._inner.InvokeAsync(arguments, cancellationToken); [Description("Download the html from the given url as markdown")] private static async Task DownloadUriAsync( diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index cbcffcc679..a246424bef 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -81,6 +81,11 @@ private static JsonSerializerOptions CreateDefaultOptions() // AgentModeProvider types [JsonSerializable(typeof(AgentModeState))] + // ToolApprovalAgent types + [JsonSerializable(typeof(ToolApprovalState))] + [JsonSerializable(typeof(ToolApprovalRule))] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "ToolApprovalRuleList")] + // FileMemoryProvider types [JsonSerializable(typeof(FileMemoryState))] [JsonSerializable(typeof(FileSearchResult))] diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs index 200baf7b94..ab62a38281 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/PerServiceCallChatHistoryPersistingChatClient.cs @@ -188,7 +188,7 @@ public override async IAsyncEnumerable GetStreamingResponseA while (hasUpdates) { var update = enumerator.Current; - responseUpdates.Add(update); + responseUpdates.Add(update.Clone()); // If the service returned a real ConversationId on any update, remember that. // Otherwise stamp our sentinel so FICC treats this as service-managed — diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContent.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContent.cs new file mode 100644 index 0000000000..df9631713e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContent.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Wraps a with additional "always approve" settings, +/// enabling the middleware to record standing approval rules +/// so that future matching tool calls are auto-approved without user interaction. +/// +/// +/// +/// Instances of this class should not be created directly. Instead, use the extension methods +/// or +/// +/// on to create instances with the appropriate flags set. +/// +/// +/// The middleware will unwrap the to forward +/// to the inner agent, while extracting the approval settings to persist as +/// entries in the session state. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AlwaysApproveToolApprovalResponseContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The underlying approval response to forward to the agent. + /// + /// When , all future calls to this tool type will be auto-approved. + /// + /// + /// When , all future calls to this tool type with the same arguments will be auto-approved. + /// + internal AlwaysApproveToolApprovalResponseContent( + ToolApprovalResponseContent innerResponse, + bool alwaysApproveTool, + bool alwaysApproveToolWithArguments) + { + this.InnerResponse = Throw.IfNull(innerResponse); + this.AlwaysApproveTool = alwaysApproveTool; + this.AlwaysApproveToolWithArguments = alwaysApproveToolWithArguments; + } + + /// + /// Gets the underlying that will be forwarded to the inner agent. + /// + public ToolApprovalResponseContent InnerResponse { get; } + + /// + /// Gets a value indicating whether all future calls to the same tool should be auto-approved + /// regardless of the arguments provided. + /// + public bool AlwaysApproveTool { get; } + + /// + /// Gets a value indicating whether all future calls to the same tool with the exact same + /// arguments should be auto-approved. + /// + public bool AlwaysApproveToolWithArguments { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs new file mode 100644 index 0000000000..8512f686ec --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgent.cs @@ -0,0 +1,781 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// A middleware that implements "don't ask again" tool approval behavior +/// and queues multiple approval requests to present them to the caller one at a time. +/// +/// +/// +/// This middleware intercepts the approval flow between the caller and the inner agent: +/// +/// +/// +/// Outbound (response to caller): When the inner agent surfaces items, +/// the middleware checks whether matching entries have been recorded. Matched requests +/// are auto-approved and stored as collected approval responses. If multiple unapproved requests remain, only the +/// first is returned to the caller while the rest are queued. On subsequent calls, queued items are re-evaluated +/// against rules (which may have been updated by the caller's "always approve" response) and presented one at a time. +/// Once all queued requests are resolved, the collected responses are injected and the inner agent is called again. +/// +/// +/// Inbound (caller to agent): When the caller sends an , +/// the middleware extracts the standing approval settings, records them as entries +/// in the session state, and forwards only the unwrapped to the inner agent. +/// Content ordering within each message is preserved. +/// +/// +/// +/// Approval rules are persisted in the and survive across agent runs within the same session. +/// Two categories of rules are supported: +/// +/// +/// Tool-level: Approve all calls to a specific tool, regardless of arguments. +/// Tool+arguments: Approve all calls to a specific tool with exactly matching arguments. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class ToolApprovalAgent : DelegatingAIAgent +{ + private readonly ProviderSessionState _sessionState; + private readonly JsonSerializerOptions _jsonSerializerOptions; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying agent to delegate to. + /// + /// Optional used for serializing argument values when storing rules + /// and for persisting state. When , is used. + /// + /// is . + public ToolApprovalAgent(AIAgent innerAgent, JsonSerializerOptions? jsonSerializerOptions = null) + : base(innerAgent) + { + this._jsonSerializerOptions = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions; + this._sessionState = new ProviderSessionState( + _ => new ToolApprovalState(), + "toolApprovalState", + this._jsonSerializerOptions); + } + + /// + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + // Steps 1–2: Unwrap AlwaysApprove wrappers, process any queued approval requests. + var (state, callerMessages, nextQueuedItem) = this.PrepareInboundMessages(messages, session); + + if (nextQueuedItem is not null) + { + // Queue still has items — return the next one to the caller for approval. + return new AgentResponse(new ChatMessage(ChatRole.Assistant, [nextQueuedItem])); + } + + // 3. Call the inner agent in a loop. If the inner agent returns approval requests + // that are ALL auto-approved by standing rules, we immediately re-call with the + // collected approval responses injected. This avoids returning empty responses. + while (true) + { + // Inject any collected approval responses as a user message ahead of the caller's messages. + var processedMessages = this.InjectCollectedResponses(callerMessages, state, session); + + var response = await this.InnerAgent.RunAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false); + + // Classify approval requests: auto-approve matching, queue excess, keep first unapproved. + bool allAutoApproved = this.ProcessAndQueueOutboundApprovalRequests(response.Messages, state, session); + + if (!allAutoApproved) + { + // Response has real content or an unapproved approval request — return to caller. + return response; + } + + // All approval requests were auto-approved. Loop to re-invoke with them injected. + callerMessages = []; + } + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Steps 1–2: Unwrap AlwaysApprove wrappers, process any queued approval requests. + var (state, callerMessages, nextQueuedItem) = this.PrepareInboundMessages(messages, session); + + if (nextQueuedItem is not null) + { + // Queue still has items — yield the next one to the caller for approval. + yield return new AgentResponseUpdate(ChatRole.Assistant, [nextQueuedItem]); + yield break; + } + + // 3. Stream from the inner agent in a loop. If all approval requests from the stream + // are auto-approved by standing rules, we immediately re-stream with the collected + // approval responses injected. This avoids returning empty streams. + while (true) + { + // Inject any collected approval responses as a user message ahead of the caller's messages. + var processedMessages = this.InjectCollectedResponses(callerMessages, state, session); + + // Stream from the inner agent. Non-approval content is yielded immediately. + // Approval requests are collected (not yielded) so we can classify the full batch. + List streamedApprovalRequests = []; + + await foreach (var update in this.InnerAgent.RunStreamingAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false)) + { + // Fast path: no approval content in this update — yield as-is. + bool hasApprovalRequests = false; + foreach (var content in update.Contents) + { + if (content is ToolApprovalRequestContent) + { + hasApprovalRequests = true; + break; + } + } + + if (!hasApprovalRequests) + { + yield return update; + continue; + } + + // Split the update: collect approval requests, keep other content. + var filteredContents = new List(); + foreach (var content in update.Contents) + { + if (content is ToolApprovalRequestContent tarc) + { + streamedApprovalRequests.Add(tarc); + } + else + { + filteredContents.Add(content); + } + } + + // Yield the non-approval portion of the update (if any) as a cloned update. + if (filteredContents.Count > 0) + { + yield return new AgentResponseUpdate(update.Role, filteredContents) + { + AuthorName = update.AuthorName, + AdditionalProperties = update.AdditionalProperties, + AgentId = update.AgentId, + ResponseId = update.ResponseId, + MessageId = update.MessageId, + CreatedAt = update.CreatedAt, + ContinuationToken = update.ContinuationToken, + FinishReason = update.FinishReason, + RawRepresentation = update.RawRepresentation, + }; + } + } + + // If the stream contained no approval requests, we're done. + if (streamedApprovalRequests.Count == 0) + { + yield break; + } + + // 4. Classify the collected approval requests against standing rules. + List unapproved = []; + foreach (var tarc in streamedApprovalRequests) + { + if (MatchesRule(tarc, state.Rules, this._jsonSerializerOptions)) + { + state.CollectedApprovalResponses.Add( + tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule")); + } + else + { + unapproved.Add(tarc); + } + } + + // If all were auto-approved, loop to re-invoke the inner agent with them injected. + if (unapproved.Count == 0) + { + callerMessages = []; + continue; + } + + // 5. Queue excess unapproved requests and yield only the first to the caller. + if (unapproved.Count > 1) + { + state.QueuedApprovalRequests.AddRange(unapproved.GetRange(1, unapproved.Count - 1)); + } + + this._sessionState.SaveState(session, state); + yield return new AgentResponseUpdate(ChatRole.Assistant, [unapproved[0]]); + yield break; + } + } + + /// + /// Extracts instances from the caller's messages + /// and collects them into . + /// Extracted responses are removed from the messages in-place. + /// + private static void CollectApprovalResponsesFromMessages( + List messages, + ToolApprovalState state) + { + // Walk messages in reverse so we can safely remove by index. + for (int i = messages.Count - 1; i >= 0; i--) + { + var message = messages[i]; + + // Quick check: does this message contain any approval responses? + bool hasApprovalResponse = false; + foreach (var content in message.Contents) + { + if (content is ToolApprovalResponseContent) + { + hasApprovalResponse = true; + break; + } + } + + if (!hasApprovalResponse) + { + continue; + } + + // Separate approval responses (→ state) from other content (→ keep in message). + var remaining = new List(message.Contents.Count); + foreach (var content in message.Contents) + { + if (content is ToolApprovalResponseContent response) + { + state.CollectedApprovalResponses.Add(response); + } + else + { + remaining.Add(content); + } + } + + // Remove the message entirely if it only contained approval responses, + // otherwise replace it with a clone that has the approval responses stripped. + if (remaining.Count == 0) + { + messages.RemoveAt(i); + } + else + { + var cloned = message.Clone(); + cloned.Contents = remaining; + messages[i] = cloned; + } + } + } + + /// + /// Re-evaluates queued approval requests against current rules and auto-approves any that now match. + /// + private void DrainAutoApprovableFromQueue(ToolApprovalState state) + { + for (int i = state.QueuedApprovalRequests.Count - 1; i >= 0; i--) + { + if (MatchesRule(state.QueuedApprovalRequests[i], state.Rules, this._jsonSerializerOptions)) + { + state.CollectedApprovalResponses.Add( + state.QueuedApprovalRequests[i].CreateResponse(approved: true, reason: "Auto-approved by standing rule")); + state.QueuedApprovalRequests.RemoveAt(i); + } + } + } + + /// + /// Performs the common inbound processing shared by both the streaming and non-streaming paths: + /// + /// Unwraps wrappers, extracting standing rules. + /// If there are queued approval requests from a previous batch, collects the caller's responses, + /// drains any items now resolvable by new rules, and dequeues the next item if any remain. + /// + /// + /// + /// A tuple of (state, processed caller messages, next queued item or if the queue is resolved). + /// When the returned item is non-null, the caller should return/yield it without calling the inner agent. + /// + private (ToolApprovalState State, List CallerMessages, ToolApprovalRequestContent? NextQueuedItem) + PrepareInboundMessages(IEnumerable messages, AgentSession? session) + { + var state = this._sessionState.GetOrInitializeState(session); + + // 1. Unwrap any AlwaysApprove wrappers in the caller's messages. + // This extracts standing approval rules into state and replaces wrappers with plain responses. + var callerMessages = UnwrapAlwaysApproveResponses(messages, state, this._jsonSerializerOptions); + + // 2. If there are queued approval requests from a previous batch, handle them + // before calling the inner agent. + if (state.QueuedApprovalRequests.Count > 0) + { + // Collect the caller's approval/denial responses for the previously dequeued item + // and store them in state for the next downstream call. + CollectApprovalResponsesFromMessages(callerMessages, state); + + // Re-evaluate remaining queued items — the caller may have added new rules + // (e.g., "always approve this tool") that resolve additional items. + this.DrainAutoApprovableFromQueue(state); + + if (state.QueuedApprovalRequests.Count > 0) + { + // More items remain — dequeue the next one for the caller. + var next = state.QueuedApprovalRequests[0]; + state.QueuedApprovalRequests.RemoveAt(0); + this._sessionState.SaveState(session, state); + return (state, callerMessages, next); + } + + // Queue fully resolved — caller should proceed to call the inner agent. + } + + return (state, callerMessages, null); + } + + /// + /// Injects any collected approval responses as user messages before the caller's messages, + /// then clears the collected responses. + /// + private List InjectCollectedResponses( + List callerMessages, + ToolApprovalState state, + AgentSession? session) + { + if (state.CollectedApprovalResponses.Count > 0) + { + List result = [new ChatMessage(ChatRole.User, [.. state.CollectedApprovalResponses])]; + result.AddRange(callerMessages); + + state.CollectedApprovalResponses.Clear(); + this._sessionState.SaveState(session, state); + + return result; + } + + return callerMessages; + } + + /// + /// Processes outbound approval requests from non-streaming response messages. + /// Auto-approvable requests are collected as responses, and if multiple unapproved requests + /// remain, only the first is kept in the response while the rest are queued for subsequent calls. + /// + /// + /// if all TARc items were auto-approved (caller should re-invoke the inner agent); + /// otherwise. + /// + private bool ProcessAndQueueOutboundApprovalRequests( + IList responseMessages, + ToolApprovalState state, + AgentSession? session) + { + // Pass 1: Scan all response messages and classify each approval request as + // auto-approved (matches a standing rule) or unapproved (needs caller decision). + var autoApproved = new List(); + var unapproved = new List(); + + foreach (var message in responseMessages) + { + foreach (var content in message.Contents) + { + if (content is ToolApprovalRequestContent tarc) + { + if (MatchesRule(tarc, state.Rules, this._jsonSerializerOptions)) + { + autoApproved.Add(tarc); + } + else + { + unapproved.Add(tarc); + } + } + } + } + + // Nothing to process: no auto-approved items and at most one unapproved (no queueing needed). + if (autoApproved.Count == 0 && unapproved.Count <= 1) + { + return false; + } + + // Store auto-approved responses for later injection into the inner agent. + foreach (var tarc in autoApproved) + { + state.CollectedApprovalResponses.Add( + tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule")); + } + + // If every approval request was auto-approved, strip them all and signal the caller + // to re-invoke the inner agent immediately with the collected responses. + if (unapproved.Count == 0) + { + RemoveAllToolApprovalRequests(responseMessages); + this._sessionState.SaveState(session, state); + return true; + } + + // Pass 2: Keep only the first unapproved request in the response (for the caller to decide). + // Queue the remaining unapproved requests for subsequent one-at-a-time delivery. + // Remove all auto-approved and queued items from the response messages. + var toRemove = new HashSet(autoApproved); + if (unapproved.Count > 1) + { + for (int i = 1; i < unapproved.Count; i++) + { + toRemove.Add(unapproved[i]); + state.QueuedApprovalRequests.Add(unapproved[i]); + } + } + + // Walk messages in reverse and strip marked items. + for (int i = responseMessages.Count - 1; i >= 0; i--) + { + var message = responseMessages[i]; + + // Quick check: does this message contain any items to remove? + bool hasRemovable = false; + foreach (var content in message.Contents) + { + if (content is ToolApprovalRequestContent tarc && toRemove.Contains(tarc)) + { + hasRemovable = true; + break; + } + } + + if (!hasRemovable) + { + continue; + } + + // Filter out the marked items, keeping everything else. + var remaining = new List(message.Contents.Count); + foreach (var content in message.Contents) + { + if (content is ToolApprovalRequestContent tarc && toRemove.Contains(tarc)) + { + continue; + } + + remaining.Add(content); + } + + // Remove the message entirely if it's now empty, otherwise replace with filtered clone. + if (remaining.Count == 0) + { + responseMessages.RemoveAt(i); + } + else + { + var clonedMessage = message.Clone(); + clonedMessage.Contents = remaining; + responseMessages[i] = clonedMessage; + } + } + + this._sessionState.SaveState(session, state); + return false; + } + + /// + /// Removes all items from response messages. + /// + private static void RemoveAllToolApprovalRequests(IList responseMessages) + { + // Walk messages in reverse so we can safely remove by index. + for (int i = responseMessages.Count - 1; i >= 0; i--) + { + var message = responseMessages[i]; + + // Quick check: does this message contain any approval requests? + bool hasTarc = false; + foreach (var content in message.Contents) + { + if (content is ToolApprovalRequestContent) + { + hasTarc = true; + break; + } + } + + if (!hasTarc) + { + continue; + } + + // Keep only non-approval content. + var remaining = new List(message.Contents.Count); + foreach (var content in message.Contents) + { + if (content is not ToolApprovalRequestContent) + { + remaining.Add(content); + } + } + + // Remove the message entirely if it's now empty, otherwise replace with filtered clone. + if (remaining.Count == 0) + { + responseMessages.RemoveAt(i); + } + else + { + var clonedMessage = message.Clone(); + clonedMessage.Contents = remaining; + responseMessages[i] = clonedMessage; + } + } + } + + /// + /// Scans input messages for instances, + /// extracts standing approval rules, and replaces them in-place with the unwrapped inner + /// , preserving content ordering. + /// + private static List UnwrapAlwaysApproveResponses( + IEnumerable messages, + ToolApprovalState state, + JsonSerializerOptions jsonSerializerOptions) + { + var messageList = messages as IList ?? new List(messages); + var result = new List(messageList.Count); + bool anyModified = false; + + foreach (var message in messageList) + { + // Quick check: does this message contain any AlwaysApprove wrappers? + bool hasAlwaysApprove = false; + foreach (var content in message.Contents) + { + if (content is AlwaysApproveToolApprovalResponseContent) + { + hasAlwaysApprove = true; + break; + } + } + + if (!hasAlwaysApprove) + { + result.Add(message); + continue; + } + + // Walk content items, replacing each AlwaysApprove wrapper with its inner response + // while extracting the standing approval rule into state. + var newContents = new List(message.Contents.Count); + foreach (var content in message.Contents) + { + if (content is AlwaysApproveToolApprovalResponseContent alwaysApprove) + { + // Extract and store the standing approval rule. + if (alwaysApprove.InnerResponse.ToolCall is FunctionCallContent toolCall) + { + if (alwaysApprove.AlwaysApproveTool) + { + AddRuleIfNotExists(state, new ToolApprovalRule { ToolName = toolCall.Name }); + } + else if (alwaysApprove.AlwaysApproveToolWithArguments) + { + AddRuleIfNotExists(state, new ToolApprovalRule + { + ToolName = toolCall.Name, + Arguments = SerializeArguments(toolCall.Arguments, jsonSerializerOptions), + }); + } + } + + // Replace the wrapper with the unwrapped inner response, preserving position. + newContents.Add(alwaysApprove.InnerResponse); + } + else + { + newContents.Add(content); + } + } + + // Clone the original message so all metadata is preserved, then replace contents. + var clonedMessage = message.Clone(); + clonedMessage.Contents = newContents; + result.Add(clonedMessage); + anyModified = true; + } + + // Avoid allocating a new list if nothing was modified. + return anyModified ? result : (messageList as List ?? messageList.ToList()); + } + + /// + /// Determines whether a tool approval request matches any of the stored rules. + /// + internal static bool MatchesRule( + ToolApprovalRequestContent request, + IReadOnlyList rules, + JsonSerializerOptions jsonSerializerOptions) + { + if (request.ToolCall is not FunctionCallContent functionCall) + { + return false; + } + + foreach (var rule in rules) + { + if (!string.Equals(rule.ToolName, functionCall.Name, StringComparison.Ordinal)) + { + continue; + } + + // Tool-level rule: matches any arguments + if (rule.Arguments is null) + { + return true; + } + + // Tool+arguments rule: exact match on all argument values + if (ArgumentsMatch(rule.Arguments, functionCall.Arguments, jsonSerializerOptions)) + { + return true; + } + } + + return false; + } + + /// + /// Compares stored rule arguments against actual function call arguments for an exact match. + /// + private static bool ArgumentsMatch(IDictionary ruleArguments, IDictionary? callArguments, JsonSerializerOptions jsonSerializerOptions) + { + if (callArguments is null) + { + return ruleArguments.Count == 0; + } + + if (ruleArguments.Count != callArguments.Count) + { + return false; + } + + foreach (var kvp in ruleArguments) + { + if (!callArguments.TryGetValue(kvp.Key, out var callValue)) + { + return false; + } + + var serializedCallValue = SerializeArgumentValue(callValue, jsonSerializerOptions); + if (!string.Equals(kvp.Value, serializedCallValue, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + /// + /// Serializes function call arguments to a string dictionary for storage and comparison. + /// + private static Dictionary? SerializeArguments(IDictionary? arguments, JsonSerializerOptions jsonSerializerOptions) + { + if (arguments is null || arguments.Count == 0) + { + return null; + } + + var serialized = new Dictionary(arguments.Count, StringComparer.Ordinal); + foreach (var kvp in arguments) + { + serialized[kvp.Key] = SerializeArgumentValue(kvp.Value, jsonSerializerOptions); + } + + return serialized; + } + + /// + /// Serializes a single argument value to its JSON string representation. + /// + private static string SerializeArgumentValue(object? value, JsonSerializerOptions jsonSerializerOptions) + { + if (value is null) + { + return "null"; + } + + if (value is JsonElement jsonElement) + { + return jsonElement.GetRawText(); + } + + return JsonSerializer.Serialize(value, jsonSerializerOptions.GetTypeInfo(value.GetType())); + } + + /// + /// Adds a rule to the state if an equivalent rule does not already exist. + /// + private static void AddRuleIfNotExists(ToolApprovalState state, ToolApprovalRule newRule) + { + foreach (var existingRule in state.Rules) + { + if (!string.Equals(existingRule.ToolName, newRule.ToolName, StringComparison.Ordinal)) + { + continue; + } + + if (existingRule.Arguments is null && newRule.Arguments is null) + { + return; // Duplicate tool-level rule + } + + if (existingRule.Arguments is not null && newRule.Arguments is not null && + ArgumentDictionariesEqual(existingRule.Arguments, newRule.Arguments)) + { + return; // Duplicate tool+args rule + } + } + + state.Rules.Add(newRule); + } + + /// + /// Compares two string dictionaries for equality. + /// + private static bool ArgumentDictionariesEqual(IDictionary a, IDictionary b) + { + if (a.Count != b.Count) + { + return false; + } + + foreach (var kvp in a) + { + if (!b.TryGetValue(kvp.Key, out var bValue) || !string.Equals(kvp.Value, bValue, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentBuilderExtensions.cs new file mode 100644 index 0000000000..ec92bb8d6c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalAgentBuilderExtensions.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides extension methods for adding tool approval middleware to instances. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public static class ToolApprovalAgentBuilderExtensions +{ + /// + /// Adds tool approval middleware to the agent pipeline, enabling "don't ask again" approval behavior. + /// + /// The to which tool approval support will be added. + /// + /// Optional used for serializing argument values when storing rules + /// and for persisting state. When , is used. + /// + /// The with tool approval middleware added, enabling method chaining. + /// is . + /// + /// + /// The middleware intercepts tool approval flows between the caller and the inner agent. + /// When a caller responds with an , the middleware records a standing + /// approval rule so that future matching tool calls are auto-approved without user interaction. + /// + /// + public static AIAgentBuilder UseToolApproval( + this AIAgentBuilder builder, + JsonSerializerOptions? jsonSerializerOptions = null) + => Throw.IfNull(builder).Use(innerAgent => new ToolApprovalAgent(innerAgent, jsonSerializerOptions)); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRequestContentExtensions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRequestContentExtensions.cs new file mode 100644 index 0000000000..9974962ed5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRequestContentExtensions.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides extension methods on for creating +/// instances that instruct the +/// middleware to record standing approval rules. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public static class ToolApprovalRequestContentExtensions +{ + /// + /// Creates an approved that also + /// instructs the middleware to always approve future calls to the same tool, + /// regardless of the arguments provided. + /// + /// The tool approval request to respond to. + /// An optional reason for the approval. + /// + /// An wrapping an approved + /// with the + /// flag set to . + /// + public static AlwaysApproveToolApprovalResponseContent CreateAlwaysApproveToolResponse( + this ToolApprovalRequestContent request, + string? reason = null) + { + _ = Throw.IfNull(request); + + return new AlwaysApproveToolApprovalResponseContent( + request.CreateResponse(approved: true, reason), + alwaysApproveTool: true, + alwaysApproveToolWithArguments: false); + } + + /// + /// Creates an approved that also + /// instructs the middleware to always approve future calls to the same tool + /// with the exact same arguments. + /// + /// The tool approval request to respond to. + /// An optional reason for the approval. + /// + /// An wrapping an approved + /// with the + /// flag set to . + /// + public static AlwaysApproveToolApprovalResponseContent CreateAlwaysApproveToolWithArgumentsResponse( + this ToolApprovalRequestContent request, + string? reason = null) + { + _ = Throw.IfNull(request); + + return new AlwaysApproveToolApprovalResponseContent( + request.CreateResponse(approved: true, reason), + alwaysApproveTool: false, + alwaysApproveToolWithArguments: true); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRule.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRule.cs new file mode 100644 index 0000000000..e633d1e003 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalRule.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents a standing approval rule for automatically approving tool calls +/// without requiring explicit user approval each time. +/// +/// +/// +/// A rule can match tool calls in two ways: +/// +/// Tool-level: When is , +/// all calls to the tool identified by are auto-approved. +/// Tool+arguments: When is non-null, +/// only calls to the specified tool with exactly matching argument values are auto-approved. +/// +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class ToolApprovalRule +{ + /// + /// Gets or sets the name of the tool function that this rule applies to. + /// + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = string.Empty; + + /// + /// Gets or sets the specific argument values that must match for this rule to apply. + /// When , the rule applies to all invocations of the tool + /// regardless of arguments. + /// + /// + /// Argument values are stored as their JSON-serialized string representations + /// for reliable comparison. + /// + [JsonPropertyName("arguments")] + public IDictionary? Arguments { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalState.cs new file mode 100644 index 0000000000..b740dc03c7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/ToolApproval/ToolApprovalState.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the persisted state of standing tool approval rules, +/// stored in the session's . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class ToolApprovalState +{ + /// + /// Gets or sets the list of standing approval rules. + /// + [JsonPropertyName("rules")] + public List Rules { get; set; } = new(); + + /// + /// Gets or sets the list of collected approval responses (both auto-approved and user-approved) + /// that are pending injection into the next inbound call to the inner agent. + /// + /// + /// + /// Responses are collected during a queue cycle: when the inner agent returns multiple tool approval + /// requests, auto-approved ones and user-approved ones are accumulated here. Once all queued requests + /// are resolved, the collected responses are injected alongside the caller's messages so the inner + /// agent receives all tool responses together. + /// + /// + [JsonPropertyName("collectedApprovalResponses")] + public List CollectedApprovalResponses { get; set; } = new(); + + /// + /// Gets or sets the list of queued tool approval requests that have not yet been + /// presented to the caller. + /// + /// + /// + /// When the inner agent returns multiple unapproved tool approval requests, only the first + /// is returned to the caller. The remaining requests are stored here and presented one at a + /// time on subsequent calls, allowing the caller's "always approve" rules to take effect on + /// later items in the same batch. + /// + /// + [JsonPropertyName("queuedApprovalRequests")] + public List QueuedApprovalRequests { get; set; } = new(); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContentTests.cs new file mode 100644 index 0000000000..b70dadab31 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/AlwaysApproveToolApprovalResponseContentTests.cs @@ -0,0 +1,288 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class +/// and extension methods. +/// +public class AlwaysApproveToolApprovalResponseContentTests +{ + #region CreateAlwaysApproveToolResponse + + /// + /// Verify that CreateAlwaysApproveToolResponse sets AlwaysApproveTool to true. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_AlwaysApproveTool_IsTrue() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolResponse(); + + // Assert + Assert.True(result.AlwaysApproveTool); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse sets AlwaysApproveToolWithArguments to false. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_AlwaysApproveToolWithArguments_IsFalse() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolResponse(); + + // Assert + Assert.False(result.AlwaysApproveToolWithArguments); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse creates an approved inner response. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_InnerResponse_IsApproved() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolResponse(); + + // Assert + Assert.True(result.InnerResponse.Approved); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse forwards the reason. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_Reason_IsForwarded() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolResponse("User trusts this tool"); + + // Assert + Assert.Equal("User trusts this tool", result.InnerResponse.Reason); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse preserves the request ID. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_RequestId_IsPreserved() + { + // Arrange + var request = CreateRequest("MyTool", "custom-request-id"); + + // Act + var result = request.CreateAlwaysApproveToolResponse(); + + // Assert + Assert.Equal("custom-request-id", result.InnerResponse.RequestId); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse preserves the tool call. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_ToolCall_IsPreserved() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolResponse(); + + // Assert + var functionCall = Assert.IsType(result.InnerResponse.ToolCall); + Assert.Equal("MyTool", functionCall.Name); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse with null reason sets reason to null. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_NullReason_ReasonIsNull() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolResponse(); + + // Assert + Assert.Null(result.InnerResponse.Reason); + } + + /// + /// Verify that CreateAlwaysApproveToolResponse throws on null request. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_NullRequest_Throws() + { + // Act & Assert + Assert.Throws("request", + () => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolResponse()); + } + + #endregion + + #region CreateAlwaysApproveToolWithArgumentsResponse + + /// + /// Verify that CreateAlwaysApproveToolWithArgumentsResponse sets AlwaysApproveToolWithArguments to true. + /// + [Fact] + public void CreateAlwaysApproveToolWithArgumentsResponse_AlwaysApproveToolWithArguments_IsTrue() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolWithArgumentsResponse(); + + // Assert + Assert.True(result.AlwaysApproveToolWithArguments); + } + + /// + /// Verify that CreateAlwaysApproveToolWithArgumentsResponse sets AlwaysApproveTool to false. + /// + [Fact] + public void CreateAlwaysApproveToolWithArgumentsResponse_AlwaysApproveTool_IsFalse() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolWithArgumentsResponse(); + + // Assert + Assert.False(result.AlwaysApproveTool); + } + + /// + /// Verify that CreateAlwaysApproveToolWithArgumentsResponse creates an approved inner response. + /// + [Fact] + public void CreateAlwaysApproveToolWithArgumentsResponse_InnerResponse_IsApproved() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolWithArgumentsResponse(); + + // Assert + Assert.True(result.InnerResponse.Approved); + } + + /// + /// Verify that CreateAlwaysApproveToolWithArgumentsResponse forwards the reason. + /// + [Fact] + public void CreateAlwaysApproveToolWithArgumentsResponse_Reason_IsForwarded() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolWithArgumentsResponse("Specific approval"); + + // Assert + Assert.Equal("Specific approval", result.InnerResponse.Reason); + } + + /// + /// Verify that CreateAlwaysApproveToolWithArgumentsResponse throws on null request. + /// + [Fact] + public void CreateAlwaysApproveToolWithArgumentsResponse_NullRequest_Throws() + { + // Act & Assert + Assert.Throws("request", + () => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolWithArgumentsResponse()); + } + + #endregion + + #region AlwaysApproveToolApprovalResponseContent Properties + + /// + /// Verify that the content is an AIContent subclass. + /// + [Fact] + public void Content_IsAIContentSubclass() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var result = request.CreateAlwaysApproveToolResponse(); + + // Assert + Assert.IsAssignableFrom(result); + } + + /// + /// Verify that InnerResponse preserves tool call arguments. + /// + [Fact] + public void InnerResponse_PreservesArguments() + { + // Arrange + var args = new Dictionary { ["path"] = "test.txt", ["count"] = 5 }; + var request = new ToolApprovalRequestContent("req1", + new FunctionCallContent("call1", "ReadFile", args)); + + // Act + var result = request.CreateAlwaysApproveToolWithArgumentsResponse(); + + // Assert + var functionCall = Assert.IsType(result.InnerResponse.ToolCall); + Assert.Equal(2, functionCall.Arguments!.Count); + Assert.Equal("test.txt", functionCall.Arguments["path"]); + } + + /// + /// Verify that both factory methods produce distinct instances from the same request. + /// + [Fact] + public void FactoryMethods_ProduceDistinctInstances() + { + // Arrange + var request = CreateRequest("MyTool"); + + // Act + var toolLevel = request.CreateAlwaysApproveToolResponse(); + var argsLevel = request.CreateAlwaysApproveToolWithArgumentsResponse(); + + // Assert + Assert.NotSame(toolLevel, argsLevel); + Assert.NotSame(toolLevel.InnerResponse, argsLevel.InnerResponse); + } + + #endregion + + #region Helpers + + private static ToolApprovalRequestContent CreateRequest(string toolName, string requestId = "req1") + { + return new ToolApprovalRequestContent(requestId, new FunctionCallContent("call1", toolName)); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentBuilderExtensionsTests.cs new file mode 100644 index 0000000000..9e43d2c0dc --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentBuilderExtensionsTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class ToolApprovalAgentBuilderExtensionsTests +{ + /// + /// Verify that UseToolApproval throws ArgumentNullException when builder is null. + /// + [Fact] + public void UseToolApproval_WithNullBuilder_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws("builder", () => ((AIAgentBuilder)null!).UseToolApproval()); + } + + /// + /// Verify that UseToolApproval returns a ToolApprovalAgent. + /// + [Fact] + public void UseToolApproval_WithValidBuilder_ReturnsToolApprovalAgent() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act + var result = builder.UseToolApproval().Build(); + + // Assert + Assert.IsType(result); + } + + /// + /// Verify that UseToolApproval returns the same builder instance for chaining. + /// + [Fact] + public void UseToolApproval_ReturnsBuilderForChaining() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act + var result = builder.UseToolApproval(); + + // Assert + Assert.Same(builder, result); + } + + /// + /// Verify that UseToolApproval with custom JsonSerializerOptions works correctly. + /// + [Fact] + public void UseToolApproval_WithCustomJsonSerializerOptions_ReturnsToolApprovalAgent() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + var options = new JsonSerializerOptions(); + + // Act + var result = builder.UseToolApproval(jsonSerializerOptions: options).Build(); + + // Assert + Assert.IsType(result); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs new file mode 100644 index 0000000000..41c7c473aa --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalAgentTests.cs @@ -0,0 +1,1538 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class ToolApprovalAgentTests +{ + #region Constructor + + /// + /// Verify that constructor throws ArgumentNullException when innerAgent is null. + /// + [Fact] + public void Constructor_NullInnerAgent_ThrowsAsync() + { + // Act & Assert + Assert.Throws("innerAgent", () => new ToolApprovalAgent(null!)); + } + + /// + /// Verify that constructor creates a valid instance. + /// + [Fact] + public void Constructor_ValidInnerAgent_CreatesInstanceAsync() + { + // Arrange + var innerAgent = new Mock().Object; + + // Act + var agent = new ToolApprovalAgent(innerAgent); + + // Assert + Assert.NotNull(agent); + } + + /// + /// Verify that constructor accepts custom JsonSerializerOptions. + /// + [Fact] + public void Constructor_CustomJsonSerializerOptions_CreatesInstanceAsync() + { + // Arrange + var innerAgent = new Mock().Object; + var options = new JsonSerializerOptions(); + + // Act + var agent = new ToolApprovalAgent(innerAgent, options); + + // Assert + Assert.NotNull(agent); + } + + #endregion + + #region RunAsync - Passthrough + + /// + /// Verify that when there are no approval requests, response passes through unchanged. + /// + [Fact] + public async Task RunAsync_NoApprovalRequests_PassesThroughAsync() + { + // Arrange + var expectedResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Hello")]); + var innerAgent = CreateMockAgent(expectedResponse); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Hi")], + new ChatClientAgentSession()); + + // Assert + Assert.Equal("Hello", response.Text); + } + + /// + /// Verify that approval requests with no matching rules are surfaced to the caller. + /// + [Fact] + public async Task RunAsync_ApprovalRequestNoRule_SurfacesToCallerAsync() + { + // Arrange + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var responseMessage = new ChatMessage(ChatRole.Assistant, [approvalRequest]); + var expectedResponse = new AgentResponse([responseMessage]); + var innerAgent = CreateMockAgent(expectedResponse); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Hi")], + new ChatClientAgentSession()); + + // Assert + var requests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests); + Assert.Equal("MyTool", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + #endregion + + #region RunAsync - Deferred Auto-Approve + + /// + /// Verify that when a tool-level rule exists, matching approval requests are + /// auto-approved immediately and the inner agent is re-called in the same run. + /// + [Fact] + public async Task RunAsync_ToolLevelRule_DeferredAutoApproveAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + + // Call 1: establish rule + inner agent returns approval request → auto-approved → re-call inner agent + var approvalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]); + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]); + + var callCount = 0; + List? secondCallMessages = null; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) => + { + callCount++; + if (callCount == 2) + { + secondCallMessages = msgs.ToList(); + } + }) + .ReturnsAsync(() => callCount == 1 ? approvalResponse : finalResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: send always-approve → establishes rule, inner returns TARc → auto-approved → re-calls inner + var alwaysApproveResponse = approvalRequest.CreateAlwaysApproveToolResponse("User said always"); + var response1 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApproveResponse])], + session); + + // Assert — inner agent was called twice within the same RunAsync call + Assert.Equal(2, callCount); + Assert.Equal("Done", response1.Text); + + // Response should NOT surface the auto-approved approval request to caller + var surfacedRequests = response1.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Empty(surfacedRequests); + + // Verify that the re-call received the injected auto-approval response + Assert.NotNull(secondCallMessages); + var injectedApprovals = secondCallMessages!.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(injectedApprovals); + Assert.True(injectedApprovals[0].Approved); + } + + /// + /// Verify that a tool+arguments rule stores pending auto-approvals for matching calls. + /// + [Fact] + public async Task RunAsync_ToolWithArgsRule_DeferredAutoApproveAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var args = new Dictionary { ["path"] = "test.txt" }; + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadFile", args)); + var approvalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]); + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "File content")]); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + callCount++; + return callCount == 1 ? approvalResponse : finalResponse; + }); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: set up the rule + var alwaysApproveResponse = approvalRequest.CreateAlwaysApproveToolWithArgumentsResponse(); + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApproveResponse])], + session); + + // Call 2: pending auto-approval injected + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Continue")], + session); + + // Assert + Assert.Equal("File content", response.Text); + } + + /// + /// Verify that a tool+arguments rule does NOT auto-approve when arguments differ. + /// + [Fact] + public async Task RunAsync_ToolWithArgsRule_DoesNotAutoApproveDifferentArgsAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + + // Set up rule with args { path: "test.txt" } + var ruleArgs = new Dictionary { ["path"] = "test.txt" }; + var ruleRequest = new ToolApprovalRequestContent("req0", new FunctionCallContent("call0", "ReadFile", ruleArgs)); + var alwaysApproveResponse = ruleRequest.CreateAlwaysApproveToolWithArgumentsResponse(); + + // Then the inner agent returns an approval for DIFFERENT args + var differentArgs = new Dictionary { ["path"] = "other.txt" }; + var newApprovalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadFile", differentArgs)); + var approvalResponseMsg = new AgentResponse([new ChatMessage(ChatRole.Assistant, [newApprovalRequest])]); + + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(approvalResponseMsg); + + var agent = new ToolApprovalAgent(innerAgent.Object); + var inputMessages = new List + { + new(ChatRole.User, [alwaysApproveResponse]), + }; + + // Act + var response = await agent.RunAsync(inputMessages, session); + + // Assert — the approval request should surface to the caller (not auto-approved) + var requests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ReadFile", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + #endregion + + #region Mixed Auto-Approve + + /// + /// Verify that when some approval requests match rules and others don't, + /// matching ones are stored as pending and non-matching are surfaced. + /// + [Fact] + public async Task RunAsync_MixedApprovalRequests_SurfacesNonMatchingStoreMatchingAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + + // Set up a rule for ToolA only + var ruleRequest = new ToolApprovalRequestContent("rule-req", new FunctionCallContent("rule-call", "ToolA")); + var alwaysApprove = ruleRequest.CreateAlwaysApproveToolResponse(); + + // Inner agent returns approval requests for both ToolA and ToolB + var approvalA = new ToolApprovalRequestContent("reqA", new FunctionCallContent("callA", "ToolA")); + var approvalB = new ToolApprovalRequestContent("reqB", new FunctionCallContent("callB", "ToolB")); + var mixedResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalA, approvalB])]); + + var innerAgent = CreateMockAgent(mixedResponse); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: establish rule + get mixed approval response + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + // Assert — ToolB request surfaced to caller, ToolA auto-approved is removed from response + var surfacedRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(surfacedRequests); + Assert.Equal("ToolB", ((FunctionCallContent)surfacedRequests[0].ToolCall).Name); + + // Call 2: verify pending auto-approval for ToolA is injected + List? capturedMessages = null; + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Final")]); + var callCount = 0; + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) => + { + callCount++; + capturedMessages = msgs.ToList(); + }) + .ReturnsAsync(finalResponse); + + // User manually approves ToolB and sends along + var toolBApproval = approvalB.CreateResponse(approved: true); + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [toolBApproval])], + session); + + // Verify pending auto-approval for ToolA was injected + Assert.NotNull(capturedMessages); + var allApprovals = capturedMessages!.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Equal(2, allApprovals.Count); // ToolA auto-approval + ToolB manual approval + } + + #endregion + + #region Content Ordering + + /// + /// Verify that content ordering is preserved when unwrapping AlwaysApproveToolApprovalResponseContent. + /// + [Fact] + public async Task RunAsync_UnwrapPreservesContentOrderAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse(); + + // Message with mixed content: text before, always-approve in middle, text after + var textBefore = new TextContent("Before"); + var textAfter = new TextContent("After"); + var inputMessage = new ChatMessage(ChatRole.User, [textBefore, alwaysApprove, textAfter]); + + List? capturedMessages = null; + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) => + capturedMessages = msgs.ToList()) + .ReturnsAsync(finalResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + await agent.RunAsync([inputMessage], session); + + // Assert — content order should be: TextContent("Before"), ToolApprovalResponseContent, TextContent("After") + Assert.NotNull(capturedMessages); + var contents = capturedMessages![0].Contents; + Assert.Equal(3, contents.Count); + Assert.IsType(contents[0]); + Assert.Equal("Before", ((TextContent)contents[0]).Text); + Assert.IsType(contents[1]); + Assert.IsType(contents[2]); + Assert.Equal("After", ((TextContent)contents[2]).Text); + } + + #endregion + + #region Unwrapping + + /// + /// Verify that AlwaysApproveToolApprovalResponseContent is unwrapped to the inner ToolApprovalResponseContent + /// before being forwarded to the inner agent. + /// + [Fact] + public async Task RunAsync_UnwrapsAlwaysApproveResponse_ForwardsInnerResponseAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse(); + + List? capturedMessages = null; + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) => + capturedMessages = msgs.ToList()) + .ReturnsAsync(finalResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + var inputMessages = new List + { + new(ChatRole.User, [alwaysApprove]), + }; + + // Act + await agent.RunAsync(inputMessages, session); + + // Assert — the forwarded message should contain ToolApprovalResponseContent, not AlwaysApproveToolApprovalResponseContent + Assert.NotNull(capturedMessages); + var contents = capturedMessages!.SelectMany(m => m.Contents).ToList(); + Assert.DoesNotContain(contents, c => c is AlwaysApproveToolApprovalResponseContent); + Assert.Contains(contents, c => c is ToolApprovalResponseContent); + } + + #endregion + + #region Rule Persistence + + /// + /// Verify that rules persist across multiple RunAsync calls on the same session, + /// and that auto-approved TARc are immediately handled via re-call within the same run. + /// + [Fact] + public async Task RunAsync_RulesPersistAcrossCallsAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse(); + + // Call 1: no approval requests in response, just establish the rule + var firstResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + // Call 2a: return an approval request that should match stored rule → auto-approved → re-call + var secondApproval = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool")); + var secondResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [secondApproval])]); + // Call 2b: re-call after auto-approve, final response + var thirdResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done after auto-approve")]); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + callCount++; + return callCount switch + { + 1 => firstResponse, + 2 => secondResponse, + _ => thirdResponse, + }; + }); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: establish rule (no approval requests returned) + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + // Call 2: inner agent returns TARc → matches rule → auto-approved → re-call → final response + var response2 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session); + + // Assert — inner agent called 3 times total (1 for rule setup, 2 for auto-approve loop) + Assert.Equal("Done after auto-approve", response2.Text); + Assert.Equal(3, callCount); + } + + /// + /// Verify that collected approval responses are cleared after injection (during the loop re-call). + /// A subsequent RunAsync call should not inject them again. + /// + [Fact] + public async Task RunAsync_CollectedApprovalResponses_ClearedAfterInjectionAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse(); + + // Call 1: establish rule + var firstResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + // Call 2a: return approval → auto-approved → loop + var approval = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool")); + var secondResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approval])]); + // Call 2b: re-call after auto-approve (injected) + var thirdResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Injected")]); + // Call 3: no pending (already cleared by the loop) + var fourthResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Clean")]); + + var callCount = 0; + List? lastCallMessages = null; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) => + { + callCount++; + lastCallMessages = msgs.ToList(); + }) + .ReturnsAsync(() => callCount switch + { + 1 => firstResponse, + 2 => secondResponse, + 3 => thirdResponse, + _ => fourthResponse, + }); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: establish rule (callCount → 1) + await agent.RunAsync([new ChatMessage(ChatRole.User, [alwaysApprove])], session); + + // Call 2: inner returns TARc → auto-approved → loop → callCount → 2, 3 + await agent.RunAsync([new ChatMessage(ChatRole.User, "Trigger approval")], session); + + // Call 3: no pending (cleared by the loop), callCount → 4 + var response3 = await agent.RunAsync([new ChatMessage(ChatRole.User, "No pending")], session); + + // Assert — last call should only have the user message, no injected approvals + Assert.NotNull(lastCallMessages); + var approvals = lastCallMessages!.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Empty(approvals); + Assert.Equal("Clean", response3.Text); + } + + #endregion + + #region RunStreamingAsync + + /// + /// Verify that streaming passthrough works when there are no approval requests. + /// + [Fact] + public async Task RunStreamingAsync_NoApprovalRequests_PassesThroughAsync() + { + // Arrange + var updates = new[] { new AgentResponseUpdate(ChatRole.Assistant, "Hello") }; + var innerAgent = CreateMockStreamingAgent(updates); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var results = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Hi")], + new ChatClientAgentSession())) + { + results.Add(update); + } + + // Assert + Assert.Single(results); + Assert.Equal("Hello", results[0].Text); + } + + #endregion + + #region MatchesRule + + /// + /// Verify that a tool-level rule matches regardless of arguments. + /// + [Fact] + public void MatchesRule_ToolLevelRule_MatchesAnyArgs() + { + // Arrange + var rules = new List + { + new() { ToolName = "MyTool" }, + }; + var request = new ToolApprovalRequestContent("req1", + new FunctionCallContent("call1", "MyTool", new Dictionary { ["x"] = "1" })); + + // Act & Assert + Assert.True(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + /// + /// Verify that a tool-level rule does not match a different tool name. + /// + [Fact] + public void MatchesRule_ToolLevelRule_DoesNotMatchDifferentTool() + { + // Arrange + var rules = new List + { + new() { ToolName = "ToolA" }, + }; + var request = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolB")); + + // Act & Assert + Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + /// + /// Verify that a tool+arguments rule matches with exact same arguments. + /// + [Fact] + public void MatchesRule_ToolWithArgsRule_MatchesExactArgs() + { + // Arrange — rule arguments must be in serialized form (JSON string representation) + var rules = new List + { + new() + { + ToolName = "ReadFile", + Arguments = new Dictionary { ["path"] = "\"test.txt\"" }, + }, + }; + var request = new ToolApprovalRequestContent("req1", + new FunctionCallContent("call1", "ReadFile", new Dictionary { ["path"] = "test.txt" })); + + // Act & Assert + Assert.True(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + /// + /// Verify that a tool+arguments rule does not match when argument values differ. + /// + [Fact] + public void MatchesRule_ToolWithArgsRule_DoesNotMatchDifferentValues() + { + // Arrange + var rules = new List + { + new() + { + ToolName = "ReadFile", + Arguments = new Dictionary { ["path"] = "\"test.txt\"" }, + }, + }; + var request = new ToolApprovalRequestContent("req1", + new FunctionCallContent("call1", "ReadFile", new Dictionary { ["path"] = "other.txt" })); + + // Act & Assert + Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + /// + /// Verify that a tool+arguments rule does not match when argument count differs. + /// + [Fact] + public void MatchesRule_ToolWithArgsRule_DoesNotMatchDifferentArgCount() + { + // Arrange + var rules = new List + { + new() + { + ToolName = "ReadFile", + Arguments = new Dictionary { ["path"] = "\"test.txt\"" }, + }, + }; + var request = new ToolApprovalRequestContent("req1", + new FunctionCallContent("call1", "ReadFile", new Dictionary + { + ["path"] = "test.txt", + ["encoding"] = "utf-8", + })); + + // Act & Assert + Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + /// + /// Verify that a non-FunctionCallContent tool call does not match any rule. + /// + [Fact] + public void MatchesRule_NonFunctionCallContent_ReturnsFalse() + { + // Arrange + var rules = new List + { + new() { ToolName = "MyTool" }, + }; + var request = new ToolApprovalRequestContent("req1", new ToolCallContent("call1")); + + // Act & Assert + Assert.False(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + /// + /// Verify that matching works with JsonElement argument values. + /// + [Fact] + public void MatchesRule_JsonElementArgs_MatchesCorrectly() + { + // Arrange + var rules = new List + { + new() + { + ToolName = "MyTool", + Arguments = new Dictionary { ["count"] = "42" }, + }, + }; + var jsonElement = JsonDocument.Parse("42").RootElement; + var request = new ToolApprovalRequestContent("req1", + new FunctionCallContent("call1", "MyTool", new Dictionary { ["count"] = jsonElement })); + + // Act & Assert + Assert.True(ToolApprovalAgent.MatchesRule(request, rules, AgentJsonUtilities.DefaultOptions)); + } + + #endregion + + #region Extension Methods + + /// + /// Verify that CreateAlwaysApproveToolResponse creates the correct wrapper. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_SetsCorrectFlags() + { + // Arrange + var request = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + + // Act + var result = request.CreateAlwaysApproveToolResponse("Test reason"); + + // Assert + Assert.True(result.AlwaysApproveTool); + Assert.False(result.AlwaysApproveToolWithArguments); + Assert.True(result.InnerResponse.Approved); + Assert.Equal("Test reason", result.InnerResponse.Reason); + Assert.Equal("req1", result.InnerResponse.RequestId); + } + + /// + /// Verify that CreateAlwaysApproveToolWithArgumentsResponse creates the correct wrapper. + /// + [Fact] + public void CreateAlwaysApproveToolWithArgumentsResponse_SetsCorrectFlags() + { + // Arrange + var request = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + + // Act + var result = request.CreateAlwaysApproveToolWithArgumentsResponse(); + + // Assert + Assert.False(result.AlwaysApproveTool); + Assert.True(result.AlwaysApproveToolWithArguments); + Assert.True(result.InnerResponse.Approved); + } + + /// + /// Verify that extension methods throw on null request. + /// + [Fact] + public void CreateAlwaysApproveToolResponse_NullRequest_Throws() + { + // Act & Assert + Assert.Throws("request", + () => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolResponse()); + } + + /// + /// Verify that extension methods throw on null request. + /// + [Fact] + public void CreateAlwaysApproveToolWithArgumentsResponse_NullRequest_Throws() + { + // Act & Assert + Assert.Throws("request", + () => ((ToolApprovalRequestContent)null!).CreateAlwaysApproveToolWithArgumentsResponse()); + } + + #endregion + + #region Duplicate Rule Prevention + + /// + /// Verify that sending the same always-approve response twice does not create duplicate rules. + /// + [Fact] + public async Task RunAsync_DuplicateAlwaysApprove_DoesNotDuplicateRuleAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var request1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var request2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool")); + + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + var innerAgent = CreateMockAgent(finalResponse); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act — send two always-approve responses for the same tool + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [request1.CreateAlwaysApproveToolResponse()])], + session); + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [request2.CreateAlwaysApproveToolResponse()])], + session); + + // Assert — verify the state works correctly (rule still matches on subsequent call) + var thirdApproval = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "MyTool")); + var approvalResponseMsg = new AgentResponse([new ChatMessage(ChatRole.Assistant, [thirdApproval])]); + var afterAutoResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Auto-approved")]); + + var callCount = 0; + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + callCount++; + return callCount == 1 ? approvalResponseMsg : afterAutoResponse; + }); + + // Call 3: triggers approval → stored as pending + await agent.RunAsync([new ChatMessage(ChatRole.User, "test")], session); + + // Call 4: pending injected + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "continue")], session); + Assert.Equal("Auto-approved", response.Text); + } + + #endregion + + #region Auto-Approved Removal + + /// + /// Verify that auto-approved requests are removed from the non-streaming response + /// and the inner agent is re-called, returning actual content. + /// + [Fact] + public async Task RunAsync_AutoApprovedRequest_RemovedFromResponseAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse(); + + // Inner agent returns a TARc on first call, then real content on second + var approvalResponseContent = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool")); + var firstResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalResponseContent])]); + var secondResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Tool executed successfully")]); + + var innerAgent = new Mock(); + var callCount = 0; + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => ++callCount == 1 ? firstResponse : secondResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act: establish rule + inner returns matching approval request → auto-approved → re-call + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + // Assert — auto-approved request removed, inner agent called twice, final response has text + var allRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Empty(allRequests); + Assert.Equal("Tool executed successfully", response.Messages[0].Text); + Assert.Equal(2, callCount); + } + + /// + /// Verify that auto-approved requests are removed from streaming updates + /// and the inner agent is re-called, yielding actual content. + /// + [Fact] + public async Task RunStreamingAsync_AutoApprovedRequest_RemovedFromUpdatesAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + + // First establish a rule via non-streaming + var alwaysApprove = approvalRequest.CreateAlwaysApproveToolResponse(); + var setupResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + + var innerAgent = new Mock(); + + // Setup non-streaming for rule establishment + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(setupResponse); + + // First streaming call: text + auto-approvable TARc. Second call: just text (tool executed). + var streamApproval = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "MyTool")); + var textUpdate = new AgentResponseUpdate(ChatRole.Assistant, "Hello"); + var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List { streamApproval }); + var finalUpdate = new AgentResponseUpdate(ChatRole.Assistant, "Done"); + + var streamCallCount = 0; + innerAgent + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(() => ++streamCallCount == 1 + ? ToAsyncEnumerableAsync(new[] { textUpdate, approvalUpdate }) + : ToAsyncEnumerableAsync(new[] { finalUpdate })); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Establish the rule + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + // Act — stream with auto-approvable request + var results = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session)) + { + results.Add(update); + } + + // Assert — text from first call + final text from re-call; no TARc surfaced + Assert.Equal(2, results.Count); + Assert.Equal("Hello", results[0].Text); + Assert.Equal("Done", results[1].Text); + Assert.Equal(2, streamCallCount); + } + + /// + /// Verify that non-matching approval requests remain in streaming updates. + /// + [Fact] + public async Task RunStreamingAsync_NonMatchingRequest_SurfacedToCallerAsync() + { + // Arrange + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "UnknownTool")); + var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List { approvalRequest }); + + var innerAgent = CreateMockStreamingAgent([approvalUpdate]); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var results = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Hi")], + new ChatClientAgentSession())) + { + results.Add(update); + } + + // Assert — non-matching request should be surfaced + Assert.Single(results); + var requests = results[0].Contents.OfType().ToList(); + Assert.Single(requests); + Assert.Equal("UnknownTool", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + /// + /// Verify that a message with only auto-approved content is removed from the response + /// when the inner agent is re-called after auto-approving. + /// + [Fact] + public async Task RunAsync_MessageWithOnlyAutoApprovedContent_RemovedEntirelyAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var ruleRequest = new ToolApprovalRequestContent("rule-req", new FunctionCallContent("rule-call", "MyTool")); + var alwaysApprove = ruleRequest.CreateAlwaysApproveToolResponse(); + + // First call: returns TARc-only message + text message. Second call: returns just text (tools executed). + var approvalContent = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var approvalMessage = new ChatMessage(ChatRole.Assistant, [approvalContent]); + var textMessage = new ChatMessage(ChatRole.Assistant, "Final text"); + var firstResponse = new AgentResponse([approvalMessage, textMessage]); + var secondResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Tools executed")]); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => ++callCount == 1 ? firstResponse : secondResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + // Assert — inner agent re-called after auto-approve, final response from second call + Assert.Equal(2, callCount); + Assert.Single(response.Messages); + Assert.Equal("Tools executed", response.Messages[0].Text); + } + + /// + /// Verify that a streaming update with mixed content (auto-approved + non-auto-approved) only removes the auto-approved part. + /// + [Fact] + public async Task RunStreamingAsync_MixedUpdate_OnlyRemovesAutoApprovedAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var ruleRequest = new ToolApprovalRequestContent("rule-req", new FunctionCallContent("rule-call", "ToolA")); + var alwaysApprove = ruleRequest.CreateAlwaysApproveToolResponse(); + + // Setup non-streaming for rule establishment + var setupResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "OK")]); + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(setupResponse); + + // Streaming update with both auto-approvable (ToolA) and non-auto-approvable (ToolB) requests + var autoApprovable = new ToolApprovalRequestContent("reqA", new FunctionCallContent("callA", "ToolA")); + var notAutoApprovable = new ToolApprovalRequestContent("reqB", new FunctionCallContent("callB", "ToolB")); + var mixedUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List { autoApprovable, notAutoApprovable }); + + innerAgent + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(ToAsyncEnumerableAsync(new[] { mixedUpdate })); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Establish rule + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + // Act + var results = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session)) + { + results.Add(update); + } + + // Assert — only ToolB request should remain + Assert.Single(results); + var requests = results[0].Contents.OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ToolB", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + #endregion + + #region Queue Behavior - Non-Streaming + + /// + /// Verify that when the inner agent returns multiple unapproved TARc, only the first is returned + /// and the rest are queued. + /// + [Fact] + public async Task RunAsync_MultipleTARc_ReturnsOnlyFirstAsync() + { + // Arrange + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB")); + var tarc3 = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "ToolC")); + var innerResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2, tarc3])]); + var innerAgent = CreateMockAgent(innerResponse); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Do something")], + new ChatClientAgentSession()); + + // Assert — only the first TARc should be returned + var requests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ToolA", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + /// + /// Verify that after approving the first queued item, the second is returned on the next call + /// without calling the inner agent. + /// + [Fact] + public async Task RunAsync_ApproveFirst_ReturnsSecondFromQueueAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB")); + var tarc3 = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "ToolC")); + var innerResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2, tarc3])]); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((_, _, _, _) => callCount++) + .ReturnsAsync(innerResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: get first TARc + var response1 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session); + + // Call 2: approve first, get second from queue + var approval1 = tarc1.CreateResponse(approved: true, reason: "OK"); + var response2 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [approval1])], + session); + + // Assert — second TARc returned, inner agent called only once + Assert.Equal(1, callCount); + var requests = response2.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ToolB", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + /// + /// Verify that "always approve" on the first queued item auto-approves matching items in the queue. + /// + [Fact] + public async Task RunAsync_AlwaysApproveFirst_AutoApprovesMatchingInQueueAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + + // Three TARc: two for ToolA (different args), one for ToolB + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA", arguments: new Dictionary { ["q"] = "query1" })); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolA", arguments: new Dictionary { ["q"] = "query2" })); + var tarc3 = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "ToolB")); + + var innerResponse1 = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2, tarc3])]); + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((_, _, _, _) => callCount++) + .ReturnsAsync(() => callCount == 1 ? innerResponse1 : finalResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: get first TARc (ToolA query1) + var response1 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session); + + Assert.Single(response1.Messages.SelectMany(m => m.Contents).OfType()); + + // Call 2: always approve ToolA → should auto-approve ToolA query2 in queue, return ToolB + var alwaysApprove = tarc1.CreateAlwaysApproveToolResponse("Always approve ToolA"); + var response2 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session); + + var requests2 = response2.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests2); + Assert.Equal("ToolB", ((FunctionCallContent)requests2[0].ToolCall).Name); + } + + /// + /// Verify that once all queued items are resolved, the inner agent is called with all collected responses. + /// + [Fact] + public async Task RunAsync_AllQueuedResolved_CallsInnerAgentWithCollectedResponsesAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB")); + + var innerResponse1 = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2])]); + var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "All tools executed")]); + + var callCount = 0; + List? thirdCallMessages = null; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) => + { + callCount++; + if (callCount == 2) + { + thirdCallMessages = msgs.ToList(); + } + }) + .ReturnsAsync(() => callCount == 1 ? innerResponse1 : finalResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: get first TARc + await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session); + + // Call 2: approve first, get second from queue + var approval1 = tarc1.CreateResponse(approved: true, reason: "OK"); + await agent.RunAsync( + [new ChatMessage(ChatRole.User, [approval1])], + session); + + // Call 3: approve second → queue empty → inner agent called + var approval2 = tarc2.CreateResponse(approved: true, reason: "OK"); + var response3 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [approval2])], + session); + + // Assert — inner agent called twice (initial + after queue resolved) + Assert.Equal(2, callCount); + Assert.Equal("All tools executed", response3.Text); + + // Verify collected responses were injected + Assert.NotNull(thirdCallMessages); + var injectedResponses = thirdCallMessages!.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Equal(2, injectedResponses.Count); + } + + /// + /// Verify that a single TARc (no excess) is returned without queueing. + /// + [Fact] + public async Task RunAsync_SingleTARc_NoQueueingAsync() + { + // Arrange + var tarc = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var innerResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc])]); + var innerAgent = CreateMockAgent(innerResponse); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var response = await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Hi")], + new ChatClientAgentSession()); + + // Assert — single TARc returned directly + var requests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests); + Assert.Equal("MyTool", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + /// + /// Verify that denying a queued item collects the denial and proceeds to the next. + /// + [Fact] + public async Task RunAsync_DenyFirst_ReturnsSecondFromQueueAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB")); + var innerResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [tarc1, tarc2])]); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((_, _, _, _) => callCount++) + .ReturnsAsync(innerResponse); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: get first TARc + await agent.RunAsync( + [new ChatMessage(ChatRole.User, "Do something")], + session); + + // Call 2: deny first, get second from queue + var denial = tarc1.CreateResponse(approved: false, reason: "No"); + var response2 = await agent.RunAsync( + [new ChatMessage(ChatRole.User, [denial])], + session); + + // Assert — second TARc returned, inner agent called only once + Assert.Equal(1, callCount); + var requests = response2.Messages.SelectMany(m => m.Contents).OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ToolB", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + #endregion + + #region Queue Behavior - Streaming + + /// + /// Verify that when streaming yields multiple unapproved TARc, only the first is yielded. + /// + [Fact] + public async Task RunStreamingAsync_MultipleTARc_YieldsOnlyFirstAsync() + { + // Arrange + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB")); + var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List { tarc1, tarc2 }); + var textUpdate = new AgentResponseUpdate(ChatRole.Assistant, "Searching..."); + + var innerAgent = CreateMockStreamingAgent([textUpdate, approvalUpdate]); + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Act + var results = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Search")], + new ChatClientAgentSession())) + { + results.Add(update); + } + + // Assert — text update + one TARc + Assert.Equal(2, results.Count); + Assert.Equal("Searching...", results[0].Text); + var requests = results[1].Contents.OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ToolA", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + /// + /// Verify that streaming returns queued items one at a time on subsequent calls. + /// + [Fact] + public async Task RunStreamingAsync_ApproveFirst_ReturnsSecondFromQueueAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolB")); + var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List { tarc1, tarc2 }); + + var callCount = 0; + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((_, _, _, _) => callCount++) + .Returns(ToAsyncEnumerableAsync(new[] { approvalUpdate })); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: get first TARc + var results1 = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Search")], + session)) + { + results1.Add(update); + } + + Assert.Single(results1); + Assert.Equal("ToolA", ((FunctionCallContent)results1[0].Contents.OfType().Single().ToolCall).Name); + + // Call 2: approve first, get second from queue + var approval = tarc1.CreateResponse(approved: true, reason: "OK"); + var results2 = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, [approval])], + session)) + { + results2.Add(update); + } + + // Assert — second TARc returned from queue, inner agent called only once + Assert.Equal(1, callCount); + Assert.Single(results2); + Assert.Equal("ToolB", ((FunctionCallContent)results2[0].Contents.OfType().Single().ToolCall).Name); + } + + /// + /// Verify that "always approve" during streaming queue auto-approves matching items. + /// + [Fact] + public async Task RunStreamingAsync_AlwaysApproveFirst_AutoApprovesMatchingInQueueAsync() + { + // Arrange + var session = new ChatClientAgentSession(); + + // Two ToolA calls + one ToolB call + var tarc1 = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ToolA")); + var tarc2 = new ToolApprovalRequestContent("req2", new FunctionCallContent("call2", "ToolA")); + var tarc3 = new ToolApprovalRequestContent("req3", new FunctionCallContent("call3", "ToolB")); + var approvalUpdate = new AgentResponseUpdate(ChatRole.Assistant, new List { tarc1, tarc2, tarc3 }); + + var innerAgent = new Mock(); + innerAgent + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(ToAsyncEnumerableAsync(new[] { approvalUpdate })); + + var agent = new ToolApprovalAgent(innerAgent.Object); + + // Call 1: get first TARc (ToolA) + var results1 = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, "Search")], + session)) + { + results1.Add(update); + } + + // Call 2: always approve ToolA → second ToolA auto-approved, returns ToolB + var alwaysApprove = tarc1.CreateAlwaysApproveToolResponse(); + var results2 = new List(); + await foreach (var update in agent.RunStreamingAsync( + [new ChatMessage(ChatRole.User, [alwaysApprove])], + session)) + { + results2.Add(update); + } + + // Assert — ToolB returned (ToolA[2] was auto-approved) + Assert.Single(results2); + var requests = results2[0].Contents.OfType().ToList(); + Assert.Single(requests); + Assert.Equal("ToolB", ((FunctionCallContent)requests[0].ToolCall).Name); + } + + #endregion + + #region Helpers + + private static Mock CreateMockAgent(AgentResponse response) + { + var mock = new Mock(); + mock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + return mock; + } + + private static Mock CreateMockStreamingAgent(AgentResponseUpdate[] updates) + { + var mock = new Mock(); + mock + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(ToAsyncEnumerableAsync(updates)); + return mock; + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync( + IEnumerable items, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + await Task.Yield(); + } + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalRuleTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalRuleTests.cs new file mode 100644 index 0000000000..9cb9e617fe --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/ToolApproval/ToolApprovalRuleTests.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class ToolApprovalRuleTests +{ + #region Construction and Defaults + + /// + /// Verify that a new rule has the expected default values. + /// + [Fact] + public void NewRule_HasDefaultValues() + { + // Act + var rule = new ToolApprovalRule(); + + // Assert + Assert.Equal(string.Empty, rule.ToolName); + Assert.Null(rule.Arguments); + } + + /// + /// Verify that ToolName can be set. + /// + [Fact] + public void ToolName_CanBeSet() + { + // Arrange & Act + var rule = new ToolApprovalRule { ToolName = "ReadFile" }; + + // Assert + Assert.Equal("ReadFile", rule.ToolName); + } + + /// + /// Verify that Arguments can be set. + /// + [Fact] + public void Arguments_CanBeSet() + { + // Arrange & Act + var args = new Dictionary { ["path"] = "test.txt" }; + var rule = new ToolApprovalRule { ToolName = "ReadFile", Arguments = args }; + + // Assert + Assert.NotNull(rule.Arguments); + Assert.Equal("test.txt", rule.Arguments["path"]); + } + + #endregion + + #region JSON Serialization + + /// + /// Verify that a tool-level rule round-trips through JSON serialization. + /// + [Fact] + public void Serialize_ToolLevelRule_RoundTrips() + { + // Arrange + var rule = new ToolApprovalRule { ToolName = "MyTool" }; + + // Act + var json = JsonSerializer.Serialize(rule, AgentJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, AgentJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal("MyTool", deserialized!.ToolName); + Assert.Null(deserialized.Arguments); + } + + /// + /// Verify that a tool+arguments rule round-trips through JSON serialization. + /// + [Fact] + public void Serialize_ToolWithArgsRule_RoundTrips() + { + // Arrange + var rule = new ToolApprovalRule + { + ToolName = "ReadFile", + Arguments = new Dictionary { ["path"] = "test.txt", ["encoding"] = "utf-8" }, + }; + + // Act + var json = JsonSerializer.Serialize(rule, AgentJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, AgentJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal("ReadFile", deserialized!.ToolName); + Assert.NotNull(deserialized.Arguments); + Assert.Equal(2, deserialized.Arguments!.Count); + Assert.Equal("test.txt", deserialized.Arguments["path"]); + Assert.Equal("utf-8", deserialized.Arguments["encoding"]); + } + + /// + /// Verify that JSON property names are correctly applied. + /// + [Fact] + public void Serialize_UsesJsonPropertyNames() + { + // Arrange + var rule = new ToolApprovalRule + { + ToolName = "MyTool", + Arguments = new Dictionary { ["key"] = "value" }, + }; + + // Act + var json = JsonSerializer.Serialize(rule, AgentJsonUtilities.DefaultOptions); + + // Assert + Assert.Contains("\"toolName\"", json); + Assert.Contains("\"arguments\"", json); + } + + /// + /// Verify that a list of rules round-trips through JSON serialization. + /// + [Fact] + public void Serialize_RuleList_RoundTrips() + { + // Arrange + var rules = new List + { + new() { ToolName = "ToolA" }, + new() { ToolName = "ToolB", Arguments = new Dictionary { ["x"] = "1" } }, + }; + + // Act + var json = JsonSerializer.Serialize(rules, AgentJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize>(json, AgentJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(2, deserialized!.Count); + Assert.Equal("ToolA", deserialized[0].ToolName); + Assert.Null(deserialized[0].Arguments); + Assert.Equal("ToolB", deserialized[1].ToolName); + Assert.NotNull(deserialized[1].Arguments); + } + + #endregion +} From f747d8a6d467cab2640369ed98e66cc6a8730672 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:43:26 +0100 Subject: [PATCH 09/13] .NET: Make Todo, Mode and FileMemory providers more configurable (#5477) * Make Todo, Mode and FileMemory providers more configurable * Address PR comments. --- .../Harness_Shared_Console/HarnessConsole.cs | 14 +- .../ToolCallFormatter.cs | 14 +- .../Harness_Step01_Research/Program.cs | 68 ++-- .../Harness/AgentMode/AgentModeProvider.cs | 152 ++++++-- .../AgentMode/AgentModeProviderOptions.cs | 73 ++++ .../Harness/AgentMode/AgentModeState.cs | 9 +- .../Harness/FileMemory/FileMemoryProvider.cs | 7 +- .../FileMemory/FileMemoryProviderOptions.cs | 22 ++ .../Harness/Todo/TodoProvider.cs | 37 +- .../Harness/Todo/TodoProviderOptions.cs | 22 ++ .../AgentMode/AgentModeProviderTests.cs | 366 +++++++++++++++++- .../FileMemory/FileMemoryProviderTests.cs | 47 +++ .../Harness/Todo/TodoProviderTests.cs | 93 +++-- 13 files changed, 778 insertions(+), 146 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProviderOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProviderOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProviderOptions.cs diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs index 9e46751001..00b997c5bd 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs @@ -282,16 +282,6 @@ private static void HandleModeCommand(AgentModeProvider? modeProvider, AgentSess string newMode = parts[1]; - // Normalize to known mode values for case-insensitive matching. - if (string.Equals(newMode, AgentModeProvider.PlanMode, StringComparison.OrdinalIgnoreCase)) - { - newMode = AgentModeProvider.PlanMode; - } - else if (string.Equals(newMode, AgentModeProvider.ExecuteMode, StringComparison.OrdinalIgnoreCase)) - { - newMode = AgentModeProvider.ExecuteMode; - } - try { modeProvider.SetMode(session, newMode); @@ -383,8 +373,8 @@ private static void WriteTokenCount(long? count, int? budget) private static ConsoleColor GetModeColor(string mode) => mode switch { - AgentModeProvider.PlanMode => ConsoleColor.Cyan, - AgentModeProvider.ExecuteMode => ConsoleColor.Green, + "plan" => ConsoleColor.Cyan, + "execute" => ConsoleColor.Green, _ => ConsoleColor.Gray, }; } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs index e3430ce10d..50fc192c59 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs @@ -23,15 +23,15 @@ public static string Format(FunctionCallContent call) string? detail = call.Name switch { // Todo tools - "AddTodos" => FormatAddTodos(call), - "CompleteTodos" => FormatIdList(call, "ids", "Complete"), - "RemoveTodos" => FormatIdList(call, "ids", "Remove"), - "GetRemainingTodos" => null, - "GetAllTodos" => null, + "TodoList_Add" => FormatAddTodos(call), + "TodoList_Complete" => FormatIdList(call, "ids", "Complete"), + "TodoList_Remove" => FormatIdList(call, "ids", "Remove"), + "TodoList_GetRemaining" => null, + "TodoList_GetAll" => null, // Mode tools - "SetMode" => FormatStringArg(call, "mode"), - "GetMode" => null, + "AgentMode_Set" => FormatStringArg(call, "mode"), + "AgentMode_Get" => null, // Sub-agent tools "StartSubTask" => FormatStartSubTask(call), diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs index e1cc2b6ff7..bbbc3416a9 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -33,11 +33,15 @@ // and research-focused instructions including the mandatory planning workflow. var instructions = """ - You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing. Use your knowledge to form good search queries and hypotheses, but always verify claims with the tools available to you rather than relying on memory alone. + You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing. + Use your knowledge to form good search queries and hypotheses, but always verify claims with the tools available to you rather than relying on memory alone. - **Mandatory planning workflow** + ## Mandatory planning workflow - For every new substantive user request, including short factual questions, you must begin in plan mode and follow this sequence: + For every new substantive user request, including short factual questions, your behavior is determined by the mode you are in. + If you are in plan mode, start with the *Plan Mode* steps, and if you are in execute mode, skip directly to the *Execute Mode* steps below. + + *Plan Mode* 1. Analyze the request. 2. Ask for clarifications where needed. @@ -47,31 +51,37 @@ 3. Create one or more todo items. 4. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes. 5. Present the plan to the user. 6. Ask for approval to switch to execute mode and process the plan. - 7. When approval is granted, always switch to execute mode, execute the plan and complete the todos. - 8. In execute mode, work autonomously — use your best judgement to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns. - 9. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going. - 10. Continue working, thinking and calling tools until you have the research result for the user. - - Explain your reasoning and thought process as you work through the tasks. - Explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process. - When calling many tools in a row, provide an explanation to the user after each 4 tool calls (or fewer) to help the user understand what you're doing and why. - Do not answer the underlying question before the plan has been presented and approved. - This rule applies even when the answer seems obvious or the task seems small. - For short requests, use a brief micro-plan rather than skipping planning. - - The only exceptions are: - - greetings, - - pure acknowledgments, - - clarification questions needed to form the plan, - - follow-up questions about results you have already presented, - - meta-discussion about the workflow itself. - - When the task is complete, switch back to plan mode for the next request, even if the next request is just a short question. + 7. When approval is granted, always switch to execute mode (using the `AgentMode_Set` tool), and follow the steps for *Execute mode*. + + *Execute Mode* + + 1. If you don't have a plan or tasks yet, analyse the user request and create tasks and a plan. (**Skip this step if you came from plan mode**) + 2. Work autonomously — use your best judgement to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns. + 3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going. + 4. Mark tasks as completed as you finish them. + 5. Continue working, thinking and calling tools until you have the research result for the user. + + ## General Instructions + + - You must check the current mode after any user input, since the user may have changed the mode themselves, + e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution. + - Explain your reasoning and thought process as you work through tasks. + - Explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process. + - Avoid making more than 4 tool calls in a row without explaining what you are doing. + - Do not answer the underlying question before the plan has been presented and approved. + - This rule applies even when the answer seems obvious or the task seems small. + - For short requests, use a brief micro-plan rather than skipping planning. The only exceptions are: + - greetings, + - pure acknowledgments, + - clarification questions needed to form the plan, + - follow-up questions about results you have already presented, + - meta-discussion about the workflow itself. **Todo management** Mark each todo complete as you finish it so the list stays current. If a todo turns out to be unnecessary or is blocked, remove it and briefly explain why. + Once the user finishes with a topic and moves onto a new one, clean up old completed todos by deleting them. **Research quality** @@ -90,12 +100,12 @@ Track your sources — you will need them when presenting results. **File memory** - When you download web pages or receive large amounts of data, save them to file memory using the FileMemory_SaveFile tool. - This ensures the data remains accessible even if older context is compacted or truncated during long research sessions. - Use descriptive file names (e.g., "openai_pricing_page.md") and include a brief description for large files. - Also save intermediate notes and findings as you go — this helps with long multi-step research where early findings inform later steps. - Before starting new research, check file memory with FileMemory_ListFiles and FileMemory_SearchFiles for relevant prior downloads. - When a temporary file is no longer needed, delete it to keep file memory tidy. + Use the FileMemory_* tools to: + - Store downloaded search results or web pages. + - Store plans. + - Read the current plan to make sure tasks were done according to plan. + - Store findings. + - Check for relevant previously downloaded data / findings before starting new research. """; // Create a compaction strategy based on the model's context window. diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs index 80f634b3b6..3615d76532 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -21,10 +22,15 @@ namespace Microsoft.Agents.AI; /// and is included in the instructions provided to the agent on each invocation. /// /// +/// The set of available modes is configurable via . +/// By default, two modes are provided: "plan" (interactive planning) and "execute" +/// (autonomous execution). +/// +/// /// This provider exposes the following tools to the agent: /// -/// SetMode — Switch the agent's operating mode. -/// GetMode — Retrieve the agent's current operating mode. +/// AgentMode_Set — Switch the agent's operating mode. +/// AgentMode_Get — Retrieve the agent's current operating mode. /// /// /// @@ -35,26 +41,68 @@ namespace Microsoft.Agents.AI; [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 static readonly IReadOnlyList s_defaultModes = + [ + new("plan", "Use this mode when analyzing requirements, breaking down tasks, and creating plans. This is the interactive mode — ask clarifying questions, discuss options, and get user approval before proceeding."), + new("execute", "Use this mode when carrying out approved plans. Work autonomously using your best judgement — do not ask the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, useful result when the user returns. If you encounter ambiguity, choose the most reasonable option and note your choice."), + ]; private readonly ProviderSessionState _sessionState; + private readonly IReadOnlyList _modes; + private readonly string _defaultMode; + private readonly string? _customInstructions; + private readonly HashSet _validModeNames; + private readonly string _modeNamesDisplay; private IReadOnlyList? _stateKeys; /// /// Initializes a new instance of the class. /// - public AgentModeProvider() + /// Optional settings that control provider behavior. When , defaults are used. + public AgentModeProvider(AgentModeProviderOptions? options = null) { + this._modes = options?.Modes ?? s_defaultModes; + + if (this._modes.Count == 0) + { + throw new ArgumentException("At least one mode must be configured.", nameof(options)); + } + + this._customInstructions = options?.Instructions; + + this._validModeNames = new HashSet(StringComparer.Ordinal); + var modeNamesList = new List(this._modes.Count); + for (int i = 0; i < this._modes.Count; i++) + { + var mode = this._modes[i]; + if (mode is null) + { + throw new ArgumentException($"Configured mode at index {i} must not be null.", nameof(options)); + } + + if (string.IsNullOrEmpty(mode.Name)) + { + throw new ArgumentException($"Configured mode at index {i} must have a non-empty name.", nameof(options)); + } + + if (!this._validModeNames.Add(mode.Name)) + { + throw new ArgumentException($"Configured modes contain a duplicate mode name \"{mode.Name}\".", nameof(options)); + } + + modeNamesList.Add(mode.Name); + } + + this._modeNamesDisplay = string.Join("\", \"", modeNamesList); + this._defaultMode = options?.DefaultMode ?? modeNamesList[0]; + + if (!this._validModeNames.Contains(this._defaultMode)) + { + throw new ArgumentException($"Default mode \"{this._defaultMode}\" is not in the configured modes list.", nameof(options)); + } + this._sessionState = new ProviderSessionState( - _ => new AgentModeState(), + _ => new AgentModeState { CurrentMode = this._defaultMode }, this.GetType().Name, AgentJsonUtilities.DefaultOptions); } @@ -77,15 +125,20 @@ public string GetMode(AgentSession? session) /// /// The agent session to update the mode in. /// The new mode to set. + /// is not a configured mode. 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)); - } + this.ValidateMode(mode); AgentModeState state = this._sessionState.GetOrInitializeState(session); + string previousMode = state.CurrentMode; state.CurrentMode = mode; + + if (!string.Equals(previousMode, mode, StringComparison.Ordinal)) + { + state.PreviousModeForNotification = previousMode; + } + this._sessionState.SaveState(session, state); } @@ -94,20 +147,54 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co { 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. This is the interactive mode — ask clarifying questions, discuss options, and get user approval before proceeding. - - "execute": Use this mode when carrying out approved plans. Work autonomously using your best judgement — do not ask the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, useful result when the user returns. If you encounter ambiguity, choose the most reasonable option and note your choice. - 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. - """; + string instructions = this._customInstructions ?? this.BuildDefaultInstructions(state.CurrentMode); - return new ValueTask(new AIContext + var aiContext = new AIContext { Instructions = instructions, Tools = this.CreateTools(state, 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) + { + 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.]"), + ]; + } + + return new ValueTask(aiContext); + } + + private string BuildDefaultInstructions(string currentMode) + { + var sb = new StringBuilder(); + sb.Append($"You are currently operating in \"{currentMode}\" mode."); + sb.AppendLine(); + sb.AppendLine("Available modes:"); + + foreach (var mode in this._modes) + { + sb.AppendLine($"- \"{mode.Name}\": {mode.Description}"); + } + + sb.AppendLine("Use the AgentMode_Set tool to switch between modes as your work progresses. Only use AgentMode_Set if the user explicitly instructs/allows you to change modes."); + sb.Append("Use the AgentMode_Get tool to check your current operating mode."); + + return sb.ToString(); + } + + private void ValidateMode(string mode) + { + if (!this._validModeNames.Contains(mode)) + { + throw new ArgumentException($"Invalid mode: \"{mode}\". Supported modes are: \"{this._modeNamesDisplay}\".", nameof(mode)); + } } private AITool[] CreateTools(AgentModeState state, AgentSession? session) @@ -119,10 +206,7 @@ private AITool[] CreateTools(AgentModeState state, AgentSession? session) AIFunctionFactory.Create( (string mode) => { - if (mode != PlanMode && mode != ExecuteMode) - { - throw new ArgumentException($"Invalid mode: {mode}. Supported modes are \"{PlanMode}\" and \"{ExecuteMode}\".", nameof(mode)); - } + this.ValidateMode(mode); state.CurrentMode = mode; this._sessionState.SaveState(session, state); @@ -130,8 +214,8 @@ private AITool[] CreateTools(AgentModeState state, AgentSession? session) }, new AIFunctionFactoryOptions { - Name = "SetMode", - Description = "Switch the agent's operating mode. Supported modes: \"plan\" and \"execute\".", + Name = "AgentMode_Set", + Description = $"Switch the agent's operating mode. Supported modes: \"{this._modeNamesDisplay}\".", SerializerOptions = serializerOptions, }), @@ -139,7 +223,7 @@ private AITool[] CreateTools(AgentModeState state, AgentSession? session) () => state.CurrentMode, new AIFunctionFactoryOptions { - Name = "GetMode", + Name = "AgentMode_Get", Description = "Get the agent's current operating mode.", SerializerOptions = serializerOptions, }), diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProviderOptions.cs new file mode 100644 index 0000000000..8b0379be28 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProviderOptions.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Options controlling the behavior of . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentModeProviderOptions +{ + /// + /// Gets or sets custom instructions provided to the agent for using the mode tools. + /// + /// + /// When (the default), the provider generates instructions dynamically + /// from the configured list. + /// + public string? Instructions { get; set; } + + /// + /// Gets or sets the list of available modes the agent can operate in. + /// + /// + /// When (the default), the provider uses two built-in modes: + /// "plan" (interactive planning) and "execute" (autonomous execution). + /// + public IReadOnlyList? Modes { get; set; } + + /// + /// Gets or sets the initial mode for new sessions. + /// + /// + /// When (the default), the first mode in the list is used. + /// Must match the of one of the configured modes. + /// + public string? DefaultMode { get; set; } + + /// + /// Represents an agent operating mode with a name and description. + /// + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] + public sealed class AgentMode + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the mode. + /// A description of when and how to use this mode. + /// or is . + /// or is empty or whitespace. + public AgentMode(string name, string description) + { + this.Name = Throw.IfNullOrWhitespace(name); + this.Description = Throw.IfNullOrWhitespace(description); + } + + /// + /// Gets the name of the mode. + /// + public string Name { get; } + + /// + /// Gets a description of when and how to use this mode. + /// + public string Description { get; } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeState.cs index e16c9c9289..63cc25eb1c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeState.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeState.cs @@ -16,5 +16,12 @@ internal sealed class AgentModeState /// Gets or sets the current operating mode of the agent. /// [JsonPropertyName("currentMode")] - public string CurrentMode { get; set; } = AgentModeProvider.PlanMode; + public string CurrentMode { get; set; } = "plan"; + + /// + /// Gets or sets the previous mode before the last external change, if a mode change notification is pending. + /// When non-null, indicates that the mode was changed externally and a notification should be injected. + /// + [JsonPropertyName("previousModeForNotification")] + public string? PreviousModeForNotification { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs index 49aaf6e79a..0a6f8c9cdd 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs @@ -58,6 +58,7 @@ This ensures important data remains accessible across long-running sessions. private readonly AgentFileStore _fileStore; private readonly ProviderSessionState _sessionState; + private readonly string _instructions; private IReadOnlyList? _stateKeys; private AITool[]? _tools; @@ -70,12 +71,14 @@ This ensures important data remains accessible across long-running sessions. /// Use this to customize the working folder (e.g., per-user or per-session subfolders). /// When , the default initializer creates state with an empty working folder. /// + /// Optional settings that control provider behavior. When , defaults are used. /// Thrown when is . - public FileMemoryProvider(AgentFileStore fileStore, Func? stateInitializer = null) + public FileMemoryProvider(AgentFileStore fileStore, Func? stateInitializer = null, FileMemoryProviderOptions? options = null) { Throw.IfNull(fileStore); this._fileStore = fileStore; + this._instructions = options?.Instructions ?? DefaultInstructions; this._sessionState = new ProviderSessionState( stateInitializer ?? (_ => new FileMemoryState()), this.GetType().Name, @@ -98,7 +101,7 @@ protected override async ValueTask ProvideAIContextAsync(InvokingCont return new AIContext { - Instructions = DefaultInstructions, + Instructions = this._instructions, Tools = this._tools ??= this.CreateTools(), }; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProviderOptions.cs new file mode 100644 index 0000000000..c8e911daa6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProviderOptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Options controlling the behavior of . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileMemoryProviderOptions +{ + /// + /// Gets or sets custom instructions provided to the agent for using the file memory tools. + /// + /// + /// When (the default), the provider uses built-in instructions + /// that guide the agent on how to use file-based memory effectively. + /// + public string? Instructions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs index e39db5e197..336aac855e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs @@ -23,11 +23,11 @@ namespace Microsoft.Agents.AI; /// /// This provider exposes the following tools to the agent: /// -/// AddTodos — Add one or more todo items, each with a title and optional description. -/// CompleteTodos — Mark one or more todo items as complete by their IDs. -/// RemoveTodos — Remove one or more todo items by their IDs. -/// GetRemainingTodos — Retrieve only incomplete todo items. -/// GetAllTodos — Retrieve all todo items (complete and incomplete). +/// TodoList_Add — Add one or more todo items, each with a title and optional description. +/// TodoList_Complete — Mark one or more todo items as complete by their IDs. +/// TodoList_Remove — Remove one or more todo items by their IDs. +/// TodoList_GetRemaining — Retrieve only incomplete todo items. +/// TodoList_GetAll — Retrieve all todo items (complete and incomplete). /// /// /// @@ -44,21 +44,24 @@ Ask questions from the user where clarification is needed to create effective to When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant items or adding new ones as needed. Use these tools to manage your tasks: - - Use AddTodos to break down complex work into trackable items (supports adding one or many at once). - - Use CompleteTodos to mark items as done when finished (supports one or many at once). - - Use GetRemainingTodos to check what work is still pending. - - Use GetAllTodos to review the full list including completed items. - - Use RemoveTodos to remove items that are no longer needed (supports one or many at once). + - Use TodoList_Add to break down complex work into trackable items (supports adding one or many at once). + - Use TodoList_Complete to mark items as done when finished (supports one or many at once). + - Use TodoList_GetRemaining to check what work is still pending. + - Use TodoList_GetAll to review the full list including completed items. + - Use TodoList_Remove to remove items that are no longer needed (supports one or many at once). """; private readonly ProviderSessionState _sessionState; + private readonly string _instructions; private IReadOnlyList? _stateKeys; /// /// Initializes a new instance of the class. /// - public TodoProvider() + /// Optional settings that control provider behavior. When , defaults are used. + public TodoProvider(TodoProviderOptions? options = null) { + this._instructions = options?.Instructions ?? DefaultInstructions; this._sessionState = new ProviderSessionState( _ => new TodoState(), this.GetType().Name, @@ -95,7 +98,7 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co return new ValueTask(new AIContext { - Instructions = DefaultInstructions, + Instructions = this._instructions, Tools = this.CreateTools(state, context.Session), }); } @@ -129,7 +132,7 @@ private AITool[] CreateTools(TodoState state, AgentSession? session) }, new AIFunctionFactoryOptions { - Name = "AddTodos", + Name = "TodoList_Add", Description = "Add one or more todo items. Each item has a title and an optional description. Returns the list of created todo items.", SerializerOptions = serializerOptions, }), @@ -157,7 +160,7 @@ private AITool[] CreateTools(TodoState state, AgentSession? session) }, new AIFunctionFactoryOptions { - Name = "CompleteTodos", + Name = "TodoList_Complete", Description = "Mark one or more todo items as complete by their IDs. Returns the number of items that were found and marked complete.", SerializerOptions = serializerOptions, }), @@ -177,7 +180,7 @@ private AITool[] CreateTools(TodoState state, AgentSession? session) }, new AIFunctionFactoryOptions { - Name = "RemoveTodos", + Name = "TodoList_Remove", Description = "Remove one or more todo items by their IDs. Returns the number of items that were found and removed.", SerializerOptions = serializerOptions, }), @@ -186,7 +189,7 @@ private AITool[] CreateTools(TodoState state, AgentSession? session) () => state.Items.Where(t => !t.IsComplete).ToList(), new AIFunctionFactoryOptions { - Name = "GetRemainingTodos", + Name = "TodoList_GetRemaining", Description = "Retrieve the list of incomplete todo items.", SerializerOptions = serializerOptions, }), @@ -195,7 +198,7 @@ private AITool[] CreateTools(TodoState state, AgentSession? session) () => state.Items, new AIFunctionFactoryOptions { - Name = "GetAllTodos", + Name = "TodoList_GetAll", Description = "Retrieve the full list of todo items, both complete and incomplete.", SerializerOptions = serializerOptions, }), diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProviderOptions.cs new file mode 100644 index 0000000000..e451ff67d9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProviderOptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Options controlling the behavior of . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class TodoProviderOptions +{ + /// + /// Gets or sets custom instructions provided to the agent for using the todo tools. + /// + /// + /// When (the default), the provider uses built-in instructions + /// that guide the agent on how to manage todos effectively. + /// + public string? Instructions { get; set; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/AgentMode/AgentModeProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/AgentMode/AgentModeProviderTests.cs index 59393d4430..61671d4904 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/AgentMode/AgentModeProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/AgentMode/AgentModeProviderTests.cs @@ -73,7 +73,7 @@ public async Task SetMode_ChangesModeAsync() { // Arrange var (tools, state) = await CreateToolsWithStateAsync(); - AIFunction setMode = GetTool(tools, "SetMode"); + AIFunction setMode = GetTool(tools, "AgentMode_Set"); // Act await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" }); @@ -90,7 +90,7 @@ public async Task SetMode_ReturnsConfirmationAsync() { // Arrange var (tools, _) = await CreateToolsWithStateAsync(); - AIFunction setMode = GetTool(tools, "SetMode"); + AIFunction setMode = GetTool(tools, "AgentMode_Set"); // Act object? result = await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" }); @@ -107,8 +107,8 @@ public async Task SetMode_InvalidMode_ThrowsAsync() { // Arrange var (tools, provider, session) = await CreateToolsWithProviderAndSessionAsync(); - AIFunction setMode = GetTool(tools, "SetMode"); - AIFunction getMode = GetTool(tools, "GetMode"); + AIFunction setMode = GetTool(tools, "AgentMode_Set"); + AIFunction getMode = GetTool(tools, "AgentMode_Get"); // Act & Assert await Assert.ThrowsAsync(async () => @@ -116,7 +116,7 @@ await Assert.ThrowsAsync(async () => // Verify mode was not changed from default object? currentMode = await getMode.InvokeAsync(new AIFunctionArguments()); - Assert.Equal(AgentModeProvider.PlanMode, GetStringResult(currentMode)); + Assert.Equal("plan", GetStringResult(currentMode)); } #endregion @@ -131,7 +131,7 @@ public async Task GetMode_ReturnsDefaultModeAsync() { // Arrange var (tools, _) = await CreateToolsWithStateAsync(); - AIFunction getMode = GetTool(tools, "GetMode"); + AIFunction getMode = GetTool(tools, "AgentMode_Get"); // Act object? result = await getMode.InvokeAsync(new AIFunctionArguments()); @@ -148,8 +148,8 @@ public async Task GetMode_ReturnsUpdatedModeAfterSetAsync() { // Arrange var (tools, _) = await CreateToolsWithStateAsync(); - AIFunction setMode = GetTool(tools, "SetMode"); - AIFunction getMode = GetTool(tools, "GetMode"); + AIFunction setMode = GetTool(tools, "AgentMode_Set"); + AIFunction getMode = GetTool(tools, "AgentMode_Get"); // Act await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" }); @@ -177,7 +177,7 @@ public void PublicGetMode_ReturnsDefaultMode() string mode = provider.GetMode(session); // Assert - Assert.Equal(AgentModeProvider.PlanMode, mode); + Assert.Equal("plan", mode); } /// @@ -191,11 +191,11 @@ public void PublicSetMode_ChangesMode() var session = new ChatClientAgentSession(); // Act - provider.SetMode(session, AgentModeProvider.ExecuteMode); + provider.SetMode(session, "execute"); string mode = provider.GetMode(session); // Assert - Assert.Equal(AgentModeProvider.ExecuteMode, mode); + Assert.Equal("execute", mode); } /// @@ -213,7 +213,7 @@ public void PublicSetMode_InvalidMode_Throws() // Verify mode was not changed from default string mode = provider.GetMode(session); - Assert.Equal(AgentModeProvider.PlanMode, mode); + Assert.Equal("plan", mode); } /// @@ -228,7 +228,7 @@ public async Task PublicSetMode_ReflectedInToolResultsAsync() var session = new ChatClientAgentSession(); // Set mode via public helper - provider.SetMode(session, AgentModeProvider.ExecuteMode); + provider.SetMode(session, "execute"); #pragma warning disable MAAI001 var context = new AIContextProvider.InvokingContext(agent, session, new AIContext()); @@ -236,7 +236,7 @@ public async Task PublicSetMode_ReflectedInToolResultsAsync() // Act AIContext result = await provider.InvokingAsync(context); - AIFunction getMode = GetTool(result.Tools!, "GetMode"); + AIFunction getMode = GetTool(result.Tools!, "AgentMode_Get"); object? modeResult = await getMode.InvokeAsync(new AIFunctionArguments()); // Assert @@ -264,12 +264,12 @@ public async Task State_PersistsAcrossInvocationsAsync() // Act — first invocation changes mode AIContext result1 = await provider.InvokingAsync(context); - AIFunction setMode = GetTool(result1.Tools!, "SetMode"); + AIFunction setMode = GetTool(result1.Tools!, "AgentMode_Set"); 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"); + AIFunction getMode = GetTool(result2.Tools!, "AgentMode_Get"); object? modeResult = await getMode.InvokeAsync(new AIFunctionArguments()); // Assert @@ -279,17 +279,341 @@ public async Task State_PersistsAcrossInvocationsAsync() #endregion - #region Constants Tests + #region Options Tests /// - /// Verify that mode constants have expected values. + /// Verify that custom instructions override the default. /// [Fact] - public void ModeConstants_HaveExpectedValues() + public async Task Options_CustomInstructions_OverridesDefaultAsync() { + // Arrange + var options = new AgentModeProviderOptions { Instructions = "Custom mode instructions." }; + var provider = new AgentModeProvider(options); + 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.Equal("Custom mode instructions.", result.Instructions); + } + + /// + /// Verify that custom modes are used. + /// + [Fact] + public void Options_CustomModes_AreUsed() + { + // Arrange + var options = new AgentModeProviderOptions + { + Modes = + [ + new AgentModeProviderOptions.AgentMode("draft", "Drafting mode."), + new AgentModeProviderOptions.AgentMode("review", "Review mode."), + ], + }; + var provider = new AgentModeProvider(options); + var session = new ChatClientAgentSession(); + + // Act + string mode = provider.GetMode(session); + + // Assert — default mode is first in list + Assert.Equal("draft", mode); + } + + /// + /// Verify that SetMode validates against custom modes. + /// + [Fact] + public void Options_CustomModes_SetModeValidatesAgainstList() + { + // Arrange + var options = new AgentModeProviderOptions + { + Modes = + [ + new AgentModeProviderOptions.AgentMode("draft", "Drafting mode."), + new AgentModeProviderOptions.AgentMode("review", "Review mode."), + ], + }; + var provider = new AgentModeProvider(options); + var session = new ChatClientAgentSession(); + + // Act — valid mode + provider.SetMode(session, "review"); + + // Assert + Assert.Equal("review", provider.GetMode(session)); + + // Act & Assert — invalid mode (plan is no longer valid) + Assert.Throws(() => provider.SetMode(session, "plan")); + } + + /// + /// Verify that a custom default mode is used. + /// + [Fact] + public void Options_CustomDefaultMode_IsUsed() + { + // Arrange + var options = new AgentModeProviderOptions + { + Modes = + [ + new AgentModeProviderOptions.AgentMode("draft", "Drafting mode."), + new AgentModeProviderOptions.AgentMode("review", "Review mode."), + ], + DefaultMode = "review", + }; + var provider = new AgentModeProvider(options); + var session = new ChatClientAgentSession(); + + // Act + string mode = provider.GetMode(session); + + // Assert + Assert.Equal("review", mode); + } + + /// + /// Verify that an invalid default mode throws. + /// + [Fact] + public void Options_InvalidDefaultMode_Throws() + { + // Arrange + var options = new AgentModeProviderOptions + { + Modes = + [ + new AgentModeProviderOptions.AgentMode("draft", "Drafting mode."), + ], + DefaultMode = "nonexistent", + }; + + // Act & Assert + Assert.Throws(() => new AgentModeProvider(options)); + } + + /// + /// Verify that an empty modes list throws. + /// + [Fact] + public void Options_EmptyModes_Throws() + { + // Arrange + var options = new AgentModeProviderOptions + { + Modes = [], + }; + + // Act & Assert + Assert.Throws(() => new AgentModeProvider(options)); + } + + /// + /// Verify that custom modes appear in generated instructions. + /// + [Fact] + public async Task Options_CustomModes_AppearInInstructionsAsync() + { + // Arrange + var options = new AgentModeProviderOptions + { + Modes = + [ + new AgentModeProviderOptions.AgentMode("draft", "Drafting mode description."), + new AgentModeProviderOptions.AgentMode("review", "Review mode description."), + ], + }; + var provider = new AgentModeProvider(options); + 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("draft", result.Instructions); + Assert.Contains("Drafting mode description.", result.Instructions); + Assert.Contains("review", result.Instructions); + Assert.Contains("Review mode description.", result.Instructions); + } + + /// + /// Verify that AgentMode requires non-empty name and description. + /// + [Fact] + public void AgentMode_RequiresNameAndDescription() + { + // Act & Assert + Assert.Throws(() => new AgentModeProviderOptions.AgentMode("", "desc")); + Assert.Throws(() => new AgentModeProviderOptions.AgentMode("name", "")); + Assert.ThrowsAny(() => new AgentModeProviderOptions.AgentMode(null!, "desc")); + Assert.ThrowsAny(() => new AgentModeProviderOptions.AgentMode("name", null!)); + } + + /// + /// Verify that duplicate mode names throw. + /// + [Fact] + public void Options_DuplicateModeNames_Throws() + { + // Arrange + var options = new AgentModeProviderOptions + { + Modes = + [ + new AgentModeProviderOptions.AgentMode("draft", "First draft."), + new AgentModeProviderOptions.AgentMode("draft", "Second draft."), + ], + }; + + // Act & Assert + var ex = Assert.Throws(() => new AgentModeProvider(options)); + Assert.Contains("duplicate", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verify that a null entry in the modes list throws. + /// + [Fact] + public void Options_NullModeEntry_Throws() + { + // Arrange + var options = new AgentModeProviderOptions + { + Modes = new List { null! }, + }; + + // Act & Assert + var ex = Assert.Throws(() => new AgentModeProvider(options)); + Assert.Contains("must not be null", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region External Mode Change Notification Tests + + /// + /// Verify that an external mode change injects a notification message. + /// + [Fact] + public async Task ExternalModeChange_InjectsNotificationMessageAsync() + { + // Arrange + var provider = new AgentModeProvider(); + var agent = new Mock().Object; + var session = new ChatClientAgentSession(); + + // Change mode externally (simulating /mode command) + provider.SetMode(session, "execute"); + +#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.Messages); + Assert.Single(result.Messages!); + ChatMessage message = result.Messages!.First(); + Assert.Equal(ChatRole.User, message.Role); + Assert.Contains("plan", message.Text); + Assert.Contains("execute", message.Text); + } + + /// + /// Verify that the notification is only injected once (cleared after first read). + /// + [Fact] + public async Task ExternalModeChange_NotificationClearedAfterFirstReadAsync() + { + // Arrange + var provider = new AgentModeProvider(); + var agent = new Mock().Object; + var session = new ChatClientAgentSession(); + provider.SetMode(session, "execute"); + +#pragma warning disable MAAI001 + var context = new AIContextProvider.InvokingContext(agent, session, new AIContext()); +#pragma warning restore MAAI001 + + // Act — first call should have the notification + AIContext result1 = await provider.InvokingAsync(context); + Assert.NotNull(result1.Messages); + + // Second call should NOT have the notification + AIContext result2 = await provider.InvokingAsync(context); + + // Assert + Assert.Null(result2.Messages); + } + + /// + /// Verify that tool-based mode change does not inject a notification message. + /// + [Fact] + public async Task ToolModeChange_DoesNotInjectNotificationAsync() + { + // 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 + + // First call to initialize + AIContext result1 = await provider.InvokingAsync(context); + AIFunction setMode = GetTool(result1.Tools!, "AgentMode_Set"); + + // Change mode via the tool (agent-initiated) + await setMode.InvokeAsync(new AIFunctionArguments() { ["mode"] = "execute" }); + + // Act — next call should NOT have a notification + AIContext result2 = await provider.InvokingAsync(context); + + // Assert + Assert.Null(result2.Messages); + } + + /// + /// Verify that setting the same mode externally does not inject a notification. + /// + [Fact] + public async Task ExternalModeChange_SameMode_NoNotificationAsync() + { + // Arrange + var provider = new AgentModeProvider(); + var agent = new Mock().Object; + var session = new ChatClientAgentSession(); + + // Set to same default mode + provider.SetMode(session, "plan"); + +#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.Equal("plan", AgentModeProvider.PlanMode); - Assert.Equal("execute", AgentModeProvider.ExecuteMode); + Assert.Null(result.Messages); } #endregion diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs index 83b2bf3d1d..eaa3286ad1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs @@ -552,4 +552,51 @@ private static AIFunction GetTool(IEnumerable tools, string name) } #endregion + + #region Options Tests + + /// + /// Verify that custom instructions override the default. + /// + [Fact] + public async Task Options_CustomInstructions_OverridesDefaultAsync() + { + // Arrange + var options = new FileMemoryProviderOptions { Instructions = "Custom file memory instructions." }; + var provider = new FileMemoryProvider(new InMemoryAgentFileStore(), options: options); + 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.Equal("Custom file memory instructions.", result.Instructions); + } + + /// + /// Verify that null options uses default instructions. + /// + [Fact] + public async Task Options_Null_UsesDefaultInstructionsAsync() + { + // Arrange + var provider = new FileMemoryProvider(new InMemoryAgentFileStore()); + 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("file-based memory", result.Instructions); + } + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs index 726de7e50a..984a2ca1bc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.cs @@ -51,7 +51,7 @@ public async Task AddTodos_CreatesSingleItemAsync() { // Arrange var (tools, state) = await CreateToolsWithStateAsync(); - AIFunction addTodos = GetTool(tools, "AddTodos"); + AIFunction addTodos = GetTool(tools, "TodoList_Add"); // Act await addTodos.InvokeAsync(new AIFunctionArguments() @@ -75,7 +75,7 @@ public async Task AddTodos_CreatesMultipleItemsWithIncrementingIdsAsync() { // Arrange var (tools, state) = await CreateToolsWithStateAsync(); - AIFunction addTodos = GetTool(tools, "AddTodos"); + AIFunction addTodos = GetTool(tools, "TodoList_Add"); // Act await addTodos.InvokeAsync(new AIFunctionArguments() @@ -111,8 +111,8 @@ public async Task CompleteTodos_MarksItemCompleteAsync() { // Arrange var (tools, state) = await CreateToolsWithStateAsync(); - AIFunction addTodos = GetTool(tools, "AddTodos"); - AIFunction completeTodos = GetTool(tools, "CompleteTodos"); + AIFunction addTodos = GetTool(tools, "TodoList_Add"); + AIFunction completeTodos = GetTool(tools, "TodoList_Complete"); await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Test", Description = null } } }); // Act @@ -131,8 +131,8 @@ public async Task CompleteTodos_MarksMultipleItemsCompleteAsync() { // Arrange var (tools, state) = await CreateToolsWithStateAsync(); - AIFunction addTodos = GetTool(tools, "AddTodos"); - AIFunction completeTodos = GetTool(tools, "CompleteTodos"); + AIFunction addTodos = GetTool(tools, "TodoList_Add"); + AIFunction completeTodos = GetTool(tools, "TodoList_Complete"); await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } }, @@ -156,7 +156,7 @@ public async Task CompleteTodos_ReturnsZeroForMissingIdsAsync() { // Arrange var (tools, _) = await CreateToolsWithStateAsync(); - AIFunction completeTodos = GetTool(tools, "CompleteTodos"); + AIFunction completeTodos = GetTool(tools, "TodoList_Complete"); // Act object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 999 } }); @@ -177,8 +177,8 @@ public async Task RemoveTodos_RemovesItemAsync() { // Arrange var (tools, state) = await CreateToolsWithStateAsync(); - AIFunction addTodos = GetTool(tools, "AddTodos"); - AIFunction removeTodos = GetTool(tools, "RemoveTodos"); + AIFunction addTodos = GetTool(tools, "TodoList_Add"); + AIFunction removeTodos = GetTool(tools, "TodoList_Remove"); await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Test", Description = null } } }); // Act @@ -197,8 +197,8 @@ public async Task RemoveTodos_RemovesMultipleItemsAsync() { // Arrange var (tools, state) = await CreateToolsWithStateAsync(); - AIFunction addTodos = GetTool(tools, "AddTodos"); - AIFunction removeTodos = GetTool(tools, "RemoveTodos"); + AIFunction addTodos = GetTool(tools, "TodoList_Add"); + AIFunction removeTodos = GetTool(tools, "TodoList_Remove"); await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } }, @@ -221,7 +221,7 @@ public async Task RemoveTodos_ReturnsZeroForMissingIdsAsync() { // Arrange var (tools, _) = await CreateToolsWithStateAsync(); - AIFunction removeTodos = GetTool(tools, "RemoveTodos"); + AIFunction removeTodos = GetTool(tools, "TodoList_Remove"); // Act object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List { 999 } }); @@ -242,9 +242,9 @@ public async Task GetRemainingTodos_ReturnsOnlyIncompleteAsync() { // Arrange var (tools, _) = await CreateToolsWithStateAsync(); - AIFunction addTodos = GetTool(tools, "AddTodos"); - AIFunction completeTodos = GetTool(tools, "CompleteTodos"); - AIFunction getRemainingTodos = GetTool(tools, "GetRemainingTodos"); + AIFunction addTodos = GetTool(tools, "TodoList_Add"); + AIFunction completeTodos = GetTool(tools, "TodoList_Complete"); + AIFunction getRemainingTodos = GetTool(tools, "TodoList_GetRemaining"); await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } }, @@ -272,9 +272,9 @@ public async Task GetAllTodos_ReturnsAllItemsAsync() { // Arrange var (tools, _) = await CreateToolsWithStateAsync(); - AIFunction addTodos = GetTool(tools, "AddTodos"); - AIFunction completeTodos = GetTool(tools, "CompleteTodos"); - AIFunction getAllTodos = GetTool(tools, "GetAllTodos"); + AIFunction addTodos = GetTool(tools, "TodoList_Add"); + AIFunction completeTodos = GetTool(tools, "TodoList_Complete"); + AIFunction getAllTodos = GetTool(tools, "TodoList_GetAll"); await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } }, @@ -309,12 +309,12 @@ public async Task State_PersistsInSessionStateBagAsync() // Act — first invocation adds a todo AIContext result1 = await provider.InvokingAsync(context); - AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "AddTodos"); + AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add"); await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Persisted", Description = null } } }); // Second invocation should see the same state AIContext result2 = await provider.InvokingAsync(context); - AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "GetAllTodos"); + AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_GetAll"); object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments()); // Assert @@ -341,7 +341,7 @@ public async Task PublicGetAllTodos_ReturnsAllItemsAsync() var context = new AIContextProvider.InvokingContext(agent, session, new AIContext()); #pragma warning restore MAAI001 AIContext result = await provider.InvokingAsync(context); - AIFunction addTodos = GetTool(result.Tools!, "AddTodos"); + AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add"); await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "First", Description = null }, new() { Title = "Second", Description = null } }, @@ -370,8 +370,8 @@ public async Task PublicGetRemainingTodos_ReturnsOnlyIncompleteAsync() var context = new AIContextProvider.InvokingContext(agent, session, new AIContext()); #pragma warning restore MAAI001 AIContext result = await provider.InvokingAsync(context); - AIFunction addTodos = GetTool(result.Tools!, "AddTodos"); - AIFunction completeTodos = GetTool(result.Tools!, "CompleteTodos"); + AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add"); + AIFunction completeTodos = GetTool(result.Tools!, "TodoList_Complete"); await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } }, @@ -442,4 +442,51 @@ private static List GetArrayResult(object? result) } #endregion + + #region Options Tests + + /// + /// Verify that custom instructions override the default. + /// + [Fact] + public async Task Options_CustomInstructions_OverridesDefaultAsync() + { + // Arrange + var options = new TodoProviderOptions { Instructions = "Custom todo instructions." }; + var provider = new TodoProvider(options); + 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.Equal("Custom todo instructions.", result.Instructions); + } + + /// + /// Verify that null options uses default instructions. + /// + [Fact] + public async Task Options_Null_UsesDefaultInstructionsAsync() + { + // Arrange + var provider = new TodoProvider(); + 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("todo list", result.Instructions); + } + + #endregion } From 899394a58c36ccf92c87d36de4af46eea38c7bec Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:22:55 +0100 Subject: [PATCH 10/13] .NET: Add subagents provider and sample (#5518) * Add subagents provider and sample * Addressing PR comments. --- dotnet/agent-framework-dotnet.slnx | 1 + .../Harness_Shared_Console/HarnessConsole.cs | 53 +- .../ToolCallFormatter.cs | 12 +- ...rness_Step02_Research_WithSubAgents.csproj | 20 + .../Program.cs | 106 ++ .../README.md | 53 + dotnet/samples/02-agents/Harness/README.md | 1 + .../Microsoft.Agents.AI/AgentJsonUtilities.cs | 7 + .../Harness/SubAgents/SubAgentRuntimeState.cs | 32 + .../Harness/SubAgents/SubAgentState.cs | 28 + .../Harness/SubAgents/SubAgentsProvider.cs | 456 ++++++++ .../SubAgents/SubAgentsProviderOptions.cs | 35 + .../Harness/SubAgents/SubTaskInfo.cs | 50 + .../Harness/SubAgents/SubTaskStatus.cs | 34 + .../SubAgents/SubAgentsProviderTests.cs | 972 ++++++++++++++++++ 15 files changed, 1842 insertions(+), 18 deletions(-) create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentRuntimeState.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentState.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskInfo.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskStatus.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 4c901e1792..fa4f515de1 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -121,6 +121,7 @@ + diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs index 00b997c5bd..182e74d86b 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs @@ -28,7 +28,20 @@ public static async Task RunAgentAsync(AIAgent agent, string title, string userP System.Console.WriteLine($"=== {title} ==="); System.Console.WriteLine(userPrompt); - System.Console.WriteLine("Commands: /todos (show todo list), /mode [plan|execute] (show or switch mode), exit (quit)"); + + var commands = new List(); + if (todoProvider is not null) + { + commands.Add("/todos (show todo list)"); + } + + if (modeProvider is not null) + { + commands.Add("/mode [plan|execute] (show or switch mode)"); + } + + commands.Add("exit (quit)"); + System.Console.WriteLine($"Commands: {string.Join(", ", commands)}"); System.Console.WriteLine(); AgentSession session = await agent.CreateSessionAsync(); @@ -76,9 +89,16 @@ private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession s private static async Task> StreamAndCollectApprovalsAsync(IAsyncEnumerable updates, AgentModeProvider? modeProvider, AgentSession session, int? maxContextWindowTokens, int? maxOutputTokens) { var approvalRequests = new List(); - string mode = modeProvider?.GetMode(session) ?? "unknown"; - System.Console.ForegroundColor = GetModeColor(mode); - System.Console.Write($"\n[{mode}] Agent: "); + string? mode = modeProvider is not null ? modeProvider.GetMode(session) : null; + if (mode is not null) + { + System.Console.ForegroundColor = GetModeColor(mode); + System.Console.Write($"\n[{mode}] Agent: "); + } + else + { + System.Console.Write("\nAgent: "); + } var spinner = new Spinner(); spinner.Start(); @@ -181,11 +201,14 @@ private static async Task> StreamAndCollectAppr hasReceivedAnyText = true; } - string currentMode = modeProvider?.GetMode(session) ?? "unknown"; - if (currentMode != mode) + if (modeProvider is not null) { - mode = currentMode; - System.Console.ForegroundColor = GetModeColor(mode); + string currentMode = modeProvider.GetMode(session); + if (currentMode != mode) + { + mode = currentMode; + System.Console.ForegroundColor = GetModeColor(mode); + } } System.Console.Write(update.Text); @@ -299,9 +322,14 @@ private static void HandleModeCommand(AgentModeProvider? modeProvider, AgentSess private static void WritePrompt(AgentModeProvider? modeProvider, AgentSession session) { - string mode = modeProvider?.GetMode(session) ?? "unknown"; - System.Console.ForegroundColor = GetModeColor(mode); - System.Console.Write($"[{mode}] You: "); + if (modeProvider is not null) + { + string mode = modeProvider.GetMode(session); + System.Console.ForegroundColor = GetModeColor(mode); + System.Console.Write($"[{mode}] "); + } + + System.Console.Write("You: "); System.Console.ResetColor(); } @@ -371,10 +399,11 @@ private static void WriteTokenCount(long? count, int? budget) } } - private static ConsoleColor GetModeColor(string mode) => mode switch + private static ConsoleColor GetModeColor(string? mode) => mode switch { "plan" => ConsoleColor.Cyan, "execute" => ConsoleColor.Green, + null => ConsoleColor.Gray, _ => ConsoleColor.Gray, }; } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs index 50fc192c59..3d4d154f50 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs @@ -34,12 +34,12 @@ public static string Format(FunctionCallContent call) "AgentMode_Get" => null, // Sub-agent tools - "StartSubTask" => FormatStartSubTask(call), - "WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"), - "GetSubTaskResults" => FormatSingleId(call, "taskId"), - "GetAllTasks" => null, - "ContinueTask" => FormatContinueTask(call), - "ClearCompletedTask" => FormatSingleId(call, "taskId"), + "SubAgents_StartTask" => FormatStartSubTask(call), + "SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"), + "SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"), + "SubAgents_GetAllTasks" => null, + "SubAgents_ContinueTask" => FormatContinueTask(call), + "SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"), // File memory tools "FileMemory_SaveFile" => FormatSaveFile(call), diff --git a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj new file mode 100644 index 0000000000..b28ff5bf42 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs new file mode 100644 index 0000000000..581f61a55c --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use the SubAgentsProvider to delegate work to sub-agents. +// A parent agent is given a list of stock tickers and instructed to find the closing price +// for each ticker on December 31, 2025. It delegates the web searches to a sub-agent +// equipped with Foundry's hosted web search tool. +// +// Special commands: +// exit — End the session. + +#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage. +#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments. + +using System.ClientModel.Primitives; +using Azure.Identity; +using Harness.Shared.Console; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Responses; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4"; + +// --- Sub-agent: Web Search Agent --- +// This agent can search the web and is used by the parent agent to look up stock prices. +AIAgent webSearchAgent = + new OpenAIClient( + new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), + new OpenAIClientOptions() + { + Endpoint = new Uri(endpoint), + RetryPolicy = new ClientRetryPolicy(3) + }) + .GetResponsesClient() + .AsIChatClientWithStoredOutputDisabled(deploymentName) + .AsAIAgent( + new ChatClientAgentOptions + { + Name = "WebSearchAgent", + Description = "An agent that can search the web to find information.", + ChatOptions = new ChatOptions + { + Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.", + Tools = + [ + ResponseTool.CreateWebSearchTool().AsAITool(), + ], + }, + }); + +// --- Parent agent: Stock Price Researcher --- +// This agent orchestrates the sub-agent to look up stock prices in parallel. +var parentInstructions = + """ + You are a stock price research assistant. You have access to a web search sub-agent that can look up information on the web. + + When given a list of stock tickers, your job is to find the closing price for each ticker on December 31, 2025. + + ## Workflow + + 1. For each ticker, start a sub-task on the WebSearchAgent asking it to find the closing price on December 31, 2025. + - Start all sub-tasks before waiting for any of them to complete, so they run concurrently. + 2. Wait for all sub-tasks to complete. + 3. Retrieve the results from each sub-task. + 4. Present a summary table with the ticker symbol and closing price for each stock. + 5. Clear all completed tasks to free memory. + + ## Important + + - Always delegate web searches to the WebSearchAgent sub-agent. Do not try to answer from memory. + - If a sub-task fails or returns unclear results, continue the task with a more specific query. + - Present results in a clean markdown table format. + """; + +AIAgent parentAgent = + new OpenAIClient( + new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), + new OpenAIClientOptions() + { + Endpoint = new Uri(endpoint), + RetryPolicy = new ClientRetryPolicy(3) + }) + .GetResponsesClient() + .AsIChatClientWithStoredOutputDisabled(deploymentName) + .AsAIAgent( + new ChatClientAgentOptions + { + Name = "StockPriceResearcher", + Description = "An agent that researches stock prices using sub-agents.", + AIContextProviders = + [ + new SubAgentsProvider([webSearchAgent]), + ], + ChatOptions = new ChatOptions + { + Instructions = parentInstructions, + MaxOutputTokens = 16_000, + }, + }); + +// Run the interactive console session. +await HarnessConsole.RunAgentAsync( + parentAgent, + title: "Stock Price Researcher (SubAgents Demo)", + userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):"); diff --git a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/README.md b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/README.md new file mode 100644 index 0000000000..6fb94b34ab --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/README.md @@ -0,0 +1,53 @@ +# Harness Step 02 — SubAgents (Stock Price Research) + +This sample demonstrates how to use the **SubAgentsProvider** to delegate work from a parent agent to sub-agents. + +## What It Does + +A parent agent receives a list of stock tickers and uses a web-search sub-agent to find the closing price for each ticker on December 31, 2025. The sub-tasks run concurrently, and results are presented in a summary table. + +### Architecture + +``` +┌─────────────────────────────────┐ +│ StockPriceResearcher │ +│ (Parent Agent) │ +│ │ +│ SubAgentsProvider │ +│ ├─ SubAgents_StartTask │ +│ ├─ SubAgents_WaitFor... │ +│ ├─ SubAgents_GetTaskResults │ +│ └─ ... │ +└────────────┬────────────────────┘ + │ delegates to + ▼ +┌─────────────────────────────────┐ +│ WebSearchAgent │ +│ (Sub-Agent) │ +│ │ +│ Tools: │ +│ └─ web_search (Foundry) │ +└─────────────────────────────────┘ +``` + +## Prerequisites + +- An Azure AI Foundry endpoint with an OpenAI model deployment +- Set the following environment variables: + - `AZURE_FOUNDRY_OPENAI_ENDPOINT` — Your Foundry OpenAI endpoint URL + - `AZURE_AI_MODEL_DEPLOYMENT_NAME` — Model deployment name (defaults to `gpt-5.4`) + +## Running the Sample + +```bash +cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents +dotnet run +``` + +When prompted, enter a list of stock tickers such as: + +``` +BAC, MSFT, BA +``` + +The parent agent will delegate each ticker lookup to the web search sub-agent concurrently and present the results in a table. diff --git a/dotnet/samples/02-agents/Harness/README.md b/dotnet/samples/02-agents/Harness/README.md index da54967258..2250fd301e 100644 --- a/dotnet/samples/02-agents/Harness/README.md +++ b/dotnet/samples/02-agents/Harness/README.md @@ -7,3 +7,4 @@ Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Ag | Sample | Description | | --- | --- | | [Harness_Step01_Research](./Harness_Step01_Research/README.md) | Using a ChatClientAgent with TodoProvider and AgentModeProvider for research, showcasing planning mode and todo management | +| [Harness_Step02_Research_WithSubAgents](./Harness_Step02_Research_WithSubAgents/README.md) | Using SubAgentsProvider to delegate stock price lookups to a web-search sub-agent concurrently | diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index a246424bef..f3bc543f03 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -95,6 +95,13 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(FileListEntry))] [JsonSerializable(typeof(List), TypeInfoPropertyName = "FileListEntryList")] + // SubAgentsProvider types + [JsonSerializable(typeof(SubAgentState))] + [JsonSerializable(typeof(SubAgentRuntimeState))] + [JsonSerializable(typeof(SubTaskInfo))] + [JsonSerializable(typeof(SubTaskStatus))] + [JsonSerializable(typeof(List), TypeInfoPropertyName = "SubTaskInfoList")] + [ExcludeFromCodeCoverage] internal sealed partial class JsonContext : JsonSerializerContext; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentRuntimeState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentRuntimeState.cs new file mode 100644 index 0000000000..7b3096dba8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentRuntimeState.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI; + +/// +/// Holds non-serializable runtime references for in-flight sub-tasks within a single parent session. +/// +/// +/// Properties are marked with because +/// and are not JSON-serializable. After deserialization (e.g., after a restart), +/// a fresh empty instance is created and any previously-running tasks are marked as +/// by . +/// +internal sealed class SubAgentRuntimeState +{ + /// + /// Gets the mapping of task IDs to their in-flight instances. + /// + [JsonIgnore] + public Dictionary> InFlightTasks { get; } = []; + + /// + /// Gets the mapping of task IDs to their sub-agent instances, + /// needed for ContinueTask. + /// + [JsonIgnore] + public Dictionary SubTaskSessions { get; } = []; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentState.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentState.cs new file mode 100644 index 0000000000..4e086fb910 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentState.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the serializable state of sub-tasks managed by the , +/// stored in the session's . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class SubAgentState +{ + /// + /// Gets or sets the next ID to assign to a new sub-task. + /// + [JsonPropertyName("nextTaskId")] + public int NextTaskId { get; set; } = 1; + + /// + /// Gets the list of sub-task metadata entries. + /// + [JsonPropertyName("tasks")] + public List Tasks { get; set; } = []; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs new file mode 100644 index 0000000000..f6c6daf592 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs @@ -0,0 +1,456 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +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; + +/// +/// An that enables an agent to delegate work to sub-agents asynchronously. +/// +/// +/// +/// The allows a parent agent to start sub-tasks on child agents, +/// wait for their completion, and retrieve results. Each sub-task runs in its own session and +/// executes concurrently. +/// +/// +/// This provider exposes the following tools to the agent: +/// +/// SubAgents_StartTask — Start a sub-task on a named agent with text input. Returns the task ID. +/// SubAgents_WaitForFirstCompletion — Block until the first of the specified tasks completes. Returns the completed task's ID. +/// SubAgents_GetTaskResults — Retrieve the text output of a completed sub-task. +/// SubAgents_GetAllTasks — List all sub-tasks with their IDs, statuses, descriptions, and agent names. +/// SubAgents_ContinueTask — Send follow-up input to a completed sub-task's session to resume work. +/// SubAgents_ClearCompletedTask — Remove a completed sub-task and release its session to free memory. +/// +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class SubAgentsProvider : AIContextProvider +{ + private const string DefaultInstructions = + """ + You have access to sub-agents that can perform work on your behalf. + Use the `SubAgents_*` list of tools to start tasks on sub agents and check their results. + Creating a sub task does not block, and sub-tasks run concurrently. + Important: Always wait for outstanding tasks to finish before you finish processing. + Important: After retrieving results from a completed task, clear it with SubAgents_ClearCompletedTask to free memory, unless you plan to continue it with SubAgents_ContinueTask. + + Use SubAgents_StartTask to delegate work to a sub-agent. This will send the task to the sub agent and return immediately. Sub-tasks run concurrently. + Use SubAgents_WaitForFirstCompletion to block until one of the specified tasks finishes. + Use SubAgents_GetTaskResults to retrieve the output of a completed task. + Use SubAgents_GetAllTasks to see the status of all sub-tasks. + Use SubAgents_ContinueTask to send follow-up input to a completed sub-task (e.g., provide clarification or additional instructions). + Use SubAgents_ClearCompletedTask to remove a completed task and free its memory after you no longer need its results or session. + """; + + private readonly Dictionary _agents; + private readonly ProviderSessionState _sessionState; + private readonly ProviderSessionState _runtimeSessionState; + private readonly string _instructions; + private IReadOnlyList? _stateKeys; + + /// + /// Initializes a new instance of the class. + /// + /// The collection of sub-agents available for delegation. + /// Optional settings controlling the provider behavior. + /// is . + /// An agent has a null or empty name, or agent names are not unique. + public SubAgentsProvider(IEnumerable agents, SubAgentsProviderOptions? options = null) + { + _ = Throw.IfNull(agents); + + this._agents = ValidateAndBuildAgentDictionary(agents); + + string baseInstructions = options?.Instructions ?? DefaultInstructions; + string agentListText = options?.AgentListBuilder is not null + ? options.AgentListBuilder(this._agents) + : BuildDefaultAgentListText(this._agents); + this._instructions = baseInstructions + "\n" + agentListText; + + this._sessionState = new ProviderSessionState( + _ => new SubAgentState(), + this.GetType().Name, + AgentJsonUtilities.DefaultOptions); + + this._runtimeSessionState = new ProviderSessionState( + _ => new SubAgentRuntimeState(), + this.GetType().Name + "_Runtime", + AgentJsonUtilities.DefaultOptions); + } + + /// + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey, this._runtimeSessionState.StateKey]; + + /// + protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + SubAgentState state = this._sessionState.GetOrInitializeState(context.Session); + SubAgentRuntimeState runtimeState = this._runtimeSessionState.GetOrInitializeState(context.Session); + + return new ValueTask(new AIContext + { + Instructions = this._instructions, + Tools = this.CreateTools(state, runtimeState, context.Session), + }); + } + + /// + /// Validates the agent collection and builds a case-insensitive name dictionary. + /// + private static Dictionary ValidateAndBuildAgentDictionary(IEnumerable agents) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (AIAgent agent in agents) + { + if (string.IsNullOrWhiteSpace(agent.Name)) + { + throw new ArgumentException("All sub-agents must have a non-empty Name.", nameof(agents)); + } + + if (dict.ContainsKey(agent.Name)) + { + throw new ArgumentException($"Duplicate sub-agent name: '{agent.Name}'. Agent names must be unique (case-insensitive).", nameof(agents)); + } + + dict[agent.Name] = agent; + } + + if (dict.Count == 0) + { + throw new ArgumentException("At least one sub-agent must be provided.", nameof(agents)); + } + + return dict; + } + + /// + /// Builds the default text listing available sub-agents and their descriptions. + /// + private static string BuildDefaultAgentListText(IReadOnlyDictionary agents) + { + var sb = new StringBuilder(); + sb.AppendLine("Available sub-agents:"); + foreach (var kvp in agents) + { + sb.Append("- ").Append(kvp.Key); + if (!string.IsNullOrWhiteSpace(kvp.Value.Description)) + { + sb.Append(": ").Append(kvp.Value.Description); + } + + sb.AppendLine(); + } + + return sb.ToString(); + } + + /// + /// Refreshes the status of in-flight tasks in the given state for the specified session. + /// + private void TryRefreshTaskState(SubAgentState state, SubAgentRuntimeState runtimeState, AgentSession? session) + { + bool changed = false; + foreach (SubTaskInfo task in state.Tasks) + { + if (task.Status != SubTaskStatus.Running) + { + continue; + } + + if (!runtimeState.InFlightTasks.TryGetValue(task.Id, out Task? inFlight)) + { + // In-flight reference lost (e.g., after restart/deserialization). + task.Status = SubTaskStatus.Lost; + changed = true; + continue; + } + + if (inFlight.IsCompleted) + { + FinalizeTask(task, inFlight, runtimeState); + changed = true; + } + } + + if (changed) + { + this._sessionState.SaveState(session, state); + } + } + + /// + /// Finalizes a task by extracting results from the completed Task and updating the SubTaskInfo. + /// + private static void FinalizeTask(SubTaskInfo taskInfo, Task completedTask, SubAgentRuntimeState runtimeState) + { + if (completedTask.Status == TaskStatus.RanToCompletion) + { + taskInfo.Status = SubTaskStatus.Completed; +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits — task is already completed + taskInfo.ResultText = completedTask.Result.Text; +#pragma warning restore VSTHRD002 + } + else if (completedTask.IsFaulted) + { + taskInfo.Status = SubTaskStatus.Failed; + taskInfo.ErrorText = completedTask.Exception?.InnerException?.Message ?? completedTask.Exception?.Message ?? "Unknown error"; + } + else if (completedTask.IsCanceled) + { + taskInfo.Status = SubTaskStatus.Failed; + taskInfo.ErrorText = "Task was canceled."; + } + + runtimeState.InFlightTasks.Remove(taskInfo.Id); + } + + private AITool[] CreateTools(SubAgentState state, SubAgentRuntimeState runtimeState, AgentSession? session) + { + var serializerOptions = AgentJsonUtilities.DefaultOptions; + + return + [ + AIFunctionFactory.Create( + async ( + [Description("The name of the sub agent to delegate the task to.")] string agentName, + [Description("The request to pass to the sub agent.")] string input, + [Description("A description of the task used to identify the task later.")] string description) => + { + if (!this._agents.TryGetValue(agentName, out AIAgent? agent)) + { + return $"Error: No sub-agent found with name '{agentName}'. Available agents: {string.Join(", ", this._agents.Keys)}"; + } + + int taskId = state.NextTaskId++; + var taskInfo = new SubTaskInfo + { + Id = taskId, + AgentName = agentName, + Description = description, + Status = SubTaskStatus.Running, + }; + state.Tasks.Add(taskInfo); + + // Create a dedicated session for this sub-task so it can be continued later. + AgentSession subSession = await agent.CreateSessionAsync().ConfigureAwait(false); + + // Wrap in Task.Run to fork the ExecutionContext. AIAgent.RunAsync is a non-async + // method that synchronously sets the static AsyncLocal CurrentRunContext. Without + // this isolation, the sub-agent's RunAsync would overwrite the outer (calling) + // agent's CurrentRunContext, corrupting all subsequent tool invocations in the + // same FICC batch. + runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(input, subSession)); + runtimeState.SubTaskSessions[taskId] = subSession; + + this._sessionState.SaveState(session, state); + return $"Sub-task {taskId} started on agent '{agentName}'."; + }, + new AIFunctionFactoryOptions + { + Name = "SubAgents_StartTask", + Description = "Start a sub-task on a named sub-agent. Returns an ID for the new task.", + SerializerOptions = serializerOptions, + }), + + AIFunctionFactory.Create( + async (List taskIds) => + { + if (taskIds.Count == 0) + { + return "Error: No task IDs provided."; + } + + // Collect in-flight tasks matching the requested IDs (including already-completed ones, + // since Task.WhenAny returns immediately for completed tasks). + var waitableTasks = new List<(int Id, Task Task)>(); + foreach (int id in taskIds) + { + if (runtimeState.InFlightTasks.TryGetValue(id, out Task? inFlight)) + { + waitableTasks.Add((id, inFlight)); + } + } + + if (waitableTasks.Count == 0) + { + // Refresh state to catch any that completed. + this.TryRefreshTaskState(state, runtimeState, session); + this._sessionState.SaveState(session, state); + + // Check if any of the requested IDs are already complete. + SubTaskInfo? alreadyComplete = state.Tasks.FirstOrDefault(t => taskIds.Contains(t.Id) && t.Status != SubTaskStatus.Running); + if (alreadyComplete is not null) + { + return $"Task {alreadyComplete.Id} is not running; current status: {alreadyComplete.Status}."; + } + + return "Error: None of the specified task IDs correspond to running tasks."; + } + + // Wait for the first one to complete. + Task completedTask = await Task.WhenAny(waitableTasks.Select(t => t.Task)).ConfigureAwait(false); + + // Find which ID completed. + var completedEntry = waitableTasks.First(t => t.Task == completedTask); + + // Finalize the completed task. + SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == completedEntry.Id); + if (taskInfo is not null) + { + FinalizeTask(taskInfo, completedEntry.Task, runtimeState); + this._sessionState.SaveState(session, state); + } + + return $"Task {completedEntry.Id} finished with status: {taskInfo?.Status.ToString() ?? "Unknown"}."; + }, + new AIFunctionFactoryOptions + { + Name = "SubAgents_WaitForFirstCompletion", + Description = "Block until the first of the specified sub-tasks completes. Provide one or more task IDs. Returns the ID of the task that completed first.", + SerializerOptions = serializerOptions, + }), + + AIFunctionFactory.Create( + (int taskId) => + { + this.TryRefreshTaskState(state, runtimeState, session); + + SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId); + if (taskInfo is null) + { + return $"Error: No task found with ID {taskId}."; + } + + return taskInfo.Status switch + { + SubTaskStatus.Completed => taskInfo.ResultText ?? "(no output)", + SubTaskStatus.Failed => $"Task failed: {taskInfo.ErrorText ?? "Unknown error"}", + SubTaskStatus.Lost => "Task state was lost (reference unavailable).", + SubTaskStatus.Running => $"Task {taskId} is still running.", + _ => $"Task {taskId} has status: {taskInfo.Status}.", + }; + }, + new AIFunctionFactoryOptions + { + Name = "SubAgents_GetTaskResults", + Description = "Get the text output of a sub-task by its ID. Returns the result text if complete, or status information if still running or failed.", + SerializerOptions = serializerOptions, + }), + + AIFunctionFactory.Create( + () => + { + this.TryRefreshTaskState(state, runtimeState, session); + + if (state.Tasks.Count == 0) + { + return "No tasks."; + } + + var sb = new StringBuilder(); + sb.AppendLine("Tasks:"); + foreach (SubTaskInfo task in state.Tasks) + { + sb.Append("- Task ").Append(task.Id).Append(" [").Append(task.Status).Append("] (").Append(task.AgentName).Append("): ").AppendLine(task.Description); + } + + return sb.ToString(); + }, + new AIFunctionFactoryOptions + { + Name = "SubAgents_GetAllTasks", + Description = "List all sub-tasks with their IDs, statuses, agent names, and descriptions.", + SerializerOptions = serializerOptions, + }), + + AIFunctionFactory.Create( + (int taskId, string text) => + { + this.TryRefreshTaskState(state, runtimeState, session); + + SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId); + if (taskInfo is null) + { + return $"Error: No task found with ID {taskId}."; + } + + if (taskInfo.Status == SubTaskStatus.Running) + { + return $"Error: Task {taskId} is still running. Wait for it to complete before continuing."; + } + + if (!this._agents.TryGetValue(taskInfo.AgentName, out AIAgent? agent)) + { + return $"Error: Agent '{taskInfo.AgentName}' is no longer available."; + } + + if (!runtimeState.SubTaskSessions.TryGetValue(taskId, out AgentSession? subSession)) + { + return $"Error: Session for task {taskId} is no longer available."; + } + + // Reset task state and start a new run on the existing session. + taskInfo.Status = SubTaskStatus.Running; + taskInfo.ResultText = null; + taskInfo.ErrorText = null; + + // Wrap in Task.Run to isolate the ExecutionContext (see StartSubTask comment). + runtimeState.InFlightTasks[taskId] = Task.Run(() => agent.RunAsync(text, subSession)); + + this._sessionState.SaveState(session, state); + return $"Task {taskId} continued with new input."; + }, + new AIFunctionFactoryOptions + { + Name = "SubAgents_ContinueTask", + Description = "Send follow-up input to a completed or failed sub-task to resume its work. The sub-task's session is preserved, so the agent retains conversational context.", + SerializerOptions = serializerOptions, + }), + + AIFunctionFactory.Create( + (int taskId) => + { + this.TryRefreshTaskState(state, runtimeState, session); + + SubTaskInfo? taskInfo = state.Tasks.FirstOrDefault(t => t.Id == taskId); + if (taskInfo is null) + { + return $"Error: No task found with ID {taskId}."; + } + + if (taskInfo.Status == SubTaskStatus.Running) + { + return $"Error: Task {taskId} is still running. Wait for it to complete before clearing."; + } + + // Remove the task from state. + state.Tasks.Remove(taskInfo); + + // Clean up runtime references. + runtimeState.InFlightTasks.Remove(taskId); + runtimeState.SubTaskSessions.Remove(taskId); + + this._sessionState.SaveState(session, state); + return $"Task {taskId} cleared."; + }, + new AIFunctionFactoryOptions + { + Name = "SubAgents_ClearCompletedTask", + Description = "Remove a completed or failed sub-task and release its session to free memory. Use this after retrieving results when you no longer need to continue the task.", + SerializerOptions = serializerOptions, + }), + ]; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs new file mode 100644 index 0000000000..06b9828e58 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Options controlling the behavior of . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class SubAgentsProviderOptions +{ + /// + /// Gets or sets custom instructions provided to the agent for using the sub-agent tools. + /// + /// + /// When (the default), the provider uses built-in instructions + /// that guide the agent on how to use the sub-agent tools. + /// The agent list is always appended after the instructions regardless of this setting. + /// + public string? Instructions { get; set; } + + /// + /// Gets or sets a custom function that builds the agent list text to append to instructions. + /// + /// + /// When (the default), the provider generates a standard list of agent names and descriptions. + /// When set, this function receives the dictionary of available agents (keyed by name) and should return + /// a formatted string describing the available sub-agents. + /// + public Func, string>? AgentListBuilder { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskInfo.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskInfo.cs new file mode 100644 index 0000000000..91b6084ece --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskInfo.cs @@ -0,0 +1,50 @@ +// 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 metadata and result of a sub-task managed by the . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class SubTaskInfo +{ + /// + /// Gets or sets the unique identifier for this sub-task. + /// + [JsonPropertyName("id")] + public int Id { get; set; } + + /// + /// Gets or sets the name of the agent that is executing this sub-task. + /// + [JsonPropertyName("agentName")] + public string AgentName { get; set; } = string.Empty; + + /// + /// Gets or sets a description of what this sub-task is doing. + /// + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets the current status of this sub-task. + /// + [JsonPropertyName("status")] + public SubTaskStatus Status { get; set; } + + /// + /// Gets or sets the text result of the sub-task, populated when the task completes successfully. + /// + [JsonPropertyName("resultText")] + public string? ResultText { get; set; } + + /// + /// Gets or sets the error message if the sub-task failed. + /// + [JsonPropertyName("errorText")] + public string? ErrorText { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskStatus.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskStatus.cs new file mode 100644 index 0000000000..f5e66f6f72 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubTaskStatus.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the status of a sub-task managed by the . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public enum SubTaskStatus +{ + /// + /// The sub-task is currently running. + /// + Running, + + /// + /// The sub-task completed successfully. + /// + Completed, + + /// + /// The sub-task failed with an error. + /// + Failed, + + /// + /// The sub-task's in-flight reference was lost (e.g., after a restart), + /// and its final state cannot be determined. + /// + Lost, +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs new file mode 100644 index 0000000000..42ee69907c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs @@ -0,0 +1,972 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class SubAgentsProviderTests +{ + #region Constructor Tests + + /// + /// Verify that the constructor throws when agents is null. + /// + [Fact] + public void Constructor_NullAgents_Throws() + { + // Act & Assert + Assert.Throws(() => new SubAgentsProvider(null!)); + } + + /// + /// Verify that the constructor throws when agents collection is empty. + /// + [Fact] + public void Constructor_EmptyAgents_Throws() + { + // Act & Assert + Assert.Throws(() => new SubAgentsProvider(Array.Empty())); + } + + /// + /// Verify that the constructor throws when an agent has a null name. + /// + [Fact] + public void Constructor_AgentWithNullName_Throws() + { + // Arrange + var agent = CreateMockAgent(null!, "desc"); + + // Act & Assert + Assert.Throws(() => new SubAgentsProvider(new[] { agent })); + } + + /// + /// Verify that the constructor throws when an agent has an empty name. + /// + [Fact] + public void Constructor_AgentWithEmptyName_Throws() + { + // Arrange + var agent = CreateMockAgent("", "desc"); + + // Act & Assert + Assert.Throws(() => new SubAgentsProvider(new[] { agent })); + } + + /// + /// Verify that the constructor throws when duplicate agent names are provided (case-insensitive). + /// + [Fact] + public void Constructor_DuplicateNames_Throws() + { + // Arrange + var agent1 = CreateMockAgent("Research", "Agent 1"); + var agent2 = CreateMockAgent("research", "Agent 2"); + + // Act & Assert + Assert.Throws(() => new SubAgentsProvider(new[] { agent1, agent2 })); + } + + /// + /// Verify that the constructor succeeds with valid agents. + /// + [Fact] + public void Constructor_ValidAgents_Succeeds() + { + // Arrange + var agent1 = CreateMockAgent("Research", "Research agent"); + var agent2 = CreateMockAgent("Writer", "Writer agent"); + + // Act + var provider = new SubAgentsProvider(new[] { agent1, agent2 }); + + // Assert + Assert.NotNull(provider); + } + + #endregion + + #region ProvideAIContextAsync Tests + + /// + /// Verify that the provider returns tools and instructions. + /// + [Fact] + public async Task ProvideAIContextAsync_ReturnsToolsAndInstructionsAsync() + { + // Arrange + var agent = CreateMockAgent("Research", "Research agent"); + var provider = new SubAgentsProvider(new[] { agent }); + var context = CreateInvokingContext(); + + // Act + AIContext result = await provider.InvokingAsync(context); + + // Assert + Assert.NotNull(result.Instructions); + Assert.NotNull(result.Tools); + Assert.Equal(6, result.Tools!.Count()); + } + + /// + /// Verify that the instructions include agent names and descriptions. + /// + [Fact] + public async Task ProvideAIContextAsync_InstructionsIncludeAgentInfoAsync() + { + // Arrange + var agent1 = CreateMockAgent("Research", "Performs research"); + var agent2 = CreateMockAgent("Writer", "Writes content"); + var provider = new SubAgentsProvider(new[] { agent1, agent2 }); + var context = CreateInvokingContext(); + + // Act + AIContext result = await provider.InvokingAsync(context); + + // Assert — agent info is appended to instructions + Assert.Contains("Research", result.Instructions); + Assert.Contains("Performs research", result.Instructions); + Assert.Contains("Writer", result.Instructions); + Assert.Contains("Writes content", result.Instructions); + } + + #endregion + + #region StartSubTask Tests + + /// + /// Verify that StartSubTask returns a task ID. + /// + [Fact] + public async Task StartSubTask_ReturnsTaskIdAsync() + { + // Arrange + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + + // Act + object? result = await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Find information about AI", + ["description"] = "Research AI topics", + }); + + // Assert + string text = GetStringResult(result); + Assert.Contains("1", text); + Assert.Contains("started", text); + + tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + + /// + /// Verify that StartSubTask with invalid agent name returns an error. + /// + [Fact] + public async Task StartSubTask_InvalidAgentName_ReturnsErrorAsync() + { + // Arrange + var agent = CreateMockAgent("Research", "Research agent"); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + + // Act + object? result = await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "NonExistent", + ["input"] = "Some input", + ["description"] = "Some task", + }); + + // Assert + string text = GetStringResult(result); + Assert.Contains("Error", text); + Assert.Contains("NonExistent", text); + } + + /// + /// Verify that StartSubTask assigns sequential IDs. + /// + [Fact] + public async Task StartSubTask_AssignsSequentialIdsAsync() + { + // Arrange + var tcs1 = new TaskCompletionSource(); + var tcs2 = new TaskCompletionSource(); + var callCount = 0; + var agent = CreateMockAgentWithCallback("Research", () => + { + callCount++; + return callCount == 1 ? tcs1.Task : tcs2.Task; + }); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + + // Act + object? result1 = await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + object? result2 = await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 2", + ["description"] = "Second task", + }); + + // Assert + Assert.Contains("1", GetStringResult(result1)); + Assert.Contains("2", GetStringResult(result2)); + + tcs1.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done"))); + tcs2.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + + #endregion + + #region WaitForFirstCompletion Tests + + /// + /// Verify that WaitForFirstCompletion returns the ID of a completed task. + /// + [Fact] + public async Task WaitForFirstCompletion_ReturnsCompletedTaskIdAsync() + { + // Arrange — use a single task to avoid Task.Run scheduling races. + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); + + // Start one task + await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Task 1", + ["description"] = "First task", + }); + + // Complete the task + tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Result 1"))); + + // Act + object? result = await waitForFirst.InvokeAsync(new AIFunctionArguments + { + ["taskIds"] = new List { 1 }, + }); + + // Assert + string text = GetStringResult(result); + Assert.Contains("1", text); + Assert.Contains("finished with status: Completed", text); + } + + /// + /// Verify that WaitForFirstCompletion with empty list returns an error. + /// + [Fact] + public async Task WaitForFirstCompletion_EmptyList_ReturnsErrorAsync() + { + // Arrange + var agent = CreateMockAgent("Research", "Research agent"); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); + + // Act + object? result = await waitForFirst.InvokeAsync(new AIFunctionArguments + { + ["taskIds"] = new List(), + }); + + // Assert + Assert.Contains("Error", GetStringResult(result)); + } + + #endregion + + #region GetSubTaskResults Tests + + /// + /// Verify that GetSubTaskResults returns the result text of a completed task. + /// + [Fact] + public async Task GetSubTaskResults_CompletedTask_ReturnsResultTextAsync() + { + // Arrange + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); + AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults"); + + // Start a task + await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Research AI", + ["description"] = "AI research", + }); + + // Complete it + tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "AI is fascinating!"))); + + // Wait for completion to finalize state + await waitForFirst.InvokeAsync(new AIFunctionArguments + { + ["taskIds"] = new List { 1 }, + }); + + // Act + object? result = await getResults.InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 1, + }); + + // Assert + Assert.Contains("AI is fascinating!", GetStringResult(result)); + } + + /// + /// Verify that GetSubTaskResults for a still-running task returns status info. + /// + [Fact] + public async Task GetSubTaskResults_RunningTask_ReturnsStatusAsync() + { + // Arrange + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults"); + + // Start a task (don't complete it) + await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Research AI", + ["description"] = "AI research", + }); + + // Act + object? result = await getResults.InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 1, + }); + + // Assert + Assert.Contains("still running", GetStringResult(result)); + + tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + + /// + /// Verify that GetSubTaskResults for a nonexistent task returns an error. + /// + [Fact] + public async Task GetSubTaskResults_NonexistentTask_ReturnsErrorAsync() + { + // Arrange + var agent = CreateMockAgent("Research", "Research agent"); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults"); + + // Act + object? result = await getResults.InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 999, + }); + + // Assert + Assert.Contains("Error", GetStringResult(result)); + } + + /// + /// Verify that GetSubTaskResults for a failed task returns the error. + /// + [Fact] + public async Task GetSubTaskResults_FailedTask_ReturnsErrorTextAsync() + { + // Arrange + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); + AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults"); + + // Start a task + await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Research AI", + ["description"] = "AI research", + }); + + // Fail it + tcs.SetException(new InvalidOperationException("Connection failed")); + + // Wait for completion to finalize state + await waitForFirst.InvokeAsync(new AIFunctionArguments + { + ["taskIds"] = new List { 1 }, + }); + + // Act + object? result = await getResults.InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 1, + }); + + // Assert + string text = GetStringResult(result); + Assert.Contains("failed", text); + Assert.Contains("Connection failed", text); + } + + #endregion + + #region GetAllTasks Tests + + /// + /// Verify that GetAllTasks returns running tasks with descriptions and status. + /// + [Fact] + public async Task GetAllTasks_ReturnsRunningTasksAsync() + { + // Arrange + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks"); + + // Start a task + await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Research AI", + ["description"] = "AI research task", + }); + + // Act + object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments()); + + // Assert + string text = GetStringResult(result); + Assert.Contains("1", text); + Assert.Contains("Research", text); + Assert.Contains("AI research task", text); + Assert.Contains("Running", text); + + tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + + /// + /// Verify that GetAllTasks returns completed tasks with their status. + /// + [Fact] + public async Task GetAllTasks_ShowsCompletedTasksAsync() + { + // Arrange + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); + AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks"); + + // Start and complete a task + await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Research AI", + ["description"] = "AI research", + }); + tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done"))); + await waitForFirst.InvokeAsync(new AIFunctionArguments + { + ["taskIds"] = new List { 1 }, + }); + + // Act + object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments()); + + // Assert + string text = GetStringResult(result); + Assert.Contains("Completed", text); + Assert.Contains("Research", text); + } + + /// + /// Verify that GetAllTasks returns no tasks when none exist. + /// + [Fact] + public async Task GetAllTasks_NoTasks_ReturnsNoneAsync() + { + // Arrange + var agent = CreateMockAgent("Research", "Research agent"); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction getAllTasks = GetTool(tools, "SubAgents_GetAllTasks"); + + // Act + object? result = await getAllTasks.InvokeAsync(new AIFunctionArguments()); + + // Assert + Assert.Contains("No tasks", GetStringResult(result)); + } + + #endregion + + #region ContinueTask Tests + + /// + /// Verify that ContinueTask resumes a completed task with new input. + /// + [Fact] + public async Task ContinueTask_CompletedTask_ResumesAsync() + { + // Arrange + var tcs1 = new TaskCompletionSource(); + var tcs2 = new TaskCompletionSource(); + var callCount = 0; + var agent = CreateMockAgentWithCallback("Research", () => + { + callCount++; + return callCount == 1 ? tcs1.Task : tcs2.Task; + }); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); + AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask"); + AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults"); + + // Start and complete a task + await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Research AI", + ["description"] = "AI research", + }); + tcs1.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "First result"))); + await waitForFirst.InvokeAsync(new AIFunctionArguments + { + ["taskIds"] = new List { 1 }, + }); + + // Act — continue the task + object? continueResult = await continueTask.InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 1, + ["text"] = "Please elaborate", + }); + + // Assert — task is resumed + Assert.Contains("continued", GetStringResult(continueResult)); + + // Complete the second run + tcs2.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Elaborated result"))); + await waitForFirst.InvokeAsync(new AIFunctionArguments + { + ["taskIds"] = new List { 1 }, + }); + + object? result = await getResults.InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 1, + }); + Assert.Contains("Elaborated result", GetStringResult(result)); + } + + /// + /// Verify that ContinueTask on a running task returns an error. + /// + [Fact] + public async Task ContinueTask_RunningTask_ReturnsErrorAsync() + { + // Arrange + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask"); + + // Start a task (don't complete it) + await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Research AI", + ["description"] = "AI research", + }); + + // Act + object? result = await continueTask.InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 1, + ["text"] = "More input", + }); + + // Assert + Assert.Contains("still running", GetStringResult(result)); + + tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + + /// + /// Verify that ContinueTask on a nonexistent task returns an error. + /// + [Fact] + public async Task ContinueTask_NonexistentTask_ReturnsErrorAsync() + { + // Arrange + var agent = CreateMockAgent("Research", "Research agent"); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction continueTask = GetTool(tools, "SubAgents_ContinueTask"); + + // Act + object? result = await continueTask.InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 999, + ["text"] = "More input", + }); + + // Assert + Assert.Contains("Error", GetStringResult(result)); + } + + #endregion + + #region ClearCompletedTask Tests + + /// + /// Verify that ClearCompletedTask removes a terminal task. + /// + [Fact] + public async Task ClearCompletedTask_RemovesTerminalTaskAsync() + { + // Arrange + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + AIFunction waitForFirst = GetTool(tools, "SubAgents_WaitForFirstCompletion"); + AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask"); + AIFunction getResults = GetTool(tools, "SubAgents_GetTaskResults"); + + // Start and complete a task + await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Research AI", + ["description"] = "AI research", + }); + tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Result"))); + await waitForFirst.InvokeAsync(new AIFunctionArguments + { + ["taskIds"] = new List { 1 }, + }); + + // Act + object? clearResult = await clearTask.InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 1, + }); + + // Assert — task is cleared + Assert.Contains("cleared", GetStringResult(clearResult)); + + // Verify it's gone + object? getResult = await getResults.InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 1, + }); + Assert.Contains("Error", GetStringResult(getResult)); + } + + /// + /// Verify that ClearCompletedTask on a running task returns an error. + /// + [Fact] + public async Task ClearCompletedTask_RunningTask_ReturnsErrorAsync() + { + // Arrange + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction startSubTask = GetTool(tools, "SubAgents_StartTask"); + AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask"); + + // Start a task (don't complete it) + await startSubTask.InvokeAsync(new AIFunctionArguments + { + ["agentName"] = "Research", + ["input"] = "Research AI", + ["description"] = "AI research", + }); + + // Act + object? result = await clearTask.InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 1, + }); + + // Assert + Assert.Contains("still running", GetStringResult(result)); + + tcs.SetResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + + /// + /// Verify that ClearCompletedTask on a nonexistent task returns an error. + /// + [Fact] + public async Task ClearCompletedTask_NonexistentTask_ReturnsErrorAsync() + { + // Arrange + var agent = CreateMockAgent("Research", "Research agent"); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + AIFunction clearTask = GetTool(tools, "SubAgents_ClearCompletedTask"); + + // Act + object? result = await clearTask.InvokeAsync(new AIFunctionArguments + { + ["taskId"] = 999, + }); + + // Assert + Assert.Contains("Error", GetStringResult(result)); + } + + #endregion + + #region StateKeys Tests + + /// + /// Verify that the provider exposes state keys. + /// + [Fact] + public void StateKeys_ReturnsExpectedKeys() + { + // Arrange + var agent = CreateMockAgent("Research", "Research agent"); + var provider = new SubAgentsProvider(new[] { agent }); + + // Act + var keys = provider.StateKeys; + + // Assert + Assert.NotNull(keys); + Assert.Equal(2, keys.Count); + } + + #endregion + + #region CurrentRunContext Isolation Tests + + /// + /// Verify that StartSubTask does not corrupt CurrentRunContext of the calling agent. + /// Because RunAsync is a non-async method that synchronously sets the static AsyncLocal + /// CurrentRunContext, the provider must isolate the sub-agent call to prevent overwriting + /// the outer agent's context. + /// + [Fact] + public async Task StartSubTask_DoesNotCorruptCurrentRunContextAsync() + { + // Arrange + var tcs = new TaskCompletionSource(); + var agent = CreateMockAgentWithRunResult("Research", tcs.Task); + var (tools, _) = await CreateToolsWithProviderAsync(agent); + var startTool = GetTool(tools, "SubAgents_StartTask"); + + AgentRunContext? contextBefore = AIAgent.CurrentRunContext; + + // Act — invoke StartSubTask; this calls agent.RunAsync internally. + var args = new AIFunctionArguments(new Dictionary + { + ["agentName"] = "Research", + ["input"] = "Do work", + ["description"] = "test task", + }); + await startTool.InvokeAsync(args); + + // Assert — CurrentRunContext should be unchanged. + Assert.Equal(contextBefore, AIAgent.CurrentRunContext); + + // Clean up + tcs.SetResult(new AgentResponse(new List { new(ChatRole.Assistant, "done") })); + } + + #endregion + + #region Options Tests + + /// + /// Verify that custom instructions from options override the default instructions but agent list is still appended. + /// + [Fact] + public async Task CustomInstructions_OverridesDefaultInstructionsAsync() + { + // Arrange + var agent = CreateMockAgent("Research", "Research agent"); + const string CustomInstructions = "These are custom sub-agent instructions."; + var options = new SubAgentsProviderOptions { Instructions = CustomInstructions }; + var provider = new SubAgentsProvider(new[] { agent }, options); + var context = CreateInvokingContext(); + + // Act + AIContext result = await provider.InvokingAsync(context); + + // Assert — custom instructions replace default, agent list is appended + Assert.StartsWith(CustomInstructions, result.Instructions); + Assert.Contains("Research", result.Instructions); + } + + /// + /// Verify that default instructions contain tool names and agent names. + /// + [Fact] + public async Task DefaultInstructions_ContainsToolNamesAndAgentListAsync() + { + // Arrange + var agent = CreateMockAgent("Research", "Research agent"); + var provider = new SubAgentsProvider(new[] { agent }); + var context = CreateInvokingContext(); + + // Act + AIContext result = await provider.InvokingAsync(context); + + // Assert — instructions contain both tool usage guidance and agent list + Assert.Contains("SubAgents_StartTask", result.Instructions); + Assert.Contains("SubAgents_WaitForFirstCompletion", result.Instructions); + Assert.Contains("SubAgents_GetTaskResults", result.Instructions); + Assert.Contains("SubAgents_GetAllTasks", result.Instructions); + Assert.Contains("SubAgents_ContinueTask", result.Instructions); + Assert.Contains("SubAgents_ClearCompletedTask", result.Instructions); + Assert.Contains("Research", result.Instructions); + Assert.Contains("Research agent", result.Instructions); + } + + /// + /// Verify that a custom AgentListBuilder function is used to build the agent list text. + /// + [Fact] + public async Task CustomAgentListBuilder_UsedForAgentListAsync() + { + // Arrange + var agent = CreateMockAgent("Research", "Research agent"); + var options = new SubAgentsProviderOptions + { + AgentListBuilder = agents => $"Custom list: {string.Join(", ", agents.Keys)}", + }; + var provider = new SubAgentsProvider(new[] { agent }, options); + var context = CreateInvokingContext(); + + // Act + AIContext result = await provider.InvokingAsync(context); + + // Assert — custom agent list builder output is in instructions + Assert.Contains("Custom list: Research", result.Instructions); + Assert.DoesNotContain("Available sub-agents:", result.Instructions); + } + + #endregion + + #region Helper Methods + + private static AIAgent CreateMockAgent(string? name, string? description) + { + var mock = new Mock(); + mock.SetupGet(a => a.Name).Returns(name!); + mock.SetupGet(a => a.Description).Returns(description); + return mock.Object; + } + + private static AIAgent CreateMockAgentWithRunResult(string name, Task result) + { + var mock = new Mock(); + mock.SetupGet(a => a.Name).Returns(name); + mock.Protected() + .Setup>( + "CreateSessionCoreAsync", + ItExpr.IsAny()) + .Returns(new ValueTask(new ChatClientAgentSession())); + mock.Protected() + .Setup>( + "RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(result); + return mock.Object; + } + + private static AIAgent CreateMockAgentWithCallback(string name, Func> callback) + { + var mock = new Mock(); + mock.SetupGet(a => a.Name).Returns(name); + mock.Protected() + .Setup>( + "CreateSessionCoreAsync", + ItExpr.IsAny()) + .Returns(new ValueTask(new ChatClientAgentSession())); + mock.Protected() + .Setup>( + "RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(callback); + return mock.Object; + } + + private static async Task<(IEnumerable Tools, SubAgentsProvider Provider)> CreateToolsWithProviderAsync(AIAgent agent) + { + var provider = new SubAgentsProvider(new[] { agent }); + var context = CreateInvokingContext(); + + AIContext result = await provider.InvokingAsync(context); + return (result.Tools!, provider); + } + + private static AIContextProvider.InvokingContext CreateInvokingContext() + { + var mockAgent = new Mock().Object; + var session = new ChatClientAgentSession(); +#pragma warning disable MAAI001 + return new AIContextProvider.InvokingContext(mockAgent, session, new AIContext()); +#pragma warning restore MAAI001 + } + + 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 +} From e3f76618c523e45b39b4d3b7d3f56c27b26690eb Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:20:43 +0100 Subject: [PATCH 11/13] .NET: Harness filememory index plus instructions consistency (#5540) * Add FileMemoryProvider index and improve instruction consistency * Address PR comments. * Address PR comments * Address PR comments. * Apply suggestion from @rogerbarreto Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --------- Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- .../WebBrowsingTool.cs | 11 +- .../Harness/AgentMode/AgentModeProvider.cs | 40 ++-- .../AgentMode/AgentModeProviderOptions.cs | 8 +- .../Harness/FileMemory/FileMemoryProvider.cs | 130 ++++++++++-- .../Harness/FileMemory/StorePaths.cs | 10 + .../Harness/SubAgents/SubAgentsProvider.cs | 30 +-- .../SubAgents/SubAgentsProviderOptions.cs | 4 + .../Harness/Todo/TodoProvider.cs | 2 + .../FileMemory/FileMemoryProviderTests.cs | 194 ++++++++++++++++++ .../Harness/FileMemory/StorePathsTests.cs | 9 +- .../SubAgents/SubAgentsProviderTests.cs | 20 +- 11 files changed, 397 insertions(+), 61 deletions(-) diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs index 8f35070bf6..c00b93b452 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs @@ -31,7 +31,7 @@ internal sealed partial class WebBrowsingTool : AIFunction CancellationToken cancellationToken) => this._inner.InvokeAsync(arguments, cancellationToken); - [Description("Download the html from the given url as markdown")] + [Description("Fetch the html from the given url as markdown")] private static async Task DownloadUriAsync( [Description("The URL to download")] string uri, CancellationToken cancellationToken = default) @@ -41,6 +41,15 @@ private static async Task DownloadUriAsync( return $"Error: '{uri}' is not a valid URL."; } + if (parsedUri.Scheme is not "http" and not "https") + { + return $"Error: Only HTTP and HTTPS URLs are supported. Got: '{parsedUri.Scheme}'."; + } + + // NOTE: In production scenarios, consider also blocking requests to private/internal IP + // ranges (e.g., 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.0.0.1, 169.254.169.254) + // to prevent SSRF attacks via prompt injection in web content. + try { string html = await s_httpClient.GetStringAsync(parsedUri, cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs index 3615d76532..714f52c577 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs @@ -41,6 +41,20 @@ namespace Microsoft.Agents.AI; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class AgentModeProvider : AIContextProvider { + private const string DefaultInstructions = + """ + ## Agent Mode + + You can operate in different modes. Depending on the mode you are in, you will be required to follow different processes. + + Use the AgentMode_Get tool to check your current operating mode. + Use the AgentMode_Set tool to switch between modes as your work progresses. Only use AgentMode_Set if the user explicitly instructs/allows you to change modes. + + {available_modes} + + You are currently operating in the {current_mode} mode. + """; + private static readonly IReadOnlyList s_defaultModes = [ new("plan", "Use this mode when analyzing requirements, breaking down tasks, and creating plans. This is the interactive mode — ask clarifying questions, discuss options, and get user approval before proceeding."), @@ -50,7 +64,7 @@ public sealed class AgentModeProvider : AIContextProvider private readonly ProviderSessionState _sessionState; private readonly IReadOnlyList _modes; private readonly string _defaultMode; - private readonly string? _customInstructions; + private readonly string? _instructions; private readonly HashSet _validModeNames; private readonly string _modeNamesDisplay; private IReadOnlyList? _stateKeys; @@ -68,7 +82,7 @@ public AgentModeProvider(AgentModeProviderOptions? options = null) throw new ArgumentException("At least one mode must be configured.", nameof(options)); } - this._customInstructions = options?.Instructions; + this._instructions = options?.Instructions ?? DefaultInstructions; this._validModeNames = new HashSet(StringComparer.Ordinal); var modeNamesList = new List(this._modes.Count); @@ -147,7 +161,7 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co { AgentModeState state = this._sessionState.GetOrInitializeState(context.Session); - string instructions = this._customInstructions ?? this.BuildDefaultInstructions(state.CurrentMode); + string instructions = this.BuildInstructions(state.CurrentMode); var aiContext = new AIContext { @@ -171,22 +185,20 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co return new ValueTask(aiContext); } - private string BuildDefaultInstructions(string currentMode) + private string BuildInstructions(string currentMode) { - var sb = new StringBuilder(); - sb.Append($"You are currently operating in \"{currentMode}\" mode."); - sb.AppendLine(); - sb.AppendLine("Available modes:"); - + // Build list of modes text: + var modesListBuilder = new StringBuilder(); foreach (var mode in this._modes) { - sb.AppendLine($"- \"{mode.Name}\": {mode.Description}"); + modesListBuilder.AppendLine($"- \"{mode.Name}\": {mode.Description}"); } + var modesListText = modesListBuilder.ToString(); - sb.AppendLine("Use the AgentMode_Set tool to switch between modes as your work progresses. Only use AgentMode_Set if the user explicitly instructs/allows you to change modes."); - sb.Append("Use the AgentMode_Get tool to check your current operating mode."); - - return sb.ToString(); + return new StringBuilder(this._instructions) + .Replace("{available_modes}", modesListText) + .Replace("{current_mode}", currentMode) + .ToString(); } private void ValidateMode(string mode) diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProviderOptions.cs index 8b0379be28..f65c80aba5 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProviderOptions.cs @@ -17,9 +17,13 @@ public sealed class AgentModeProviderOptions /// /// Gets or sets custom instructions provided to the agent for using the mode tools. /// + /// + /// The instructions must contain a {available_modes} placeholder for the provider to inject the + /// currently available list of modes, and a {current_mode} placeholder to inject the currently + /// active mode. + /// /// - /// When (the default), the provider generates instructions dynamically - /// from the configured list. + /// When (the default), the provider uses a default set of instructions. /// public string? Instructions { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs index 0a6f8c9cdd..b16e9408fa 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -42,18 +43,22 @@ namespace Microsoft.Agents.AI; public sealed class FileMemoryProvider : AIContextProvider { private const string DescriptionSuffix = "_description.md"; + private const string MemoryIndexFileName = "memories.md"; + private const int MaxIndexEntries = 50; private const string DefaultInstructions = """ - You have access to a file-based memory system via the FileMemory_* tools for storing and retrieving information across interactions. - Use FileMemory_SaveFile to store one memory per file with a clear, descriptive file name (e.g., "projectarchitecture.md", "userpreferences.md"). - For large files, include a description when saving to provide a summary that helps with discovery. - Before starting new tasks, use FileMemory_ListFiles and FileMemory_SearchFiles to check for relevant existing memories. - Use FileMemory_ReadFile to retrieve file contents and FileMemory_DeleteFile to remove outdated memories. - Keep memories up-to-date by overwriting files when information changes. - When you receive large amounts of data (e.g., downloaded web pages, API responses, research results), - save them to files if they will be required later, so that they are not lost when older context is compacted or truncated. - This ensures important data remains accessible across long-running sessions. + ## File Based Memory + You have access to a file-based memory system via the `FileMemory_*` tools for storing and retrieving information across interactions. + Use these tools to store plans, memories, processing results, or downloaded data. + + - Use descriptive file names (e.g., "projectarchitecture.md", "userpreferences.md"). + - Include a description when saving a file to help with future discovery. + - Before starting new tasks, use FileMemory_ListFiles and FileMemory_SearchFiles to check for relevant existing memories. + - Keep memories up-to-date by overwriting files when information changes. + - When you receive large amounts of data (e.g., downloaded web pages, API responses, research results), + save them to files if they will be required later, so that they are not lost when older context is compacted or truncated. + This ensures important data remains accessible across long-running sessions. """; private readonly AgentFileStore _fileStore; @@ -99,11 +104,27 @@ protected override async ValueTask ProvideAIContextAsync(InvokingCont await this._fileStore.CreateDirectoryAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false); } - return new AIContext + var aiContext = new AIContext { Instructions = this._instructions, Tools = this._tools ??= this.CreateTools(), }; + + // Inject the memory index as a user message so the agent knows what memories are available. + string indexPath = CombinePaths(state.WorkingFolder, MemoryIndexFileName); + string? indexContent = await this._fileStore.ReadFileAsync(indexPath, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(indexContent)) + { + aiContext.Messages = + [ + new ChatMessage(ChatRole.User, + "The following is your memory index — a list of files you have previously saved. " + + "You can read any of these files using the FileMemory_ReadFile tool.\n\n" + + indexContent), + ]; + } + + return aiContext; } /// @@ -119,6 +140,11 @@ protected override async ValueTask ProvideAIContextAsync(InvokingCont [Description("Save a memory file with the given name and content. Overwrites the file if it already exists. Include a description for large files to provide a summary that helps with discovery.")] private async Task SaveFileAsync(string fileName, string content, string? description = null, CancellationToken cancellationToken = default) { + if (IsInternalFile(fileName)) + { + throw new ArgumentException("The provided file name is reserved by the system for internal use. Please choose a different file name.", nameof(fileName)); + } + FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); string path = ResolvePath(state.WorkingFolder, fileName); await this._fileStore.WriteFileAsync(path, content, cancellationToken).ConfigureAwait(false); @@ -135,9 +161,12 @@ private async Task SaveFileAsync(string fileName, string content, string await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false); } - return string.IsNullOrWhiteSpace(description) + string result = string.IsNullOrWhiteSpace(description) ? $"File '{fileName}' saved." : $"File '{fileName}' saved with description."; + + await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false); + return result; } /// @@ -173,6 +202,7 @@ private async Task DeleteFileAsync(string fileName, CancellationToken ca string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName)); await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false); + await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false); return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found."; } @@ -204,6 +234,11 @@ private async Task> ListFilesAsync(CancellationToken cancell continue; } + if (IsInternalFile(file)) + { + continue; + } + string? fileDescription = null; string descFileName = GetDescriptionFileName(file); @@ -234,7 +269,20 @@ private async Task> SearchFilesAsync(string regexPattern, FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern; IReadOnlyList results = await this._fileStore.SearchFilesAsync(state.WorkingFolder, regexPattern, pattern, cancellationToken).ConfigureAwait(false); - return new List(results); + + // Filter out internal files (description sidecars and memory index) so they stay hidden. + var filtered = new List(results.Count); + foreach (var result in results) + { + if (IsInternalFile(result.FileName)) + { + continue; + } + + filtered.Add(result); + } + + return filtered; } private AITool[] CreateTools() @@ -251,6 +299,56 @@ private AITool[] CreateTools() ]; } + /// + /// Rebuilds the memories.md index file by listing all user files in the working folder, + /// reading their companion description files, and writing a markdown summary capped at entries. + /// + private async Task RebuildMemoryIndexAsync(FileMemoryState state, CancellationToken cancellationToken) + { + IReadOnlyList fileNames = await this._fileStore.ListFilesAsync(state.WorkingFolder, cancellationToken).ConfigureAwait(false); + + // Sort deterministically so the index is stable across runs and platforms. + var sortedFiles = fileNames.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList(); + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("# Memory Index"); + sb.AppendLine(); + + int count = 0; + foreach (string file in sortedFiles) + { + // Skip internal system files. + if (IsInternalFile(file)) + { + continue; + } + + if (count >= MaxIndexEntries) + { + break; + } + + string? description = null; + string descFileName = GetDescriptionFileName(file); + string descPath = CombinePaths(state.WorkingFolder, descFileName); + description = await this._fileStore.ReadFileAsync(descPath, cancellationToken).ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(description)) + { + sb.AppendLine($"- **{file}**: {description}"); + } + else + { + sb.AppendLine($"- **{file}**"); + } + + count++; + } + + string indexPath = CombinePaths(state.WorkingFolder, MemoryIndexFileName); + await this._fileStore.WriteFileAsync(indexPath, sb.ToString(), cancellationToken).ConfigureAwait(false); + } + private static string GetDescriptionFileName(string fileName) { int extIndex = fileName.LastIndexOf('.'); @@ -264,6 +362,14 @@ private static string GetDescriptionFileName(string fileName) return fileName + DescriptionSuffix; } + /// + /// Returns if the file is an internal system file that should be hidden + /// from user-facing operations (description sidecars and the memory index). + /// + private static bool IsInternalFile(string fileName) => + fileName.EndsWith(DescriptionSuffix, StringComparison.OrdinalIgnoreCase) || + fileName.Equals(MemoryIndexFileName, StringComparison.OrdinalIgnoreCase); + private static string ResolvePath(string workingFolder, string fileName) { // Validate and normalize the file name (rejects rooted, traversal, empty, etc.). diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs index 4714993c45..98049de57f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs @@ -30,6 +30,16 @@ internal static class StorePaths /// internal static string NormalizeRelativePath(string path, bool isDirectory = false) { + if (string.IsNullOrWhiteSpace(path)) + { + if (!isDirectory) + { + throw new ArgumentException("A file path must not be empty or whitespace-only.", nameof(path)); + } + + return string.Empty; + } + string normalized = path.Replace('\\', '/').Trim('/'); if (Path.IsPathRooted(path) || diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs index f6c6daf592..254e082523 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProvider.cs @@ -40,18 +40,15 @@ public sealed class SubAgentsProvider : AIContextProvider { private const string DefaultInstructions = """ + ## SubAgents You have access to sub-agents that can perform work on your behalf. - Use the `SubAgents_*` list of tools to start tasks on sub agents and check their results. - Creating a sub task does not block, and sub-tasks run concurrently. - Important: Always wait for outstanding tasks to finish before you finish processing. - Important: After retrieving results from a completed task, clear it with SubAgents_ClearCompletedTask to free memory, unless you plan to continue it with SubAgents_ContinueTask. - - Use SubAgents_StartTask to delegate work to a sub-agent. This will send the task to the sub agent and return immediately. Sub-tasks run concurrently. - Use SubAgents_WaitForFirstCompletion to block until one of the specified tasks finishes. - Use SubAgents_GetTaskResults to retrieve the output of a completed task. - Use SubAgents_GetAllTasks to see the status of all sub-tasks. - Use SubAgents_ContinueTask to send follow-up input to a completed sub-task (e.g., provide clarification or additional instructions). - Use SubAgents_ClearCompletedTask to remove a completed task and free its memory after you no longer need its results or session. + + - Use the `SubAgents_*` list of tools to start tasks on sub agents and check their results. + - Creating a sub task does not block, and sub-tasks run concurrently. + - Important: Always wait for outstanding tasks to finish before you finish processing. + - Important: After retrieving results from a completed task, clear it with SubAgents_ClearCompletedTask to free memory, unless you plan to continue it with SubAgents_ContinueTask. + + {sub_agents} """; private readonly Dictionary _agents; @@ -77,7 +74,7 @@ public SubAgentsProvider(IEnumerable agents, SubAgentsProviderOptions? string agentListText = options?.AgentListBuilder is not null ? options.AgentListBuilder(this._agents) : BuildDefaultAgentListText(this._agents); - this._instructions = baseInstructions + "\n" + agentListText; + this._instructions = baseInstructions.Replace("{sub_agents}", agentListText); this._sessionState = new ProviderSessionState( _ => new SubAgentState(), @@ -260,7 +257,7 @@ private AITool[] CreateTools(SubAgentState state, SubAgentRuntimeState runtimeSt new AIFunctionFactoryOptions { Name = "SubAgents_StartTask", - Description = "Start a sub-task on a named sub-agent. Returns an ID for the new task.", + Description = "Start a sub-task on a named sub-agent. Returns a confirmation message containing the task ID.", SerializerOptions = serializerOptions, }), @@ -318,7 +315,7 @@ private AITool[] CreateTools(SubAgentState state, SubAgentRuntimeState runtimeSt new AIFunctionFactoryOptions { Name = "SubAgents_WaitForFirstCompletion", - Description = "Block until the first of the specified sub-tasks completes. Provide one or more task IDs. Returns the ID of the task that completed first.", + Description = "Block until the first of the specified sub-tasks completes. Provide one or more task IDs. Returns a status message containing the ID of the task that completed first.", SerializerOptions = serializerOptions, }), @@ -386,6 +383,11 @@ private AITool[] CreateTools(SubAgentState state, SubAgentRuntimeState runtimeSt return $"Error: No task found with ID {taskId}."; } + if (taskInfo.Status == SubTaskStatus.Lost) + { + return $"Error: Task {taskId} cannot be continued because its session was lost (e.g., after a session restore). Start a new task instead."; + } + if (taskInfo.Status == SubTaskStatus.Running) { return $"Error: Task {taskId} is still running. Wait for it to complete before continuing."; diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs index 06b9828e58..27a4c1530d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/SubAgents/SubAgentsProviderOptions.cs @@ -16,6 +16,10 @@ public sealed class SubAgentsProviderOptions /// /// Gets or sets custom instructions provided to the agent for using the sub-agent tools. /// + /// + /// Use the {sub_agents} placeholder to allow the provider to inject + /// the formatted list of available sub agents. + /// /// /// When (the default), the provider uses built-in instructions /// that guide the agent on how to use the sub-agent tools. diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs index 336aac855e..3a26a07f0b 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoProvider.cs @@ -36,6 +36,8 @@ public sealed class TodoProvider : AIContextProvider { private const string DefaultInstructions = """ + ## Todo Items + You have access to a todo list for tracking work items. While planning, make sure that you break down complex tasks into manageable todo items and add them to the list. Ask questions from the user where clarification is needed to create effective todos. diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs index eaa3286ad1..a5341f802b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs @@ -488,6 +488,200 @@ public async Task SaveFile_DoubleDotsInFileName_AllowedAsync() #endregion + #region Memory Index Tests + + [Fact] + public async Task SaveFile_CreatesMemoryIndexAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var (tools, _, session) = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + + // Act + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["content"] = "Test content", + }, session); + + // Assert — memories.md should exist and contain the file entry. + string? index = await store.ReadFileAsync("memories.md"); + Assert.NotNull(index); + Assert.Contains("**notes.md**", index); + } + + [Fact] + public async Task SaveFile_WithDescription_IndexIncludesDescriptionAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var (tools, _, session) = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + + // Act + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "research.md", + ["content"] = "Research data", + ["description"] = "Key findings", + }, session); + + // Assert + string? index = await store.ReadFileAsync("memories.md"); + Assert.NotNull(index); + Assert.Contains("**research.md**: Key findings", index); + } + + [Fact] + public async Task DeleteFile_UpdatesMemoryIndexAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var (tools, _, session) = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + var deleteFile = GetTool(tools, "FileMemory_DeleteFile"); + + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["content"] = "Content", + }, session); + + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "other.md", + ["content"] = "Other", + }, session); + + // Act + await InvokeWithRunContextAsync(deleteFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + }, session); + + // Assert — index should only contain other.md + string? index = await store.ReadFileAsync("memories.md"); + Assert.NotNull(index); + Assert.DoesNotContain("notes.md", index); + Assert.Contains("**other.md**", index); + } + + [Fact] + public async Task MemoryIndex_CappedAt50EntriesAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var (tools, _, session) = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + + // Act — save 55 files + for (int i = 0; i < 55; i++) + { + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = $"file{i:D3}.md", + ["content"] = $"Content {i}", + }, session); + } + + // Assert — index should have at most 50 entries + string? index = await store.ReadFileAsync("memories.md"); + Assert.NotNull(index); + + int entryCount = 0; + foreach (string line in index!.Split('\n')) + { + if (line.StartsWith("- **", StringComparison.Ordinal)) + { + entryCount++; + } + } + + Assert.Equal(50, entryCount); + } + + [Fact] + public async Task ListFiles_HidesMemoryIndexAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var (tools, _, session) = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + var listFiles = GetTool(tools, "FileMemory_ListFiles"); + + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["content"] = "Content", + }, session); + + // Act + var result = await InvokeWithRunContextAsync(listFiles, new AIFunctionArguments(), session); + + // Assert — memories.md should not appear in the listing + var entries = Assert.IsType(result).EnumerateArray().ToList(); + Assert.Single(entries); + Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString()); + } + + [Fact] + public async Task ProvideAIContextAsync_InjectsMemoryIndexMessageAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var provider = new FileMemoryProvider(store); + var agent = new Mock().Object; + var session = new ChatClientAgentSession(); + + // First, save a file via tool invocation to create the index. +#pragma warning disable MAAI001 + var initContext = new AIContextProvider.InvokingContext(agent, session, new AIContext()); +#pragma warning restore MAAI001 + AIContext initResult = await provider.InvokingAsync(initContext); + var saveFile = GetTool(initResult.Tools!, "FileMemory_SaveFile"); + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "research.md", + ["content"] = "Data", + ["description"] = "Research summary", + }, session); + + // Act — invoke the provider again; it should now inject the memory index. +#pragma warning disable MAAI001 + var context = new AIContextProvider.InvokingContext(agent, session, new AIContext()); +#pragma warning restore MAAI001 + AIContext result = await provider.InvokingAsync(context); + + // Assert + Assert.NotNull(result.Messages); + var messages = result.Messages!.ToList(); + Assert.Single(messages); + Assert.Equal(ChatRole.User, messages[0].Role); + Assert.Contains("memory index", messages[0].Text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("research.md", messages[0].Text); + } + + [Fact] + public async Task ProvideAIContextAsync_NoFiles_NoMessageInjectedAsync() + { + // Arrange + var provider = new FileMemoryProvider(new InMemoryAgentFileStore()); + 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 — no memories.md exists, so no message should be injected + Assert.Null(result.Messages); + } + + #endregion + #region Helper Methods private static FileMemoryProvider CreateProvider(InMemoryAgentFileStore? store = null, Func? stateInitializer = null) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs index 20a45a3ac4..676b41ef5e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs @@ -98,13 +98,10 @@ public void NormalizeRelativePath_EmptyFile_Throws() } [Fact] - public void NormalizeRelativePath_WhitespaceOnlyFile_DoesNotThrowAsTraversal() + public void NormalizeRelativePath_WhitespaceOnlyFile_Throws() { - // Act — whitespace characters are not path separators, so " " becomes a valid segment. - string result = StorePaths.NormalizeRelativePath(" "); - - // Assert - Assert.Equal(" ", result); + // Act & Assert — whitespace-only paths are rejected as invalid file names. + Assert.Throws(() => StorePaths.NormalizeRelativePath(" ")); } #endregion diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs index 42ee69907c..4f76d610b3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs @@ -819,14 +819,14 @@ public async Task StartSubTask_DoesNotCorruptCurrentRunContextAsync() #region Options Tests /// - /// Verify that custom instructions from options override the default instructions but agent list is still appended. + /// Verify that custom instructions from options override the default instructions but agent list is still injected via placeholder. /// [Fact] public async Task CustomInstructions_OverridesDefaultInstructionsAsync() { // Arrange var agent = CreateMockAgent("Research", "Research agent"); - const string CustomInstructions = "These are custom sub-agent instructions."; + const string CustomInstructions = "These are custom sub-agent instructions.\n{sub_agents}"; var options = new SubAgentsProviderOptions { Instructions = CustomInstructions }; var provider = new SubAgentsProvider(new[] { agent }, options); var context = CreateInvokingContext(); @@ -834,16 +834,16 @@ public async Task CustomInstructions_OverridesDefaultInstructionsAsync() // Act AIContext result = await provider.InvokingAsync(context); - // Assert — custom instructions replace default, agent list is appended - Assert.StartsWith(CustomInstructions, result.Instructions); + // Assert — custom instructions replace default, agent list is injected via {sub_agents} placeholder + Assert.Contains("These are custom sub-agent instructions.", result.Instructions); Assert.Contains("Research", result.Instructions); } /// - /// Verify that default instructions contain tool names and agent names. + /// Verify that default instructions contain tool reference and agent names. /// [Fact] - public async Task DefaultInstructions_ContainsToolNamesAndAgentListAsync() + public async Task DefaultInstructions_ContainsToolReferenceAndAgentListAsync() { // Arrange var agent = CreateMockAgent("Research", "Research agent"); @@ -853,12 +853,8 @@ public async Task DefaultInstructions_ContainsToolNamesAndAgentListAsync() // Act AIContext result = await provider.InvokingAsync(context); - // Assert — instructions contain both tool usage guidance and agent list - Assert.Contains("SubAgents_StartTask", result.Instructions); - Assert.Contains("SubAgents_WaitForFirstCompletion", result.Instructions); - Assert.Contains("SubAgents_GetTaskResults", result.Instructions); - Assert.Contains("SubAgents_GetAllTasks", result.Instructions); - Assert.Contains("SubAgents_ContinueTask", result.Instructions); + // Assert — instructions contain tool usage guidance and agent list + Assert.Contains("SubAgents_*", result.Instructions); Assert.Contains("SubAgents_ClearCompletedTask", result.Instructions); Assert.Contains("Research", result.Instructions); Assert.Contains("Research agent", result.Instructions); From 97228e49b6cfec360deee332126fbb9ed84abf6b Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:07:11 +0100 Subject: [PATCH 12/13] .NET: Refactor harness console to be more extensible and easy to understand with better UX (#5573) * Refactor harness console to be more extensible and easy to understand with better UX. * Fix formatting issues. * Allow multiple clarifications in one response * Address PR comments --- dotnet/Directory.Packages.props | 2 + .../Commands/ICommandHandler.cs | 28 ++ .../Commands/ModeCommandHandler.cs | 69 +++ .../Commands/TodoCommandHandler.cs | 66 +++ .../Harness_Shared_Console/ConsoleWriter.cs | 278 +++++++++++ .../Harness_Shared_Console/HarnessConsole.cs | 437 +++++------------- .../HarnessConsoleOptions.cs | 52 +++ .../Harness_Shared_Console.csproj | 4 + .../Observers/ConsoleObserver.cs | 53 +++ .../Observers/ErrorDisplayObserver.cs | 31 ++ .../Observers/PlanningOutputObserver.cs | 177 +++++++ .../Observers/PlanningResponse.cs | 51 ++ .../Observers/PlanningResponseType.cs | 25 + .../Observers/ReasoningDisplayObserver.cs | 20 + .../Observers/TextOutputObserver.cs | 16 + .../Observers/ToolApprovalObserver.cs | 92 ++++ .../Observers/ToolCallDisplayObserver.cs | 25 + .../{ => Observers}/ToolCallFormatter.cs | 2 +- .../Observers/UsageDisplayObserver.cs | 68 +++ .../Harness_Step01_Research/Program.cs | 31 +- 20 files changed, 1201 insertions(+), 326 deletions(-) create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ICommandHandler.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ConsoleWriter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleOptions.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ConsoleObserver.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ErrorDisplayObserver.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponse.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponseType.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ReasoningDisplayObserver.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/TextOutputObserver.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolApprovalObserver.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallDisplayObserver.cs rename dotnet/samples/02-agents/Harness/Harness_Shared_Console/{ => Observers}/ToolCallFormatter.cs (99%) create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/UsageDisplayObserver.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 0010ca0840..56de97dbcb 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -136,6 +136,8 @@ + + diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ICommandHandler.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ICommandHandler.cs new file mode 100644 index 0000000000..223c60e3ba --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ICommandHandler.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; + +namespace Harness.Shared.Console.Commands; + +/// +/// Handles a console command (e.g., /todos, /mode). Command handlers are checked +/// in order before user input is sent to the agent. The first handler that +/// accepts the input prevents further handlers from being checked. +/// +public interface ICommandHandler +{ + /// + /// Gets the help text for this command, displayed in the console header. + /// Returns if the command is not currently available. + /// + /// Help text like "/todos (show todo list)", or . + string? GetHelpText(); + + /// + /// Attempts to handle the given user input. + /// + /// The raw user input string. + /// The current agent session. + /// if this handler handled the input; otherwise. + bool TryHandle(string input, AgentSession session); +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs new file mode 100644 index 0000000000..c3d112ce11 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; + +namespace Harness.Shared.Console.Commands; + +/// +/// Handles the /mode command to display or switch the current agent mode. +/// +internal sealed class ModeCommandHandler : ICommandHandler +{ + private readonly AgentModeProvider? _modeProvider; + private readonly IReadOnlyDictionary? _modeColors; + + /// + /// Initializes a new instance of the class. + /// + /// The mode provider, or if not available. + /// Optional mapping of mode names to console colors. + public ModeCommandHandler(AgentModeProvider? modeProvider, IReadOnlyDictionary? modeColors = null) + { + this._modeProvider = modeProvider; + this._modeColors = modeColors; + } + + /// + public string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null; + + /// + public bool TryHandle(string input, AgentSession session) + { + if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (this._modeProvider is null) + { + System.Console.WriteLine("AgentModeProvider is not available."); + return true; + } + + string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length < 2) + { + string current = this._modeProvider.GetMode(session); + System.Console.WriteLine($"\n Current mode: {current}\n"); + return true; + } + + string newMode = parts[1]; + + try + { + this._modeProvider.SetMode(session, newMode); + System.Console.ForegroundColor = ConsoleWriter.GetModeColor(newMode, this._modeColors); + System.Console.WriteLine($"\n Switched to {newMode} mode.\n"); + System.Console.ResetColor(); + } + catch (ArgumentException ex) + { + System.Console.ForegroundColor = ConsoleColor.Red; + System.Console.WriteLine($"\n {ex}\n"); + System.Console.ResetColor(); + } + + return true; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs new file mode 100644 index 0000000000..6cc52e56bf --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; + +namespace Harness.Shared.Console.Commands; + +/// +/// Handles the /todos command to display the current todo list. +/// +internal sealed class TodoCommandHandler : ICommandHandler +{ + private readonly TodoProvider? _todoProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The todo provider, or if not available. + public TodoCommandHandler(TodoProvider? todoProvider) + { + this._todoProvider = todoProvider; + } + + /// + public string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null; + + /// + public bool TryHandle(string input, AgentSession session) + { + if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (this._todoProvider is null) + { + System.Console.WriteLine("TodoProvider is not available."); + return true; + } + + var todos = this._todoProvider.GetAllTodos(session); + if (todos.Count == 0) + { + System.Console.WriteLine("\n No todos yet.\n"); + return true; + } + + System.Console.WriteLine(); + System.Console.WriteLine(" ── Todo List ──"); + foreach (var item in todos) + { + string status = item.IsComplete ? "✓" : "○"; + System.Console.ForegroundColor = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White; + System.Console.Write($" [{status}] #{item.Id} {item.Title}"); + if (!string.IsNullOrWhiteSpace(item.Description)) + { + System.Console.Write($" — {item.Description}"); + } + + System.Console.WriteLine(); + } + + System.Console.ResetColor(); + System.Console.WriteLine(); + return true; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ConsoleWriter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ConsoleWriter.cs new file mode 100644 index 0000000000..1f23bf48ff --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ConsoleWriter.cs @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Spectre.Console; + +namespace Harness.Shared.Console; + +/// +/// Centralizes all console output and spinner management for the harness console. +/// Observers write through this class so the spinner is automatically paused before output. +/// +public sealed class ConsoleWriter : IDisposable +{ + private readonly Spinner _spinner = new(); + private readonly IReadOnlyDictionary? _modeColors; + + private bool _lastWasText; + private bool _hasReceivedAnyText; + + /// + /// Initializes a new instance of the class. + /// + /// Optional mapping of mode names to console colors. + public ConsoleWriter(IReadOnlyDictionary? modeColors = null) + { + this._modeColors = modeColors; + } + + /// + /// Gets or sets the current agent mode (e.g., "plan", "execute"). + /// Used to determine the console color for mode-prefixed output. + /// + public string? CurrentMode { get; set; } + + /// + /// Writes the agent response header (e.g., "[plan] Agent: ") and starts the spinner. + /// + public void WriteResponseHeader() + { + if (this.CurrentMode is not null) + { + System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors); + System.Console.Write($"\n[{this.CurrentMode}] Agent: "); + } + else + { + System.Console.Write("\nAgent: "); + } + + this._lastWasText = true; + this._hasReceivedAnyText = false; + this._spinner.Start(); + } + + /// + /// Writes informational output with automatic prefix spacing, without a trailing newline. + /// Use when continuation content will be appended on the same line. + /// + /// The informational text to write (without leading newline/indent — added automatically). + /// Optional console color for the text. + public async Task WriteInfoAsync(string text, ConsoleColor? color = null) + { + await this.WriteInfoCoreAsync(text, color, newLine: false); + } + + /// + /// Writes informational output with automatic prefix spacing, followed by a newline. + /// + /// The informational text to write (without leading newline/indent — added automatically). + /// Optional console color for the text. + public async Task WriteInfoLineAsync(string text, ConsoleColor? color = null) + { + await this.WriteInfoCoreAsync(text, color, newLine: true); + } + + private async Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine) + { + await this._spinner.StopAsync(); + + string prefix = this._lastWasText ? "\n\n " : " "; + this._lastWasText = false; + + System.Console.ForegroundColor = color ?? GetModeColor(this.CurrentMode, this._modeColors); + + if (newLine) + { + System.Console.WriteLine(prefix + text); + } + else + { + System.Console.Write(prefix + text); + } + + System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors); + + this._spinner.Start(); + } + + /// + /// Writes text output from the agent, managing line break state. + /// Ensures a newline is written before the first text output. + /// + /// The text to write. + /// Optional console color override for this text. + public async Task WriteTextAsync(string text, ConsoleColor? color = null) + { + await this._spinner.StopAsync(); + + if (!this._lastWasText) + { + System.Console.Write("\n"); + this._lastWasText = true; + } + + this._hasReceivedAnyText = true; + + if (color.HasValue) + { + System.Console.ForegroundColor = color.Value; + } + + System.Console.Write(text); + + if (color.HasValue) + { + System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors); + } + + this._spinner.Start(); + } + + /// + /// Reads a line of input from the console, pausing the spinner while waiting for input. + /// Optionally displays a prompt before reading. The prompt is rendered between + /// two horizontal rules for visual clarity. + /// + /// Optional prompt text to display before reading input. + /// Optional console color for the prompt text. + /// The line read from the console, or null if no input is available. + public async Task ReadLineAsync(string? prompt = null, ConsoleColor? promptColor = null) + { + await this._spinner.StopAsync(); + + if (prompt is not null) + { + System.Console.WriteLine(); + AnsiConsole.Write(this.CreateModeRule()); + + if (promptColor.HasValue) + { + System.Console.ForegroundColor = promptColor.Value; + } + + System.Console.Write($" {prompt}"); + + if (promptColor.HasValue) + { + System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors); + } + } + + string? input = System.Console.ReadLine(); + + if (prompt is not null) + { + AnsiConsole.Write(this.CreateModeRule()); + } + + this._lastWasText = false; + return input; + } + + /// + /// Presents a selection prompt with the given choices, plus an option to type a custom response. + /// Uses Spectre.Console for interactive arrow-key selection. + /// + /// The title/question displayed above the selection list. + /// The list of choices to present. + /// The selected choice text, or the custom-typed response. + public async Task ReadSelectionAsync(string title, IList choices) + { + await this._spinner.StopAsync(); + + AnsiConsole.Write(this.CreateModeRule()); + + const string FreeformOption = "✏️ Type a custom response..."; + var allChoices = choices.Concat([FreeformOption]).ToList(); + + var prompt = new SelectionPrompt() + .Title($" [bold]{Markup.Escape(title)}[/]") + .PageSize(10) + .AddChoices(allChoices); + + string selection = AnsiConsole.Prompt(prompt); + + if (selection == FreeformOption) + { + var textPrompt = new TextPrompt(" [grey]Response:[/]"); + selection = AnsiConsole.Prompt(textPrompt); + } + + AnsiConsole.MarkupLine($" [dim]→ {Markup.Escape(selection)}[/]"); + AnsiConsole.Write(this.CreateModeRule()); + + this._lastWasText = false; + return selection; + } + + /// + /// Writes the stream-complete footer (handles "no text response" fallback, resets color). + /// + public async Task WriteStreamFooterAsync(bool hasFollowUpMessages) + { + await this._spinner.StopAsync(); + + if (!this._hasReceivedAnyText && !hasFollowUpMessages) + { + System.Console.ForegroundColor = ConsoleColor.DarkYellow; + System.Console.Write("\n (no text response from agent)"); + } + + System.Console.ResetColor(); + System.Console.WriteLine(); + } + + /// + public void Dispose() + { + this._spinner.Dispose(); + } + + /// + /// Gets the console color associated with a mode name, using the provided color map. + /// + internal static ConsoleColor GetModeColor(string? mode, IReadOnlyDictionary? modeColors = null) + { + if (mode is null) + { + return ConsoleColor.Gray; + } + + if (modeColors is not null && modeColors.TryGetValue(mode, out var color)) + { + return color; + } + + return ConsoleColor.Gray; + } + + /// + /// Creates a styled with the current mode color. + /// + internal Rule CreateModeRule() + { + var spectreColor = ToSpectreColor(GetModeColor(this.CurrentMode, this._modeColors)); + return new Rule().RuleStyle(new Style(spectreColor)); + } + + internal static Color ToSpectreColor(ConsoleColor consoleColor) => consoleColor switch + { + ConsoleColor.Black => Color.Black, + ConsoleColor.DarkBlue => Color.Blue, + ConsoleColor.DarkGreen => Color.Green, + ConsoleColor.DarkCyan => Color.Teal, + ConsoleColor.DarkRed => Color.Red, + ConsoleColor.DarkMagenta => Color.Purple, + ConsoleColor.DarkYellow => Color.Olive, + ConsoleColor.Gray => Color.Silver, + ConsoleColor.DarkGray => Color.Grey, + ConsoleColor.Blue => Color.Blue1, + ConsoleColor.Green => Color.Green1, + ConsoleColor.Cyan => Color.Aqua, + ConsoleColor.Red => Color.Red1, + ConsoleColor.Magenta => Color.Fuchsia, + ConsoleColor.Yellow => Color.Yellow, + ConsoleColor.White => Color.White, + _ => Color.Silver, + }; +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs index 182e74d86b..a5ebaaf918 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using Harness.Shared.Console.Commands; +using Harness.Shared.Console.Observers; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -7,403 +9,206 @@ namespace Harness.Shared.Console; /// /// Provides a reusable interactive console loop for running an -/// with streaming output, tool call display, spinner, and mode-aware prompts. +/// with streaming output, extensible observers, and mode-aware interaction strategies. /// public static class HarnessConsole { /// /// Runs an interactive console session with the specified agent. /// Supports streaming output, tool call display, spinner animation, - /// and the /todos command. + /// optional planning UX with structured output, and the /todos command. /// /// The agent to interact with. /// The title displayed in the console header. /// A short prompt to the user, displayed below the title. - /// Optional max context window size in tokens. When set, usage is displayed as a percentage. - /// Optional max output tokens. Used with to show input/output budget breakdown. - public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, int? maxContextWindowTokens = null, int? maxOutputTokens = null) + /// Optional configuration options for the console session. + public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, HarnessConsoleOptions? options = null) { - var todoProvider = agent.GetService(); - var modeProvider = agent.GetService(); + options ??= new(); + + if (options.EnablePlanningUx + && (string.IsNullOrWhiteSpace(options.PlanningModeName) || string.IsNullOrWhiteSpace(options.ExecutionModeName))) + { + throw new ArgumentException( + "When EnablePlanningUx is true, both PlanningModeName and ExecutionModeName must be configured.", + nameof(options)); + } System.Console.WriteLine($"=== {title} ==="); System.Console.WriteLine(userPrompt); - var commands = new List(); - if (todoProvider is not null) - { - commands.Add("/todos (show todo list)"); - } + var todoProvider = agent.GetService(); + var modeProvider = agent.GetService(); - if (modeProvider is not null) + // Build command handlers. + var commandHandlers = new List { - commands.Add("/mode [plan|execute] (show or switch mode)"); - } + new TodoCommandHandler(todoProvider), + new ModeCommandHandler(modeProvider, options.ModeColors), + }; + + var commands = commandHandlers + .Select(h => h.GetHelpText()) + .Where(t => t is not null) + .Append("exit (quit)"); - commands.Add("exit (quit)"); System.Console.WriteLine($"Commands: {string.Join(", ", commands)}"); System.Console.WriteLine(); AgentSession session = await agent.CreateSessionAsync(); + using var writer = new ConsoleWriter(options.ModeColors); + writer.CurrentMode = modeProvider?.GetMode(session); - WritePrompt(modeProvider, session); - string? userInput = System.Console.ReadLine(); + string prompt = BuildUserPrompt(modeProvider, session); + string? userInput = await writer.ReadLineAsync(prompt); + // Main loop to run a command or agent and get the next user command/input. while (!string.IsNullOrWhiteSpace(userInput) && !userInput.Equals("exit", StringComparison.OrdinalIgnoreCase)) { - if (userInput.Equals("/todos", StringComparison.OrdinalIgnoreCase)) + // Check command handlers first — first one to handle wins. + bool handled = false; + foreach (var handler in commandHandlers) { - PrintTodos(todoProvider, session); - } - else if (userInput.StartsWith("/mode", StringComparison.OrdinalIgnoreCase)) - { - HandleModeCommand(modeProvider, session, userInput); + if (handler.TryHandle(userInput, session)) + { + handled = true; + break; + } } - else + + if (!handled) { - await StreamAgentResponseAsync(agent, session, modeProvider, userInput, maxContextWindowTokens, maxOutputTokens); + await RunAgentTurnAsync(agent, session, modeProvider, options, writer, userInput); } - WritePrompt(modeProvider, session); - userInput = System.Console.ReadLine(); + writer.CurrentMode = modeProvider?.GetMode(session); + prompt = BuildUserPrompt(modeProvider, session); + userInput = await writer.ReadLineAsync(prompt); } System.Console.ResetColor(); System.Console.WriteLine("Goodbye!"); } - private static async Task StreamAgentResponseAsync(AIAgent agent, AgentSession session, AgentModeProvider? modeProvider, string userInput, int? maxContextWindowTokens, int? maxOutputTokens) + /// + /// Runs one or more agent invocations for a single user turn, using the current + /// observers. Re-invokes automatically for tool approvals and mode-driven follow-ups + /// (e.g., planning clarification loops). + /// + private static async Task RunAgentTurnAsync( + AIAgent agent, + AgentSession session, + AgentModeProvider? modeProvider, + HarnessConsoleOptions options, + ConsoleWriter writer, + string userInput) { - // Initial user input - var approvalRequests = await StreamAndCollectApprovalsAsync(agent.RunStreamingAsync(userInput, session), modeProvider, session, maxContextWindowTokens, maxOutputTokens); - var messagesToSend = PromptForApprovals(approvalRequests); + IList? nextMessages = [new ChatMessage(ChatRole.User, userInput)]; - // Loop while there are approval responses to send back - while (messagesToSend is not null) + while (nextMessages is not null) { - approvalRequests = await StreamAndCollectApprovalsAsync(agent.RunStreamingAsync(messagesToSend, session), modeProvider, session, maxContextWindowTokens, maxOutputTokens); - messagesToSend = PromptForApprovals(approvalRequests); - } - } + // Build observers for this invocation (may change between iterations due to mode changes). + var observers = CreateObservers(options, modeProvider, session); - private static async Task> StreamAndCollectApprovalsAsync(IAsyncEnumerable updates, AgentModeProvider? modeProvider, AgentSession session, int? maxContextWindowTokens, int? maxOutputTokens) - { - var approvalRequests = new List(); - string? mode = modeProvider is not null ? modeProvider.GetMode(session) : null; - if (mode is not null) - { - System.Console.ForegroundColor = GetModeColor(mode); - System.Console.Write($"\n[{mode}] Agent: "); - } - else - { - System.Console.Write("\nAgent: "); - } + // Build run options — observers may inject ResponseFormat, etc. + var runOptions = new AgentRunOptions(); + foreach (var observer in observers) + { + observer.ConfigureRunOptions(runOptions); + } - var spinner = new Spinner(); - spinner.Start(); - bool hasTextOutput = false; - bool hasReceivedAnyText = false; + // Stream the response, fanning out to all observers. + writer.CurrentMode = modeProvider?.GetMode(session); + writer.WriteResponseHeader(); - try - { - await foreach (var update in updates) + try { - foreach (var content in update.Contents) + await foreach (var update in agent.RunStreamingAsync(nextMessages, session, runOptions)) { - if (content is FunctionCallContent functionCall) - { - await spinner.StopAsync(); - System.Console.ForegroundColor = ConsoleColor.DarkYellow; - System.Console.Write(hasTextOutput ? "\n\n 🔧 Calling tool: " : "\n 🔧 Calling tool: "); - System.Console.Write($"{ToolCallFormatter.Format(functionCall)}..."); - System.Console.ForegroundColor = GetModeColor(mode); - hasTextOutput = false; - spinner.Start(); - } - else if (content is ToolCallContent toolCall) - { - await spinner.StopAsync(); - System.Console.ForegroundColor = ConsoleColor.DarkYellow; - System.Console.Write(hasTextOutput ? "\n\n 🔧 Calling tool: " : "\n 🔧 Calling tool: "); - System.Console.Write($"{toolCall}..."); - System.Console.ForegroundColor = GetModeColor(mode); - hasTextOutput = false; - spinner.Start(); - } - else if (content is ToolApprovalRequestContent approvalRequest) - { - await spinner.StopAsync(); - approvalRequests.Add(approvalRequest); - string toolName = approvalRequest.ToolCall is FunctionCallContent fc ? ToolCallFormatter.Format(fc) : approvalRequest.ToolCall?.ToString() ?? "unknown"; - System.Console.ForegroundColor = ConsoleColor.Yellow; - System.Console.Write(hasTextOutput ? "\n\n ⚠️ Approval needed: " : "\n ⚠️ Approval needed: "); - System.Console.Write(toolName); - System.Console.ForegroundColor = GetModeColor(mode); - hasTextOutput = false; - } - else if (content is ErrorContent errorContent) + // Update mode color if the mode changed during streaming. + if (modeProvider is not null) { - await spinner.StopAsync(); - System.Console.ForegroundColor = ConsoleColor.Red; - System.Console.Write($"\n ❌ Error: {errorContent.Message}"); - if (errorContent.ErrorCode is not null) + string currentMode = modeProvider.GetMode(session); + if (currentMode != writer.CurrentMode) { - System.Console.Write($" (code: {errorContent.ErrorCode})"); + writer.CurrentMode = currentMode; } - - System.Console.ForegroundColor = GetModeColor(mode); } - else if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text)) - { - await spinner.StopAsync(); - if (!hasTextOutput) + foreach (var content in update.Contents) + { + foreach (var observer in observers) { - System.Console.Write("\n"); - hasTextOutput = true; - hasReceivedAnyText = true; + await observer.OnContentAsync(writer, content); } - - System.Console.ForegroundColor = ConsoleColor.DarkMagenta; - System.Console.Write(reasoning.Text); - System.Console.ForegroundColor = GetModeColor(mode); } - else if (content is UsageContent usage) + + if (!string.IsNullOrEmpty(update.Text)) { - await spinner.StopAsync(); - System.Console.ForegroundColor = ConsoleColor.DarkGray; - System.Console.Write("\n\n 📊 Tokens"); - if (usage.Details is not null) - { - WriteUsageBreakdown(usage.Details, maxContextWindowTokens, maxOutputTokens); - } - else + foreach (var observer in observers) { - System.Console.Write(" —"); + await observer.OnTextAsync(writer, update.Text); } - System.Console.ForegroundColor = GetModeColor(mode); - hasTextOutput = false; } } + } + catch (Exception ex) + { + await writer.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red); + } - if (string.IsNullOrEmpty(update.Text)) - { - continue; - } - - await spinner.StopAsync(); - - if (!hasTextOutput) + // Collect messages from all observers. + var combinedMessages = new List(); + bool hasObserverMessages = false; + foreach (var observer in observers) + { + var messages = await observer.OnStreamCompleteAsync(writer, agent, session, options); + if (messages is { Count: > 0 }) { - System.Console.Write("\n"); - hasTextOutput = true; - hasReceivedAnyText = true; + combinedMessages.AddRange(messages); + hasObserverMessages = true; } - - if (modeProvider is not null) - { - string currentMode = modeProvider.GetMode(session); - if (currentMode != mode) - { - mode = currentMode; - System.Console.ForegroundColor = GetModeColor(mode); - } - } - - System.Console.Write(update.Text); } - } - catch (Exception ex) - { - await spinner.StopAsync(); - System.Console.ForegroundColor = ConsoleColor.Red; - System.Console.Write($"\n ❌ Stream error: {ex.GetType().Name}:\n{ex}"); - } - - await spinner.StopAsync(); - if (!hasReceivedAnyText && approvalRequests.Count == 0) - { - System.Console.ForegroundColor = ConsoleColor.DarkYellow; - System.Console.Write("\n (no text response from agent)"); + await writer.WriteStreamFooterAsync(hasFollowUpMessages: hasObserverMessages); + nextMessages = combinedMessages.Count > 0 ? combinedMessages : null; } - - System.Console.ResetColor(); - System.Console.WriteLine(); - System.Console.WriteLine(); - - return approvalRequests; } - /// - /// Prompts the user for approval of each tool approval request. - /// Returns a list of messages to send back to the agent, or if there are no requests. - /// - private static List? PromptForApprovals(List approvalRequests) + private static List CreateObservers(HarnessConsoleOptions options, AgentModeProvider? modeProvider, AgentSession session) { - if (approvalRequests.Count == 0) + var observers = new List { - return null; - } + new ToolCallDisplayObserver(), + new ToolApprovalObserver(), + new ErrorDisplayObserver(), + new ReasoningDisplayObserver(), + new UsageDisplayObserver(options.MaxContextWindowTokens, options.MaxOutputTokens), + }; - var responses = new List(); - foreach (var request in approvalRequests) + // Add the appropriate output observer based on the current mode. + if (options.EnablePlanningUx + && modeProvider is not null + && string.Equals(modeProvider.GetMode(session), options.PlanningModeName, StringComparison.OrdinalIgnoreCase)) { - string toolName = request.ToolCall is FunctionCallContent fc ? ToolCallFormatter.Format(fc) : request.ToolCall?.ToString() ?? "unknown"; - - System.Console.ForegroundColor = ConsoleColor.Yellow; - System.Console.WriteLine($"\n 🔐 Tool approval required: {toolName}"); - System.Console.ResetColor(); - System.Console.WriteLine(" 1) Approve this call"); - System.Console.WriteLine(" 2) Always approve this tool (any arguments)"); - System.Console.WriteLine(" 3) Always approve this tool with these arguments"); - System.Console.WriteLine(" 4) Deny"); - System.Console.Write(" Choice [1-4]: "); - - string? choice = System.Console.ReadLine()?.Trim(); - AIContent response = choice switch - { - "2" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"), - "3" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"), - "4" => request.CreateResponse(approved: false, reason: "User denied"), - _ => request.CreateResponse(approved: true, reason: "User approved"), - }; - - string action = choice switch - { - "2" => "✅ Always approved (any args)", - "3" => "✅ Always approved (these args)", - "4" => "❌ Denied", - _ => "✅ Approved", - }; - System.Console.ForegroundColor = ConsoleColor.DarkGray; - System.Console.WriteLine($" {action}"); - System.Console.ResetColor(); - - responses.Add(response); + observers.Add(new PlanningOutputObserver(modeProvider)); } - - return [new ChatMessage(ChatRole.User, responses)]; - } - - private static void HandleModeCommand(AgentModeProvider? modeProvider, AgentSession session, string input) - { - if (modeProvider is null) - { - System.Console.WriteLine("AgentModeProvider is not available."); - return; - } - - string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length < 2) + else { - string current = modeProvider.GetMode(session); - System.Console.WriteLine($"\n Current mode: {current}\n"); - return; + observers.Add(new TextOutputObserver()); } - string newMode = parts[1]; - - try - { - modeProvider.SetMode(session, newMode); - System.Console.ForegroundColor = GetModeColor(newMode); - System.Console.WriteLine($"\n Switched to {newMode} mode.\n"); - System.Console.ResetColor(); - } - catch (ArgumentException ex) - { - System.Console.ForegroundColor = ConsoleColor.Red; - System.Console.WriteLine($"\n {ex}\n"); - System.Console.ResetColor(); - } + return observers; } - private static void WritePrompt(AgentModeProvider? modeProvider, AgentSession session) + private static string BuildUserPrompt(AgentModeProvider? modeProvider, AgentSession session) { if (modeProvider is not null) { string mode = modeProvider.GetMode(session); - System.Console.ForegroundColor = GetModeColor(mode); - System.Console.Write($"[{mode}] "); - } - - System.Console.Write("You: "); - System.Console.ResetColor(); - } - - private static void PrintTodos(TodoProvider? todoProvider, AgentSession session) - { - if (todoProvider is null) - { - System.Console.WriteLine("TodoProvider is not available."); - return; - } - - var todos = todoProvider.GetAllTodos(session); - if (todos.Count == 0) - { - System.Console.WriteLine("\n No todos yet.\n"); - return; - } - - System.Console.WriteLine(); - System.Console.WriteLine(" ── Todo List ──"); - foreach (var item in todos) - { - string status = item.IsComplete ? "✓" : "○"; - System.Console.ForegroundColor = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White; - System.Console.Write($" [{status}] #{item.Id} {item.Title}"); - if (!string.IsNullOrWhiteSpace(item.Description)) - { - System.Console.Write($" — {item.Description}"); - } - - System.Console.WriteLine(); + return $"[{mode}] You: "; } - System.Console.ResetColor(); - System.Console.WriteLine(); + return "You: "; } - - private static void WriteUsageBreakdown(UsageDetails details, int? maxContextWindowTokens, int? maxOutputTokens) - { - int? inputBudget = (maxContextWindowTokens is not null && maxOutputTokens is not null) - ? maxContextWindowTokens.Value - maxOutputTokens.Value - : null; - - System.Console.Write(" — input: "); - WriteTokenCount(details.InputTokenCount, inputBudget); - - System.Console.Write(" | output: "); - WriteTokenCount(details.OutputTokenCount, maxOutputTokens); - - System.Console.Write(" | total: "); - WriteTokenCount(details.TotalTokenCount, maxContextWindowTokens); - } - - private static void WriteTokenCount(long? count, int? budget) - { - if (count is null) - { - System.Console.Write("—"); - return; - } - - System.Console.Write($"{count.Value:N0}"); - if (budget is not null && budget.Value > 0) - { - double pct = (double)count.Value / budget.Value * 100; - System.Console.Write($"/{budget.Value:N0} ({pct:F1}%)"); - } - } - - private static ConsoleColor GetModeColor(string? mode) => mode switch - { - "plan" => ConsoleColor.Cyan, - "execute" => ConsoleColor.Green, - null => ConsoleColor.Gray, - _ => ConsoleColor.Gray, - }; } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleOptions.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleOptions.cs new file mode 100644 index 0000000000..2a9b580c0e --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleOptions.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Harness.Shared.Console; + +/// +/// Configuration options for . +/// +public class HarnessConsoleOptions +{ + /// + /// Gets or sets the optional maximum context window size in tokens. + /// When set, token usage is displayed as a percentage of the budget. + /// + public int? MaxContextWindowTokens { get; set; } + + /// + /// Gets or sets the optional maximum output tokens. + /// Used with to show input/output budget breakdown. + /// + public int? MaxOutputTokens { get; set; } + + /// + /// Gets or sets a value indicating whether the planning UX is enabled. + /// When and the agent is in the mode specified by , + /// the console uses structured output to present clarification questions and approval requests + /// instead of streaming free-form text. + /// + /// Defaults to . + public bool EnablePlanningUx { get; set; } + + /// + /// Gets or sets the name of the agent mode that activates the planning UX. + /// Must be set when is . + /// + public string? PlanningModeName { get; set; } + + /// + /// Gets or sets the name of the agent mode to switch to when the user approves a plan. + /// Must be set when is . + /// + public string? ExecutionModeName { get; set; } + + /// + /// Gets or sets a mapping of agent mode names to console colors. + /// When a mode is not found in this dictionary, the default color () is used. + /// + public Dictionary ModeColors { get; set; } = new(StringComparer.OrdinalIgnoreCase) + { + ["plan"] = ConsoleColor.Cyan, + ["execute"] = ConsoleColor.Green, + }; +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj index 7483be77bf..09abd76edc 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj @@ -7,6 +7,10 @@ enable + + + + diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ConsoleObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ConsoleObserver.cs new file mode 100644 index 0000000000..119d9a8784 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ConsoleObserver.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.Observers; + +/// +/// Abstract base class for console observers that participate in the agent response +/// streaming lifecycle. Observers can configure run options, observe streamed content, +/// and return messages to re-invoke the agent after the stream completes. +/// All methods have default no-op implementations so subclasses only override what they need. +/// +public abstract class ConsoleObserver +{ + /// + /// Configures before the agent is invoked. + /// Override to set options such as . + /// + /// The run options to configure. + public virtual void ConfigureRunOptions(AgentRunOptions options) + { + } + + /// + /// Called for each item in the response stream. + /// + /// The console writer for rendering output. + /// The content item from the stream. + public virtual Task OnContentAsync(ConsoleWriter writer, AIContent content) => Task.CompletedTask; + + /// + /// Called for each text update in the response stream. + /// + /// The console writer for rendering output. + /// The text from the update. + public virtual Task OnTextAsync(ConsoleWriter writer, string text) => Task.CompletedTask; + + /// + /// Called after the response stream completes. Returns messages to include in the + /// next agent invocation, or if no re-invocation is needed. + /// + /// The console writer for rendering output. + /// The agent being interacted with. + /// The current agent session. + /// The console options. + /// Messages to send to the agent, or if no action is needed. + public virtual Task?> OnStreamCompleteAsync( + ConsoleWriter writer, + AIAgent agent, + AgentSession session, + HarnessConsoleOptions options) => Task.FromResult?>(null); +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ErrorDisplayObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ErrorDisplayObserver.cs new file mode 100644 index 0000000000..30f7f81a3d --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ErrorDisplayObserver.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.Observers; + +/// +/// Displays error content (❌) from the response stream. +/// +internal sealed class ErrorDisplayObserver : ConsoleObserver +{ + /// + public override async Task OnContentAsync(ConsoleWriter writer, AIContent content) + { + if (content is ErrorContent errorContent) + { + string errorText = $"❌ Error: {errorContent.Message}"; + if (!string.IsNullOrWhiteSpace(errorContent.ErrorCode)) + { + errorText += $" (code: {errorContent.ErrorCode})"; + } + + if (!string.IsNullOrWhiteSpace(errorContent.Details)) + { + errorText += $" details: {errorContent.Details}"; + } + + await writer.WriteInfoLineAsync(errorText, ConsoleColor.Red); + } + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs new file mode 100644 index 0000000000..844d76ad72 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.Observers; + +/// +/// Planning observer that configures structured output, collects streamed text, +/// and deserializes it as a . Renders clarification +/// questions and approval prompts, and manages mode switching when the user approves a plan. +/// +internal sealed class PlanningOutputObserver : ConsoleObserver +{ + private readonly StringBuilder _textCollector = new(); + private readonly AgentModeProvider _modeProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The mode provider for switching modes on approval. + public PlanningOutputObserver(AgentModeProvider modeProvider) + { + this._modeProvider = modeProvider; + } + + /// + public override void ConfigureRunOptions(AgentRunOptions options) + { + options.ResponseFormat = ChatResponseFormat.ForJsonSchema(); + } + + /// + public override Task OnTextAsync(ConsoleWriter writer, string text) + { + // Collect text silently instead of displaying it. + this._textCollector.Append(text); + return Task.CompletedTask; + } + + /// + public override async Task?> OnStreamCompleteAsync( + ConsoleWriter writer, + AIAgent agent, + AgentSession session, + HarnessConsoleOptions options) + { + // Read collected text from our stream observation. + string collectedText = this._textCollector.ToString(); + this._textCollector.Clear(); + + if (string.IsNullOrWhiteSpace(collectedText)) + { + return null; + } + + // Deserialize the structured response. + PlanningResponse? planningResponse; + try + { + planningResponse = JsonSerializer.Deserialize(collectedText); + } + catch (JsonException ex) + { + await writer.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red); + await writer.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow); + return null; + } + + if (planningResponse is null) + { + await writer.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow); + return null; + } + + // Render based on response type. + if (planningResponse.Type == PlanningResponseType.Clarification) + { + return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(writer, planningResponse)); + } + + if (planningResponse.Type == PlanningResponseType.Approval) + { + var question = planningResponse.Questions.FirstOrDefault(); + if (question is null) + { + await writer.WriteInfoLineAsync("(approval response had no content)", ConsoleColor.DarkYellow); + return null; + } + + string response = await this.RenderApprovalAndCollectResponseAsync(writer, question, options); + if (response == "Approved") + { + this._modeProvider.SetMode(session, options.ExecutionModeName!); + + await writer.WriteInfoLineAsync($"✅ Switched to {options.ExecutionModeName} mode.", + ConsoleWriter.GetModeColor(options.ExecutionModeName, options.ModeColors)); + } + + return AsUserMessages(response); + } + + await writer.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow); + return null; + } + + private static IList? AsUserMessages(string? text) => + text is not null ? [new ChatMessage(ChatRole.User, text)] : null; + + private async Task RenderClarificationsAndCollectResponsesAsync(ConsoleWriter writer, PlanningResponse response) + { + var answers = new List(); + + foreach (var question in response.Questions) + { + await writer.WriteInfoLineAsync(string.Empty); + await writer.WriteInfoLineAsync(question.Message); + + string? answer; + if (question.Choices is { Count: > 0 }) + { + answer = await writer.ReadSelectionAsync( + "Choose an option:", + question.Choices); + } + else + { + answer = (await writer.ReadLineAsync("Response: "))?.Trim(); + } + + if (!string.IsNullOrWhiteSpace(answer)) + { + answers.Add($"Q: {question.Message}\nA: {answer}"); + } + } + + return answers.Count > 0 ? string.Join("\n\n", answers) : null; + } + + private async Task RenderApprovalAndCollectResponseAsync(ConsoleWriter writer, PlanningQuestion question, HarnessConsoleOptions options) + { + await writer.WriteInfoLineAsync(question.Message); + + var choices = new List + { + "Approve and switch to execute mode", + "Suggest changes", + }; + + string selection = await writer.ReadSelectionAsync("What would you like to do?", choices); + + if (selection == choices[0]) + { + return "Approved"; + } + + if (selection == choices[1]) + { + string? feedback = await writer.ReadLineAsync( + "Your feedback: ", + ConsoleWriter.GetModeColor(options.PlanningModeName, options.ModeColors)); + + if (string.IsNullOrWhiteSpace(feedback)) + { + // Treat empty feedback as no changes — re-prompt the agent with the plan. + return "No changes suggested. Please re-present the plan for approval."; + } + + return feedback; + } + + // Custom freeform input — treat as suggested changes. + return selection; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponse.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponse.cs new file mode 100644 index 0000000000..04d6552092 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponse.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace Harness.Shared.Console.Observers; + +/// +/// Represents a structured response from the agent while in planning mode. +/// Used with structured output to enable consistent rendering of clarification +/// questions and approval requests in the console. +/// +public class PlanningResponse +{ + /// + /// Gets or sets the type of planning response. + /// + [JsonPropertyName("type")] + public required PlanningResponseType Type { get; set; } + + /// + /// Gets or sets the list of questions or items to present to the user. + /// For clarification, this contains one or more questions (each with choices). + /// For approval, this contains exactly one item with the plan summary. + /// + [JsonPropertyName("questions")] + [Description("For clarifications, this has one or more questions to ask the user (each with choices). For approvals, this has exactly one item containing the plan summary for the user to approve.")] + public required List Questions { get; set; } +} + +/// +/// Represents a single question or item within a . +/// +public class PlanningQuestion +{ + /// + /// Gets or sets the message to display to the user. + /// For clarification, this is the question. For approval, this is the plan summary. + /// + [JsonPropertyName("message")] + [Description("For clarifications, this has the question that needs to be clarified with the user. For approvals, this would contain a summary of the execution plan that the user needs to approve.")] + public required string Message { get; set; } + + /// + /// Gets or sets the list of choices for the user to pick from. + /// Only used for clarification questions. Null when no predefined choices are offered. + /// + [JsonPropertyName("choices")] + [Description("For clarifications, this has a list of options that the user can choose from. null for approvals.")] + public List? Choices { get; set; } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponseType.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponseType.cs new file mode 100644 index 0000000000..bf1804e8b0 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningResponseType.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using System.Text.Json.Serialization; + +namespace Harness.Shared.Console.Observers; + +/// +/// Specifies the type of planning response from the agent. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PlanningResponseType +{ + /// + /// The agent needs clarification and presents options for the user to choose from. + /// + [Description("Use this type when you need clarification around the user request and you want to present the user with options to choose from.")] + Clarification, + + /// + /// The agent is seeking approval to proceed with execution. + /// + [Description("Use this type when you are ready to start execution, but need approval to start executing.")] + Approval, +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ReasoningDisplayObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ReasoningDisplayObserver.cs new file mode 100644 index 0000000000..74f764b7dd --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ReasoningDisplayObserver.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.Observers; + +/// +/// Displays reasoning content in dark magenta from the response stream. +/// +internal sealed class ReasoningDisplayObserver : ConsoleObserver +{ + /// + public override async Task OnContentAsync(ConsoleWriter writer, AIContent content) + { + if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text)) + { + await writer.WriteTextAsync(reasoning.Text, ConsoleColor.DarkMagenta); + } + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/TextOutputObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/TextOutputObserver.cs new file mode 100644 index 0000000000..197e7eb0c8 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/TextOutputObserver.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Harness.Shared.Console.Observers; + +/// +/// Streams agent text output directly to the console. +/// Used in normal (non-planning) mode. +/// +internal sealed class TextOutputObserver : ConsoleObserver +{ + /// + public override async Task OnTextAsync(ConsoleWriter writer, string text) + { + await writer.WriteTextAsync(text); + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolApprovalObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolApprovalObserver.cs new file mode 100644 index 0000000000..a089653f47 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolApprovalObserver.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.Observers; + +/// +/// Collects items during the response stream, +/// displays approval-needed notifications inline, and prompts the user for approval +/// decisions after the stream completes. +/// +internal sealed class ToolApprovalObserver : ConsoleObserver +{ + private readonly List _approvalRequests = []; + + /// + public override async Task OnContentAsync(ConsoleWriter writer, AIContent content) + { + if (content is ToolApprovalRequestContent approvalRequest) + { + this._approvalRequests.Add(approvalRequest); + string toolName = approvalRequest.ToolCall is FunctionCallContent fc + ? ToolCallFormatter.Format(fc) + : approvalRequest.ToolCall?.ToString() ?? "unknown"; + await writer.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow); + } + } + + /// + public override async Task?> OnStreamCompleteAsync( + ConsoleWriter writer, + AIAgent agent, + AgentSession session, + HarnessConsoleOptions options) + { + if (this._approvalRequests.Count == 0) + { + return null; + } + + var messages = await PromptForApprovalsAsync(writer, this._approvalRequests); + this._approvalRequests.Clear(); + return messages; + } + + private static async Task?> PromptForApprovalsAsync(ConsoleWriter writer, List approvalRequests) + { + if (approvalRequests.Count == 0) + { + return null; + } + + var responses = new List(); + foreach (var request in approvalRequests) + { + string toolName = request.ToolCall is FunctionCallContent fc + ? ToolCallFormatter.Format(fc) + : request.ToolCall?.ToString() ?? "unknown"; + + var choices = new List + { + "Approve this call", + "Always approve this tool (any arguments)", + "Always approve this tool with these arguments", + "Deny", + }; + + string selection = await writer.ReadSelectionAsync($"🔐 Tool approval: {toolName}", choices); + AIContent response = selection switch + { + "Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"), + "Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"), + "Deny" => request.CreateResponse(approved: false, reason: "User denied"), + _ => request.CreateResponse(approved: true, reason: "User approved"), + }; + + string action = selection switch + { + "Always approve this tool (any arguments)" => "✅ Always approved (any args)", + "Always approve this tool with these arguments" => "✅ Always approved (these args)", + "Deny" => "❌ Denied", + _ => "✅ Approved", + }; + await writer.WriteInfoLineAsync($" {action}", ConsoleColor.DarkGray); + + responses.Add(response); + } + + return [new ChatMessage(ChatRole.User, responses)]; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallDisplayObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallDisplayObserver.cs new file mode 100644 index 0000000000..5939053438 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallDisplayObserver.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.Observers; + +/// +/// Displays tool call notifications (🔧) for +/// and items in the response stream. +/// +internal sealed class ToolCallDisplayObserver : ConsoleObserver +{ + /// + public override async Task OnContentAsync(ConsoleWriter writer, AIContent content) + { + if (content is FunctionCallContent functionCall) + { + await writer.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow); + } + else if (content is ToolCallContent toolCall) + { + await writer.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow); + } + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallFormatter.cs similarity index 99% rename from dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs rename to dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallFormatter.cs index 3d4d154f50..09c1ea290b 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolCallFormatter.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallFormatter.cs @@ -4,7 +4,7 @@ using System.Text.Json; using Microsoft.Extensions.AI; -namespace Harness.Shared.Console; +namespace Harness.Shared.Console.Observers; /// /// Formats instances into human-readable strings diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/UsageDisplayObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/UsageDisplayObserver.cs new file mode 100644 index 0000000000..80e24a9d0a --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/UsageDisplayObserver.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.Observers; + +/// +/// Displays token usage statistics (📊) from the response stream. +/// +internal sealed class UsageDisplayObserver : ConsoleObserver +{ + private readonly int? _maxContextWindowTokens; + private readonly int? _maxOutputTokens; + + /// + /// Initializes a new instance of the class. + /// + /// Optional max context window size in tokens. + /// Optional max output tokens. + public UsageDisplayObserver(int? maxContextWindowTokens, int? maxOutputTokens) + { + this._maxContextWindowTokens = maxContextWindowTokens; + this._maxOutputTokens = maxOutputTokens; + } + + /// + public override async Task OnContentAsync(ConsoleWriter writer, AIContent content) + { + if (content is UsageContent usage) + { + if (usage.Details is not null) + { + await writer.WriteInfoLineAsync(this.FormatUsageBreakdown(usage.Details), ConsoleColor.DarkGray); + } + else + { + await writer.WriteInfoLineAsync("📊 Tokens —", ConsoleColor.DarkGray); + } + } + } + + private string FormatUsageBreakdown(UsageDetails details) + { + int? inputBudget = (this._maxContextWindowTokens is not null && this._maxOutputTokens is not null) + ? this._maxContextWindowTokens.Value - this._maxOutputTokens.Value + : null; + + return $"📊 Tokens — input: {FormatTokenCount(details.InputTokenCount, inputBudget)}" + + $" | output: {FormatTokenCount(details.OutputTokenCount, this._maxOutputTokens)}" + + $" | total: {FormatTokenCount(details.TotalTokenCount, this._maxContextWindowTokens)}"; + } + + private static string FormatTokenCount(long? count, int? budget) + { + if (count is null) + { + return "—"; + } + + if (budget is not null && budget.Value > 0) + { + double pct = (double)count.Value / budget.Value * 100; + return $"{count.Value:N0}/{budget.Value:N0} ({pct:F1}%)"; + } + + return $"{count.Value:N0}"; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs index bbbc3416a9..d6b9f1d96e 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -43,14 +43,16 @@ *Plan Mode* - 1. Analyze the request. - 2. Ask for clarifications where needed. - 1. When asking for clarification and you have specific options in mind, present them to the user with numbers, so they can respond with the number instead of having to retype the entire response. - 2. Always also allow the user to respond with free-form text in case they want to provide information or context that you didn't specifically ask for. - 3. Create one or more todo items. - 4. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes. - 5. Present the plan to the user. - 6. Ask for approval to switch to execute mode and process the plan. + 1. Analyze the request with the purpose of building a research plan. + 2. Create a list of todo items. + 3. If needed, use the provided tools to do some exploratory checks to help build a plan and determine what clarifying questions you may need from the user. + 4. Ask for clarifications from the user where needed. + 1. Ask each clarification one by one. + 2. When asking for clarification and you have specific options in mind, present them to the user, so they can choose the option instead of having to retype the entire response. + 3. Do not proceed until you have received all the needed clarifications. + 4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user. + 5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes. + 6. Present the plan to the user and ask for approval to switch to execute mode and process the plan. 7. When approval is granted, always switch to execute mode (using the `AgentMode_Set` tool), and follow the steps for *Execute mode*. *Execute Mode* @@ -174,4 +176,15 @@ Track your sources — you will need them when presenting results. .Build(); // Run the interactive console session using the shared HarnessConsole helper. -await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started.", maxContextWindowTokens: MaxContextWindowTokens, maxOutputTokens: MaxOutputTokens); +await HarnessConsole.RunAgentAsync( + agent, + title: "Research Assistant", + userPrompt: "Enter a research topic to get started.", + new HarnessConsoleOptions + { + MaxContextWindowTokens = MaxContextWindowTokens, + MaxOutputTokens = MaxOutputTokens, + EnablePlanningUx = true, + PlanningModeName = "plan", + ExecutionModeName = "execute" + }); From 0295b4c4c7e5e43f5f7622f6df652d229aaea73a Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:04:56 +0100 Subject: [PATCH 13/13] .NET: Add FileAccessProvdider and concurrency fix for FileMemoryProvider (#5583) * Add FileAccessProvdider and concurrency fix for FileMemoryProvider * Address PR comments --- dotnet/agent-framework-dotnet.slnx | 1 + .../Harness_Step03_DataProcessing.csproj | 24 + .../Harness_Step03_DataProcessing/Program.cs | 110 ++++ .../Harness_Step03_DataProcessing/README.md | 65 ++ .../data/sales.csv | 50 ++ dotnet/samples/02-agents/Harness/README.md | 1 + .../Harness/FileAccess/FileAccessProvider.cs | 180 ++++++ .../FileAccess/FileAccessProviderOptions.cs | 22 + .../Harness/FileMemory/FileMemoryProvider.cs | 74 ++- .../AgentFileStore.cs | 0 .../FileSearchMatch.cs | 0 .../FileSearchResult.cs | 0 .../FileSystemAgentFileStore.cs | 0 .../InMemoryAgentFileStore.cs | 0 .../{FileMemory => FileStore}/StorePaths.cs | 0 .../FileAccess/FileAccessProviderTests.cs | 604 ++++++++++++++++++ .../FileMemory/FileMemoryProviderTests.cs | 120 ++++ .../FileSystemAgentFileStoreTests.cs | 0 .../InMemoryAgentFileStoreTests.cs | 0 .../StorePathsTests.cs | 0 20 files changed, 1228 insertions(+), 23 deletions(-) create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/data/sales.csv create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProviderOptions.cs rename dotnet/src/Microsoft.Agents.AI/Harness/{FileMemory => FileStore}/AgentFileStore.cs (100%) rename dotnet/src/Microsoft.Agents.AI/Harness/{FileMemory => FileStore}/FileSearchMatch.cs (100%) rename dotnet/src/Microsoft.Agents.AI/Harness/{FileMemory => FileStore}/FileSearchResult.cs (100%) rename dotnet/src/Microsoft.Agents.AI/Harness/{FileMemory => FileStore}/FileSystemAgentFileStore.cs (100%) rename dotnet/src/Microsoft.Agents.AI/Harness/{FileMemory => FileStore}/InMemoryAgentFileStore.cs (100%) rename dotnet/src/Microsoft.Agents.AI/Harness/{FileMemory => FileStore}/StorePaths.cs (100%) create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs rename dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/{FileMemory => FileStore}/FileSystemAgentFileStoreTests.cs (100%) rename dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/{FileMemory => FileStore}/InMemoryAgentFileStoreTests.cs (100%) rename dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/{FileMemory => FileStore}/StorePathsTests.cs (100%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c3f6f974fc..8b734b6308 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -122,6 +122,7 @@ + diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj new file mode 100644 index 0000000000..afd02b5641 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs new file mode 100644 index 0000000000..02463365d1 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use a ChatClientAgent with the FileAccessProvider +// to give an agent access to a folder of CSV data files. The agent can read, analyze, +// and extract information from the data, then write results back as new files. +// +// The sample includes a pre-populated `data/` folder with sales transaction data. +// Ask the agent to analyze the data, produce summaries, or create new output files. +// +// Special commands: +// exit — End the session. + +#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage. +#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments. + +using System.ClientModel.Primitives; +using Azure.Identity; +using Harness.Shared.Console; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Responses; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4"; + +const int MaxContextWindowTokens = 1_050_000; +const int MaxOutputTokens = 128_000; + +// Point the file store at the data/ folder that ships with the sample. +var dataFolder = Path.Combine(AppContext.BaseDirectory, "data"); +var fileStore = new FileSystemAgentFileStore(dataFolder); + +var instructions = + """ + You are a data analyst assistant. You have access to a folder of data files via the FileAccess_* tools. + + ## Getting started + - Start by listing available files with FileAccess_ListFiles to see what data is available. + - Read the files to understand their structure and contents. + + ## Working with data + - When asked to analyze data, read the relevant files first, then perform the analysis. + - Show your analysis clearly with tables, summaries, and key insights. + - When calculations are needed, work through them step by step and show your reasoning. + + ## Writing output + - When asked to produce output files (e.g., reports, summaries, filtered data), use FileAccess_SaveFile to write them. + - Use appropriate file formats: CSV for tabular data, Markdown for reports. + - Confirm what you wrote and where. + + ## Important + - Never modify or delete the original input data files unless explicitly asked to do so. + - If asked about data you haven't read yet, read it first before answering. + - Always explain your reasoning and thought process as you work through tasks. + - Always explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process. + """; + +// Create a compaction strategy based on the model's context window. +var compactionStrategy = new ContextWindowCompactionStrategy( + maxContextWindowTokens: MaxContextWindowTokens, + maxOutputTokens: MaxOutputTokens); + +AIAgent agent = + new OpenAIClient( + new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), + new OpenAIClientOptions() + { + Endpoint = new Uri(endpoint), + RetryPolicy = new ClientRetryPolicy(3) + }) + .GetResponsesClient() + .AsIChatClientWithStoredOutputDisabled(deploymentName) + + .AsBuilder() + .UseFunctionInvocation() + .UsePerServiceCallChatHistoryPersistence() + .UseAIContextProviders(new CompactionProvider(compactionStrategy)) + + .BuildAIAgent( + new ChatClientAgentOptions + { + Name = "DataAnalyst", + Description = "A data analyst assistant that reads, analyzes, and processes data files.", + UseProvidedChatClientAsIs = true, + RequirePerServiceCallChatHistoryPersistence = true, + ChatHistoryProvider = new InMemoryChatHistoryProvider( + new InMemoryChatHistoryProviderOptions + { + ChatReducer = compactionStrategy.AsChatReducer(), + }), + AIContextProviders = + [ + new FileAccessProvider(fileStore), + ], + ChatOptions = new ChatOptions + { + Instructions = instructions, + MaxOutputTokens = MaxOutputTokens, + }, + }) + .AsBuilder() + .Build(); + +// Run the interactive console session. +await HarnessConsole.RunAgentAsync( + agent, + title: "Data Processing Assistant", + userPrompt: "Ask me to analyze the data files, produce summaries, or create output files."); diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md new file mode 100644 index 0000000000..6577ed2b0f --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md @@ -0,0 +1,65 @@ +# What this sample demonstrates + +This sample demonstrates how to use a `ChatClientAgent` with the `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. + +Key features showcased: + +- **FileAccessProvider** — gives the agent tools to read, write, list, search, and delete files in a shared data folder +- **CSV data processing** — the agent reads sales transaction data and performs analysis on demand +- **Output file creation** — the agent can write summaries, filtered data, or reports back to the data folder +- **Streaming output** — responses are streamed token-by-token for a natural experience +- **No planning mode** — this is a simple conversational sample focused on data interaction + +## Prerequisites + +Before running this sample, ensure you have: + +1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`) +2. Azure CLI installed and authenticated (`az login`) + +## Environment Variables + +Set the following environment variables: + +```bash +# Required: Your Azure AI Foundry OpenAI endpoint +export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/" + +# Optional: Model deployment name (defaults to gpt-5.4) +export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4" +``` + +## Running the Sample + +```bash +cd dotnet +dotnet run --project samples/02-agents/Harness/Harness_Step03_DataProcessing +``` + +## What to Expect + +The sample starts an interactive conversation with a data analyst agent. The `data/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson). + +You can ask the agent to: + +1. **List available files** — "What files do you have?" +2. **Analyze the data** — "What are the total sales by region?" or "Which salesperson has the highest revenue?" +3. **Create output files** — "Create a summary report as a markdown file" or "Write a CSV with monthly totals" +4. **Search for patterns** — "Find all transactions over $1000" +5. **Type `exit`** — to end the session + +E.g. try the following prompt `Please process the sales.csv file by first filtering it to only North region sales, and then calculating the sum of sales by person. I'd like to write the results of the processing to north_region_totals.csv`. + +## Sample Data + +The included `data/sales.csv` contains sales transactions from January to March 2025 with the following columns: + +| Column | Description | +| --- | --- | +| `date` | Transaction date (YYYY-MM-DD) | +| `product` | Product name | +| `category` | Product category (Electronics, Furniture, Stationery) | +| `quantity` | Units sold | +| `unit_price` | Price per unit | +| `region` | Sales region (North, South, West) | +| `salesperson` | Name of the salesperson | diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/data/sales.csv b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/data/sales.csv new file mode 100644 index 0000000000..50a2369942 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/data/sales.csv @@ -0,0 +1,50 @@ +date,product,category,quantity,unit_price,region,salesperson +2025-01-03,Laptop Pro 15,Electronics,2,1299.99,North,Alice +2025-01-05,Ergonomic Chair,Furniture,5,349.50,South,Bob +2025-01-07,Wireless Mouse,Electronics,12,24.99,North,Alice +2025-01-08,Standing Desk,Furniture,1,599.00,West,Carol +2025-01-10,USB-C Hub,Electronics,8,45.99,North,David +2025-01-12,Monitor 27in,Electronics,3,429.00,South,Bob +2025-01-14,Desk Lamp,Furniture,6,79.95,West,Carol +2025-01-15,Keyboard Mech,Electronics,4,149.99,North,Alice +2025-01-17,Filing Cabinet,Furniture,2,189.00,South,David +2025-01-20,Webcam HD,Electronics,10,89.99,West,Bob +2025-01-22,Laptop Pro 15,Electronics,1,1299.99,South,Carol +2025-01-24,Ergonomic Chair,Furniture,3,349.50,North,Alice +2025-01-25,Notebook Pack,Stationery,20,12.99,South,David +2025-01-27,Wireless Mouse,Electronics,15,24.99,West,Carol +2025-01-28,Whiteboard,Stationery,4,129.00,North,Bob +2025-01-30,Standing Desk,Furniture,2,599.00,South,Alice +2025-02-02,USB-C Hub,Electronics,6,45.99,West,David +2025-02-04,Monitor 27in,Electronics,2,429.00,North,Carol +2025-02-05,Desk Lamp,Furniture,8,79.95,South,Bob +2025-02-07,Keyboard Mech,Electronics,5,149.99,West,Alice +2025-02-09,Filing Cabinet,Furniture,1,189.00,North,David +2025-02-11,Webcam HD,Electronics,7,89.99,South,Carol +2025-02-13,Laptop Pro 15,Electronics,3,1299.99,West,Bob +2025-02-15,Notebook Pack,Stationery,30,12.99,North,Alice +2025-02-17,Ergonomic Chair,Furniture,4,349.50,South,David +2025-02-19,Wireless Mouse,Electronics,20,24.99,North,Carol +2025-02-20,Whiteboard,Stationery,2,129.00,West,Bob +2025-02-22,Standing Desk,Furniture,1,599.00,North,Alice +2025-02-24,USB-C Hub,Electronics,10,45.99,South,David +2025-02-26,Monitor 27in,Electronics,4,429.00,West,Carol +2025-02-28,Desk Lamp,Furniture,3,79.95,North,Bob +2025-03-02,Keyboard Mech,Electronics,6,149.99,South,Alice +2025-03-04,Filing Cabinet,Furniture,3,189.00,West,David +2025-03-06,Webcam HD,Electronics,9,89.99,North,Carol +2025-03-08,Laptop Pro 15,Electronics,2,1299.99,South,Bob +2025-03-10,Notebook Pack,Stationery,25,12.99,West,Alice +2025-03-12,Ergonomic Chair,Furniture,6,349.50,North,David +2025-03-14,Wireless Mouse,Electronics,18,24.99,South,Carol +2025-03-15,Whiteboard,Stationery,5,129.00,North,Bob +2025-03-17,Standing Desk,Furniture,3,599.00,West,Alice +2025-03-19,USB-C Hub,Electronics,7,45.99,North,David +2025-03-21,Monitor 27in,Electronics,5,429.00,South,Carol +2025-03-23,Desk Lamp,Furniture,4,79.95,West,Bob +2025-03-25,Keyboard Mech,Electronics,3,149.99,North,Alice +2025-03-27,Filing Cabinet,Furniture,2,189.00,South,David +2025-03-28,Webcam HD,Electronics,11,89.99,West,Carol +2025-03-29,Laptop Pro 15,Electronics,1,1299.99,North,Bob +2025-03-30,Notebook Pack,Stationery,15,12.99,South,Alice +2025-03-31,Ergonomic Chair,Furniture,2,349.50,West,David diff --git a/dotnet/samples/02-agents/Harness/README.md b/dotnet/samples/02-agents/Harness/README.md index 2250fd301e..d868323648 100644 --- a/dotnet/samples/02-agents/Harness/README.md +++ b/dotnet/samples/02-agents/Harness/README.md @@ -8,3 +8,4 @@ Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Ag | --- | --- | | [Harness_Step01_Research](./Harness_Step01_Research/README.md) | Using a ChatClientAgent with TodoProvider and AgentModeProvider for research, showcasing planning mode and todo management | | [Harness_Step02_Research_WithSubAgents](./Harness_Step02_Research_WithSubAgents/README.md) | Using SubAgentsProvider to delegate stock price lookups to a web-search sub-agent concurrently | +| [Harness_Step03_DataProcessing](./Harness_Step03_DataProcessing/README.md) | Using FileAccessProvider to give an agent access to CSV data files for reading, analysis, and output generation | diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs new file mode 100644 index 0000000000..f050a8431b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An that provides file access tools to an agent +/// for saving, reading, deleting, listing, and searching files. +/// +/// +/// +/// The gives agents the ability to work with files +/// in a folder that the user has granted access to. Unlike , +/// which provides session-scoped memory that may be isolated per session, +/// operates on a shared, persistent folder whose contents are visible across sessions and agents. +/// This makes it suitable for reading input data, writing output artifacts, and working with +/// files that have a lifetime beyond any single agent session. +/// +/// +/// File access is mediated through a abstraction, allowing pluggable +/// backends (in-memory, local file system, remote blob storage, etc.). +/// +/// +/// This provider exposes the following tools to the agent: +/// +/// SaveFile — Save a file with the given name and content. +/// ReadFile — Read the content of a file by name. +/// DeleteFile — Delete a file by name. +/// ListFiles — List all file names. +/// SearchFiles — Search file contents using a regular expression pattern. +/// +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileAccessProvider : AIContextProvider +{ + private const string DefaultInstructions = + """ + ## File Access + You have access to a shared file storage area via the `FileAccess_*` tools for reading, writing, and managing files. + These files persist beyond the current session and may be shared across sessions or agents. + Use these tools to read input data provided by the user, write output artifacts, and manage any files the user has asked you to work with. + + - Never delete or overwrite existing files unless the user has explicitly asked you to do so. + """; + + private readonly AgentFileStore _fileStore; + private readonly string _instructions; + private AITool[]? _tools; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The file store implementation used for storage operations. + /// The store should already be scoped to the desired folder or storage location. + /// + /// Optional settings that control provider behavior. When , defaults are used. + /// Thrown when is . + public FileAccessProvider(AgentFileStore fileStore, FileAccessProviderOptions? options = null) + { + Throw.IfNull(fileStore); + + this._fileStore = fileStore; + this._instructions = options?.Instructions ?? DefaultInstructions; + } + + /// + public override IReadOnlyList StateKeys => []; + + /// + protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + return new ValueTask(new AIContext + { + Instructions = this._instructions, + Tools = this._tools ??= this.CreateTools(), + }); + } + + /// + /// Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true. + /// + /// The name of the file to save. + /// The content to write to the file. + /// Whether to overwrite the file if it already exists. + /// A token to cancel the operation. + /// A confirmation message. + [Description("Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.")] + private async Task SaveFileAsync(string fileName, string content, bool overwrite = false, CancellationToken cancellationToken = default) + { + string path = StorePaths.NormalizeRelativePath(fileName); + + if (!overwrite && await this._fileStore.FileExistsAsync(path, cancellationToken).ConfigureAwait(false)) + { + return $"File '{fileName}' already exists. To replace it, save again with overwrite set to true."; + } + + await this._fileStore.WriteFileAsync(path, content, cancellationToken).ConfigureAwait(false); + return $"File '{fileName}' saved."; + } + + /// + /// Read the content of a file by name. Returns the file content or a message indicating the file was not found. + /// + /// The name of the file to read. + /// A token to cancel the operation. + /// The file content or a not-found message. + [Description("Read the content of a file by name. Returns the file content or a message indicating the file was not found.")] + private async Task ReadFileAsync(string fileName, CancellationToken cancellationToken = default) + { + string path = StorePaths.NormalizeRelativePath(fileName); + string? content = await this._fileStore.ReadFileAsync(path, cancellationToken).ConfigureAwait(false); + return content ?? $"File '{fileName}' not found."; + } + + /// + /// Delete a file by name. + /// + /// The name of the file to delete. + /// A token to cancel the operation. + /// A confirmation or not-found message. + [Description("Delete a file by name.")] + private async Task DeleteFileAsync(string fileName, CancellationToken cancellationToken = default) + { + string path = StorePaths.NormalizeRelativePath(fileName); + bool deleted = await this._fileStore.DeleteFileAsync(path, cancellationToken).ConfigureAwait(false); + return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found."; + } + + /// + /// List all file names. + /// + /// A token to cancel the operation. + /// A list of file names. + [Description("List all file names.")] + private async Task> ListFilesAsync(CancellationToken cancellationToken = default) + { + IReadOnlyList fileNames = await this._fileStore.ListFilesAsync(string.Empty, cancellationToken).ConfigureAwait(false); + return new List(fileNames); + } + + /// + /// Search file contents using a regular expression pattern (case-insensitive). + /// Optionally filter which files to search using a glob pattern. + /// + /// A regular expression pattern to match against file contents (case-insensitive). + /// An optional glob pattern to filter which files to search (e.g., "*.md", "research*"). Leave empty or omit to search all files. + /// A token to cancel the operation. + /// A list of search results with matching file names, snippets, and matching lines. + [Description("Search file contents using a regular expression pattern (case-insensitive). Optionally filter which files to search using a glob pattern (e.g., \"*.md\", \"research*\"). Returns matching file names, snippets, and matching lines with line numbers.")] + private async Task> SearchFilesAsync(string regexPattern, string? filePattern = null, CancellationToken cancellationToken = default) + { + string? pattern = string.IsNullOrWhiteSpace(filePattern) ? null : filePattern; + IReadOnlyList results = await this._fileStore.SearchFilesAsync(string.Empty, regexPattern, pattern, cancellationToken).ConfigureAwait(false); + return new List(results); + } + + private AITool[] CreateTools() + { + var serializerOptions = AgentJsonUtilities.DefaultOptions; + + return + [ + AIFunctionFactory.Create(this.SaveFileAsync, new AIFunctionFactoryOptions { Name = "FileAccess_SaveFile", SerializerOptions = serializerOptions }), + AIFunctionFactory.Create(this.ReadFileAsync, new AIFunctionFactoryOptions { Name = "FileAccess_ReadFile", SerializerOptions = serializerOptions }), + AIFunctionFactory.Create(this.DeleteFileAsync, new AIFunctionFactoryOptions { Name = "FileAccess_DeleteFile", SerializerOptions = serializerOptions }), + AIFunctionFactory.Create(this.ListFilesAsync, new AIFunctionFactoryOptions { Name = "FileAccess_ListFiles", SerializerOptions = serializerOptions }), + AIFunctionFactory.Create(this.SearchFilesAsync, new AIFunctionFactoryOptions { Name = "FileAccess_SearchFiles", SerializerOptions = serializerOptions }), + ]; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProviderOptions.cs new file mode 100644 index 0000000000..b8d1e0c475 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProviderOptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Options controlling the behavior of . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileAccessProviderOptions +{ + /// + /// Gets or sets custom instructions provided to the agent for using the file access tools. + /// + /// + /// When (the default), the provider uses built-in instructions + /// that guide the agent on how to use file storage effectively. + /// + public string? Instructions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs index b16e9408fa..7052cd0b42 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs @@ -40,7 +40,7 @@ namespace Microsoft.Agents.AI; /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class FileMemoryProvider : AIContextProvider +public sealed class FileMemoryProvider : AIContextProvider, IDisposable { private const string DescriptionSuffix = "_description.md"; private const string MemoryIndexFileName = "memories.md"; @@ -49,7 +49,8 @@ public sealed class FileMemoryProvider : AIContextProvider private const string DefaultInstructions = """ ## File Based Memory - You have access to a file-based memory system via the `FileMemory_*` tools for storing and retrieving information across interactions. + You have access to a session-scoped, file-based memory system via the `FileMemory_*` tools for storing and retrieving information across interactions. + These files act as your working memory for the current session and are isolated from other sessions. Use these tools to store plans, memories, processing results, or downloaded data. - Use descriptive file names (e.g., "projectarchitecture.md", "userpreferences.md"). @@ -63,6 +64,7 @@ This ensures important data remains accessible across long-running sessions. private readonly AgentFileStore _fileStore; private readonly ProviderSessionState _sessionState; + private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly string _instructions; private IReadOnlyList? _stateKeys; private AITool[]? _tools; @@ -93,6 +95,14 @@ public FileMemoryProvider(AgentFileStore fileStore, Func public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; + /// + /// Releases the resources used by the . + /// + public void Dispose() + { + this._writeLock.Dispose(); + } + /// protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) { @@ -147,26 +157,35 @@ private async Task SaveFileAsync(string fileName, string content, string FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); string path = ResolvePath(state.WorkingFolder, fileName); - await this._fileStore.WriteFileAsync(path, content, cancellationToken).ConfigureAwait(false); - - string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName)); - if (!string.IsNullOrWhiteSpace(description)) + await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - await this._fileStore.WriteFileAsync(descPath, description, cancellationToken).ConfigureAwait(false); + await this._fileStore.WriteFileAsync(path, content, cancellationToken).ConfigureAwait(false); + + string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName)); + + if (!string.IsNullOrWhiteSpace(description)) + { + await this._fileStore.WriteFileAsync(descPath, description, cancellationToken).ConfigureAwait(false); + } + else + { + // Remove any stale description file when no description is provided. + await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false); + } + + string result = string.IsNullOrWhiteSpace(description) + ? $"File '{fileName}' saved." + : $"File '{fileName}' saved with description."; + + await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false); + return result; } - else + finally { - // Remove any stale description file when no description is provided. - await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false); + this._writeLock.Release(); } - - string result = string.IsNullOrWhiteSpace(description) - ? $"File '{fileName}' saved." - : $"File '{fileName}' saved with description."; - - await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false); - return result; } /// @@ -196,14 +215,23 @@ private async Task DeleteFileAsync(string fileName, CancellationToken ca { FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session); string path = ResolvePath(state.WorkingFolder, fileName); - bool deleted = await this._fileStore.DeleteFileAsync(path, cancellationToken).ConfigureAwait(false); - // Also delete companion description file if it exists. - string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName)); - await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false); + await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + bool deleted = await this._fileStore.DeleteFileAsync(path, cancellationToken).ConfigureAwait(false); + + // Also delete companion description file if it exists. + string descPath = ResolvePath(state.WorkingFolder, GetDescriptionFileName(fileName)); + await this._fileStore.DeleteFileAsync(descPath, cancellationToken).ConfigureAwait(false); - await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false); - return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found."; + await this.RebuildMemoryIndexAsync(state, cancellationToken).ConfigureAwait(false); + return deleted ? $"File '{fileName}' deleted." : $"File '{fileName}' not found."; + } + finally + { + this._writeLock.Release(); + } } /// diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/AgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/AgentFileStore.cs rename to dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchMatch.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchMatch.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchMatch.cs rename to dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchMatch.cs diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchResult.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchResult.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSearchResult.cs rename to dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchResult.cs diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSystemAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileSystemAgentFileStore.cs rename to dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/InMemoryAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/InMemoryAgentFileStore.cs rename to dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/StorePaths.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/StorePaths.cs rename to dotnet/src/Microsoft.Agents.AI/Harness/FileStore/StorePaths.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs new file mode 100644 index 0000000000..51b84fdca2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs @@ -0,0 +1,604 @@ +// 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.Harness.FileAccess; + +public class FileAccessProviderTests +{ + #region Constructor Validation + + [Fact] + public void Constructor_NullFileStore_Throws() + { + Assert.Throws(() => new FileAccessProvider(null!)); + } + + [Fact] + public void Constructor_WithDefaults_Succeeds() + { + // Act + var provider = new FileAccessProvider(new InMemoryAgentFileStore()); + + // Assert + Assert.NotNull(provider); + } + + #endregion + + #region ProvideAIContextAsync Tests + + [Fact] + public async Task ProvideAIContextAsync_ReturnsToolsAsync() + { + // Arrange + var tools = await CreateToolsAsync(); + + // Assert — 5 tools: SaveFile, ReadFile, DeleteFile, ListFiles, SearchFiles + Assert.Equal(5, tools.Count()); + } + + [Fact] + public async Task ProvideAIContextAsync_ReturnsInstructionsAsync() + { + // Arrange + var provider = new FileAccessProvider(new InMemoryAgentFileStore()); + 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.Contains("File Access", result.Instructions); + Assert.Contains("FileAccess_", result.Instructions); + Assert.Contains("persist beyond the current session", result.Instructions); + } + + [Fact] + public async Task ProvideAIContextAsync_DoesNotInjectMessagesAsync() + { + // Arrange — FileAccessProvider should never inject messages (unlike FileMemoryProvider). + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Content"); + var provider = new FileAccessProvider(store); + 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.Null(result.Messages); + } + + [Fact] + public void StateKeys_ReturnsEmpty() + { + // Arrange — FileAccessProvider has no session state. + var provider = new FileAccessProvider(new InMemoryAgentFileStore()); + + // Act + var keys = provider.StateKeys; + + // Assert + Assert.Empty(keys); + } + + #endregion + + #region SaveFile Tests + + [Fact] + public async Task SaveFile_CreatesFileAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var tools = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileAccess_SaveFile"); + + // Act + await InvokeToolAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["content"] = "Test content", + }); + + // Assert + var content = await store.ReadFileAsync("notes.md"); + Assert.Equal("Test content", content); + } + + [Fact] + public async Task SaveFile_DoesNotCreateDescriptionSidecarAsync() + { + // Arrange — FileAccessProvider should never create description sidecar files. + var store = new InMemoryAgentFileStore(); + var tools = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileAccess_SaveFile"); + + // Act + await InvokeToolAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "research.md", + ["content"] = "Long research content...", + }); + + // Assert — file exists, no description sidecar + Assert.Equal("Long research content...", await store.ReadFileAsync("research.md")); + Assert.Null(await store.ReadFileAsync("research_description.md")); + } + + [Fact] + public async Task SaveFile_ExistingFile_WithoutOverwrite_ReturnsErrorAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var tools = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileAccess_SaveFile"); + + await InvokeToolAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["content"] = "Original", + }); + + // Act — try to save again without overwrite + var result = await InvokeToolAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["content"] = "Updated", + }); + + // Assert — original content preserved, error message returned + Assert.Equal("Original", await store.ReadFileAsync("notes.md")); + var text = Assert.IsType(result).GetString(); + Assert.Contains("already exists", text); + } + + [Fact] + public async Task SaveFile_ExistingFile_WithOverwrite_SucceedsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var tools = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileAccess_SaveFile"); + + await InvokeToolAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["content"] = "Original", + }); + + // Act — save again with overwrite=true + await InvokeToolAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["content"] = "Updated", + ["overwrite"] = true, + }); + + // Assert + Assert.Equal("Updated", await store.ReadFileAsync("notes.md")); + } + + [Fact] + public async Task SaveFile_ReturnsConfirmationAsync() + { + // Arrange + var tools = await CreateToolsAsync(); + var saveFile = GetTool(tools, "FileAccess_SaveFile"); + + // Act + var result = await InvokeToolAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "test.md", + ["content"] = "Content", + }); + + // Assert + var text = Assert.IsType(result).GetString(); + Assert.Contains("saved", text); + } + + #endregion + + #region ReadFile Tests + + [Fact] + public async Task ReadFile_ExistingFile_ReturnsContentAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Stored content"); + var tools = await CreateToolsAsync(store); + var readFile = GetTool(tools, "FileAccess_ReadFile"); + + // Act + var result = await InvokeToolAsync(readFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + }); + + // Assert + var text = Assert.IsType(result).GetString(); + Assert.Equal("Stored content", text); + } + + [Fact] + public async Task ReadFile_NonExistent_ReturnsNotFoundMessageAsync() + { + // Arrange + var tools = await CreateToolsAsync(); + var readFile = GetTool(tools, "FileAccess_ReadFile"); + + // Act + var result = await InvokeToolAsync(readFile, new AIFunctionArguments + { + ["fileName"] = "nonexistent.md", + }); + + // Assert + var text = Assert.IsType(result).GetString(); + Assert.Contains("not found", text); + } + + #endregion + + #region DeleteFile Tests + + [Fact] + public async Task DeleteFile_ExistingFile_DeletesAndReturnsConfirmationAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Content"); + var tools = await CreateToolsAsync(store); + var deleteFile = GetTool(tools, "FileAccess_DeleteFile"); + + // Act + var result = await InvokeToolAsync(deleteFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + }); + + // Assert + var text = Assert.IsType(result).GetString(); + Assert.Contains("deleted", text); + Assert.False(await store.FileExistsAsync("notes.md")); + } + + [Fact] + public async Task DeleteFile_NonExistent_ReturnsNotFoundAsync() + { + // Arrange + var tools = await CreateToolsAsync(); + var deleteFile = GetTool(tools, "FileAccess_DeleteFile"); + + // Act + var result = await InvokeToolAsync(deleteFile, new AIFunctionArguments + { + ["fileName"] = "missing.md", + }); + + // Assert + var text = Assert.IsType(result).GetString(); + Assert.Contains("not found", text); + } + + #endregion + + #region ListFiles Tests + + [Fact] + public async Task ListFiles_ReturnsFileNamesAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Content"); + await store.WriteFileAsync("data.txt", "Data"); + var tools = await CreateToolsAsync(store); + var listFiles = GetTool(tools, "FileAccess_ListFiles"); + + // Act + var result = await InvokeToolAsync(listFiles, new AIFunctionArguments()); + + // Assert — returns plain list of file names (no description properties) + var entries = Assert.IsType(result).EnumerateArray().ToList(); + Assert.Equal(2, entries.Count); + Assert.Contains(entries, e => e.GetString() == "data.txt"); + Assert.Contains(entries, e => e.GetString() == "notes.md"); + } + + [Fact] + public async Task ListFiles_DoesNotFilterDescriptionFilesAsync() + { + // Arrange — FileAccessProvider doesn't know about description sidecars, so all files are visible. + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Content"); + await store.WriteFileAsync("notes_description.md", "Description"); + var tools = await CreateToolsAsync(store); + var listFiles = GetTool(tools, "FileAccess_ListFiles"); + + // Act + var result = await InvokeToolAsync(listFiles, new AIFunctionArguments()); + + // Assert — both files should be visible + var entries = Assert.IsType(result).EnumerateArray().ToList(); + Assert.Equal(2, entries.Count); + } + + [Fact] + public async Task ListFiles_EmptyStore_ReturnsEmptyListAsync() + { + // Arrange + var tools = await CreateToolsAsync(); + var listFiles = GetTool(tools, "FileAccess_ListFiles"); + + // Act + var result = await InvokeToolAsync(listFiles, new AIFunctionArguments()); + + // Assert + var entries = Assert.IsType(result).EnumerateArray().ToList(); + Assert.Empty(entries); + } + + #endregion + + #region SearchFiles Tests + + [Fact] + public async Task SearchFiles_FindsMatchingContentAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Important research findings about AI"); + var tools = await CreateToolsAsync(store); + var searchFiles = GetTool(tools, "FileAccess_SearchFiles"); + + // Act + var result = await InvokeToolAsync(searchFiles, new AIFunctionArguments + { + ["regexPattern"] = "research findings", + ["filePattern"] = "", + }); + + // Assert + var entries = Assert.IsType(result).EnumerateArray().ToList(); + Assert.Single(entries); + Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString()); + Assert.True(entries[0].TryGetProperty("matchingLines", out var matchingLines)); + Assert.True(matchingLines.GetArrayLength() > 0); + } + + [Fact] + public async Task SearchFiles_WithFilePattern_FiltersResultsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "Important data"); + await store.WriteFileAsync("data.txt", "Important data"); + var tools = await CreateToolsAsync(store); + var searchFiles = GetTool(tools, "FileAccess_SearchFiles"); + + // Act + var result = await InvokeToolAsync(searchFiles, new AIFunctionArguments + { + ["regexPattern"] = "Important", + ["filePattern"] = "*.md", + }); + + // Assert + var entries = Assert.IsType(result).EnumerateArray().ToList(); + Assert.Single(entries); + Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString()); + } + + [Fact] + public async Task SearchFiles_NoMatches_ReturnsEmptyAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteFileAsync("notes.md", "No matching content here"); + var tools = await CreateToolsAsync(store); + var searchFiles = GetTool(tools, "FileAccess_SearchFiles"); + + // Act + var result = await InvokeToolAsync(searchFiles, new AIFunctionArguments + { + ["regexPattern"] = "nonexistent pattern xyz", + }); + + // Assert + var entries = Assert.IsType(result).EnumerateArray().ToList(); + Assert.Empty(entries); + } + + #endregion + + #region Path Traversal Protection + + [Fact] + public async Task SaveFile_PathTraversal_ThrowsAsync() + { + // Arrange + var tools = await CreateToolsAsync(); + var saveFile = GetTool(tools, "FileAccess_SaveFile"); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await InvokeToolAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "../escape.md", + ["content"] = "Content", + })); + } + + [Fact] + public async Task SaveFile_AbsolutePath_ThrowsAsync() + { + // Arrange + var tools = await CreateToolsAsync(); + var saveFile = GetTool(tools, "FileAccess_SaveFile"); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await InvokeToolAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "/etc/passwd", + ["content"] = "Content", + })); + } + + [Fact] + public async Task SaveFile_DriveRootedPath_ThrowsAsync() + { + // Arrange + var tools = await CreateToolsAsync(); + var saveFile = GetTool(tools, "FileAccess_SaveFile"); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await InvokeToolAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "C:\\temp\\file.md", + ["content"] = "Content", + })); + } + + [Fact] + public async Task SaveFile_DoubleDotsInFileName_AllowedAsync() + { + // Arrange — "notes..md" is not a path traversal attempt. + var store = new InMemoryAgentFileStore(); + var tools = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileAccess_SaveFile"); + + // Act + await InvokeToolAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes..md", + ["content"] = "Content", + }); + + // Assert + Assert.Equal("Content", await store.ReadFileAsync("notes..md")); + } + + [Fact] + public async Task ReadFile_PathTraversal_ThrowsAsync() + { + // Arrange + var tools = await CreateToolsAsync(); + var readFile = GetTool(tools, "FileAccess_ReadFile"); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await InvokeToolAsync(readFile, new AIFunctionArguments + { + ["fileName"] = "../../etc/passwd", + })); + } + + [Fact] + public async Task DeleteFile_PathTraversal_ThrowsAsync() + { + // Arrange + var tools = await CreateToolsAsync(); + var deleteFile = GetTool(tools, "FileAccess_DeleteFile"); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await InvokeToolAsync(deleteFile, new AIFunctionArguments + { + ["fileName"] = "../escape.md", + })); + } + + #endregion + + #region Options Tests + + [Fact] + public async Task Options_CustomInstructions_OverridesDefaultAsync() + { + // Arrange + var options = new FileAccessProviderOptions { Instructions = "Custom file access instructions." }; + var provider = new FileAccessProvider(new InMemoryAgentFileStore(), options: options); + 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.Equal("Custom file access instructions.", result.Instructions); + } + + [Fact] + public async Task Options_Null_UsesDefaultInstructionsAsync() + { + // Arrange + var provider = new FileAccessProvider(new InMemoryAgentFileStore()); + 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("File Access", result.Instructions); + } + + #endregion + + #region Helper Methods + + private static async Task> CreateToolsAsync(InMemoryAgentFileStore? store = null) + { + var provider = new FileAccessProvider(store ?? new InMemoryAgentFileStore()); + 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!; + } + + private static AIFunction GetTool(IEnumerable tools, string name) + { + return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name); + } + + /// + /// Invokes a tool. Since does not use session state, + /// the tools don't need an ambient . + /// + private static async Task InvokeToolAsync(AIFunction tool, AIFunctionArguments arguments) + { + return await tool.InvokeAsync(arguments); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs index a5341f802b..8642375663 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs @@ -793,4 +793,124 @@ public async Task Options_Null_UsesDefaultInstructionsAsync() } #endregion + + #region Thread Safety Tests + + [Fact] + public async Task ConcurrentSaves_ProduceConsistentIndexAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + var (tools, _, session) = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + const int FileCount = 20; + + // Act — save multiple files in parallel. + var tasks = new Task[FileCount]; + for (int i = 0; i < FileCount; i++) + { + int index = i; + tasks[i] = InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = $"file{index}.md", + ["content"] = $"Content {index}", + ["description"] = $"Description {index}", + }, session); + } + + await Task.WhenAll(tasks); + + // Assert — the memory index should contain all files. + string? indexContent = await store.ReadFileAsync("memories.md"); + Assert.NotNull(indexContent); + for (int i = 0; i < FileCount; i++) + { + Assert.Contains($"**file{i}.md**", indexContent); + } + } + + [Fact] + public async Task ConcurrentSaveAndDelete_ProduceConsistentIndexAsync() + { + // Arrange — pre-populate files that will be deleted. + var store = new InMemoryAgentFileStore(); + var (tools, _, session) = await CreateToolsAsync(store); + var saveFile = GetTool(tools, "FileMemory_SaveFile"); + var deleteFile = GetTool(tools, "FileMemory_DeleteFile"); + + for (int i = 0; i < 5; i++) + { + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = $"delete{i}.md", + ["content"] = $"To be deleted {i}", + }, session); + } + + // Act — concurrently save new files and delete existing ones. + var tasks = new List(); + for (int i = 0; i < 5; i++) + { + int index = i; + tasks.Add(InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = $"keep{index}.md", + ["content"] = $"Kept {index}", + }, session)); + tasks.Add(InvokeWithRunContextAsync(deleteFile, new AIFunctionArguments + { + ["fileName"] = $"delete{index}.md", + }, session)); + } + + await Task.WhenAll(tasks); + + // Assert — index should contain only the kept files. + string? indexContent = await store.ReadFileAsync("memories.md"); + Assert.NotNull(indexContent); + for (int i = 0; i < 5; i++) + { + Assert.Contains($"**keep{i}.md**", indexContent); + Assert.DoesNotContain($"**delete{i}.md**", indexContent); + } + } + + [Fact] + public void Dispose_ReleasesResources() + { + // Arrange + var provider = new FileMemoryProvider(new InMemoryAgentFileStore()); + + // Act + provider.Dispose(); + + // Assert — calling Dispose again should not throw (idempotent SemaphoreSlim.Dispose). + provider.Dispose(); + } + + [Fact] + public async Task SaveFile_AfterDispose_ThrowsAsync() + { + // Arrange — create tools from a provider, then dispose the provider. + var store = new InMemoryAgentFileStore(); + var provider = CreateProvider(store); + 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); + var saveFile = GetTool(result.Tools!, "FileMemory_SaveFile"); + provider.Dispose(); + + // Act & Assert — the disposed SemaphoreSlim should throw ObjectDisposedException. + await Assert.ThrowsAsync(async () => + await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["content"] = "Should fail", + }, session)); + } + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileSystemAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileSystemAgentFileStoreTests.cs rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/InMemoryAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/InMemoryAgentFileStoreTests.cs rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/StorePathsTests.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/StorePathsTests.cs rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/StorePathsTests.cs