From 626b418622aeea7cdf0f58480ec19b96dfc41c95 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Fri, 1 May 2026 11:52:38 +0100
Subject: [PATCH] .NET: Harness Feature branch (#5310)
* .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
* .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
* .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
* .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>
* .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.
* .NET: Harness: Improve prompts and add FileSystem store (#5365)
* Harness: Improve prompts and add FileSystem store
* Address PR comments
* .NET: Harness: Improve path validation (#5404)
* Harness: Improve path validation
* Address PR comments
* .NET: Add always approve helpers, improve sample and fix bug (#5451)
* Add always approve helpers, improve sample and fix bug
* Address PR comments
* .NET: Make Todo, Mode and FileMemory providers more configurable (#5477)
* Make Todo, Mode and FileMemory providers more configurable
* Address PR comments.
* .NET: Add subagents provider and sample (#5518)
* Add subagents provider and sample
* Addressing PR comments.
* .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>
* .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
* .NET: Add FileAccessProvdider and concurrency fix for FileMemoryProvider (#5583)
* Add FileAccessProvdider and concurrency fix for FileMemoryProvider
* Address PR comments
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
---
dotnet/Directory.Packages.props | 3 +
dotnet/agent-framework-dotnet.slnx | 7 +
.../Commands/ICommandHandler.cs | 28 +
.../Commands/ModeCommandHandler.cs | 69 +
.../Commands/TodoCommandHandler.cs | 66 +
.../Harness_Shared_Console/ConsoleWriter.cs | 278 +++
.../Harness_Shared_Console/HarnessConsole.cs | 214 +++
.../HarnessConsoleOptions.cs | 52 +
.../Harness_Shared_Console.csproj | 18 +
.../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 | 288 +++
.../Observers/UsageDisplayObserver.cs | 68 +
.../Harness/Harness_Shared_Console/Spinner.cs | 77 +
.../Harness_Step01_Research.csproj | 20 +
.../Harness_Step01_Research/Program.cs | 190 ++
.../Harness/Harness_Step01_Research/README.md | 52 +
.../WebBrowsingTool.cs | 287 +++
...rness_Step02_Research_WithSubAgents.csproj | 20 +
.../Program.cs | 106 ++
.../README.md | 53 +
.../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 | 11 +
dotnet/samples/02-agents/README.md | 1 +
.../Microsoft.Agents.AI/AgentJsonUtilities.cs | 33 +
...viceCallChatHistoryPersistingChatClient.cs | 2 +-
.../ContextWindowCompactionStrategy.cs | 148 ++
.../Harness/AgentMode/AgentModeProvider.cs | 244 +++
.../AgentMode/AgentModeProviderOptions.cs | 77 +
.../Harness/AgentMode/AgentModeState.cs | 27 +
.../Harness/FileAccess/FileAccessProvider.cs | 180 ++
.../FileAccess/FileAccessProviderOptions.cs | 22 +
.../Harness/FileMemory/FileListEntry.cs | 27 +
.../Harness/FileMemory/FileMemoryProvider.cs | 425 +++++
.../FileMemory/FileMemoryProviderOptions.cs | 22 +
.../Harness/FileMemory/FileMemoryState.cs | 21 +
.../Harness/FileStore/AgentFileStore.cs | 93 +
.../Harness/FileStore/FileSearchMatch.cs | 26 +
.../Harness/FileStore/FileSearchResult.cs | 33 +
.../FileStore/FileSystemAgentFileStore.cs | 269 +++
.../FileStore/InMemoryAgentFileStore.cs | 160 ++
.../Harness/FileStore/StorePaths.cs | 119 ++
.../Harness/SubAgents/SubAgentRuntimeState.cs | 32 +
.../Harness/SubAgents/SubAgentState.cs | 28 +
.../Harness/SubAgents/SubAgentsProvider.cs | 458 +++++
.../SubAgents/SubAgentsProviderOptions.cs | 39 +
.../Harness/SubAgents/SubTaskInfo.cs | 50 +
.../Harness/SubAgents/SubTaskStatus.cs | 34 +
.../Harness/Todo/TodoItem.cs | 38 +
.../Harness/Todo/TodoItemInput.cs | 26 +
.../Harness/Todo/TodoProvider.cs | 209 +++
.../Harness/Todo/TodoProviderOptions.cs | 22 +
.../Harness/Todo/TodoState.cs | 28 +
...lwaysApproveToolApprovalResponseContent.cs | 67 +
.../Harness/ToolApproval/ToolApprovalAgent.cs | 781 +++++++++
.../ToolApprovalAgentBuilderExtensions.cs | 37 +
.../ToolApprovalRequestContentExtensions.cs | 65 +
.../Harness/ToolApproval/ToolApprovalRule.cs | 45 +
.../Harness/ToolApproval/ToolApprovalState.cs | 53 +
.../Microsoft.Agents.AI.csproj | 1 +
.../ContextWindowCompactionStrategyTests.cs | 219 +++
.../AgentMode/AgentModeProviderTests.cs | 665 +++++++
.../FileAccess/FileAccessProviderTests.cs | 604 +++++++
.../FileMemory/FileMemoryProviderTests.cs | 916 ++++++++++
.../FileSystemAgentFileStoreTests.cs | 337 ++++
.../FileStore/InMemoryAgentFileStoreTests.cs | 523 ++++++
.../Harness/FileStore/StorePathsTests.cs | 174 ++
.../SubAgents/SubAgentsProviderTests.cs | 968 +++++++++++
.../Harness/Todo/TodoProviderTests.cs | 492 ++++++
...ApproveToolApprovalResponseContentTests.cs | 288 +++
...ToolApprovalAgentBuilderExtensionsTests.cs | 75 +
.../ToolApproval/ToolApprovalAgentTests.cs | 1538 +++++++++++++++++
.../ToolApproval/ToolApprovalRuleTests.cs | 154 ++
83 files changed, 13540 insertions(+), 1 deletion(-)
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/HarnessConsole.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/Harness_Shared_Console.csproj
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
create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallFormatter.cs
create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/UsageDisplayObserver.cs
create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Spinner.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/WebBrowsingTool.cs
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/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/samples/02-agents/Harness/README.md
create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/ContextWindowCompactionStrategy.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProvider.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeProviderOptions.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/AgentMode/AgentModeState.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProviderOptions.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/FileMemoryProviderOptions.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryState.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchMatch.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchResult.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/FileStore/StorePaths.cs
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/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/TodoProviderOptions.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Todo/TodoState.cs
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/Compaction/ContextWindowCompactionStrategyTests.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/AgentMode/AgentModeProviderTests.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/StorePathsTests.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/SubAgents/SubAgentsProviderTests.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Todo/TodoProviderTests.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/Directory.Packages.props b/dotnet/Directory.Packages.props
index c57af55ef9..56de97dbcb 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -86,6 +86,7 @@
+
@@ -135,6 +136,8 @@
+
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 05979b090f..4d7b2a2fc8 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -117,6 +117,13 @@
+
+
+
+
+
+
+
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
new file mode 100644
index 0000000000..a5ebaaf918
--- /dev/null
+++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs
@@ -0,0 +1,214 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Harness.Shared.Console.Commands;
+using Harness.Shared.Console.Observers;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+namespace Harness.Shared.Console;
+
+///
+/// Provides a reusable interactive console loop for running an
+/// 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,
+ /// 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 configuration options for the console session.
+ public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, HarnessConsoleOptions? options = null)
+ {
+ 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 todoProvider = agent.GetService();
+ var modeProvider = agent.GetService();
+
+ // Build command handlers.
+ var commandHandlers = new List
+ {
+ new TodoCommandHandler(todoProvider),
+ new ModeCommandHandler(modeProvider, options.ModeColors),
+ };
+
+ var commands = commandHandlers
+ .Select(h => h.GetHelpText())
+ .Where(t => t is not null)
+ .Append("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);
+
+ 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))
+ {
+ // Check command handlers first — first one to handle wins.
+ bool handled = false;
+ foreach (var handler in commandHandlers)
+ {
+ if (handler.TryHandle(userInput, session))
+ {
+ handled = true;
+ break;
+ }
+ }
+
+ if (!handled)
+ {
+ await RunAgentTurnAsync(agent, session, modeProvider, options, writer, userInput);
+ }
+
+ writer.CurrentMode = modeProvider?.GetMode(session);
+ prompt = BuildUserPrompt(modeProvider, session);
+ userInput = await writer.ReadLineAsync(prompt);
+ }
+
+ System.Console.ResetColor();
+ System.Console.WriteLine("Goodbye!");
+ }
+
+ ///
+ /// 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)
+ {
+ IList? nextMessages = [new ChatMessage(ChatRole.User, userInput)];
+
+ while (nextMessages is not null)
+ {
+ // Build observers for this invocation (may change between iterations due to mode changes).
+ var observers = CreateObservers(options, modeProvider, session);
+
+ // Build run options — observers may inject ResponseFormat, etc.
+ var runOptions = new AgentRunOptions();
+ foreach (var observer in observers)
+ {
+ observer.ConfigureRunOptions(runOptions);
+ }
+
+ // Stream the response, fanning out to all observers.
+ writer.CurrentMode = modeProvider?.GetMode(session);
+ writer.WriteResponseHeader();
+
+ try
+ {
+ await foreach (var update in agent.RunStreamingAsync(nextMessages, session, runOptions))
+ {
+ // Update mode color if the mode changed during streaming.
+ if (modeProvider is not null)
+ {
+ string currentMode = modeProvider.GetMode(session);
+ if (currentMode != writer.CurrentMode)
+ {
+ writer.CurrentMode = currentMode;
+ }
+ }
+
+ foreach (var content in update.Contents)
+ {
+ foreach (var observer in observers)
+ {
+ await observer.OnContentAsync(writer, content);
+ }
+ }
+
+ if (!string.IsNullOrEmpty(update.Text))
+ {
+ foreach (var observer in observers)
+ {
+ await observer.OnTextAsync(writer, update.Text);
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ await writer.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red);
+ }
+
+ // 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 })
+ {
+ combinedMessages.AddRange(messages);
+ hasObserverMessages = true;
+ }
+ }
+
+ await writer.WriteStreamFooterAsync(hasFollowUpMessages: hasObserverMessages);
+ nextMessages = combinedMessages.Count > 0 ? combinedMessages : null;
+ }
+ }
+
+ private static List CreateObservers(HarnessConsoleOptions options, AgentModeProvider? modeProvider, AgentSession session)
+ {
+ var observers = new List
+ {
+ new ToolCallDisplayObserver(),
+ new ToolApprovalObserver(),
+ new ErrorDisplayObserver(),
+ new ReasoningDisplayObserver(),
+ new UsageDisplayObserver(options.MaxContextWindowTokens, options.MaxOutputTokens),
+ };
+
+ // 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))
+ {
+ observers.Add(new PlanningOutputObserver(modeProvider));
+ }
+ else
+ {
+ observers.Add(new TextOutputObserver());
+ }
+
+ return observers;
+ }
+
+ private static string BuildUserPrompt(AgentModeProvider? modeProvider, AgentSession session)
+ {
+ if (modeProvider is not null)
+ {
+ string mode = modeProvider.GetMode(session);
+ return $"[{mode}] You: ";
+ }
+
+ return "You: ";
+ }
+}
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
new file mode 100644
index 0000000000..09abd76edc
--- /dev/null
+++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net10.0
+
+ enable
+ 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/Observers/ToolCallFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallFormatter.cs
new file mode 100644
index 0000000000..09c1ea290b
--- /dev/null
+++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallFormatter.cs
@@ -0,0 +1,288 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.AI;
+
+namespace Harness.Shared.Console.Observers;
+
+///
+/// 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
+ "TodoList_Add" => FormatAddTodos(call),
+ "TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
+ "TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
+ "TodoList_GetRemaining" => null,
+ "TodoList_GetAll" => null,
+
+ // Mode tools
+ "AgentMode_Set" => FormatStringArg(call, "mode"),
+ "AgentMode_Get" => null,
+
+ // Sub-agent tools
+ "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),
+ "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"),
+
+ _ => 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? 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);
+ 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_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_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_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..b28ff5bf42
--- /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..d6b9f1d96e
--- /dev/null
+++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs
@@ -0,0 +1,190 @@
+// 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.
+#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;
+using SampleApp;
+
+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 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.
+ 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
+
+ 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 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*
+
+ 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**
+
+ 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**
+
+ 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.
+// 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()
+ {
+ 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
+ {
+ 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.",
+ new HarnessConsoleOptions
+ {
+ MaxContextWindowTokens = MaxContextWindowTokens,
+ MaxOutputTokens = MaxOutputTokens,
+ EnablePlanningUx = true,
+ PlanningModeName = "plan",
+ ExecutionModeName = "execute"
+ });
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..270acf409b
--- /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 `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
+- **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 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_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/WebBrowsingTool.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs
new file mode 100644
index 0000000000..c00b93b452
--- /dev/null
+++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/WebBrowsingTool.cs
@@ -0,0 +1,287 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.ComponentModel;
+using System.Net;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.AI;
+
+namespace SampleApp;
+
+///
+/// An AI function that downloads HTML pages and converts them to markdown.
+///
+internal sealed partial class WebBrowsingTool : AIFunction
+{
+ private static readonly HttpClient s_httpClient = new();
+ private readonly AIFunction _inner = 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