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 $"";
+ });
+
+ 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[^>]*>(.*?)\1>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
+ private static partial Regex BoldRegex();
+
+ [GeneratedRegex(@"<(em|i)\b[^>]*>(.*?)\1>", 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 |