From 3047ad3066cce37cac79c923570a169362cf8ae1 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 14 May 2026 16:22:11 +0100 Subject: [PATCH 1/2] .NET: Harness console refactoring (#5811) * Restructure harness console so that reactive app is the entry point * Further refactoring to split tool formatters, improve UX, make console configurable and fix bugs * Address PR comments. * UX tweak * Fix streaming text bug * Address PR comments. --- .../ConsoleReactiveComponents/TextPanel.cs | 26 +- .../TextScrollPanel.cs | 16 +- .../Harness_ConsoleSandbox/AppComponent.cs | 315 ------------ .../Commands/CommandHandler.cs | 4 +- .../Commands/ExitCommandHandler.cs | 26 + .../Commands/ModeCommandHandler.cs | 4 +- .../Commands/TodoCommandHandler.cs | 4 +- .../Harness_Shared_Console/FollowUpAction.cs | 59 +++ .../HarnessAgentRunner.cs | 279 ++++++++++ .../HarnessAppComponent.cs | 450 +++++++++-------- .../HarnessAppComponentState.cs | 125 +++++ .../Harness_Shared_Console/HarnessConsole.cs | 250 ++------- .../HarnessConsoleOptions.cs | 137 ++++- .../HarnessConsoleUXStateDriver.cs | 408 +++++++++++++++ .../HarnessUXContainer.cs | 478 ------------------ .../Harness_Shared_Console/IUXStateDriver.cs | 120 +++++ .../Observers/ConsoleObserver.cs | 39 +- .../Observers/ErrorDisplayObserver.cs | 5 +- .../Observers/PlanningOutputObserver.cs | 155 ++++-- .../Observers/ReasoningDisplayObserver.cs | 5 +- .../Observers/TextOutputObserver.cs | 6 +- .../Observers/ToolApprovalObserver.cs | 115 +++-- .../Observers/ToolCallDisplayObserver.cs | 20 +- .../Observers/ToolCallFormatter.cs | 288 ----------- .../Observers/UsageDisplayObserver.cs | 5 +- .../Harness_Shared_Console/OutputEntry.cs | 7 +- .../ToolFormatters/FallbackToolFormatter.cs | 51 ++ .../ToolFormatters/FileMemoryToolFormatter.cs | 61 +++ .../ToolFormatters/ModeToolFormatter.cs | 27 + .../ToolFormatters/SubAgentToolFormatter.cs | 101 ++++ .../ToolFormatters/TodoToolFormatter.cs | 84 +++ .../ToolFormatters/ToolCallFormatter.cs | 135 +++++ .../ToolFormatters/WebSearchToolFormatter.cs | 22 + .../DownloadUriToolFormatter.cs | 23 + .../Harness_Step01_Research/Program.cs | 18 +- .../Program.cs | 3 +- .../Harness_Step03_DataProcessing/Program.cs | 3 +- .../HarnessAgent.cs | 2 + 38 files changed, 2152 insertions(+), 1724 deletions(-) delete mode 100644 dotnet/samples/02-agents/Harness/Harness_ConsoleSandbox/AppComponent.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ExitCommandHandler.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/FollowUpAction.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAgentRunner.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponentState.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleUXStateDriver.cs delete mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessUXContainer.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/IUXStateDriver.cs delete mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FallbackToolFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FileMemoryToolFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ModeToolFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/SubAgentToolFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/TodoToolFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ToolCallFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/WebSearchToolFormatter.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step01_Research/DownloadUriToolFormatter.cs diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs index cfd2b8c5ee..a9651f7726 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs @@ -9,42 +9,30 @@ namespace Harness.ConsoleReactiveComponents; /// public record TextPanelProps : ConsoleReactiveProps { - /// Gets the items to render in the panel. - public IReadOnlyList Items { get; init; } = []; + /// Gets the items to render in the panel. Each item is a pre-rendered + /// console string (may include ANSI escape sequences and newlines). + public IReadOnlyList Items { get; init; } = []; } /// -/// A component that renders a list of items vertically using a custom render delegate. +/// A component that renders a list of pre-rendered string items vertically. /// Designed for rendering dynamic items in a non-scroll region that may be /// re-rendered on each update. If the component's /// exceeds the number of output lines, leftover lines are erased. /// public class TextPanel : ConsoleReactiveComponent { - private readonly Func _renderItem; - - /// - /// Initializes a new instance of the class. - /// - /// A delegate that renders an item and returns the text to display (may contain newlines). - public TextPanel(Func renderItem) - { - this._renderItem = renderItem; - } - /// /// Calculates the height (in lines) needed to render all items. /// /// The items to measure. - /// The render delegate to use for measuring. /// The total number of lines all items will occupy. - public static int CalculateHeight(IReadOnlyList items, Func renderItem) + public static int CalculateHeight(IReadOnlyList items) { int total = 0; for (int i = 0; i < items.Count; i++) { - string text = renderItem(items[i]); - total += CountLines(text); + total += CountLines(items[i]); } return total; @@ -57,7 +45,7 @@ public class TextPanel : ConsoleReactiveComponent public record TextScrollPanelProps : ConsoleReactiveProps { - /// Gets the items to render in the scroll panel. - public IReadOnlyList Items { get; init; } = []; + /// Gets the items to render in the scroll panel. Each item is a pre-rendered + /// console string (may include ANSI escape sequences and newlines). + public IReadOnlyList Items { get; init; } = []; } /// @@ -20,21 +21,17 @@ public record TextScrollPanelProps : ConsoleReactiveProps public record TextScrollPanelState(int RenderedCount = 0) : ConsoleReactiveState; /// -/// A component that renders items within a scroll area using a custom render delegate. +/// A component that renders pre-rendered string items within a scroll area. /// All items are considered finalized — only new items since the last render are output. /// Use to force a full re-render. /// public class TextScrollPanel : ConsoleReactiveComponent { - private readonly Func _renderItem; - /// /// Initializes a new instance of the class. /// - /// A delegate that renders a single item and returns the text to display (may contain newlines). - public TextScrollPanel(Func renderItem) + public TextScrollPanel() { - this._renderItem = renderItem; this.State = new TextScrollPanelState(); } @@ -60,8 +57,7 @@ public class TextScrollPanel : ConsoleReactiveComponent -/// Determines which component is shown in the bottom panel. -/// -public enum BottomPanelMode -{ - /// Show the list selection component. - ListSelection, - - /// Show the text input component. - TextInput -} - -public record AppComponentProps : ConsoleReactiveProps -{ - public IReadOnlyList Items { get; init; } = Array.Empty(); - public IReadOnlyList ScrollItems { get; init; } = []; - - /// Gets the bottom panel mode. - public BottomPanelMode Mode { get; init; } = BottomPanelMode.ListSelection; - - /// Gets the prompt string for text input mode. - public string Prompt { get; init; } = "> "; - - /// Gets the placeholder text shown when the input is empty. - public string Placeholder { get; init; } = ""; - - /// Gets the highlight color for the active list item. Defaults to . - public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan; - - /// Gets the placeholder text for the custom text input option in the list. If null, no custom option is shown. - public string? ListCustomTextPlaceholder { get; init; } - - /// Gets the foreground color for the rule borders. If null, uses the default terminal color. - public ConsoleColor? RuleColor { get; init; } -} - -/// -/// Internal state for the . -/// -public record AppComponentState : ConsoleReactiveState -{ - /// Gets the selected index in list selection mode. - public int SelectedIndex { get; init; } - - /// Gets the current input text being typed in text input mode. - public string InputText { get; init; } = ""; - - /// Gets the current text being typed into the list's custom text option. - public string ListInputText { get; init; } = ""; -} - -public class AppComponent : ConsoleReactiveComponent -{ - private readonly TopBottomRule _rule = new(); - private readonly ListSelection _listSelection = new(); - private readonly TextInput _textInput = new(); - private readonly TextScrollPanel _textScrollPanel; - private readonly TextPanel _textPanel; - private readonly Func _renderItem; - private readonly Action _onTextInputSubmit; - private readonly Action _onListInputSubmit; - private bool _resizedSinceLastRender; - private int _lastScrollBottom; - - /// - /// Initializes a new instance of the class. - /// - /// A delegate that renders a single scroll panel item and returns the text to display. - /// A callback invoked with the input text when the user presses Enter in text input mode. - /// A callback invoked with the selected or typed text when the user presses Enter in list selection mode. - public AppComponent(Func renderScrollItem, Action onTextInputSubmit, Action onListInputSubmit) - { - this._renderItem = renderScrollItem; - this._onTextInputSubmit = onTextInputSubmit; - this._onListInputSubmit = onListInputSubmit; - this._textScrollPanel = new TextScrollPanel(renderScrollItem); - this._textPanel = new TextPanel(renderScrollItem); - this.State = new AppComponentState(); - KeyEventListener.Instance.KeyPressed += this.OnKeyPressed; - ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized; - } - - private void OnKeyPressed(object? sender, KeyPressEventArgs e) - { - if (this.Props!.Mode == BottomPanelMode.TextInput) - { - this.HandleTextInputKey(e); - } - else - { - this.HandleListSelectionKey(e); - } - } - - private void HandleTextInputKey(KeyPressEventArgs e) - { - if (e.KeyInfo.Key == ConsoleKey.Enter) - { - string text = this.State!.InputText; - this.SetState(this.State with { InputText = "" }); - this._onTextInputSubmit(text); - } - else if (e.KeyInfo.Key == ConsoleKey.Backspace) - { - if (this.State!.InputText.Length > 0) - { - this.SetState(this.State with { InputText = this.State.InputText[..^1] }); - } - } - else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar)) - { - this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar }); - } - } - - private void HandleListSelectionKey(KeyPressEventArgs e) - { - int maxIndex = this.Props!.Items.Count - 1; - if (this.Props.ListCustomTextPlaceholder != null) - { - maxIndex = this.Props.Items.Count; // extra option at the end - } - - bool isOnCustomTextOption = this.Props.ListCustomTextPlaceholder != null - && this.State!.SelectedIndex == this.Props.Items.Count; - - if (e.KeyInfo.Key == ConsoleKey.UpArrow) - { - this.SetState(this.State! with { SelectedIndex = Math.Max(0, this.State.SelectedIndex - 1) }); - } - else if (e.KeyInfo.Key == ConsoleKey.DownArrow) - { - this.SetState(this.State! with { SelectedIndex = Math.Min(maxIndex, this.State.SelectedIndex + 1) }); - } - else if (e.KeyInfo.Key == ConsoleKey.Enter) - { - if (isOnCustomTextOption) - { - string text = this.State!.ListInputText; - this.SetState(this.State with { ListInputText = "" }); - this._onListInputSubmit(text); - } - else - { - this._onListInputSubmit(this.Props.Items[this.State!.SelectedIndex]); - } - } - else if (isOnCustomTextOption) - { - // Typing only works when on the custom text option - if (e.KeyInfo.Key == ConsoleKey.Backspace) - { - if (this.State!.ListInputText.Length > 0) - { - this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] }); - } - } - else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar)) - { - this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar }); - } - } - } - - private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e) - { - this._resizedSinceLastRender = true; - this.Render(); - } - - public override void RenderCore(AppComponentProps props, AppComponentState state) - { - // Determine the text panel height for the last scroll item - object? lastItem = props.ScrollItems.Count > 0 ? props.ScrollItems[^1] : null; - IReadOnlyList lastItems = lastItem != null ? [lastItem] : []; - int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem); - if (textPanelHeight > 0) - { - textPanelHeight++; // Extra line for spacing between text panel and rule - } - - // Build the bottom panel child based on mode - ConsoleReactiveComponent bottomChild; - int bottomChildHeight; - - if (props.Mode == BottomPanelMode.TextInput) - { - var textInputProps = new TextInputProps - { - Prompt = props.Prompt, - Text = state.InputText, - Placeholder = props.Placeholder - }; - - bottomChildHeight = TextInput.CalculateHeight(textInputProps, Console.WindowWidth); - this._textInput.Width = Console.WindowWidth; - this._textInput.Height = bottomChildHeight; - this._textInput.Props = textInputProps; - bottomChild = this._textInput; - } - else - { - var listProps = new ListSelectionProps - { - Items = props.Items, - SelectedIndex = state.SelectedIndex, - HighlightColor = props.ListHighlightColor, - CustomTextPlaceholder = props.ListCustomTextPlaceholder, - CustomText = state.ListInputText - }; - - bottomChildHeight = ListSelection.CalculateHeight(listProps); - this._listSelection.Height = bottomChildHeight; - this._listSelection.Props = listProps; - bottomChild = this._listSelection; - } - - var ruleProps = new TopBottomRuleProps - { - Width = Console.WindowWidth, - Color = props.RuleColor, - Children = [bottomChild] - }; - - int ruleHeight = TopBottomRule.CalculateHeight(ruleProps); - int scrollBottom = Console.WindowHeight - ruleHeight - textPanelHeight; - - // If scroll region changed or a clear is needed, reset everything - if (this._resizedSinceLastRender || (this._lastScrollBottom != 0 && scrollBottom != this._lastScrollBottom)) - { - Console.Write(AnsiEscapes.EraseEntireScreen); - Console.Write(AnsiEscapes.EraseScrollbackBuffer); - this._textScrollPanel.Reset(); - this._resizedSinceLastRender = false; - } - - this._lastScrollBottom = scrollBottom; - - Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom)); - - // Render text scroll panel in the scroll area (all items except the last) - IReadOnlyList scrollItems = props.ScrollItems.Count > 1 - ? props.ScrollItems.Take(props.ScrollItems.Count - 1).ToList() - : []; - - this._textScrollPanel.X = 1; - this._textScrollPanel.Y = 1; - this._textScrollPanel.Width = Console.WindowWidth; - this._textScrollPanel.Height = scrollBottom; - this._textScrollPanel.Props = new TextScrollPanelProps - { - Items = scrollItems - }; - this._textScrollPanel.Render(); - - // Render the text panel for the last (dynamic) item just below the scroll region - this._textPanel.X = 1; - this._textPanel.Y = scrollBottom + 1; - this._textPanel.Width = Console.WindowWidth; - this._textPanel.Height = textPanelHeight; - this._textPanel.Props = new TextPanelProps - { - Items = lastItems, - }; - this._textPanel.Render(); - - // Render the bottom rule + child below the text panel - this._rule.X = 1; - this._rule.Y = scrollBottom + textPanelHeight + 1; - this._rule.Props = ruleProps; - this._rule.Render(); - - // Position cursor for natural typing appearance - if (props.Mode == BottomPanelMode.TextInput) - { - int promptLength = props.Prompt.Length; - int textWidth = Console.WindowWidth - promptLength; - int textLength = state.InputText.Length; - - // The TextInput starts at rule.Y + 1 (first row inside the rule) - int textInputY = this._rule.Y + 1; - - if (textWidth <= 0 || textLength == 0) - { - // Cursor right after the prompt - Console.Write(AnsiEscapes.MoveCursor(textInputY, promptLength + 1)); - } - else - { - // Calculate which row and column the cursor lands on - int cursorRow = textLength < textWidth ? 0 : 1 + ((textLength - textWidth) / textWidth); - int cursorCol = textLength < textWidth ? textLength : (textLength - textWidth) % textWidth; - Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1)); - } - } - else if (props.Mode == BottomPanelMode.ListSelection - && props.ListCustomTextPlaceholder != null - && state.SelectedIndex == props.Items.Count) - { - // Cursor after the typed text in the custom text option - // The custom text option is at rule.Y + 1 + Items.Count (0-based row inside rule) - int customOptionY = this._rule.Y + 1 + props.Items.Count; - // "> " prefix is 2 chars, then the typed text - int cursorCol = 2 + state.ListInputText.Length + 1; - Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol)); - } - } -} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/CommandHandler.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/CommandHandler.cs index 702500e283..86e9241cf4 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/CommandHandler.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/CommandHandler.cs @@ -23,7 +23,7 @@ public abstract class CommandHandler /// /// The raw user input string. /// The current agent session. - /// The UX container for rendering output. + /// The UX state driver for rendering output. /// if this handler handled the input; otherwise. - public abstract ValueTask TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux); + public abstract ValueTask TryHandleAsync(string input, AgentSession session, IUXStateDriver ux); } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ExitCommandHandler.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ExitCommandHandler.cs new file mode 100644 index 0000000000..dd9d4b75e1 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ExitCommandHandler.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; + +namespace Harness.Shared.Console.Commands; + +/// +/// Handles the /exit command to shut down the console application. +/// +public sealed class ExitCommandHandler : CommandHandler +{ + /// + public override string? GetHelpText() => "/exit (quit)"; + + /// + public override ValueTask TryHandleAsync(string input, AgentSession session, IUXStateDriver ux) + { + if (!input.Equals("/exit", StringComparison.OrdinalIgnoreCase)) + { + return new ValueTask(false); + } + + ux.RequestShutdown(); + return new ValueTask(true); + } +} 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 index 2fb58fc79c..09c2cd3cb5 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/ModeCommandHandler.cs @@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands; /// /// Handles the /mode command to display or switch the current agent mode. /// -internal sealed class ModeCommandHandler : CommandHandler +public sealed class ModeCommandHandler : CommandHandler { private readonly AgentModeProvider? _modeProvider; private readonly IReadOnlyDictionary? _modeColors; @@ -27,7 +27,7 @@ internal sealed class ModeCommandHandler : CommandHandler public override string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null; /// - public override async ValueTask TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux) + public override async ValueTask TryHandleAsync(string input, AgentSession session, IUXStateDriver ux) { if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase)) { 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 index 506648dccc..b3f8b8588d 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Commands/TodoCommandHandler.cs @@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands; /// /// Handles the /todos command to display the current todo list. /// -internal sealed class TodoCommandHandler : CommandHandler +public sealed class TodoCommandHandler : CommandHandler { private readonly TodoProvider? _todoProvider; @@ -24,7 +24,7 @@ internal sealed class TodoCommandHandler : CommandHandler public override string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null; /// - public override async ValueTask TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux) + public override async ValueTask TryHandleAsync(string input, AgentSession session, IUXStateDriver ux) { if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase)) { diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/FollowUpAction.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/FollowUpAction.cs new file mode 100644 index 0000000000..c08554ec4a --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/FollowUpAction.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console; + +/// +/// Represents an action returned by an observer at the end of an agent turn. +/// Subtypes describe either a question to ask the user () +/// or a message to add directly to the next agent input (). +/// +public abstract record FollowUpAction; + +/// +/// Represents a question that should be presented to the user. The +/// delegate is invoked with the user's answer and the +/// UX state driver, and returns an optional to add to the +/// next agent invocation. +/// +/// The question text shown to the user. +/// +/// Invoked with the user's answer and the UX state driver. The driver lets the +/// continuation write output (e.g., an action label like "Approved") in addition +/// to producing an optional for the next agent invocation. +/// +public abstract record FollowUpQuestion( + string Prompt, + Func> Continuation) : FollowUpAction; + +/// +/// A free-form text question. The user may type any response. +/// +/// The question text shown to the user. +/// Continuation that builds the response message. +public sealed record TextFollowUpQuestion( + string Prompt, + Func> Continuation) + : FollowUpQuestion(Prompt, Continuation); + +/// +/// A choice question. The user picks from , optionally with +/// the ability to enter custom text when is true. +/// +/// The question text shown to the user. +/// The list of pre-defined choices. +/// If true, the user may type a custom response in addition to the listed choices. +/// Continuation that builds the response message. +public sealed record ChoiceFollowUpQuestion( + string Prompt, + IReadOnlyList Choices, + bool AllowCustomText, + Func> Continuation) + : FollowUpQuestion(Prompt, Continuation); + +/// +/// A message to add directly to the next agent invocation without prompting the user. +/// +/// The chat message to add. +public sealed record FollowUpMessage(ChatMessage Message) : FollowUpAction; diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAgentRunner.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAgentRunner.cs new file mode 100644 index 0000000000..d0affc5f77 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAgentRunner.cs @@ -0,0 +1,279 @@ +// 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; + +/// +/// Orchestrates agent invocations driven by user-input events from the UI. +/// The component invokes the runner's input handlers (, +/// , ) directly; +/// the runner mutates UI state through the supplied . +/// All per-turn follow-up state (pending questions and accumulated responses) lives +/// in the component's state record — the runner reads/writes it exclusively through +/// the driver and holds no per-turn fields itself. +/// +public sealed class HarnessAgentRunner : IDisposable +{ + private readonly AIAgent _agent; + private readonly AgentSession _session; + private readonly AgentModeProvider? _modeProvider; + private readonly MessageInjectingChatClient? _messageInjector; + private readonly IReadOnlyList _commandHandlers; + private readonly IReadOnlyList _observers; + private readonly IUXStateDriver _ux; + + private readonly SemaphoreSlim _inputGate = new(1, 1); + + /// + /// Initializes a new instance of the class. + /// + public HarnessAgentRunner( + AIAgent agent, + AgentSession session, + AgentModeProvider? modeProvider, + MessageInjectingChatClient? messageInjector, + IReadOnlyList commandHandlers, + IReadOnlyList observers, + IUXStateDriver ux) + { + this._agent = agent; + this._session = session; + this._modeProvider = modeProvider; + this._messageInjector = messageInjector; + this._commandHandlers = commandHandlers; + this._observers = observers; + this._ux = ux; + + this.HelpText = string.Join( + ", ", + commandHandlers + .Select(h => h.GetHelpText()) + .Where(t => t is not null)!); + } + + /// + /// Gets the help text describing all available commands (joined by ", "), suitable + /// for display in the mode-and-help bar. Computed from the supplied + /// commandHandlers. + /// + public string HelpText { get; } + + /// + public void Dispose() => this._inputGate.Dispose(); + + /// + /// Handles a top-level user input submission (TextInput mode, no pending question). + /// Dispatches to command handlers, or starts an agent turn. + /// + internal async Task OnUserInputAsync(string text) + { + await this._inputGate.WaitAsync().ConfigureAwait(false); + try + { + this._ux.WriteUserInputEcho(text); + + foreach (var handler in this._commandHandlers) + { + if (await handler.TryHandleAsync(text, this._session, this._ux).ConfigureAwait(false)) + { + this._ux.CurrentMode = this._modeProvider?.GetMode(this._session); + return; + } + } + + await this.RunAgentLoopAsync([new ChatMessage(ChatRole.User, text)]).ConfigureAwait(false); + } + finally + { + this._inputGate.Release(); + } + } + + /// + /// Handles a user input submission while an agent turn is streaming. The text is + /// enqueued via the so it can be picked up + /// by the agent on its next opportunity. + /// + internal Task OnStreamingInputAsync(string text) + { + if (this._messageInjector is null) + { + return Task.CompletedTask; + } + + this._messageInjector.EnqueueMessages(this._session, [new ChatMessage(ChatRole.User, text)]); + this._ux.SetQueuedMessages(this._messageInjector.GetPendingMessages(this._session)); + return Task.CompletedTask; + } + + /// + /// Resumes (or completes) a turn after the user has answered all pending follow-up + /// questions. The component invokes this with the messages drained from + /// ; an empty list simply ends + /// the streaming display state without invoking the agent. + /// + internal async Task StartAgentTurnAsync(IList messages) + { + await this._inputGate.WaitAsync().ConfigureAwait(false); + try + { + if (messages.Count == 0) + { + this.CompleteTurn(); + return; + } + + await this.RunAgentLoopAsync(messages).ConfigureAwait(false); + } + finally + { + this._inputGate.Release(); + } + } + + private async Task RunAgentLoopAsync(IList messages) + { + IList? nextMessages = messages; + IReadOnlyList lastPendingMessages = this._messageInjector?.GetPendingMessages(this._session) ?? []; + + while (nextMessages is not null) + { + var runOptions = new AgentRunOptions(); + foreach (var observer in this._observers) + { + observer.ConfigureRunOptions(runOptions, this._agent, this._session); + } + + this._ux.CurrentMode = this._modeProvider?.GetMode(this._session); + this._ux.BeginStreaming(); + this._ux.BeginStreamingOutput(); + + try + { + await foreach (var update in this._agent.RunStreamingAsync(nextMessages, this._session, runOptions)) + { + if (this._modeProvider is not null) + { + string currentMode = this._modeProvider.GetMode(this._session); + if (currentMode != this._ux.CurrentMode) + { + this._ux.CurrentMode = currentMode; + } + } + + foreach (var content in update.Contents) + { + foreach (var observer in this._observers) + { + await observer.OnContentAsync(this._ux, content, this._agent, this._session).ConfigureAwait(false); + } + } + + if (!string.IsNullOrEmpty(update.Text)) + { + foreach (var observer in this._observers) + { + await observer.OnTextAsync(this._ux, update.Text, this._agent, this._session).ConfigureAwait(false); + } + } + + this.SyncQueuedMessageDisplay(ref lastPendingMessages); + } + } + catch (Exception ex) + { + await this._ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red).ConfigureAwait(false); + } + + // Final sync after streaming. + this.SyncQueuedMessageDisplay(ref lastPendingMessages); + + this._ux.StopSpinner(); + await this._ux.EndStreamingOutputAsync().ConfigureAwait(false); + + // Collect FollowUpActions from each observer. + var directMessages = new List(); + var questions = new List(); + foreach (var observer in this._observers) + { + var actions = await observer.OnStreamCompleteAsync(this._ux, this._agent, this._session).ConfigureAwait(false); + if (actions is null) + { + continue; + } + + foreach (var action in actions) + { + switch (action) + { + case FollowUpMessage msg: + directMessages.Add(msg.Message); + break; + case FollowUpQuestion q: + questions.Add(q); + break; + } + } + } + + bool hasFollowUpActions = directMessages.Count > 0 || questions.Count > 0; + await this._ux.WriteNoTextWarningAsync(hasFollowUpActions).ConfigureAwait(false); + + // Add any direct messages to the accumulator regardless of whether questions follow — + // they're sent on the next agent invocation, either by us (if no questions) or by + // the component (after the user finishes answering, via StartAgentTurnAsync). + foreach (var msg in directMessages) + { + this._ux.AddFollowUpResponse(msg); + } + + if (questions.Count > 0) + { + // Pause: hand control back to the UX to collect answers. + this._ux.QueueFollowUpQuestions(questions); + return; + } + + // No questions to ask — drain anything we just accumulated and loop with it. + IReadOnlyList drained = this._ux.TakeFollowUpResponses(); + nextMessages = drained.Count > 0 ? [.. drained] : null; + } + + this.CompleteTurn(); + } + + private void CompleteTurn() + { + this._ux.EndStreaming(); + this._ux.CurrentMode = this._modeProvider?.GetMode(this._session); + } + + /// + /// Synchronizes the queued items display with the message injector's pending messages. + /// Messages that have been consumed (drained by the service) are echoed to the output + /// area as regular user-input entries. + /// + private void SyncQueuedMessageDisplay(ref IReadOnlyList lastPendingMessages) + { + if (this._messageInjector is null) + { + return; + } + + var pending = this._messageInjector.GetPendingMessages(this._session); + + int consumedCount = lastPendingMessages.Count - pending.Count; + for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++) + { + string text = lastPendingMessages[i].Text ?? string.Empty; + this._ux.WriteUserInputEcho(text); + } + + lastPendingMessages = pending; + this._ux.SetQueuedMessages(pending); + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs index 034cbfb000..e120329f12 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs @@ -3,171 +3,89 @@ using Harness.ConsoleReactiveComponents; using Harness.ConsoleReactiveFramework; using Harness.Shared.Console.Components; +using Microsoft.Extensions.AI; namespace Harness.Shared.Console; -/// -/// Determines which component is shown in the bottom panel. -/// -public enum BottomPanelMode -{ - /// Show the text input component for user input. - TextInput, - - /// Show the list selection component for interactive prompts. - ListSelection, - - /// Show a disabled input indicator during agent streaming. - Streaming, -} - -/// -/// Event arguments for the event. -/// -public sealed class InputSubmittedEventArgs : EventArgs -{ - /// - /// Initializes a new instance of the class. - /// - /// The submitted text. - /// The bottom panel mode in which the input was submitted. - public InputSubmittedEventArgs(string text, BottomPanelMode mode) - { - this.Text = text; - this.Mode = mode; - } - - /// Gets the submitted text. - public string Text { get; } - - /// Gets the bottom panel mode in which the input was submitted. - public BottomPanelMode Mode { get; } -} - -/// -/// Props for . -/// -public record HarnessAppComponentProps : ConsoleReactiveProps -{ - /// Gets or sets the list selection choices (for ListSelection mode). - public IReadOnlyList Items { get; set; } = Array.Empty(); - - /// Gets or sets the scroll items (output entries) to render in the scroll panel. - public IReadOnlyList ScrollItems { get; set; } = []; - - /// Gets or sets the bottom panel mode. - public BottomPanelMode Mode { get; set; } = BottomPanelMode.TextInput; - - /// Gets or sets the prompt string for text input mode. - public string Prompt { get; set; } = "You: "; - - /// Gets or sets the placeholder text shown when the input is empty. - public string Placeholder { get; set; } = ""; - - /// Gets or sets the highlight color for the active list item. - public ConsoleColor ListHighlightColor { get; set; } = ConsoleColor.Cyan; - - /// Gets or sets the placeholder text for the custom text input option in the list. - public string? ListCustomTextPlaceholder { get; set; } - - /// Gets or sets the foreground color for the rule borders and mode label. - public ConsoleColor? ModeColor { get; set; } - - /// Gets or sets the current mode name displayed below the bottom rule (e.g. "plan"). - public string? ModeText { get; set; } - - /// Gets or sets the help text displayed below the bottom rule (available commands). - public string? HelpText { get; set; } - - /// Gets or sets the title text displayed above the list selection (for interactive prompts). - public string? ListTitle { get; set; } - - /// Gets or sets a value indicating whether input is enabled during streaming. - public bool InputEnabled { get; set; } - - /// Gets or sets the prompt to show during streaming when input is disabled. - public string StreamingPrompt { get; set; } = "(agent is running...)"; - - /// Gets or sets a value indicating whether the agent status spinner is visible. - public bool ShowSpinner { get; set; } - - /// Gets or sets the formatted token usage text to display in the status bar. - public string? UsageText { get; set; } - - /// Gets or sets the queued input items to display above the rule. - public IReadOnlyList QueuedItems { get; set; } = []; -} - -/// -/// Internal state for . -/// -public record HarnessAppComponentState : ConsoleReactiveState -{ - /// Gets the selected index in list selection mode. - public int SelectedIndex { get; init; } - - /// Gets the current input text being typed. - public string InputText { get; init; } = ""; - - /// Gets the current text being typed into the list's custom text option. - public string ListInputText { get; init; } = ""; - - /// Gets the current console width in columns. - public int ConsoleWidth { get; init; } - - /// Gets the current console height in rows. - public int ConsoleHeight { get; init; } -} - /// /// The main application component for the Harness console. Manages the scroll region -/// and bottom panel (text input, list selection, or streaming indicator), and emits -/// an event when the user submits text in any mode. +/// and bottom panel (text input, list selection, or streaming indicator). Owns the +/// and routes user input events to the +/// registered . /// -public class HarnessAppComponent : ConsoleReactiveComponent, IDisposable +public class HarnessAppComponent : ConsoleReactiveComponent, IDisposable { private readonly TopBottomRule _rule = new(); private readonly ListSelection _listSelection = new(); private readonly TextInput _textInput = new(); - private readonly TextScrollPanel _textScrollPanel; - private readonly TextPanel _textPanel; - private readonly TextPanel _queuedPanel; + private readonly TextScrollPanel _textScrollPanel = new(); + private readonly TextPanel _textPanel = new(); + private readonly TextPanel _queuedPanel = new(); private readonly AgentStatus _agentStatus = new(); private readonly AgentModeAndHelp _modeAndHelp = new(); - private readonly Func _renderItem; - private bool _resizedSinceLastRender; + private readonly HarnessConsoleUXStateDriver _uxDriver; + private readonly TaskCompletionSource _shutdownTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly SemaphoreSlim _followUpGate = new(1, 1); + private int _scrollRegionBottom; + private bool _resizedSinceLastRender = true; private bool _deactivated; /// /// Initializes a new instance of the class. /// - /// A delegate that renders a single output entry and returns the text to display. - public HarnessAppComponent(Func renderScrollItem) + /// Placeholder text shown when the input is empty. + /// The current agent mode, used to colour the rule and prompt. + /// Whether the bottom-panel input accepts keystrokes during streaming. + /// Factory invoked with the component's + /// to construct the that owns the agent loop. + /// Optional mapping of mode names to console colors. + public HarnessAppComponent( + string placeholder, + string? initialMode, + bool inputEnabled, + Func runnerFactory, + IReadOnlyDictionary? modeColors = null) { - this._renderItem = renderScrollItem; - this._textScrollPanel = new TextScrollPanel(renderScrollItem); - this._textPanel = new TextPanel(renderScrollItem); - this._queuedPanel = new TextPanel(renderScrollItem); + this.Props = new ConsoleReactiveProps(); this.State = new HarnessAppComponentState { + Mode = BottomPanelMode.TextInput, + Prompt = "> ", + Placeholder = placeholder, + ModeColor = ModeColors.Get(initialMode, modeColors), + ModeText = initialMode, + InputEnabled = inputEnabled, ConsoleWidth = System.Console.WindowWidth, ConsoleHeight = System.Console.WindowHeight, }; + + this._uxDriver = new HarnessConsoleUXStateDriver( + getState: () => this.State!, + setState: s => this.SetState(s), + requestShutdown: () => this._shutdownTcs.TrySetResult(true), + modeColors: modeColors); + + this.Runner = runnerFactory(this._uxDriver); + + // Seed help text now that the runner (which knows the registered command handlers) + // is available. Direct assignment — no Render is triggered until the caller invokes Render(). + this.State = this.State with { HelpText = this.Runner.HelpText }; + KeyEventListener.Instance.KeyPressed += this.OnKeyPressed; ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized; } /// - /// Gets the 1-based row number of the last row in the output scroll region. + /// Gets the agent runner that owns the agent loop. Constructed by the factory + /// passed to the component's constructor. /// - public int ScrollRegionBottom { get; private set; } + public HarnessAgentRunner Runner { get; } /// - /// Occurs when the user submits input via Enter, in any mode (text input, list selection, - /// or streaming injection). Consumers inspect - /// to decide how to handle the submission. + /// Completes when a command handler requests application shutdown (e.g. the user types /exit). + /// Awaited by . /// - public event EventHandler? InputSubmitted; + public Task ShutdownTask => this._shutdownTcs.Task; /// /// Deactivates the component, resetting the scroll region and unsubscribing from events. @@ -184,9 +102,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent @@ -205,20 +120,23 @@ public class HarnessAppComponent : ConsoleReactiveComponent 0) + if (this.State.ListSelectionCustomInputText.Length > 0) { - this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] }); + this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText[..^1] }); } } else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar)) { - this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar }); + this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText + e.KeyInfo.KeyChar }); } } } private void HandleStreamingInputKey(KeyPressEventArgs e) { - // During streaming with input enabled, capture text for message injection if (e.KeyInfo.Key == ConsoleKey.Enter) { string text = this.State!.InputText; @@ -306,7 +223,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent 0) + { + _ = this.HandleFollowUpAnswerAsync(text); + } + else + { + _ = this.Runner.OnUserInputAsync(text); + } + } + + private void DispatchListSelectionSubmission(string text) + { + // List selection is only used to answer FollowUpQuestions. + _ = this.HandleFollowUpAnswerAsync(text); + } + + /// + /// Handles a user answer to the head of the pending follow-up question queue: + /// awaits the question's continuation (which is responsible for echoing both the + /// question and answer to the scroll area as it sees fit), appends any returned + /// chat message to the response accumulator, advances the queue, and — when the + /// queue empties — drains the accumulator and resumes the runner. + /// + private async Task HandleFollowUpAnswerAsync(string text) + { + IReadOnlyList? messagesToSend = null; + + await this._followUpGate.WaitAsync().ConfigureAwait(false); + try + { + HarnessConsoleUXStateDriver ux = this._uxDriver; + IReadOnlyList queue = this.State!.PendingQuestions; + if (queue.Count == 0) + { + return; + } + + FollowUpQuestion head = queue[0]; + + ChatMessage? response; + try + { + response = await head.Continuation(text, ux).ConfigureAwait(false); + } + catch (Exception ex) + { + await ux.WriteInfoLineAsync($"❌ Follow-up handler error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false); + response = null; + } + + if (response is not null) + { + ux.AddFollowUpResponse(response); + } + + ux.AdvanceFollowUpQuestion(); + + if (this.State!.PendingQuestions.Count == 0) + { + messagesToSend = ux.TakeFollowUpResponses(); + } + } + finally + { + this._followUpGate.Release(); + } + + // Resume the agent outside the gate — StartAgentTurnAsync runs the full agent + // loop which may queue new follow-up questions (re-entering this method). + if (messagesToSend is not null) + { + try + { + await this.Runner.StartAgentTurnAsync([.. messagesToSend]).ConfigureAwait(false); + } + catch (Exception ex) + { + await this._uxDriver.WriteInfoLineAsync($"❌ Agent error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false); + } + } + } + private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e) { this._resizedSinceLastRender = true; @@ -332,35 +333,40 @@ public class HarnessAppComponent : ConsoleReactiveComponent - public override void RenderCore(HarnessAppComponentProps props, HarnessAppComponentState state) + public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentState state) { + if (this._deactivated) + { + return; + } + // Determine the text panel height for the last scroll item - IReadOnlyList lastItems = props.ScrollItems.Count > 0 - ? [props.ScrollItems[^1]] + IReadOnlyList lastItems = state.ScrollAreaContentItems.Count > 0 + ? [state.ScrollAreaContentItems[^1]] : []; - int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem); + int textPanelHeight = TextPanel.CalculateHeight(lastItems); if (textPanelHeight > 0) { textPanelHeight++; // Extra line for spacing between text panel and rule } // Calculate queued items panel height - int queuedPanelHeight = TextPanel.CalculateHeight(props.QueuedItems, this._renderItem); + int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems); // Build the bottom panel child based on mode ConsoleReactiveComponent bottomChild; int bottomChildHeight; - if (props.Mode == BottomPanelMode.ListSelection) + if (state.Mode == BottomPanelMode.ListSelection) { var listProps = new ListSelectionProps { - Title = props.ListTitle, - Items = props.Items, - SelectedIndex = state.SelectedIndex, - HighlightColor = props.ListHighlightColor, - CustomTextPlaceholder = props.ListCustomTextPlaceholder, - CustomText = state.ListInputText, + Title = state.ListSelectionTitle, + Items = state.ListSelectionOptions, + SelectedIndex = state.ListSelectionIndex, + HighlightColor = state.ListHighlightColor, + CustomTextPlaceholder = state.ListSelectionCustomTextPlaceholder, + CustomText = state.ListSelectionCustomInputText, }; bottomChildHeight = ListSelection.CalculateHeight(listProps); @@ -368,25 +374,25 @@ public class HarnessAppComponent : ConsoleReactiveComponent scrollItems = props.ScrollItems.Count > 1 - ? props.ScrollItems.Take(props.ScrollItems.Count - 1).ToList() + IReadOnlyList scrollItems = state.ScrollAreaContentItems.Count > 1 + ? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList() : []; this._textScrollPanel.X = 1; @@ -486,18 +498,21 @@ public class HarnessAppComponent : ConsoleReactiveComponent +/// Determines which component is shown in the bottom panel. +/// +public enum BottomPanelMode +{ + /// Show the text input component for user input. + TextInput, + + /// Show the list selection component for interactive prompts. + ListSelection, + + /// Show a disabled input indicator during agent streaming. + Streaming, +} + +/// +/// Internal state for . All UI fields that may +/// change after construction live here; they are mutated exclusively via +/// by the +/// owning . +/// +public record HarnessAppComponentState : ConsoleReactiveState +{ + // --- Console dimensions --- + + /// Gets the current console width in columns. + public int ConsoleWidth { get; init; } + + /// Gets the current console height in rows. + public int ConsoleHeight { get; init; } + + // --- Bottom panel mode --- + + /// Gets the bottom panel mode. + public BottomPanelMode Mode { get; init; } = BottomPanelMode.TextInput; + + /// + /// Gets the queue of follow-up questions waiting for user answers. The head + /// ([0]) is the question currently being displayed; subsequent items + /// are dispatched in order as each is answered. While this queue is non-empty, + /// the next user submission is treated as the answer to the head question + /// instead of going to the agent runner's normal input handler. + /// + public IReadOnlyList PendingQuestions { get; init; } = []; + + /// + /// Gets the accumulated follow-up response messages collected during the + /// current agent turn — both direct s emitted + /// by observers and continuation results from answered questions. Consumed + /// by the runner via + /// before the next agent invocation. + /// + public IReadOnlyList AccumulatedFollowUpResponses { get; init; } = []; + + // --- Text input (active in TextInput / Streaming modes) --- + + /// Gets the prompt string for text input mode. + public string Prompt { get; init; } = "> "; + + /// Gets the placeholder text shown when the input is empty. + public string Placeholder { get; init; } = ""; + + /// Gets the current input text being typed. + public string InputText { get; init; } = ""; + + /// Gets a value indicating whether input is enabled during streaming. + public bool InputEnabled { get; init; } + + /// Gets the prompt to show during streaming when input is disabled. + public string StreamingPrompt { get; init; } = "(agent is running...)"; + + // --- List selection (active in ListSelection mode) --- + + /// Gets the title text displayed above the list selection (for interactive prompts). + public string? ListSelectionTitle { get; init; } + + /// Gets the list selection options. + public IReadOnlyList ListSelectionOptions { get; init; } = []; + + /// Gets the highlighted option index in list selection mode. + public int ListSelectionIndex { get; init; } + + /// Gets the placeholder text for the custom text input option in the list. + public string? ListSelectionCustomTextPlaceholder { get; init; } + + /// Gets the current text being typed into the list's custom text option. + public string ListSelectionCustomInputText { get; init; } = ""; + + /// Gets the highlight color for the active list item. + public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan; + + // --- Scroll / output area --- + + /// Gets the items rendered in the scroll-area. Each item is a pre-rendered + /// console string (may include ANSI escape sequences and newlines). + public IReadOnlyList ScrollAreaContentItems { get; init; } = []; + + /// Gets the queued input items to display above the rule. Each item is a + /// pre-rendered console string (may include ANSI escape sequences and newlines). + public IReadOnlyList QueuedItems { get; init; } = []; + + // --- Agent mode + status display --- + + /// Gets the foreground color for the rule borders and mode label. + public ConsoleColor? ModeColor { get; init; } + + /// Gets the current mode name displayed below the bottom rule (e.g. "plan"). + public string? ModeText { get; init; } + + /// Gets the help text displayed below the bottom rule (available commands). + public string? HelpText { get; init; } + + /// Gets a value indicating whether the agent status spinner is visible. + public bool ShowSpinner { get; init; } + + /// Gets the formatted token usage text to display in the status bar. + public string? UsageText { get; init; } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs index 99a0580706..1f313d1008 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsole.cs @@ -1,9 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -using Harness.Shared.Console.Commands; -using Harness.Shared.Console.Observers; +using Harness.ConsoleReactiveComponents; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; namespace Harness.Shared.Console; @@ -15,244 +13,58 @@ 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. + /// Constructs the reactive UI component and the , + /// wires them together, and awaits the component's + /// (which completes when the user types /exit). /// /// The agent to interact with. - /// The title displayed in the console header. - /// A short prompt to the user, displayed below the title. + /// A short prompt to the user, displayed as a placeholder in the input area. /// Optional configuration options for the console session. - public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, HarnessConsoleOptions? options = null) + public static async Task RunAgentAsync(AIAgent agent, 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)); - } + // Null means use defaults; an explicit (possibly empty) list means use exactly what was provided. + var observers = options.Observers + ?? HarnessConsoleOptions.BuildDefaultObservers(); + var commandHandlers = options.CommandHandlers + ?? HarnessConsoleOptions.BuildDefaultCommandHandlers(agent, options.ModeColors); - var todoProvider = agent.GetService(); var modeProvider = agent.GetService(); var messageInjector = agent.GetService(); - var commandHandlers = new List - { - new TodoCommandHandler(todoProvider), - new ModeCommandHandler(modeProvider, options.ModeColors), - }; - AgentSession session = await agent.CreateSessionAsync(); - using var ux = new HarnessUXContainer( + using var component = new HarnessAppComponent( placeholder: userPrompt, initialMode: modeProvider?.GetMode(session), inputEnabled: messageInjector is not null, + runnerFactory: ux => new HarnessAgentRunner( + agent: agent, + session: session, + modeProvider: modeProvider, + messageInjector: messageInjector, + commandHandlers: commandHandlers, + observers: observers, + ux: ux), modeColors: options.ModeColors); - // Streaming-mode submissions are enqueued for injection; the queued display - // is then refreshed from the injector's current pending list. - ux.StreamingInputReceived += (sender, e) => + // Trigger the initial render of the component now that state is seeded. + component.Render(); + + try { - if (messageInjector is null) - { - return; - } - - messageInjector.EnqueueMessages(session, [new ChatMessage(ChatRole.User, e.Text)]); - ux.ShowQueuedMessages(messageInjector.GetPendingMessages(session)); - }; - - var commandHelp = commandHandlers - .Select(h => h.GetHelpText()) - .Where(t => t is not null) - .Append("exit (quit)")!; - - ux.Initialize(title, commandHelp!, messageInjector is not null); - - string userInput = await ux.WaitForInputAsync(); - - while (!string.IsNullOrWhiteSpace(userInput) && !userInput.Equals("exit", StringComparison.OrdinalIgnoreCase)) + await component.ShutdownTask.ConfigureAwait(false); + } + finally { - ux.WriteUserInputEcho(userInput); - - // Check command handlers first — first one to handle wins. - bool handled = false; - foreach (var handler in commandHandlers) - { - if (await handler.TryHandleAsync(userInput, session, ux).ConfigureAwait(false)) - { - handled = true; - break; - } - } - - if (!handled) - { - await RunAgentTurnAsync(agent, session, modeProvider, messageInjector, options, ux, userInput); - } - - ux.CurrentMode = modeProvider?.GetMode(session); - userInput = await ux.WaitForInputAsync(); + component.Deactivate(); } - ux.Deactivate(); System.Console.ResetColor(); + System.Console.Write(AnsiEscapes.ResetScrollRegion); + System.Console.Write(AnsiEscapes.EraseEntireScreen); + System.Console.Write(AnsiEscapes.MoveCursor(1, 1)); 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, - MessageInjectingChatClient? messageInjector, - HarnessConsoleOptions options, - HarnessUXContainer ux, - string userInput) - { - IList? nextMessages = [new ChatMessage(ChatRole.User, userInput)]; - IReadOnlyList lastPendingMessages = messageInjector?.GetPendingMessages(session) ?? []; - - while (nextMessages is not null) - { - var observers = CreateObservers(options, modeProvider, session); - - var runOptions = new AgentRunOptions(); - foreach (var observer in observers) - { - observer.ConfigureRunOptions(runOptions); - } - - ux.CurrentMode = modeProvider?.GetMode(session); - ux.BeginStreaming(); - ux.BeginStreamingOutput(); - - 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 != ux.CurrentMode) - { - ux.CurrentMode = currentMode; - } - } - - foreach (var content in update.Contents) - { - foreach (var observer in observers) - { - await observer.OnContentAsync(ux, content); - } - } - - if (!string.IsNullOrEmpty(update.Text)) - { - foreach (var observer in observers) - { - await observer.OnTextAsync(ux, update.Text); - } - } - - SyncQueuedMessageDisplay(messageInjector, session, ux, ref lastPendingMessages); - } - } - catch (Exception ex) - { - await ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red); - } - - // Final sync after streaming — messages may have been consumed during the last iteration. - SyncQueuedMessageDisplay(messageInjector, session, ux, ref lastPendingMessages); - - // Stop spinner before observer completions (which may prompt for input). - ux.StopSpinner(); - - // Close the streaming output to provide visual separation from observer output. - await ux.EndStreamingOutputAsync(); - - var combinedMessages = new List(); - bool hasObserverMessages = false; - foreach (var observer in observers) - { - var messages = await observer.OnStreamCompleteAsync(ux, agent, session, options); - if (messages is { Count: > 0 }) - { - combinedMessages.AddRange(messages); - hasObserverMessages = true; - } - } - - await ux.WriteNoTextWarningAsync(hasFollowUpMessages: hasObserverMessages); - - ux.EndStreaming(); - - nextMessages = combinedMessages.Count > 0 ? combinedMessages : null; - } - } - - /// - /// Synchronizes the queued items display with the message injector's pending messages. - /// Messages that have been consumed (drained by the service) are echoed to the output - /// area as regular user-input entries. - /// - private static void SyncQueuedMessageDisplay( - MessageInjectingChatClient? messageInjector, - AgentSession session, - HarnessUXContainer ux, - ref IReadOnlyList lastPendingMessages) - { - if (messageInjector is null) - { - return; - } - - var pending = messageInjector.GetPendingMessages(session); - - // If previously pending messages exceed current pending count, some were consumed. - int consumedCount = lastPendingMessages.Count - pending.Count; - for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++) - { - string text = lastPendingMessages[i].Text ?? string.Empty; - ux.WriteUserInputEcho(text); - } - - lastPendingMessages = pending; - ux.ShowQueuedMessages(pending); - } - - 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), - }; - - 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; - } } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleOptions.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleOptions.cs index 2a9b580c0e..2fd2139990 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleOptions.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleOptions.cs @@ -1,5 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.ObjectModel; +using Harness.Shared.Console.Commands; +using Harness.Shared.Console.Observers; +using Harness.Shared.Console.ToolFormatters; +using Microsoft.Agents.AI; + namespace Harness.Shared.Console; /// @@ -8,45 +14,120 @@ namespace Harness.Shared.Console; 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. + /// Gets or sets the list of console observers that participate in the agent response + /// streaming lifecycle. Use the factory methods on this class to create common observer sets. + /// When (the default), a default set of observers is used. + /// Set to an empty list to disable all observers. /// - public int? MaxContextWindowTokens { get; set; } + public IReadOnlyList? Observers { get; set; } /// - /// Gets or sets the optional maximum output tokens. - /// Used with to show input/output budget breakdown. + /// Gets or sets the list of command handlers to check before sending user input to the agent. + /// Use to create the default set. + /// When (the default), a default set of handlers is used. + /// Set to an empty list to disable all command handlers. /// - public int? MaxOutputTokens { get; set; } + public IReadOnlyList? CommandHandlers { 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. + /// The default mode-to-color mapping used when no custom are provided. /// - /// 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; } + public static readonly IReadOnlyDictionary DefaultModeColors = new ReadOnlyDictionary( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["plan"] = ConsoleColor.Cyan, + ["execute"] = ConsoleColor.Green, + }); /// /// 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) + public Dictionary ModeColors { get; set; } = new(DefaultModeColors, StringComparer.OrdinalIgnoreCase); + + /// + /// Creates the default set of observers without planning support. + /// Includes tool call display, tool approval, error display, reasoning display, + /// usage display, and text output. + /// + /// Optional maximum context window size in tokens for usage display. + /// Optional maximum output tokens for usage display. + /// Optional tool call formatters. When , + /// each observer uses the default formatters from . + /// A list of observers for a standard (non-planning) console session. + public static List BuildDefaultObservers( + int? maxContextWindowTokens = null, + int? maxOutputTokens = null, + IReadOnlyList? toolFormatters = null) { - ["plan"] = ConsoleColor.Cyan, - ["execute"] = ConsoleColor.Green, - }; + return + [ + new ToolCallDisplayObserver(toolFormatters), + new ToolApprovalObserver(toolFormatters), + new ErrorDisplayObserver(), + new ReasoningDisplayObserver(), + new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens), + new TextOutputObserver(), + ]; + } + + /// + /// Creates the default set of observers with planning support. + /// Includes a instead of . + /// + /// The agent, used to resolve . + /// The mode name that represents the planning mode. + /// The mode name to switch to when the user approves a plan. + /// Optional mode-to-color mapping for display. + /// Defaults to when . + /// Optional maximum context window size in tokens for usage display. + /// Optional maximum output tokens for usage display. + /// Optional tool call formatters. When , + /// each observer uses the default formatters from . + /// A list of observers for a planning-enabled console session. + public static List BuildObserversWithPlanning( + AIAgent agent, + string planModeName, + string executionModeName, + IReadOnlyDictionary? modeColors = null, + int? maxContextWindowTokens = null, + int? maxOutputTokens = null, + IReadOnlyList? toolFormatters = null) + { + var modeProvider = agent.GetService() + ?? throw new InvalidOperationException("Planning requires an AgentModeProvider service on the agent."); + + return + [ + new ToolCallDisplayObserver(toolFormatters), + new ToolApprovalObserver(toolFormatters), + new ErrorDisplayObserver(), + new ReasoningDisplayObserver(), + new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens), + new PlanningOutputObserver(modeProvider, planModeName, executionModeName, modeColors ?? DefaultModeColors), + ]; + } + + /// + /// Creates the default set of command handlers. + /// Includes exit, todo, and mode command handlers. + /// + /// The agent, used to resolve and . + /// Optional mode-to-color mapping for the mode command display. + /// Defaults to when . + /// A list of command handlers for a standard console session. + public static List BuildDefaultCommandHandlers( + AIAgent agent, + IReadOnlyDictionary? modeColors = null) + { + var todoProvider = agent.GetService(); + var modeProvider = agent.GetService(); + + return + [ + new ExitCommandHandler(), + new TodoCommandHandler(todoProvider), + new ModeCommandHandler(modeProvider, modeColors ?? DefaultModeColors), + ]; + } } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleUXStateDriver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleUXStateDriver.cs new file mode 100644 index 0000000000..4dbbedb9b5 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessConsoleUXStateDriver.cs @@ -0,0 +1,408 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Harness.ConsoleReactiveComponents; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console; + +/// +/// Default implementation. Owned by +/// ; mutates the component's state via a +/// SetState-style callback. Each public operation updates state and lets +/// the component's render-skip optimization handle the actual draw. +/// +internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver +{ + private readonly Func _getState; + private readonly Action _setState; + private readonly Action _requestShutdown; + private readonly IReadOnlyDictionary? _modeColors; + private readonly List _outputItems = []; + private readonly object _stateLock = new(); + + private OutputEntryType? _lastEntryType; + private bool _hasReceivedAnyText; + private OutputEntry? _currentStreamingEntry; + private int _currentStreamingEntryIndex = -1; + private string? _currentMode; + + /// + /// Initializes a new instance of the class. + /// + /// Returns the component's current state. + /// Replaces the component's state and triggers a re-render. + /// Callback invoked when a command handler requests application shutdown. + /// Optional mapping of mode names to console colors. + public HarnessConsoleUXStateDriver( + Func getState, + Action setState, + Action requestShutdown, + IReadOnlyDictionary? modeColors = null) + { + this._getState = getState; + this._setState = setState; + this._requestShutdown = requestShutdown; + this._modeColors = modeColors; + this._currentMode = getState().ModeText; + } + + /// + public string? CurrentMode + { + get => this._currentMode; + set + { + this.UpdateState(s => + { + this._currentMode = value; + return s with + { + ModeColor = ModeColors.Get(value, this._modeColors), + ModeText = value, + }; + }); + } + } + + /// + public void BeginStreaming() => + this.UpdateState(s => s with + { + Mode = BottomPanelMode.Streaming, + ShowSpinner = true, + }); + + /// + public void StopSpinner() => + this.UpdateState(s => s with { ShowSpinner = false }); + + /// + public void EndStreaming() => + this.UpdateState(s => s with + { + Mode = BottomPanelMode.TextInput, + ShowSpinner = false, + }); + + /// + public void BeginStreamingOutput() + { + lock (this._stateLock) + { + this._hasReceivedAnyText = false; + this._currentStreamingEntry = null; + this._currentStreamingEntryIndex = -1; + } + } + + /// + public void SetUsageText(string usageText) => + this.UpdateState(s => s with { UsageText = usageText }); + + /// + public void SetQueuedMessages(IReadOnlyList pending) + { + var newQueued = new List(pending.Count); + foreach (var msg in pending) + { + string text = msg.Text ?? string.Empty; + newQueued.Add(RenderEntry($" 💬 {text}\n", ConsoleColor.DarkGray)); + } + + this.UpdateState(s => s with { QueuedItems = newQueued }); + } + + /// + public void QueueFollowUpQuestions(IReadOnlyList questions) + { + if (questions.Count == 0) + { + return; + } + + this.UpdateState(s => + { + bool wasEmpty = s.PendingQuestions.Count == 0; + + var combined = new List(s.PendingQuestions.Count + questions.Count); + combined.AddRange(s.PendingQuestions); + combined.AddRange(questions); + + HarnessAppComponentState next = s with { PendingQuestions = combined }; + + if (wasEmpty) + { + next = this.ConfigureForHeadQuestion(next, combined[0]); + } + + return next; + }); + } + + /// + public void AddFollowUpResponse(ChatMessage response) + { + this.UpdateState(s => + { + var combined = new List(s.AccumulatedFollowUpResponses.Count + 1); + combined.AddRange(s.AccumulatedFollowUpResponses); + combined.Add(response); + return s with { AccumulatedFollowUpResponses = combined }; + }); + } + + /// + public void AdvanceFollowUpQuestion() + { + this.UpdateState(s => + { + if (s.PendingQuestions.Count == 0) + { + return s; + } + + var remaining = s.PendingQuestions.Skip(1).ToList(); + HarnessAppComponentState next = s with { PendingQuestions = remaining }; + + if (remaining.Count > 0) + { + return this.ConfigureForHeadQuestion(next, remaining[0]); + } + + return next with + { + Mode = BottomPanelMode.TextInput, + ListSelectionOptions = [], + ListSelectionTitle = null, + ListSelectionCustomTextPlaceholder = null, + ListSelectionIndex = 0, + ListSelectionCustomInputText = "", + }; + }); + } + + /// + public IReadOnlyList TakeFollowUpResponses() + { + return this.UpdateState(s => + { + IReadOnlyList responses = s.AccumulatedFollowUpResponses; + if (responses.Count == 0) + { + return (s, responses); + } + + return (s with { AccumulatedFollowUpResponses = [] }, responses); + }); + } + + /// + /// Configures the bottom-panel display fields on the supplied state for the + /// given head question. For text questions, also writes the prompt as an + /// info line above the input row as a side effect. + /// + private HarnessAppComponentState ConfigureForHeadQuestion(HarnessAppComponentState state, FollowUpQuestion question) + { + if (question is ChoiceFollowUpQuestion choice) + { + return state with + { + Mode = BottomPanelMode.ListSelection, + ListSelectionOptions = choice.Choices.ToList(), + ListSelectionTitle = choice.Prompt, + ListSelectionCustomTextPlaceholder = choice.AllowCustomText ? "✏️ Type a custom response..." : null, + ListSelectionIndex = 0, + ListSelectionCustomInputText = "", + }; + } + + // Text question — prompt is rendered as an info line above the input row. + // We append entries and capture the scroll snapshot inline so the caller's + // single _setState picks up both the new output and the UI mode change. + ConsoleColor ruleColor = ModeColors.Get(this._currentMode, this._modeColors); + List scrollSnapshot = this.AppendOutputEntriesAndSnapshot( + new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor), + new OutputEntry(OutputEntryType.InfoLine, $" {question.Prompt}", ruleColor)); + + return state with + { + Mode = BottomPanelMode.TextInput, + ListSelectionOptions = [], + ListSelectionTitle = null, + ListSelectionCustomTextPlaceholder = null, + ListSelectionIndex = 0, + ListSelectionCustomInputText = "", + ScrollAreaContentItems = scrollSnapshot, + }; + } + + /// + public void WriteUserInputEcho(string text) + { + this.UpdateState(s => + { + List snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry( + OutputEntryType.UserInput, + $"\nYou: {text}\n\n", + ConsoleColor.Green)); + return s with { ScrollAreaContentItems = snapshot }; + }); + } + + /// + public Task WriteInfoAsync(string text, ConsoleColor? color = null) => + this.WriteInfoCoreAsync(text, color, newLine: false); + + /// + public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) => + this.WriteInfoCoreAsync(text, color, newLine: true); + + private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine) + { + this.UpdateState(s => + { + // Add a blank line separator when transitioning from streaming text or user input. + string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter + ? "\n " + : " "; + + string fullText = newLine ? prefix + text + "\n\n" : prefix + text; + List snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry( + OutputEntryType.InfoLine, + fullText, + color ?? ModeColors.Get(this._currentMode, this._modeColors))); + return s with { ScrollAreaContentItems = snapshot }; + }); + return Task.CompletedTask; + } + + /// + public Task WriteTextAsync(string text, ConsoleColor? color = null) + { + this.UpdateState(s => + { + this._lastEntryType = OutputEntryType.StreamingText; + this._hasReceivedAnyText = true; + + ConsoleColor effectiveColor = color ?? ModeColors.Get(this._currentMode, this._modeColors); + + if (this._currentStreamingEntry is not null + && this._currentStreamingEntryIndex == this._outputItems.Count - 1) + { + // The streaming entry is still the last item — safe to replace in place. + this._currentStreamingEntry = this._currentStreamingEntry with + { + Text = this._currentStreamingEntry.Text + text, + }; + this._outputItems[^1] = RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color); + } + else + { + // Either the first text delta or other entries (tool calls, info lines) + // were appended after the previous streaming entry — start a fresh one. + const string Prefix = "\n"; + this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor); + this._outputItems.Add(RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color)); + this._currentStreamingEntryIndex = this._outputItems.Count - 1; + } + + return s with { ScrollAreaContentItems = new List(this._outputItems) }; + }); + + return Task.CompletedTask; + } + + /// + public Task EndStreamingOutputAsync() + { + this.UpdateState(s => + { + if (this._hasReceivedAnyText) + { + this._outputItems.Add(RenderEntry("\n", null)); + this._currentStreamingEntry = null; + this._lastEntryType = OutputEntryType.StreamFooter; + return s with { ScrollAreaContentItems = new List(this._outputItems) }; + } + + return s; + }); + + return Task.CompletedTask; + } + + /// + public Task WriteNoTextWarningAsync(bool hasFollowUpActions) + { + if (!this._hasReceivedAnyText && !hasFollowUpActions) + { + this.UpdateState(s => + { + List snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry( + OutputEntryType.StreamFooter, + " (no text response from agent)\n", + ConsoleColor.DarkYellow)); + return s with { ScrollAreaContentItems = snapshot }; + }); + } + + return Task.CompletedTask; + } + + /// + /// Wraps the supplied text with ANSI foreground color escape sequences (or returns + /// the text unchanged when no color is specified). Output is appended to + /// and consumed verbatim by + /// and . + /// + private static string RenderEntry(string text, ConsoleColor? color) => + color.HasValue + ? $"{AnsiEscapes.SetForegroundColor(color.Value)}{text}{AnsiEscapes.ResetAttributes}" + : text; + + private void UpdateState(Func update) + { + lock (this._stateLock) + { + this._setState(update(this._getState())); + } + } + + private T UpdateState(Func update) + { + lock (this._stateLock) + { + var (newState, result) = update(this._getState()); + this._setState(newState); + return result; + } + } + + /// + /// Appends one or more output entries to the output list, updates + /// to the last entry's type, and returns a + /// snapshot of . Must be called inside a locked + /// context (e.g. within an callback). + /// + private List AppendOutputEntriesAndSnapshot(params OutputEntry[] entries) + { + this.AppendOutputEntriesCore(entries); + return new List(this._outputItems); + } + + private void AppendOutputEntriesCore(OutputEntry[] entries) + { + foreach (OutputEntry entry in entries) + { + this._outputItems.Add(RenderEntry(entry.Text, entry.Color)); + } + + if (entries.Length > 0) + { + this._lastEntryType = entries[^1].Type; + } + } + + /// + public void RequestShutdown() => this._requestShutdown(); +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessUXContainer.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessUXContainer.cs deleted file mode 100644 index 99699267fa..0000000000 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessUXContainer.cs +++ /dev/null @@ -1,478 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Harness.ConsoleReactiveComponents; -using Microsoft.Extensions.AI; - -namespace Harness.Shared.Console; - -/// -/// Event arguments raised when the user submits text while the bottom panel is in -/// streaming mode (i.e. an agent turn is in progress). -/// -public sealed class StreamingInputReceivedEventArgs : EventArgs -{ - /// - /// Initializes a new instance of the class. - /// - /// The submitted text. - public StreamingInputReceivedEventArgs(string text) - { - this.Text = text; - } - - /// - /// Gets the submitted text. - /// - public string Text { get; } -} - -/// -/// Façade over the harness UI: owns the , manages -/// its props, dispatches input submissions, and provides the high-level read/write -/// operations used by observers, command handlers, and the harness loop. -/// -/// -/// All callers interact with the UI exclusively through this class. The underlying -/// and its props are an implementation detail and -/// must not be exposed. -/// -public sealed class HarnessUXContainer : IDisposable -{ - /// - /// The prompt displayed in the bottom-panel input area. - /// - private const string UserPrompt = "> "; - - private readonly IReadOnlyDictionary? _modeColors; - private readonly List _outputItems = []; - private readonly HarnessAppComponent _appComponent; - private readonly object _outputLock = new(); - - private TaskCompletionSource? _pendingInputTcs; - private OutputEntryType? _lastEntryType; - private bool _hasReceivedAnyText; - private OutputEntry? _currentStreamingEntry; - private string? _currentMode; - - /// - /// Initializes a new instance of the class. - /// - /// Placeholder text shown when the input is empty. - /// The current agent mode, used to colour the rule and prompt. - /// Whether the bottom-panel input accepts keystrokes during streaming. - /// Optional mapping of mode names to console colors. - public HarnessUXContainer( - string placeholder, - string? initialMode, - bool inputEnabled, - IReadOnlyDictionary? modeColors = null) - { - this._modeColors = modeColors; - this._currentMode = initialMode; - - this._appComponent = new HarnessAppComponent(RenderOutputEntry) - { - Props = new HarnessAppComponentProps - { - ScrollItems = this._outputItems, - Mode = BottomPanelMode.TextInput, - Prompt = UserPrompt, - Placeholder = placeholder, - ModeColor = ModeColors.Get(initialMode, modeColors), - ModeText = initialMode, - InputEnabled = inputEnabled, - }, - }; - - this._appComponent.InputSubmitted += this.OnInputSubmitted; - } - - /// - /// Raised when the user submits text while the bottom panel is in streaming mode. - /// Subscribers typically enqueue the text into a message-injecting chat client. - /// - public event EventHandler? StreamingInputReceived; - - /// - /// Gets or sets the current agent mode (e.g. "plan", "execute"). Updating this - /// also refreshes the rule colour and bottom-panel prompt to match the new mode. - /// - public string? CurrentMode - { - get => this._currentMode; - set - { - this._currentMode = value; - this._appComponent.Props = this._appComponent.Props! with - { - ModeColor = ModeColors.Get(value, this._modeColors), - ModeText = value, - }; - this._appComponent.Render(); - } - } - - /// - /// Performs the initial screen clear, sets the help text in the mode-and-help bar, - /// and adds the title to the output area. - /// - /// The title displayed in the console header. - /// The command help strings displayed in the mode-and-help bar. - /// Whether streaming-time message injection is enabled. - public void Initialize(string title, IEnumerable commandHelpTexts, bool messageInjectionActive) - { - // Set the help text on the mode-and-help bar (persists below the rule). - this._appComponent.Props = this._appComponent.Props! with - { - HelpText = string.Join(", ", commandHelpTexts), - ModeText = this._currentMode, - }; - - System.Console.Write(AnsiEscapes.EraseEntireScreen); - System.Console.Write(AnsiEscapes.EraseScrollbackBuffer); - this._appComponent.Render(); - - this.AppendOutputEntries( - new OutputEntry(OutputEntryType.InfoLine, $"=== {title} ===\n", ConsoleColor.White), - new OutputEntry(OutputEntryType.InfoLine, "\n")); - } - - /// - /// Restores the cursor and exits the alternate screen, ending the interactive UI. - /// - public void Deactivate() => this._appComponent.Deactivate(); - - /// - /// Switches the bottom panel to streaming mode and starts the spinner. - /// - public void BeginStreaming() - { - this._appComponent.Props = this._appComponent.Props! with - { - Mode = BottomPanelMode.Streaming, - ShowSpinner = true, - }; - this._appComponent.Render(); - } - - /// - /// Stops the spinner without leaving streaming mode. Use between the end of the - /// stream and any observer-driven prompts (e.g. tool approvals). - /// - public void StopSpinner() - { - this._appComponent.Props = this._appComponent.Props! with { ShowSpinner = false }; - this._appComponent.Render(); - } - - /// - /// Switches the bottom panel back to text-input mode and stops the spinner. - /// - public void EndStreaming() - { - this._appComponent.Props = this._appComponent.Props! with - { - Mode = BottomPanelMode.TextInput, - ShowSpinner = false, - }; - this._appComponent.Render(); - } - - /// - /// Resets per-turn streaming bookkeeping in preparation for a new agent turn. - /// - public void BeginStreamingOutput() - { - this._hasReceivedAnyText = false; - this._currentStreamingEntry = null; - } - - /// - /// Sets the formatted usage text shown on the agent status bar. - /// - public void SetUsageText(string usageText) - { - this._appComponent.Props = this._appComponent.Props! with { UsageText = usageText }; - this._appComponent.Render(); - } - - /// - /// Clears the usage text from the agent status bar. - /// - public void ClearUsageText() - { - this._appComponent.Props = this._appComponent.Props! with { UsageText = null }; - this._appComponent.Render(); - } - - /// - /// Replaces the queued-message display with one entry per pending message. - /// - public void ShowQueuedMessages(IReadOnlyList pending) - { - var newQueued = new List(pending.Count); - foreach (var msg in pending) - { - string text = msg.Text ?? string.Empty; - newQueued.Add(new OutputEntry(OutputEntryType.UserInput, $" 💬 {text}\n", ConsoleColor.DarkGray)); - } - - this._appComponent.Props = this._appComponent.Props! with { QueuedItems = newQueued }; - this._appComponent.Render(); - } - - /// - /// Echoes a submitted user input as a regular user-input entry in the output area, - /// using the current mode-aware prompt prefix. - /// - /// The user-entered text. - public void WriteUserInputEcho(string text) - { - this.AppendOutputEntries(new OutputEntry( - OutputEntryType.UserInput, - $"\nYou: {text}\n", - ConsoleColor.Green)); - } - - /// - /// Writes informational output as an output entry, without a trailing newline. - /// - public Task WriteInfoAsync(string text, ConsoleColor? color = null) => - this.WriteInfoCoreAsync(text, color, newLine: false); - - /// - /// Writes informational output as an output entry, followed by a newline. - /// - public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) => - this.WriteInfoCoreAsync(text, color, newLine: true); - - private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine) - { - // Add a blank line separator when transitioning from streaming text or user input. - string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter - ? "\n\n " - : " "; - - string fullText = newLine ? prefix + text + "\n" : prefix + text; - this.AppendOutputEntries(new OutputEntry( - OutputEntryType.InfoLine, - fullText, - color ?? ModeColors.Get(this.CurrentMode, this._modeColors))); - return Task.CompletedTask; - } - - /// - /// Writes streaming text output from the agent. Successive calls accumulate into a - /// single streaming entry that is re-rendered by the text panel. - /// - public Task WriteTextAsync(string text, ConsoleColor? color = null) - { - lock (this._outputLock) - { - this._lastEntryType = OutputEntryType.StreamingText; - this._hasReceivedAnyText = true; - - ConsoleColor effectiveColor = color ?? ModeColors.Get(this.CurrentMode, this._modeColors); - - if (this._currentStreamingEntry is not null) - { - this._currentStreamingEntry = this._currentStreamingEntry with - { - Text = this._currentStreamingEntry.Text + text, - }; - this._outputItems[^1] = this._currentStreamingEntry; - } - else - { - const string Prefix = "\n"; - this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor); - this._outputItems.Add(this._currentStreamingEntry); - } - - this._appComponent.Props = this._appComponent.Props! with - { - ScrollItems = new List(this._outputItems), - }; - } - - this._appComponent.Render(); - return Task.CompletedTask; - } - - /// - /// Writes a blank-line separator to visually close the streaming output section. - /// Call before observer completions so their output is visually separated. - /// - public Task EndStreamingOutputAsync() - { - lock (this._outputLock) - { - this._outputItems.Add(new OutputEntry(OutputEntryType.StreamFooter, "\n")); - this._currentStreamingEntry = null; - this._lastEntryType = OutputEntryType.StreamFooter; - this._appComponent.Props = this._appComponent.Props! with - { - ScrollItems = new List(this._outputItems), - }; - } - - this._appComponent.Render(); - return Task.CompletedTask; - } - - /// - /// Shows a "(no text response from agent)" warning if no text was received - /// and no observer produced follow-up messages. Call after observer completions. - /// - /// Whether any observer produced follow-up messages. - public Task WriteNoTextWarningAsync(bool hasFollowUpMessages) - { - if (!this._hasReceivedAnyText && !hasFollowUpMessages) - { - this.AppendOutputEntries(new OutputEntry( - OutputEntryType.StreamFooter, - " (no text response from agent)\n", - ConsoleColor.DarkYellow)); - } - - return Task.CompletedTask; - } - - /// - /// Reads a line of input from the user. If is supplied - /// it is rendered as an info line above the input row before reading. - /// - public async Task ReadLineAsync(string? prompt = null, ConsoleColor? promptColor = null) - { - if (prompt is not null) - { - ConsoleColor ruleColor = ModeColors.Get(this.CurrentMode, this._modeColors); - this.AppendOutputEntries( - new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor), - new OutputEntry(OutputEntryType.InfoLine, $" {prompt}", promptColor ?? ruleColor)); - } - - this._appComponent.Props = this._appComponent.Props! with { Mode = BottomPanelMode.TextInput }; - this._appComponent.Render(); - - string input = await this.WaitForInputAsync(); - - this.AppendOutputEntries(new OutputEntry( - OutputEntryType.UserInput, - $"\nYou: {input}\n", - ConsoleColor.Green)); - - return input; - } - - /// - /// Presents a selection prompt with the given choices and waits for the user's - /// selection. The title is displayed above the list in the bottom panel. After - /// selection the bottom panel is restored to text-input mode and both the question - /// and selection are echoed in the output area. - /// - public async Task ReadSelectionAsync(string title, IList choices) - { - this._appComponent.Props = this._appComponent.Props! with - { - Mode = BottomPanelMode.ListSelection, - Items = choices.ToList(), - ListTitle = title, - ListCustomTextPlaceholder = "✏️ Type a custom response...", - }; - this._appComponent.Render(); - - string selection = await this.WaitForInputAsync(); - - this._appComponent.Props = this._appComponent.Props with { Mode = BottomPanelMode.TextInput }; - - this.AppendOutputEntries( - new OutputEntry( - OutputEntryType.InfoLine, - $"\n {title}\n", - ModeColors.Get(this.CurrentMode, this._modeColors)), - new OutputEntry( - OutputEntryType.UserInput, - $"\nYou: {selection}\n", - ConsoleColor.Green)); - - return selection; - } - - /// - /// Awaits the next non-streaming user input submission. - /// - public Task WaitForInputAsync() - { - this._pendingInputTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - return this._pendingInputTcs.Task; - } - - private void OnInputSubmitted(object? sender, InputSubmittedEventArgs e) - { - if (e.Mode == BottomPanelMode.Streaming) - { - this.StreamingInputReceived?.Invoke(this, new StreamingInputReceivedEventArgs(e.Text)); - } - else - { - var waiter = this._pendingInputTcs; - this._pendingInputTcs = null; - waiter?.TrySetResult(e.Text); - } - } - - /// - public void Dispose() - { - this._appComponent.InputSubmitted -= this.OnInputSubmitted; - this._appComponent.Deactivate(); - this._appComponent.Dispose(); - } - - /// - /// Renders an to a string with ANSI color codes. - /// Used as the render delegate for the . - /// - private static string RenderOutputEntry(object item) - { - if (item is not OutputEntry entry) - { - return item?.ToString() ?? string.Empty; - } - - if (entry.Color.HasValue) - { - return $"{AnsiEscapes.SetForegroundColor(entry.Color.Value)}{entry.Text}{AnsiEscapes.ResetAttributes}"; - } - - return entry.Text; - } - - /// - /// Appends one or more output entries to the output list under lock, - /// updates to the last entry's type, and renders. - /// - private void AppendOutputEntries(params OutputEntry[] entries) - { - lock (this._outputLock) - { - foreach (OutputEntry entry in entries) - { - this._outputItems.Add(entry); - } - - if (entries.Length > 0) - { - this._lastEntryType = entries[^1].Type; - } - - this._appComponent.Props = this._appComponent.Props! with - { - ScrollItems = new List(this._outputItems), - }; - } - - this._appComponent.Render(); - } -} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/IUXStateDriver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/IUXStateDriver.cs new file mode 100644 index 0000000000..7c01545b68 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/IUXStateDriver.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console; + +/// +/// Abstraction over the harness UI state. All callers (observers, command handlers, +/// the agent runner) interact with the UI exclusively through this interface, which +/// internally translates each operation into a SetState call on the underlying +/// reactive component. +/// +/// +/// This interface is intentionally narrow: it does not expose blocking input methods. +/// The agent runner orchestrates input flow via +/// objects returned from observers. +/// +public interface IUXStateDriver +{ + /// + /// Gets or sets the current agent mode (e.g. "plan", "execute"). Setting also + /// refreshes the rule colour and bottom-panel prompt to match the new mode. + /// + string? CurrentMode { get; set; } + + /// + /// Echoes a submitted user input as a regular user-input entry in the output area. + /// + void WriteUserInputEcho(string text); + + /// + /// Writes informational output as an output entry, without a trailing newline. + /// + Task WriteInfoAsync(string text, ConsoleColor? color = null); + + /// + /// Writes informational output as an output entry, followed by a newline. + /// + Task WriteInfoLineAsync(string text, ConsoleColor? color = null); + + /// + /// Writes streaming text output from the agent. Successive calls accumulate into a + /// single streaming entry that is re-rendered by the text panel. + /// + Task WriteTextAsync(string text, ConsoleColor? color = null); + + /// + /// Writes a blank-line separator to visually close the streaming output section. + /// + Task EndStreamingOutputAsync(); + + /// + /// Shows a "(no text response from agent)" warning if no text was received + /// and no observer produced follow-up actions. + /// + Task WriteNoTextWarningAsync(bool hasFollowUpActions); + + /// + /// Switches the bottom panel to streaming mode and starts the spinner. + /// + void BeginStreaming(); + + /// + /// Stops the spinner without leaving streaming mode. + /// + void StopSpinner(); + + /// + /// Switches the bottom panel back to text-input mode and stops the spinner. + /// + void EndStreaming(); + + /// + /// Resets per-turn streaming bookkeeping in preparation for a new agent turn. + /// + void BeginStreamingOutput(); + + /// + /// Sets the formatted usage text shown on the agent status bar. + /// + void SetUsageText(string usageText); + + /// + /// Replaces the queued-message display with one entry per pending message. + /// + void SetQueuedMessages(IReadOnlyList pending); + + /// + /// Appends the supplied questions to the pending follow-up question queue in + /// component state. If the queue was empty, the bottom-panel display is + /// reconfigured to present the new head question. + /// + void QueueFollowUpQuestions(IReadOnlyList questions); + + /// + /// Appends a message to the accumulated follow-up response list in component state. + /// Called by the runner for direct outputs and by + /// the component when a question's continuation produces a response. + /// + void AddFollowUpResponse(ChatMessage response); + + /// + /// Pops the head of the pending follow-up question queue. Reconfigures the + /// bottom-panel display for the new head, or restores the default text-input + /// mode if the queue is now empty. + /// + void AdvanceFollowUpQuestion(); + + /// + /// Returns the current accumulated follow-up responses and clears them in state. + /// Called by the runner immediately before invoking the next agent turn. + /// + IReadOnlyList TakeFollowUpResponses(); + + /// + /// Signals that the application should shut down. Completes the shutdown task + /// on the owning component. + /// + void RequestShutdown(); +} 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 index a868e61bdf..0a0307f661 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ConsoleObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ConsoleObserver.cs @@ -18,36 +18,41 @@ public abstract class ConsoleObserver /// Override to set options such as . /// /// The run options to configure. - public virtual void ConfigureRunOptions(AgentRunOptions options) + /// The agent being interacted with. + /// The current agent session. + public virtual void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session) { } /// /// Called for each item in the response stream. /// - /// The harness UX container, used for rendering output and interacting with the user. + /// The UX state driver, used for rendering output. /// The content item from the stream. - public virtual Task OnContentAsync(HarnessUXContainer ux, AIContent content) => Task.CompletedTask; + /// The agent being interacted with. + /// The current agent session. + public virtual Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) => Task.CompletedTask; /// /// Called for each text update in the response stream. /// - /// The harness UX container, used for rendering output and interacting with the user. + /// The UX state driver, used for rendering output. /// The text from the update. - public virtual Task OnTextAsync(HarnessUXContainer ux, 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 harness UX container, used for rendering output and interacting with the user. /// 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( - HarnessUXContainer ux, + public virtual Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session) => Task.CompletedTask; + + /// + /// Called after the response stream completes. Returns a heterogeneous list of + /// follow-up actions (questions to ask the user, and/or messages to add directly to + /// the next agent invocation), or if no follow-up is needed. + /// + /// The UX state driver, used for rendering output. + /// The agent being interacted with. + /// The current agent session. + /// Follow-up actions to process after the stream completes, or . + public virtual Task?> OnStreamCompleteAsync( + IUXStateDriver ux, AIAgent agent, - AgentSession session, - HarnessConsoleOptions options) => Task.FromResult?>(null); + AgentSession session) => 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 index 5e7ddc567c..03af74970a 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ErrorDisplayObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ErrorDisplayObserver.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace Harness.Shared.Console.Observers; @@ -7,10 +8,10 @@ namespace Harness.Shared.Console.Observers; /// /// Displays error content (❌) from the response stream. /// -internal sealed class ErrorDisplayObserver : ConsoleObserver +public sealed class ErrorDisplayObserver : ConsoleObserver { /// - public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content) + public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) { if (content is ErrorContent errorContent) { 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 index 45b00a8d4c..1e7a73df96 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs @@ -2,51 +2,77 @@ using System.Text; using System.Text.Json; +using Harness.ConsoleReactiveComponents; 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. +/// Planning observer that is mode-aware: in planning mode it configures structured +/// JSON output, collects streamed text, and deserializes it as a ; +/// in execution mode it passes text straight through to +/// for live streaming display. /// -internal sealed class PlanningOutputObserver : ConsoleObserver +public sealed class PlanningOutputObserver : ConsoleObserver { private readonly StringBuilder _textCollector = new(); private readonly AgentModeProvider _modeProvider; + private readonly string _planModeName; + private readonly string _executionModeName; + private readonly IReadOnlyDictionary? _modeColors; /// /// Initializes a new instance of the class. /// /// The mode provider for switching modes on approval. - public PlanningOutputObserver(AgentModeProvider modeProvider) + /// The mode name that represents the planning mode. + /// The mode name to switch to when the user approves a plan. + /// Optional mode-to-color mapping for display. + public PlanningOutputObserver(AgentModeProvider modeProvider, string planModeName, string executionModeName, IReadOnlyDictionary? modeColors = null) { this._modeProvider = modeProvider; + this._planModeName = planModeName; + this._executionModeName = executionModeName; + this._modeColors = modeColors; } /// - public override void ConfigureRunOptions(AgentRunOptions options) + public override void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session) { - options.ResponseFormat = ChatResponseFormat.ForJsonSchema(); + if (this.IsPlanningMode(this._modeProvider.GetMode(session))) + { + options.ResponseFormat = ChatResponseFormat.ForJsonSchema(); + } } /// - public override Task OnTextAsync(HarnessUXContainer ux, string text) + public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session) { - // Collect text silently instead of displaying it. - this._textCollector.Append(text); - return Task.CompletedTask; + if (this.IsPlanningMode(ux.CurrentMode)) + { + // Planning mode: collect text silently for JSON parsing after the stream. + this._textCollector.Append(text); + return Task.CompletedTask; + } + + // Execution mode: stream text directly to the console. + return ux.WriteTextAsync(text); } /// - public override async Task?> OnStreamCompleteAsync( - HarnessUXContainer ux, + public override async Task?> OnStreamCompleteAsync( + IUXStateDriver ux, AIAgent agent, - AgentSession session, - HarnessConsoleOptions options) + AgentSession session) { + if (!this.IsPlanningMode(ux.CurrentMode)) + { + // Execution mode: text was already streamed live; nothing to parse. + this._textCollector.Clear(); + return null; + } + // Read collected text from our stream observation. string collectedText = this._textCollector.ToString(); this._textCollector.Clear(); @@ -75,10 +101,9 @@ internal sealed class PlanningOutputObserver : ConsoleObserver return null; } - // Render based on response type. if (planningResponse.Type == PlanningResponseType.Clarification) { - return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(ux, planningResponse)); + return BuildClarificationActions(planningResponse); } if (planningResponse.Type == PlanningResponseType.Approval) @@ -90,67 +115,87 @@ internal sealed class PlanningOutputObserver : ConsoleObserver return null; } - string response = await this.RenderApprovalAndCollectResponseAsync(ux, question, options); - if (response == "Approved") - { - this._modeProvider.SetMode(session, options.ExecutionModeName!); - - await ux.WriteInfoLineAsync($"✅ Switched to {options.ExecutionModeName} mode.", - ModeColors.Get(options.ExecutionModeName, options.ModeColors)); - } - - return AsUserMessages(response); + return new List { this.BuildApprovalAction(question, session) }; } await ux.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(HarnessUXContainer ux, PlanningResponse response) + private static List BuildClarificationActions(PlanningResponse response) { - var answers = new List(); + var actions = new List(response.Questions.Count); foreach (var question in response.Questions) { - string? answer; + string prompt = question.Message; + + async Task Continuation(string answer, IUXStateDriver ux) + { + if (string.IsNullOrWhiteSpace(answer)) + { + string noAnswer = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray)}(no answer){AnsiEscapes.ResetAttributes}"; + await ux.WriteInfoLineAsync(noAnswer, ConsoleColor.Gray).ConfigureAwait(false); + return null; + } + + string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{answer}{AnsiEscapes.ResetAttributes}"; + await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false); + + return new ChatMessage(ChatRole.User, $"Q: {prompt}\nA: {answer}"); + } + if (question.Choices is { Count: > 0 }) { - answer = await ux.ReadSelectionAsync( - question.Message, - question.Choices); + actions.Add(new ChoiceFollowUpQuestion( + Prompt: prompt, + Choices: question.Choices, + AllowCustomText: true, + Continuation: Continuation)); } else { - answer = (await ux.ReadLineAsync(question.Message))?.Trim(); - } - - if (!string.IsNullOrWhiteSpace(answer)) - { - answers.Add($"Q: {question.Message}\nA: {answer}"); + actions.Add(new TextFollowUpQuestion( + Prompt: prompt, + Continuation: Continuation)); } } - return answers.Count > 0 ? string.Join("\n\n", answers) : null; + return actions; } - private async Task RenderApprovalAndCollectResponseAsync(HarnessUXContainer ux, PlanningQuestion question, HarnessConsoleOptions options) + private ChoiceFollowUpQuestion BuildApprovalAction(PlanningQuestion question, AgentSession session) { - var choices = new List - { - "Approve and switch to execute mode", - }; + const string ApproveOption = "Approve and switch to execute mode"; + var choices = new List { ApproveOption }; - string selection = await ux.ReadSelectionAsync(question.Message, choices); + return new ChoiceFollowUpQuestion( + Prompt: question.Message, + Choices: choices, + AllowCustomText: true, + Continuation: async (selection, ux) => + { + string formatted = $"🔹 {question.Message}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{selection}{AnsiEscapes.ResetAttributes}"; + await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false); - if (selection == choices[0]) - { - return "Approved"; - } + if (selection == ApproveOption) + { + this._modeProvider.SetMode(session, this._executionModeName); + await ux.WriteInfoLineAsync( + $"✅ Switched to {this._executionModeName} mode.", + ModeColors.Get(this._executionModeName, this._modeColors)).ConfigureAwait(false); + return new ChatMessage(ChatRole.User, "Approved"); + } - // Custom freeform input — treat as suggested changes. - return selection; + // Custom freeform input — treat as suggested changes. + return new ChatMessage(ChatRole.User, selection); + }); } + + /// + /// Returns when the current mode matches the configured plan mode name. + /// A mode (no mode provider) is also treated as planning mode. + /// + private bool IsPlanningMode(string? currentMode) => + currentMode is null || string.Equals(currentMode, this._planModeName, StringComparison.OrdinalIgnoreCase); } 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 index 7cbaa56f58..4d7e95f754 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ReasoningDisplayObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ReasoningDisplayObserver.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace Harness.Shared.Console.Observers; @@ -7,10 +8,10 @@ namespace Harness.Shared.Console.Observers; /// /// Displays reasoning content in dark magenta from the response stream. /// -internal sealed class ReasoningDisplayObserver : ConsoleObserver +public sealed class ReasoningDisplayObserver : ConsoleObserver { /// - public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content) + public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) { if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text)) { 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 index 2c502aa361..a81d8e829d 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/TextOutputObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/TextOutputObserver.cs @@ -1,15 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI; + 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 sealed class TextOutputObserver : ConsoleObserver { /// - public override async Task OnTextAsync(HarnessUXContainer ux, string text) + public override async Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session) { await ux.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 index c75cbe6dbb..20889d61fa 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolApprovalObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolApprovalObserver.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using Harness.ConsoleReactiveComponents; +using Harness.Shared.Console.ToolFormatters; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -7,86 +9,103 @@ 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. +/// displays approval-needed notifications inline, and after the stream completes returns +/// one per pending approval request. Each question's +/// continuation produces a separate carrying the approval +/// response content. /// -internal sealed class ToolApprovalObserver : ConsoleObserver +public sealed class ToolApprovalObserver : ConsoleObserver { private readonly List _approvalRequests = []; + private readonly IReadOnlyList _formatters; + + /// + /// Initializes a new instance of the class. + /// + /// Optional list of tool formatters. When , + /// the default formatters from are used. + public ToolApprovalObserver(IReadOnlyList? formatters = null) + { + this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters(); + } /// - public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content) + public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) { if (content is ToolApprovalRequestContent approvalRequest) { this._approvalRequests.Add(approvalRequest); string toolName = approvalRequest.ToolCall is FunctionCallContent fc - ? ToolCallFormatter.Format(fc) + ? ToolCallFormatter.Format(this._formatters, fc) : approvalRequest.ToolCall?.ToString() ?? "unknown"; await ux.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow); } } /// - public override async Task?> OnStreamCompleteAsync( - HarnessUXContainer ux, + public override Task?> OnStreamCompleteAsync( + IUXStateDriver ux, AIAgent agent, - AgentSession session, - HarnessConsoleOptions options) + AgentSession session) { if (this._approvalRequests.Count == 0) { - return null; + return Task.FromResult?>(null); + } + + var actions = new List(this._approvalRequests.Count); + foreach (var request in this._approvalRequests) + { + actions.Add(this.BuildApprovalQuestion(request)); } - var messages = await PromptForApprovalsAsync(ux, this._approvalRequests); this._approvalRequests.Clear(); - return messages; + return Task.FromResult?>(actions); } - private static async Task?> PromptForApprovalsAsync(HarnessUXContainer ux, List approvalRequests) + private ChoiceFollowUpQuestion BuildApprovalQuestion(ToolApprovalRequestContent request) { - if (approvalRequests.Count == 0) + string toolName = request.ToolCall is FunctionCallContent fc + ? ToolCallFormatter.Format(this._formatters, fc) + : request.ToolCall?.ToString() ?? "unknown"; + + var choices = new List { - return null; - } + "Approve this call", + "Always approve this tool (any arguments)", + "Always approve this tool with these arguments", + "Deny", + }; - var responses = new List(); - foreach (var request in approvalRequests) - { - string toolName = request.ToolCall is FunctionCallContent fc - ? ToolCallFormatter.Format(fc) - : request.ToolCall?.ToString() ?? "unknown"; + string prompt = $"🔐 Tool approval: {toolName}"; - var choices = new List + return new ChoiceFollowUpQuestion( + Prompt: prompt, + Choices: choices, + AllowCustomText: false, + Continuation: async (selection, ux) => { - "Approve this call", - "Always approve this tool (any arguments)", - "Always approve this tool with these arguments", - "Deny", - }; + 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 selection = await ux.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", + }; - 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 ux.WriteInfoLineAsync($" {action}", ConsoleColor.DarkGray); + ConsoleColor answerColor = selection == "Deny" ? ConsoleColor.Red : ConsoleColor.Green; + string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(answerColor)}{action}{AnsiEscapes.ResetAttributes}"; + await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false); - responses.Add(response); - } - - return [new ChatMessage(ChatRole.User, responses)]; + return new ChatMessage(ChatRole.User, [response]); + }); } } 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 index 0ca55edf36..d47ce4c636 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallDisplayObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallDisplayObserver.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using Harness.Shared.Console.ToolFormatters; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace Harness.Shared.Console.Observers; @@ -8,14 +10,26 @@ namespace Harness.Shared.Console.Observers; /// Displays tool call notifications (🔧) for /// and items in the response stream. /// -internal sealed class ToolCallDisplayObserver : ConsoleObserver +public sealed class ToolCallDisplayObserver : ConsoleObserver { + private readonly IReadOnlyList _formatters; + + /// + /// Initializes a new instance of the class. + /// + /// Optional list of tool formatters. When , + /// the default formatters from are used. + public ToolCallDisplayObserver(IReadOnlyList? formatters = null) + { + this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters(); + } + /// - public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content) + public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) { if (content is FunctionCallContent functionCall) { - await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow); + await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(this._formatters, functionCall)}...", ConsoleColor.DarkYellow); } else if (content is ToolCallContent toolCall) { 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 deleted file mode 100644 index 09c1ea290b..0000000000 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/ToolCallFormatter.cs +++ /dev/null @@ -1,288 +0,0 @@ -// 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 index 7e845ff0ad..14241f6823 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/UsageDisplayObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/UsageDisplayObserver.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace Harness.Shared.Console.Observers; @@ -7,7 +8,7 @@ namespace Harness.Shared.Console.Observers; /// /// Displays token usage statistics (📊) from the response stream. /// -internal sealed class UsageDisplayObserver : ConsoleObserver +public sealed class UsageDisplayObserver : ConsoleObserver { private readonly int? _maxContextWindowTokens; private readonly int? _maxOutputTokens; @@ -24,7 +25,7 @@ internal sealed class UsageDisplayObserver : ConsoleObserver } /// - public override Task OnContentAsync(HarnessUXContainer ux, AIContent content) + public override Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) { if (content is UsageContent usage) { diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/OutputEntry.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/OutputEntry.cs index a838e09007..a9f2956fbc 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/OutputEntry.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/OutputEntry.cs @@ -5,7 +5,7 @@ namespace Harness.Shared.Console; /// /// Represents the type of an output entry in the console conversation. /// -public enum OutputEntryType +internal enum OutputEntryType { /// User input echo (e.g. "You: hello"). UserInput, @@ -25,9 +25,10 @@ public enum OutputEntryType /// /// Represents a single output entry in the console conversation history. -/// These entries are rendered by the via its render delegate. +/// Used internally by to track +/// the in-progress streaming entry and last-entry type for spacing decisions. /// /// The type of output entry. /// The text content of the entry. /// Optional foreground color for rendering. -public record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null); +internal sealed record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null); diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FallbackToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FallbackToolFormatter.cs new file mode 100644 index 0000000000..4d5df2b5fd --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FallbackToolFormatter.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Catch-all formatter that handles any tool not matched by a more specific formatter. +/// Displays a generic summary of the tool's arguments. This formatter should always be +/// placed last in the formatter list. +/// +public sealed class FallbackToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => true; + + /// + public override string? FormatDetail(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; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FileMemoryToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FileMemoryToolFormatter.cs new file mode 100644 index 0000000000..7240089e03 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/FileMemoryToolFormatter.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Formats FileMemory_* tool calls, showing file names and search patterns +/// with tree-view corners for save operations. +/// +public sealed class FileMemoryToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("FileMemory_", StringComparison.Ordinal); + + /// + public override string? FormatDetail(FunctionCallContent call) => call.Name switch + { + "FileMemory_SaveFile" => FormatSaveFile(call), + "FileMemory_ReadFile" => FormatStringArg(call, "fileName"), + "FileMemory_DeleteFile" => FormatStringArg(call, "fileName"), + "FileMemory_SearchFiles" => FormatSearchFiles(call), + _ => null, + }; + + private static string? FormatSaveFile(FunctionCallContent call) + { + string? fileName = GetStringArgumentValue(call, "fileName"); + string? description = GetStringArgumentValue(call, "description"); + + if (fileName is null) + { + return null; + } + + return string.IsNullOrEmpty(description) + ? $"\n └─ {fileName}" + : $"\n └─ {fileName} (with description)"; + } + + private static string? FormatSearchFiles(FunctionCallContent call) + { + string? pattern = GetStringArgumentValue(call, "regexPattern"); + string? filePattern = GetStringArgumentValue(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 = GetStringArgumentValue(call, paramName); + return value is not null ? $"({value})" : null; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ModeToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ModeToolFormatter.cs new file mode 100644 index 0000000000..940a810c59 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ModeToolFormatter.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Formats AgentMode_* tool calls, showing the target mode for Set operations. +/// +public sealed class ModeToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("AgentMode_", StringComparison.Ordinal); + + /// + public override string? FormatDetail(FunctionCallContent call) => call.Name switch + { + "AgentMode_Set" => FormatStringArg(call, "mode"), + _ => null, + }; + + private static string? FormatStringArg(FunctionCallContent call, string paramName) + { + string? value = GetStringArgumentValue(call, paramName); + return value is not null ? $"({value})" : null; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/SubAgentToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/SubAgentToolFormatter.cs new file mode 100644 index 0000000000..915491d354 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/SubAgentToolFormatter.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Formats SubAgents_* tool calls with human-readable details +/// for task start, continue, wait, and result retrieval operations. +/// +public sealed class SubAgentToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("SubAgents_", StringComparison.Ordinal); + + /// + public override string? FormatDetail(FunctionCallContent call) => call.Name switch + { + "SubAgents_StartTask" => FormatStartSubTask(call), + "SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"), + "SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"), + "SubAgents_ContinueTask" => FormatContinueTask(call), + "SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"), + _ => null, + }; + + private static string? FormatStartSubTask(FunctionCallContent call) + { + string? agentName = GetStringArgumentValue(call, "agentName"); + string? description = GetStringArgumentValue(call, "description"); + + if (agentName is null && description is null) + { + return null; + } + + var sb = new StringBuilder(); + + if (agentName is not null && description is not null) + { + sb.Append($"\n ├─ Agent: {agentName}"); + sb.Append($"\n └─ \"{Truncate(description, 80)}\""); + } + else if (agentName is not null) + { + sb.Append($"\n └─ Agent: {agentName}"); + } + else + { + sb.Append($"\n └─ \"{Truncate(description!, 80)}\""); + } + + return sb.ToString(); + } + + private static string? FormatIdList(FunctionCallContent call, string paramName, string verb) + { + List? ids = GetIntListArgumentValue(call, paramName); + if (ids is null || ids.Count == 0) + { + return null; + } + + var sb = new StringBuilder(); + for (int i = 0; i < ids.Count; i++) + { + string connector = i < ids.Count - 1 ? "├─" : "└─"; + sb.Append($"\n {connector} {verb} #{ids[i]}"); + } + + return sb.ToString(); + } + + private static string? FormatSingleId(FunctionCallContent call, string paramName) + { + int? id = GetIntArgumentValue(call, paramName); + return id.HasValue ? $"(task #{id.Value})" : null; + } + + private static string? FormatContinueTask(FunctionCallContent call) + { + int? taskId = GetIntArgumentValue(call, "taskId"); + string? text = GetStringArgumentValue(call, "text"); + + if (!taskId.HasValue) + { + return null; + } + + if (text is not null) + { + var sb = new StringBuilder(); + sb.Append($"\n ├─ Task #{taskId.Value}"); + sb.Append($"\n └─ \"{Truncate(text, 80)}\""); + return sb.ToString(); + } + + return $"\n └─ Task #{taskId.Value}"; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/TodoToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/TodoToolFormatter.cs new file mode 100644 index 0000000000..98e041ede7 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/TodoToolFormatter.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Formats TodoList_* tool calls with tree-view output for added items +/// and structured output for complete/remove operations. +/// +public sealed class TodoToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("TodoList_", StringComparison.Ordinal); + + /// + public override string? FormatDetail(FunctionCallContent call) => call.Name switch + { + "TodoList_Add" => FormatAddTodos(call), + "TodoList_Complete" => FormatIdList(call, "ids", "Complete"), + "TodoList_Remove" => FormatIdList(call, "ids", "Remove"), + _ => null, + }; + + 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")})"); + for (int i = 0; i < titles.Count; i++) + { + string connector = i < titles.Count - 1 ? "├─" : "└─"; + sb.Append($"\n {connector} {titles[i]}"); + } + + return sb.ToString(); + } + + private static string? FormatIdList(FunctionCallContent call, string paramName, string verb) + { + List? ids = GetIntListArgumentValue(call, paramName); + if (ids is null || ids.Count == 0) + { + return null; + } + + var sb = new StringBuilder(); + for (int i = 0; i < ids.Count; i++) + { + string connector = i < ids.Count - 1 ? "├─" : "└─"; + sb.Append($"\n {connector} {verb} #{ids[i]}"); + } + + return sb.ToString(); + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ToolCallFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ToolCallFormatter.cs new file mode 100644 index 0000000000..f8a131dd74 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/ToolCallFormatter.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Base class for tool call formatters that produce human-readable display strings +/// for items shown in the console. +/// +public abstract class ToolCallFormatter +{ + /// + /// Returns if this formatter can handle the given function call. + /// + /// The function call content to check. + /// if this formatter should be used; otherwise . + public abstract bool CanFormat(FunctionCallContent call); + + /// + /// Returns the detail portion of the formatted output for the given tool call, + /// or if only the tool name should be displayed. + /// + /// The function call content to format. + /// A detail string to append after the tool name, or . + public abstract string? FormatDetail(FunctionCallContent call); + + /// + /// Formats a tool call using the first matching formatter from the provided list. + /// Returns "{toolName} {detail}" when a formatter produces detail, + /// or just "{toolName}" otherwise. + /// + internal static string Format(IReadOnlyList formatters, FunctionCallContent call) + { + foreach (var formatter in formatters) + { + if (formatter.CanFormat(call)) + { + string? detail = formatter.FormatDetail(call); + return detail is not null ? $"{call.Name} {detail}" : call.Name; + } + } + + return call.Name; + } + + /// + /// Creates the default list of tool call formatters. The + /// is always last. Users can call this method and combine the result with their own formatters. + /// + /// A list of all built-in tool call formatters. + public static List BuildDefaultToolFormatters() + { + return + [ + new TodoToolFormatter(), + new ModeToolFormatter(), + new SubAgentToolFormatter(), + new FileMemoryToolFormatter(), + new WebSearchToolFormatter(), + new FallbackToolFormatter(), + ]; + } + + /// + /// Extracts a string argument value from a function call. + /// + protected static string? GetStringArgumentValue(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(), + }; + } + + /// + /// Extracts an integer argument value from a function call. + /// + protected static int? GetIntArgumentValue(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, + }; + } + + /// + /// Extracts a list of integer argument values from a function call. + /// + protected static List? GetIntListArgumentValue(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; + } + + /// + /// Truncates a string to the specified maximum length, appending an ellipsis if truncated. + /// + protected 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/ToolFormatters/WebSearchToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/WebSearchToolFormatter.cs new file mode 100644 index 0000000000..b2c681306f --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/ToolFormatters/WebSearchToolFormatter.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Harness.Shared.Console.ToolFormatters; + +/// +/// Formats web_search tool calls, showing the search query. +/// +public sealed class WebSearchToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => + call.Name is "web_search"; + + /// + public override string? FormatDetail(FunctionCallContent call) + { + string? value = GetStringArgumentValue(call, "query"); + return value is not null ? $"({value})" : null; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/DownloadUriToolFormatter.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/DownloadUriToolFormatter.cs new file mode 100644 index 0000000000..4175f2b1f3 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/DownloadUriToolFormatter.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Harness.Shared.Console.ToolFormatters; +using Microsoft.Extensions.AI; + +namespace SampleApp; + +/// +/// Formats DownloadUri tool calls, showing the target URI. +/// +public sealed class DownloadUriToolFormatter : ToolCallFormatter +{ + /// + public override bool CanFormat(FunctionCallContent call) => + call.Name is "DownloadUri"; + + /// + public override string? FormatDetail(FunctionCallContent call) + { + string? value = GetStringArgumentValue(call, "uri"); + return value is not null ? $"({value})" : null; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs index 2e79dd572b..1c9e93588c 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -8,7 +8,8 @@ // // Special commands: // /todos — Display the current todo list without invoking the agent. -// exit — End the session. +// /mode — Get or set the current agent mode. +// /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. @@ -16,6 +17,7 @@ using System.ClientModel.Primitives; using Azure.Identity; using Harness.Shared.Console; +using Harness.Shared.Console.ToolFormatters; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI; @@ -158,13 +160,15 @@ AIAgent agent = // 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" + Observers = HarnessConsoleOptions.BuildObserversWithPlanning( + agent, + planModeName: "plan", + executionModeName: "execute", + maxContextWindowTokens: MaxContextWindowTokens, + maxOutputTokens: MaxOutputTokens, + toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()]), + CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent), }); diff --git a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs index d34ac786e0..721da3339c 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Program.cs @@ -6,7 +6,7 @@ // equipped with Foundry's hosted web search tool. // // Special commands: -// exit — End the session. +// /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. @@ -103,5 +103,4 @@ AIAgent parentAgent = // Run the interactive console session. await HarnessConsole.RunAgentAsync( parentAgent, - title: "Stock Price Researcher (SubAgents Demo)", userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):"); diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs index 60505cbe7d..b1b5bc5f2d 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs @@ -8,7 +8,7 @@ // Ask the agent to analyze the data, produce summaries, or create new output files. // // Special commands: -// exit — End the session. +// /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. @@ -85,5 +85,4 @@ AIAgent agent = // Run the interactive console session. await HarnessConsole.RunAgentAsync( agent, - title: "Data Processing Assistant", userPrompt: "Ask me to analyze the data files, produce summaries, or create output files."); diff --git a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs index 9839d2a157..c22adca090 100644 --- a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs @@ -17,6 +17,7 @@ namespace Microsoft.Agents.AI; /// assembles the following pipeline from a caller-supplied : /// /// — automatic function/tool invocation. +/// — allows external code to inject messages into the conversation mid-stream. /// — persists chat history after every individual service call within a function-invocation loop. /// with a — applies context-window compaction before each call so long function-invocation loops do not overflow the context window. /// @@ -110,6 +111,7 @@ public sealed class HarnessAgent : DelegatingAIAgent return chatClient .AsBuilder() .UseFunctionInvocation() + .UseMessageInjection() .UsePerServiceCallChatHistoryPersistence() .UseAIContextProviders(compactionProvider) .BuildAIAgent(new ChatClientAgentOptions From 189e64bfdd8b7eedf7087a31f5eeab5cac1a6a4c Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Thu, 14 May 2026 08:30:48 -0700 Subject: [PATCH 2/2] .NET: Add sample for invoking Foundry Toolbox tools from declarative workflows (#5829) * Add sample for invoking Foundry Toolbox tools from declarative workflows * Addressed initial PR comments. --- dotnet/agent-framework-dotnet.slnx | 1 + dotnet/eng/verify-samples/WorkflowSamples.cs | 11 + .../InvokeFoundryToolboxMcp.csproj | 42 ++++ .../InvokeFoundryToolboxMcp.yaml | 87 +++++++ .../InvokeFoundryToolboxMcp/Program.cs | 218 ++++++++++++++++++ .../DefaultMcpToolHandler.cs | 95 +++++++- .../DefaultMcpToolHandlerTests.cs | 157 +++++++++++++ .../ObjectModel/InvokeMcpToolExecutorTest.cs | 38 +++ 8 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj create mode 100644 dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml create mode 100644 dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 87e6d9d3c6..750af38d7a 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -242,6 +242,7 @@ + diff --git a/dotnet/eng/verify-samples/WorkflowSamples.cs b/dotnet/eng/verify-samples/WorkflowSamples.cs index 2842f4af89..2793dd04c5 100644 --- a/dotnet/eng/verify-samples/WorkflowSamples.cs +++ b/dotnet/eng/verify-samples/WorkflowSamples.cs @@ -478,6 +478,17 @@ internal static class WorkflowSamples ExpectedOutputDescription = ["The output should show a workflow invoking a function tool (e.g. a menu plugin) to answer a question about the soup of the day."], }, + new SampleDefinition + { + Name = "Workflow_Declarative_InvokeFoundryToolboxMcp", + ProjectPath = "samples/03-workflows/Declarative/InvokeFoundryToolboxMcp", + RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], + OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME", "FOUNDRY_TOOLBOX_NAME", "FOUNDRY_AGENT_TOOLSET_API_VERSION"], + Inputs = ["How do I use Azure OpenAI with my data?"], + InputDelayMs = 3000, + ExpectedOutputDescription = ["The output should show a workflow using Foundry Toolbox MCP tools to search Microsoft Learn documentation and web search to provide a summary of results."], + }, + new SampleDefinition { Name = "Workflow_Declarative_InvokeMcpTool", diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj new file mode 100644 index 0000000000..3e70c3f994 --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj @@ -0,0 +1,42 @@ + + + + Exe + net10.0 + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml new file mode 100644 index 0000000000..b5f6f39316 --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.yaml @@ -0,0 +1,87 @@ +# +# This workflow demonstrates invoking MCP tools through a Foundry toolbox MCP proxy. +# +# The toolbox is provisioned with TWO different tool types: +# 1. A Foundry built-in web_search tool +# 2. A Microsoft Learn MCP server (microsoft_docs) +# Both are surfaced through the same MCP-compatible toolbox endpoint. +# +# The workflow: +# 1. Accepts a documentation/web search query as input +# 2. Lists the tools exposed by the Foundry toolbox using reserved toolName: tools/list +# 3. Invokes the microsoft_docs_search MCP tool +# 4. Invokes the built-in web_search tool against the same toolbox endpoint +# 5. Uses an agent to summarize and combine both result sets +# +# Example input: +# How do I use Azure OpenAI with my data? +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_invoke_foundry_toolbox_mcp + actions: + + # Set the search query from user input. + - kind: SetVariable + id: set_search_query + variable: Local.SearchQuery + value: =System.LastMessage.Text + + # List tools exposed by the Foundry toolbox MCP proxy. + - kind: InvokeMcpTool + id: list_toolbox_tools + serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL + serverLabel: foundry_toolbox + toolName: tools/list + conversationId: =System.ConversationId + headers: + Foundry-Features: Toolboxes=V1Preview + output: + autoSend: true + result: Local.ToolboxTools + + # Invoke a specific tool exposed through the toolbox and add the result to the conversation. + - kind: InvokeMcpTool + id: search_docs_with_toolbox + serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL + serverLabel: foundry_toolbox + toolName: =Env.FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL & "___microsoft_docs_search" + conversationId: =System.ConversationId + headers: + Foundry-Features: Toolboxes=V1Preview + arguments: + query: =Local.SearchQuery + output: + autoSend: true + result: Local.SearchResult + + # Invoke the web_search built-in tool through the same toolbox proxy. The toolbox surfaces + # built-in Foundry tools (like web_search) alongside MCP tools through one MCP-compatible + # endpoint. Note that web_search expects argument 'search_query' (not 'query'). + - kind: InvokeMcpTool + id: search_web_with_toolbox + serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL + serverLabel: foundry_toolbox + toolName: =Env.FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME + conversationId: =System.ConversationId + headers: + Foundry-Features: Toolboxes=V1Preview + arguments: + search_query: =Local.SearchQuery + output: + autoSend: true + result: Local.WebSearchResult + + # Use the agent to summarize what happened and answer from the toolbox result. + - kind: InvokeAzureAgent + id: summarize_toolbox_result + agent: + name: FoundryToolboxMcpAgent + conversationId: =System.ConversationId + input: + messages: =UserMessage("Combine the Microsoft Learn docs results and the Foundry web search results in the conversation to answer the query " & Local.SearchQuery) + output: + autoSend: true + messages: Local.Summary diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs new file mode 100644 index 0000000000..6636cb13a7 --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates using InvokeMcpTool to call MCP tools through a Foundry toolbox. +// It creates a sample toolbox that exposes Microsoft Learn MCP tools, lists the toolbox tools +// through the reserved tools/list operation, then calls microsoft_docs_search from the workflow. + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.AI.Workflows.Declarative.Mcp; +using Microsoft.Extensions.Configuration; +using OpenAI.Responses; +using Shared.Foundry; +using Shared.Workflows; + +#pragma warning disable OPENAI001 // Experimental API +#pragma warning disable AAIP001 // AgentToolboxes is experimental + +namespace Demo.Workflows.Declarative.InvokeFoundryToolboxMcp; + +/// +/// Demonstrates a workflow that uses InvokeMcpTool to call MCP tools exposed through a Foundry toolbox. +/// +/// +/// This sample provisions a toolbox with Microsoft Learn MCP tools, uses the reserved +/// tools/list tool name to list the toolbox tools, calls one specific toolbox tool, +/// and has a Foundry agent summarize the results. +/// +internal sealed class Program +{ + private const string ToolboxNameSetting = "FOUNDRY_TOOLBOX_NAME"; + private const string ToolboxApiVersionSetting = "FOUNDRY_AGENT_TOOLSET_API_VERSION"; + private const string ToolboxMcpServerUrlSetting = "FOUNDRY_TOOLBOX_MCP_SERVER_URL"; + private const string DocsServerLabelSetting = "FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL"; + private const string WebSearchToolNameSetting = "FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME"; + private const string DefaultToolboxName = "declarative_foundry_toolbox_mcp"; + private const string DefaultToolboxApiVersion = "v1"; + private const string DefaultDocsServerLabel = "microsoft_docs"; + private const string DefaultWebSearchToolName = "web_search"; + + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + string toolboxName = configuration[ToolboxNameSetting] ?? DefaultToolboxName; + string toolboxApiVersion = configuration[ToolboxApiVersionSetting] ?? DefaultToolboxApiVersion; + string docsServerLabel = configuration[DocsServerLabelSetting] ?? DefaultDocsServerLabel; + string webSearchToolName = configuration[WebSearchToolNameSetting] ?? DefaultWebSearchToolName; + + // 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. + DefaultAzureCredential credential = new(); + + // Ensure sample toolbox and agent exist in Foundry + string toolboxEndpoint = await CreateSampleToolboxAsync(toolboxName, docsServerLabel, foundryEndpoint, credential); + string toolboxMcpServerUrl = BuildToolboxMcpServerUrl(toolboxEndpoint, toolboxName, toolboxApiVersion); + IConfiguration workflowConfiguration = new ConfigurationBuilder() + .AddConfiguration(configuration) + .AddInMemoryCollection(new Dictionary + { + [ToolboxMcpServerUrlSetting] = toolboxMcpServerUrl, + [DocsServerLabelSetting] = docsServerLabel, + [WebSearchToolNameSetting] = webSearchToolName, + }) + .Build(); + + await CreateAgentAsync(foundryEndpoint, configuration, credential); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // Create the MCP tool handler for invoking the Foundry toolbox MCP proxy. + ConcurrentBag createdHttpClients = []; + DefaultMcpToolHandler mcpToolHandler = new( + httpClientProvider: async (serverUrl, _) => + { + await Task.CompletedTask.ConfigureAwait(false); + + if (!string.Equals(serverUrl, toolboxMcpServerUrl, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + FoundryToolboxBearerTokenHandler handler = new(credential) + { + InnerHandler = new HttpClientHandler() + }; + HttpClient httpClient = new(handler); + createdHttpClients.Add(httpClient); + return httpClient; + }); + + try + { + // Create the workflow factory with MCP tool provider + WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint) + { + Configuration = workflowConfiguration, + McpToolHandler = mcpToolHandler + }; + + // Execute the workflow + WorkflowRunner runner = new() { UseJsonCheckpoints = true }; + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + finally + { + // Clean up connections and dispose created HttpClients + await mcpToolHandler.DisposeAsync(); + + foreach (HttpClient httpClient in createdHttpClients) + { + httpClient.Dispose(); + } + } + } + + private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration, TokenCredential credential) + { + AIProjectClient aiProjectClient = new(foundryEndpoint, credential); + + await aiProjectClient.CreateAgentAsync( + agentName: "FoundryToolboxMcpAgent", + agentDefinition: DefineToolboxAgent(configuration), + agentDescription: "Summarizes Foundry toolbox MCP tool results"); + } + + private static DeclarativeAgentDefinition DefineToolboxAgent(IConfiguration configuration) + { + return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel)) + { + Instructions = + """ + You are a helpful assistant that explains results produced by tools exposed through a Foundry toolbox. + The conversation history contains output from BOTH a Microsoft Learn documentation search (MCP) and a Foundry web search. + Synthesize an answer that draws on both sources, calls out where they agree or differ, and notes which toolbox tool produced each fact when it is relevant. + Be concise. + """ + }; + } + + private static async Task CreateSampleToolboxAsync(string name, string serverLabel, Uri foundryEndpoint, TokenCredential credential) + { + AgentAdministrationClientOptions options = new(); + options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall); + AgentAdministrationClient adminClient = new(foundryEndpoint, credential, options); + AgentToolboxes toolboxClient = adminClient.GetAgentToolboxes(); + + try + { + await toolboxClient.DeleteToolboxAsync(name); + Console.WriteLine($"Deleted existing toolbox '{name}'"); + } + catch (ClientResultException ex) when (ex.Status == 404) + { + // Toolbox does not exist. + } + + ProjectsAgentTool webTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateWebSearchTool()); + + ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool( + serverLabel: serverLabel, + serverUri: new Uri("https://learn.microsoft.com/api/mcp"), + toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval))); + + ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync( + name: name, + tools: [webTool, mcpTool], + description: "Sample toolbox combining Foundry web search with the Microsoft Learn MCP tools for the declarative InvokeFoundryToolboxMcp sample.")).Value; + + Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))"); + + return $"{foundryEndpoint.ToString().TrimEnd('/')}/toolboxes"; + } + + private static string BuildToolboxMcpServerUrl(string toolboxEndpoint, string toolboxName, string apiVersion) => + $"{toolboxEndpoint.TrimEnd('/')}/{toolboxName}/mcp?api-version={Uri.EscapeDataString(apiVersion)}"; + + private sealed class FoundryToolboxBearerTokenHandler(TokenCredential credential) : DelegatingHandler + { + private static readonly TokenRequestContext s_tokenContext = + new(["https://ai.azure.com/.default"]); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + AccessToken token = await credential.GetTokenAsync(s_tokenContext, cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + + return await base.SendAsync(request, cancellationToken); + } + } + + private sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy + { + private const string FeatureHeader = "Foundry-Features"; + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Add(FeatureHeader, feature); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Add(FeatureHeader, feature); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs index 681cd5dc85..66da428cf6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs @@ -3,12 +3,15 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Net.Http; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -24,6 +27,14 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp; /// public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable { + /// + /// Reserved toolName value that maps an request + /// to the MCP protocol tools/list discovery operation. + /// + public const string ListToolsToolName = "tools/list"; + + private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true }; + private readonly Func>? _httpClientProvider; private readonly Dictionary _clients = []; private readonly Dictionary _ownedHttpClients = []; @@ -53,9 +64,18 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable CancellationToken cancellationToken = default) { // TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore - McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString()); + if (IsListToolsToolName(toolName)) + { + ThrowIfListToolsArgumentsSpecified(arguments); + McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false); + IList tools = await listToolsClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool)); + } + McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false); + McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString()); + // Convert IDictionary to IReadOnlyDictionary for CallToolAsync IReadOnlyDictionary? readOnlyArguments = arguments is null ? null @@ -72,6 +92,23 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable return resultContent; } + internal static bool IsListToolsToolName(string toolName) => + string.Equals(toolName, ListToolsToolName, StringComparison.Ordinal); + + internal static McpServerToolResultContent CreateListToolsResultContent(IEnumerable tools) + { + Throw.IfNull(tools); + + McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString()) + { + Outputs = [] + }; + + resultContent.Outputs.Add(new TextContent(SerializeToolsList(tools))); + + return resultContent; + } + /// public async ValueTask DisposeAsync() { @@ -183,6 +220,16 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable return hashCode.ToString(CultureInfo.InvariantCulture); } + private static void ThrowIfListToolsArgumentsSpecified(IDictionary? arguments) + { + if (arguments is { Count: > 0 }) + { + throw new ArgumentException( + $"The reserved MCP '{ListToolsToolName}' operation does not accept tool arguments.", + nameof(arguments)); + } + } + private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result) { // Ensure Outputs list is initialized @@ -230,6 +277,17 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable TextContentBlock text => new TextContent(text.Text), ImageContentBlock image => CreateDataContent(image.Data, image.MimeType ?? "image/*"), AudioContentBlock audio => CreateDataContent(audio.Data, audio.MimeType ?? "audio/*"), + EmbeddedResourceBlock embedded => ConvertEmbeddedResource(embedded), + _ => new TextContent(block.ToString() ?? string.Empty), + }; + } + + private static AIContent ConvertEmbeddedResource(EmbeddedResourceBlock block) + { + return block.Resource switch + { + TextResourceContents text => new TextContent(text.Text), + BlobResourceContents blob => CreateDataContent(blob.Blob, blob.MimeType ?? "application/octet-stream"), _ => new TextContent(block.ToString() ?? string.Empty), }; } @@ -255,4 +313,39 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable return new DataContent($"data:{mediaType};base64,{base64}", mediaType); } + + private static string SerializeToolsList(IEnumerable tools) + { + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream, s_toolListJsonWriterOptions)) + { + writer.WriteStartObject(); + writer.WriteStartArray("tools"); + + foreach (Tool tool in tools) + { + writer.WriteStartObject(); + writer.WriteString("name", tool.Name); + writer.WriteString("description", tool.Description); + writer.WritePropertyName("inputSchema"); + tool.InputSchema.WriteTo(writer); + writer.WritePropertyName("outputSchema"); + if (tool.OutputSchema is JsonElement outputSchema) + { + outputSchema.WriteTo(writer); + } + else + { + writer.WriteNullValue(); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs index abfa95cc36..f9cb5cdb56 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Net.Http; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -320,6 +321,92 @@ public sealed class DefaultMcpToolHandlerTests #endregion + #region Reserved Tools/List Tests + + [Fact] + public void IsListToolsToolName_WithReservedName_ShouldReturnTrue() + { + // Act + bool result = DefaultMcpToolHandler.IsListToolsToolName(DefaultMcpToolHandler.ListToolsToolName); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void IsListToolsToolName_WithRegularToolName_ShouldReturnFalse() + { + // Act + bool result = DefaultMcpToolHandler.IsListToolsToolName("search"); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task InvokeToolAsync_WithListToolsArguments_ShouldThrowArgumentExceptionAsync() + { + // Arrange + DefaultMcpToolHandler handler = new(); + + try + { + // Act + Func act = async () => await handler.InvokeToolAsync( + serverUrl: "http://localhost:12345/mcp", + serverLabel: "test", + toolName: DefaultMcpToolHandler.ListToolsToolName, + arguments: new Dictionary { ["ignored"] = true }, + headers: null, + connectionName: null); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*does not accept tool arguments*"); + } + finally + { + await handler.DisposeAsync(); + } + } + + [Fact] + public async Task CreateListToolsResultContent_WithTools_ShouldSerializeToolMetadataAsync() + { + // Arrange + JsonElement inputSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "query": { + "type": "string" + } + }, + "required": [ "query" ] + } + """); + Tool tool = new() + { + Name = "search", + Description = "Searches documentation.", + InputSchema = inputSchema + }; + + // Act + McpServerToolResultContent result = DefaultMcpToolHandler.CreateListToolsResultContent([tool]); + + // Assert + TextContent text = result.Outputs.Should().ContainSingle().Subject.Should().BeOfType().Subject; + using JsonDocument document = JsonDocument.Parse(text.Text); + JsonElement listedTool = document.RootElement.GetProperty("tools")[0]; + listedTool.GetProperty("name").GetString().Should().Be("search"); + listedTool.GetProperty("description").GetString().Should().Be("Searches documentation."); + listedTool.GetProperty("inputSchema").GetProperty("properties").GetProperty("query").GetProperty("type").GetString().Should().Be("string"); + } + + #endregion + #region Interface Implementation Tests [Fact] @@ -488,5 +575,75 @@ public sealed class DefaultMcpToolHandlerTests dataContent.MediaType.Should().Be("audio/*"); } + [Fact] + public void ConvertContentBlock_EmbeddedResourceBlock_WithTextResource_ShouldReturnTextContent() + { + // Arrange + EmbeddedResourceBlock block = new() + { + Resource = new TextResourceContents + { + Text = "embedded text payload", + Uri = "resource://example", + MimeType = "text/plain", + }, + }; + + // Act + AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); + + // Assert + result.Should().BeOfType() + .Which.Text.Should().Be("embedded text payload"); + } + + [Fact] + public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent() + { + // Arrange + byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA"); + EmbeddedResourceBlock block = new() + { + Resource = new BlobResourceContents + { + Blob = new ReadOnlyMemory(base64Bytes), + Uri = "resource://example.bin", + MimeType = "application/zip", + }, + }; + + // Act + AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); + + // Assert + DataContent dataContent = result.Should().BeOfType().Subject; + dataContent.MediaType.Should().Be("application/zip"); + dataContent.Uri.Should().Be("data:application/zip;base64,UklGRiQA"); + } + + [Fact] + public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_NullMimeType_DefaultsToOctetStream() + { + // Arrange + byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA"); + EmbeddedResourceBlock block = new() + { + Resource = new BlobResourceContents + { + Blob = new ReadOnlyMemory(base64Bytes), + Uri = "resource://example.bin", + MimeType = null!, + }, + }; + + // Act + AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); + + // Assert + DataContent dataContent = result.Should().BeOfType().Subject; + dataContent.MediaType.Should().Be("application/octet-stream"); + dataContent.Uri.Should().Be("data:application/octet-stream;base64,UklGRiQA"); + } + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs index a1337b3e2d..d047badaf7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs @@ -432,6 +432,44 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl VerifyInvocationEvent(events); } + [Fact] + public async Task InvokeMcpToolExecuteWithReservedListToolsNameAsync() + { + // Arrange + this.State.InitializeSystem(); + const string ListToolsToolName = "tools/list"; + string? capturedToolName = null; + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithReservedListToolsNameAsync), + serverUrl: TestServerUrl, + toolName: ListToolsToolName); + Mock mockProvider = new(); + mockProvider.Setup(provider => provider.InvokeToolAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, IDictionary?, string?, CancellationToken>( + (_, _, toolName, _, _, _, _) => capturedToolName = toolName) + .ReturnsAsync(new McpServerToolResultContent("list-tools-call-id") + { + Outputs = [new TextContent("{\"tools\":[]}")] + }); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert + VerifyModel(model, action); + VerifyInvocationEvent(events); + Assert.Equal(ListToolsToolName, capturedToolName); + } + [Fact] public async Task InvokeMcpToolExecuteWithMultipleContentTypesAsync() {