chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for console command handlers (e.g., /todos, /mode). Command handlers
|
||||
/// are checked in order before user input is sent to the agent. The first handler
|
||||
/// that accepts the input prevents further handlers from being checked.
|
||||
/// </summary>
|
||||
public abstract class CommandHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the help text for this command, displayed in the mode-and-help bar.
|
||||
/// Returns <see langword="null"/> if the command is not currently available.
|
||||
/// </summary>
|
||||
/// <returns>Help text like <c>"/todos (show todo list)"</c>, or <see langword="null"/>.</returns>
|
||||
public abstract string? GetHelpText();
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to handle the given user input.
|
||||
/// </summary>
|
||||
/// <param name="input">The raw user input string.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
/// <param name="ux">The UX state driver for rendering output.</param>
|
||||
/// <returns><see langword="true"/> if this handler handled the input; <see langword="false"/> otherwise.</returns>
|
||||
public abstract ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <c>/exit</c> command to shut down the console application.
|
||||
/// </summary>
|
||||
public sealed class ExitCommandHandler : CommandHandler
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override string? GetHelpText() => "/exit (quit)";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||
{
|
||||
if (!input.Equals("/exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new ValueTask<bool>(false);
|
||||
}
|
||||
|
||||
ux.RequestShutdown();
|
||||
return new ValueTask<bool>(true);
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <c>/mode</c> command to display or switch the current agent mode.
|
||||
/// </summary>
|
||||
public sealed class ModeCommandHandler : CommandHandler
|
||||
{
|
||||
private readonly AgentModeProvider? _modeProvider;
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ModeCommandHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="modeProvider">The mode provider, or <see langword="null"/> if not available.</param>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public ModeCommandHandler(AgentModeProvider? modeProvider, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._modeProvider = modeProvider;
|
||||
this._modeColors = modeColors;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||
{
|
||||
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._modeProvider is null)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("AgentModeProvider is not available.").ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
string current = this._modeProvider.GetMode(session);
|
||||
await ux.WriteInfoLineAsync($"Current mode: {current}").ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
string newMode = parts[1];
|
||||
|
||||
try
|
||||
{
|
||||
this._modeProvider.SetMode(session, newMode);
|
||||
ux.CurrentMode = newMode;
|
||||
await ux.WriteInfoLineAsync($"Switched to {newMode} mode.", ModeColors.Get(newMode, this._modeColors)).ConfigureAwait(false);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
await ux.WriteInfoLineAsync(ex.Message, ConsoleColor.Red).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <c>/session-export <filename></c> and <c>/session-import <filename></c>
|
||||
/// commands for serializing the current session to a file and restoring a session from a file.
|
||||
/// </summary>
|
||||
public sealed class SessionCommandHandler : CommandHandler
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SessionCommandHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent used for session serialization and deserialization.</param>
|
||||
public SessionCommandHandler(AIAgent agent)
|
||||
{
|
||||
this._agent = agent;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? GetHelpText() => "/session-export <file> | /session-import <file>";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||
{
|
||||
string command = input.Split(' ', 2)[0];
|
||||
|
||||
if (command.Equals("/session-export", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await this.HandleExportAsync(input, session, ux).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (command.Equals("/session-import", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await this.HandleImportAsync(input, ux).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task HandleExportAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||
{
|
||||
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("Usage: /session-export <filename>").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
string filename = parts[1];
|
||||
try
|
||||
{
|
||||
JsonElement serialized = await this._agent.SerializeSessionAsync(session).ConfigureAwait(false);
|
||||
string json = JsonSerializer.Serialize(serialized);
|
||||
await File.WriteAllTextAsync(filename, json).ConfigureAwait(false);
|
||||
await ux.WriteInfoLineAsync($"Session exported to {filename}").ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"Failed to export session to {filename}: {ex.Message}").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleImportAsync(string input, IUXStateDriver ux)
|
||||
{
|
||||
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("Usage: /session-import <filename>").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
string filename = parts[1];
|
||||
try
|
||||
{
|
||||
string json = await File.ReadAllTextAsync(filename).ConfigureAwait(false);
|
||||
JsonElement element = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
AgentSession newSession = await this._agent.DeserializeSessionAsync(element).ConfigureAwait(false);
|
||||
await ux.ReplaceSessionAsync(newSession).ConfigureAwait(false);
|
||||
await ux.WriteInfoLineAsync($"Session imported from {filename}").ConfigureAwait(false);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"File not found: {filename}").ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"Failed to import session from {filename}: {ex.Message}").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <c>/todos</c> command to display the current todo list.
|
||||
/// </summary>
|
||||
public sealed class TodoCommandHandler : CommandHandler
|
||||
{
|
||||
private readonly TodoProvider? _todoProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TodoCommandHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="todoProvider">The todo provider, or <see langword="null"/> if not available.</param>
|
||||
public TodoCommandHandler(TodoProvider? todoProvider)
|
||||
{
|
||||
this._todoProvider = todoProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||
{
|
||||
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._todoProvider is null)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("TodoProvider is not available.").ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
var todos = await this._todoProvider.GetAllTodosAsync(session).ConfigureAwait(false);
|
||||
if (todos.Count == 0)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("No todos yet.").ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
await ux.WriteInfoLineAsync("── Todo List ──").ConfigureAwait(false);
|
||||
foreach (var item in todos)
|
||||
{
|
||||
string status = item.IsComplete ? "✓" : "○";
|
||||
ConsoleColor color = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White;
|
||||
string description = string.IsNullOrWhiteSpace(item.Description)
|
||||
? string.Empty
|
||||
: $" — {item.Description}";
|
||||
await ux.WriteInfoLineAsync($"[{status}] #{item.Id} {item.Title}{description}", color).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.Shared.Console.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="AgentModeAndHelp"/>.
|
||||
/// </summary>
|
||||
public record AgentModeAndHelpProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets or sets the current mode name (e.g. "plan", "execute"), or <see langword="null"/> if no mode is active.</summary>
|
||||
public string? Mode { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the foreground color for the mode label.</summary>
|
||||
public ConsoleColor? ModeColor { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the help text to display (e.g. available commands and exit info).</summary>
|
||||
public string? HelpText { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a single fixed line below the bottom rule showing
|
||||
/// the current agent mode (in the mode colour) and available commands (in dark grey).
|
||||
/// </summary>
|
||||
public class AgentModeAndHelp : ConsoleReactiveComponent<AgentModeAndHelpProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the height of the component.
|
||||
/// </summary>
|
||||
/// <param name="props">The component props.</param>
|
||||
/// <returns>1 if there is content to display; otherwise 0.</returns>
|
||||
public static int CalculateHeight(AgentModeAndHelpProps props) =>
|
||||
(props.Mode is not null || !string.IsNullOrEmpty(props.HelpText)) ? 1 : 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(AgentModeAndHelpProps props, ConsoleReactiveState state)
|
||||
{
|
||||
if (props.Mode is null && string.IsNullOrEmpty(props.HelpText))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
System.Console.Write(AnsiEscapes.SaveCursor);
|
||||
System.Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y));
|
||||
|
||||
bool hasMode = props.Mode is not null;
|
||||
|
||||
if (hasMode)
|
||||
{
|
||||
if (props.ModeColor.HasValue)
|
||||
{
|
||||
System.Console.Write(AnsiEscapes.SetForegroundColor(props.ModeColor.Value));
|
||||
}
|
||||
|
||||
System.Console.Write($" [{props.Mode}]");
|
||||
System.Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(props.HelpText))
|
||||
{
|
||||
string prefix = hasMode ? " " : " ";
|
||||
System.Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||
System.Console.Write($"{prefix}{props.HelpText}");
|
||||
System.Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
|
||||
System.Console.Write(AnsiEscapes.RestoreCursor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.Shared.Console.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="AgentStatus"/>.
|
||||
/// </summary>
|
||||
public record AgentStatusProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether the spinner is visible.</summary>
|
||||
public bool ShowSpinner { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the formatted token usage text to display.</summary>
|
||||
public string? UsageText { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State for <see cref="AgentStatus"/>.
|
||||
/// </summary>
|
||||
/// <param name="SpinnerIndex">The current spinner animation frame index.</param>
|
||||
public record AgentStatusState(int SpinnerIndex = 0) : ConsoleReactiveState;
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a single-line agent status bar with an animated spinner
|
||||
/// and token usage statistics. Positioned above the rule in the non-scrolling area.
|
||||
/// </summary>
|
||||
public class AgentStatus : ConsoleReactiveComponent<AgentStatusProps, AgentStatusState>, IDisposable
|
||||
{
|
||||
private static readonly string[] s_spinnerFrames =
|
||||
[
|
||||
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
|
||||
];
|
||||
|
||||
private readonly Timer _timer;
|
||||
private AgentStatusProps? _previousProps;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentStatus"/> class.
|
||||
/// </summary>
|
||||
public AgentStatus()
|
||||
{
|
||||
this.State = new AgentStatusState();
|
||||
this._timer = new Timer(this.OnTimerTick, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the height of the agent status component.
|
||||
/// </summary>
|
||||
/// <param name="props">The component props.</param>
|
||||
/// <returns>1 if the spinner or usage text is visible; otherwise 0.</returns>
|
||||
public static int CalculateHeight(AgentStatusProps props)
|
||||
{
|
||||
return (props.ShowSpinner || !string.IsNullOrEmpty(props.UsageText)) ? 1 : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the internal spinner timer.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
this.Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases managed resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing"><c>true</c> to release managed resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
this._timer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(AgentStatusProps props, AgentStatusState state)
|
||||
{
|
||||
if (!props.ShowSpinner && string.IsNullOrEmpty(props.UsageText))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
System.Console.Write(AnsiEscapes.SaveCursor);
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
|
||||
if (props != this._previousProps)
|
||||
{
|
||||
System.Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
this._previousProps = props;
|
||||
}
|
||||
|
||||
if (props.ShowSpinner)
|
||||
{
|
||||
string frame = s_spinnerFrames[state.SpinnerIndex];
|
||||
System.Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.Cyan));
|
||||
System.Console.Write($" {frame} ");
|
||||
System.Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Console.Write(" ");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(props.UsageText))
|
||||
{
|
||||
System.Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||
System.Console.Write(props.UsageText);
|
||||
System.Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
|
||||
System.Console.Write(AnsiEscapes.RestoreCursor);
|
||||
}
|
||||
|
||||
private void OnTimerTick(object? timerState)
|
||||
{
|
||||
if (this.Props is { ShowSpinner: true })
|
||||
{
|
||||
int nextIndex = ((this.State?.SpinnerIndex ?? 0) + 1) % s_spinnerFrames.Length;
|
||||
this.SetState(new AgentStatusState(nextIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using OpenTelemetry;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// A simple OpenTelemetry span exporter that writes completed activities (spans) to a text file.
|
||||
/// Each span is formatted as a human-readable block with timestamps, operation name, duration,
|
||||
/// status, and any tags/events.
|
||||
/// </summary>
|
||||
public sealed class FileSpanExporter : BaseExporter<Activity>
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public FileSpanExporter(string filePath)
|
||||
{
|
||||
this._filePath = filePath;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
||||
}
|
||||
|
||||
public override ExportResult Export(in Batch<Activity> batch)
|
||||
{
|
||||
lock (this._lock)
|
||||
{
|
||||
using var writer = new StreamWriter(this._filePath, append: true);
|
||||
foreach (var activity in batch)
|
||||
{
|
||||
WriteActivity(writer, activity);
|
||||
}
|
||||
}
|
||||
|
||||
return ExportResult.Success;
|
||||
}
|
||||
|
||||
private static void WriteActivity(StreamWriter writer, Activity activity)
|
||||
{
|
||||
var start = activity.StartTimeUtc.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
|
||||
var duration = activity.Duration.TotalMilliseconds.ToString("F1", CultureInfo.InvariantCulture);
|
||||
|
||||
writer.WriteLine($"[{start}] {activity.OperationName} ({duration}ms) [{activity.Status}]");
|
||||
|
||||
if (!string.IsNullOrEmpty(activity.DisplayName) && activity.DisplayName != activity.OperationName)
|
||||
{
|
||||
writer.WriteLine($" DisplayName: {activity.DisplayName}");
|
||||
}
|
||||
|
||||
foreach (var tag in activity.Tags)
|
||||
{
|
||||
writer.WriteLine($" {tag.Key}: {tag.Value}");
|
||||
}
|
||||
|
||||
foreach (var ev in activity.Events)
|
||||
{
|
||||
writer.WriteLine($" Event: {ev.Name} @ {ev.Timestamp:HH:mm:ss.fff}");
|
||||
foreach (var tag in ev.Tags)
|
||||
{
|
||||
writer.WriteLine($" {tag.Key}: {tag.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
writer.WriteLine();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an action returned by an observer at the end of an agent turn.
|
||||
/// Subtypes describe either a question to ask the user (<see cref="FollowUpQuestion"/>)
|
||||
/// or a message to add directly to the next agent input (<see cref="FollowUpMessage"/>).
|
||||
/// </summary>
|
||||
public abstract record FollowUpAction;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a question that should be presented to the user. The
|
||||
/// <see cref="Continuation"/> delegate is invoked with the user's answer and the
|
||||
/// UX state driver, and returns an optional <see cref="ChatMessage"/> to add to the
|
||||
/// next agent invocation.
|
||||
/// </summary>
|
||||
/// <param name="Prompt">The question text shown to the user.</param>
|
||||
/// <param name="Continuation">
|
||||
/// 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 <see cref="ChatMessage"/> for the next agent invocation.
|
||||
/// </param>
|
||||
public abstract record FollowUpQuestion(
|
||||
string Prompt,
|
||||
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation) : FollowUpAction;
|
||||
|
||||
/// <summary>
|
||||
/// A free-form text question. The user may type any response.
|
||||
/// </summary>
|
||||
/// <param name="Prompt">The question text shown to the user.</param>
|
||||
/// <param name="Continuation">Continuation that builds the response message.</param>
|
||||
public sealed record TextFollowUpQuestion(
|
||||
string Prompt,
|
||||
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
|
||||
: FollowUpQuestion(Prompt, Continuation);
|
||||
|
||||
/// <summary>
|
||||
/// A choice question. The user picks from <paramref name="Choices"/>, optionally with
|
||||
/// the ability to enter custom text when <paramref name="AllowCustomText"/> is true.
|
||||
/// </summary>
|
||||
/// <param name="Prompt">The question text shown to the user.</param>
|
||||
/// <param name="Choices">The list of pre-defined choices.</param>
|
||||
/// <param name="AllowCustomText">If true, the user may type a custom response in addition to the listed choices.</param>
|
||||
/// <param name="Continuation">Continuation that builds the response message.</param>
|
||||
public sealed record ChoiceFollowUpQuestion(
|
||||
string Prompt,
|
||||
IReadOnlyList<string> Choices,
|
||||
bool AllowCustomText,
|
||||
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
|
||||
: FollowUpQuestion(Prompt, Continuation);
|
||||
|
||||
/// <summary>
|
||||
/// A message to add directly to the next agent invocation without prompting the user.
|
||||
/// </summary>
|
||||
/// <param name="Message">The chat message to add.</param>
|
||||
public sealed record FollowUpMessage(ChatMessage Message) : FollowUpAction;
|
||||
@@ -0,0 +1,297 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates agent invocations driven by user-input events from the UI.
|
||||
/// The component invokes the runner's input handlers (<see cref="OnUserInputAsync"/>,
|
||||
/// <see cref="OnStreamingInputAsync"/>, <see cref="StartAgentTurnAsync"/>) directly;
|
||||
/// the runner mutates UI state through the supplied <see cref="IUXStateDriver"/>.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class HarnessAgentRunner : IDisposable
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private readonly AgentModeProvider? _modeProvider;
|
||||
private readonly MessageInjectingChatClient? _messageInjector;
|
||||
private readonly IReadOnlyList<CommandHandler> _commandHandlers;
|
||||
private readonly IReadOnlyList<ConsoleObserver> _observers;
|
||||
private readonly IUXStateDriver _ux;
|
||||
private readonly SemaphoreSlim _inputGate = new(1, 1);
|
||||
|
||||
private AgentSession _session;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessAgentRunner"/> class.
|
||||
/// </summary>
|
||||
public HarnessAgentRunner(
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
AgentModeProvider? modeProvider,
|
||||
MessageInjectingChatClient? messageInjector,
|
||||
IReadOnlyList<CommandHandler> commandHandlers,
|
||||
IReadOnlyList<ConsoleObserver> 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)!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the help text describing all available commands (joined by ", "), suitable
|
||||
/// for display in the mode-and-help bar. Computed from the supplied
|
||||
/// <c>commandHandlers</c>.
|
||||
/// </summary>
|
||||
public string HelpText { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the current session with the specified session. Used by the UX driver
|
||||
/// when importing a serialized session. This method is always called from within
|
||||
/// a command handler (which already holds the input gate), so no additional
|
||||
/// synchronization is needed.
|
||||
/// </summary>
|
||||
/// <param name="newSession">The new session to use.</param>
|
||||
internal Task ReplaceSessionAsync(AgentSession newSession)
|
||||
{
|
||||
this._session = newSession;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() => this._inputGate.Dispose();
|
||||
|
||||
/// <summary>
|
||||
/// Handles a top-level user input submission (TextInput mode, no pending question).
|
||||
/// Dispatches to command handlers, or starts an agent turn.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a user input submission while an agent turn is streaming. The text is
|
||||
/// enqueued via the <see cref="MessageInjectingChatClient"/> so it can be picked up
|
||||
/// by the agent on its next opportunity.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes (or completes) a turn after the user has answered all pending follow-up
|
||||
/// questions. The component invokes this with the messages drained from
|
||||
/// <see cref="IUXStateDriver.TakeFollowUpResponses"/>; an empty list simply ends
|
||||
/// the streaming display state without invoking the agent.
|
||||
/// </summary>
|
||||
internal async Task StartAgentTurnAsync(IList<ChatMessage> 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<ChatMessage> messages)
|
||||
{
|
||||
IList<ChatMessage>? nextMessages = messages;
|
||||
IReadOnlyList<ChatMessage> 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);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var observer in this._observers)
|
||||
{
|
||||
await observer.OnResponseUpdateAsync(this._ux, update, 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<ChatMessage>();
|
||||
var questions = new List<FollowUpQuestion>();
|
||||
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<ChatMessage> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void SyncQueuedMessageDisplay(ref IReadOnlyList<ChatMessage> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
using Harness.Shared.Console.Components;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// The main application component for the Harness console. Manages the scroll region
|
||||
/// and bottom panel (text input, list selection, or streaming indicator). Owns the
|
||||
/// <see cref="HarnessConsoleUXStateDriver"/> and routes user input events to the
|
||||
/// registered <see cref="HarnessAgentRunner"/>.
|
||||
/// </summary>
|
||||
public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps, HarnessAppComponentState>, IDisposable
|
||||
{
|
||||
private readonly TopBottomRule _rule = new();
|
||||
private readonly ListSelection _listSelection = new();
|
||||
private readonly TextInput _textInput = new();
|
||||
private readonly TextScrollPanel _textScrollPanel = new();
|
||||
private readonly TextPanel _queuedPanel = new();
|
||||
private readonly AgentStatus _agentStatus = new();
|
||||
private readonly AgentModeAndHelp _modeAndHelp = new();
|
||||
private readonly HarnessConsoleUXStateDriver _uxDriver;
|
||||
private readonly TaskCompletionSource<bool> _shutdownTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly SemaphoreSlim _followUpGate = new(1, 1);
|
||||
private int _scrollRegionBottom;
|
||||
private bool _resizedSinceLastRender = true;
|
||||
private bool _deactivated;
|
||||
private BottomPanelMode _lastRenderedBottomPanelMode;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="placeholder">Placeholder text shown when the input is empty.</param>
|
||||
/// <param name="initialMode">The current agent mode, used to colour the rule and prompt.</param>
|
||||
/// <param name="inputEnabled">Whether the bottom-panel input accepts keystrokes during streaming.</param>
|
||||
/// <param name="runnerFactory">Factory invoked with the component's <see cref="IUXStateDriver"/>
|
||||
/// to construct the <see cref="HarnessAgentRunner"/> that owns the agent loop.</param>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public HarnessAppComponent(
|
||||
string placeholder,
|
||||
string? initialMode,
|
||||
bool inputEnabled,
|
||||
Func<IUXStateDriver, HarnessAgentRunner> runnerFactory,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
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),
|
||||
replaceSession: s => this.Runner!.ReplaceSessionAsync(s),
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent runner that owns the agent loop. Constructed by the factory
|
||||
/// passed to the component's constructor.
|
||||
/// </summary>
|
||||
public HarnessAgentRunner Runner { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Completes when a command handler requests application shutdown (e.g. the user types <c>/exit</c>).
|
||||
/// Awaited by <see cref="HarnessConsole.RunAgentAsync"/>.
|
||||
/// </summary>
|
||||
public Task ShutdownTask => this._shutdownTcs.Task;
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates the component, resetting the scroll region and unsubscribing from events.
|
||||
/// This method is idempotent and safe to call multiple times.
|
||||
/// </summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
if (this._deactivated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._deactivated = true;
|
||||
this._agentStatus.Dispose();
|
||||
KeyEventListener.Instance.KeyPressed -= this.OnKeyPressed;
|
||||
ConsoleResizeListener.Instance.ConsoleResized -= this.OnConsoleResized;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
this.Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases managed resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing"><c>true</c> to release managed resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
this.Deactivate();
|
||||
this._followUpGate.Dispose();
|
||||
this.Runner.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnKeyPressed(object? sender, KeyPressEventArgs e)
|
||||
{
|
||||
BottomPanelMode mode = this.State!.Mode;
|
||||
if (mode == BottomPanelMode.TextInput)
|
||||
{
|
||||
this.HandleTextInputKey(e);
|
||||
}
|
||||
else if (mode == BottomPanelMode.ListSelection)
|
||||
{
|
||||
this.HandleListSelectionKey(e);
|
||||
}
|
||||
else if (mode == BottomPanelMode.Streaming && this.State.InputEnabled)
|
||||
{
|
||||
this.HandleStreamingInputKey(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleTextInputKey(KeyPressEventArgs e)
|
||||
{
|
||||
if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||
{
|
||||
string text = this.State!.InputText;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.SetState(this.State with { InputText = "" });
|
||||
this.DispatchTextInputSubmission(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.State!.ListSelectionOptions.Count - 1;
|
||||
if (this.State.ListSelectionCustomTextPlaceholder != null)
|
||||
{
|
||||
maxIndex = this.State.ListSelectionOptions.Count;
|
||||
}
|
||||
|
||||
bool isOnCustomTextOption = this.State.ListSelectionCustomTextPlaceholder != null
|
||||
&& this.State.ListSelectionIndex == this.State.ListSelectionOptions.Count;
|
||||
|
||||
if (e.KeyInfo.Key == ConsoleKey.UpArrow)
|
||||
{
|
||||
this.SetState(this.State with { ListSelectionIndex = Math.Max(0, this.State.ListSelectionIndex - 1) });
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.DownArrow)
|
||||
{
|
||||
this.SetState(this.State with { ListSelectionIndex = Math.Min(maxIndex, this.State.ListSelectionIndex + 1) });
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||
{
|
||||
string result = isOnCustomTextOption
|
||||
? this.State.ListSelectionCustomInputText
|
||||
: this.State.ListSelectionOptions[this.State.ListSelectionIndex];
|
||||
|
||||
this.SetState(this.State with { ListSelectionCustomInputText = "", ListSelectionIndex = 0 });
|
||||
this.DispatchListSelectionSubmission(result);
|
||||
}
|
||||
else if (isOnCustomTextOption)
|
||||
{
|
||||
if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (this.State.ListSelectionCustomInputText.Length > 0)
|
||||
{
|
||||
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 { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText + e.KeyInfo.KeyChar });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleStreamingInputKey(KeyPressEventArgs e)
|
||||
{
|
||||
if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||
{
|
||||
string text = this.State!.InputText;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.SetState(this.State with { InputText = "" });
|
||||
_ = this.Runner.OnStreamingInputAsync(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 DispatchTextInputSubmission(string text)
|
||||
{
|
||||
if (this.State!.PendingQuestions.Count > 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task HandleFollowUpAnswerAsync(string text)
|
||||
{
|
||||
IReadOnlyList<ChatMessage>? messagesToSend = null;
|
||||
|
||||
await this._followUpGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
HarnessConsoleUXStateDriver ux = this._uxDriver;
|
||||
IReadOnlyList<FollowUpQuestion> 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;
|
||||
this.SetState(this.State! with
|
||||
{
|
||||
ConsoleWidth = e.NewWidth,
|
||||
ConsoleHeight = e.NewHeight,
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentState state)
|
||||
{
|
||||
if (this._deactivated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate queued items panel height
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems, state.ConsoleWidth);
|
||||
|
||||
// Build the bottom panel child based on mode
|
||||
ConsoleReactiveComponent bottomChild;
|
||||
int bottomChildHeight;
|
||||
|
||||
if (state.Mode == BottomPanelMode.ListSelection)
|
||||
{
|
||||
var listProps = new ListSelectionProps
|
||||
{
|
||||
Title = state.ListSelectionTitle,
|
||||
Items = state.ListSelectionOptions,
|
||||
SelectedIndex = state.ListSelectionIndex,
|
||||
HighlightColor = state.ListHighlightColor,
|
||||
CustomTextPlaceholder = state.ListSelectionCustomTextPlaceholder,
|
||||
CustomText = state.ListSelectionCustomInputText,
|
||||
};
|
||||
|
||||
bottomChildHeight = ListSelection.CalculateHeight(listProps);
|
||||
listProps = listProps with { Height = bottomChildHeight };
|
||||
this._listSelection.Props = listProps;
|
||||
bottomChild = this._listSelection;
|
||||
}
|
||||
else if (state.Mode == BottomPanelMode.Streaming)
|
||||
{
|
||||
TextInputProps textInputProps;
|
||||
if (state.InputEnabled)
|
||||
{
|
||||
textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = state.Prompt,
|
||||
Text = state.InputText,
|
||||
Placeholder = state.Placeholder,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = state.Prompt,
|
||||
Text = "",
|
||||
Placeholder = state.StreamingPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
|
||||
textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight };
|
||||
this._textInput.Props = textInputProps;
|
||||
bottomChild = this._textInput;
|
||||
}
|
||||
else
|
||||
{
|
||||
var textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = state.Prompt,
|
||||
Text = state.InputText,
|
||||
Placeholder = state.Placeholder,
|
||||
};
|
||||
|
||||
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
|
||||
textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight };
|
||||
this._textInput.Props = textInputProps;
|
||||
bottomChild = this._textInput;
|
||||
}
|
||||
|
||||
// When the bottom panel mode changes, the new child must repaint even if its
|
||||
// props haven't changed — the screen area was overwritten by the previous child.
|
||||
if (state.Mode != this._lastRenderedBottomPanelMode)
|
||||
{
|
||||
bottomChild.Invalidate();
|
||||
this._lastRenderedBottomPanelMode = state.Mode;
|
||||
}
|
||||
|
||||
var ruleProps = new TopBottomRuleProps
|
||||
{
|
||||
Width = state.ConsoleWidth,
|
||||
Color = state.ModeColor,
|
||||
Children = [bottomChild],
|
||||
};
|
||||
|
||||
var agentStatusProps = new AgentStatusProps
|
||||
{
|
||||
ShowSpinner = state.ShowSpinner,
|
||||
UsageText = state.UsageText,
|
||||
};
|
||||
|
||||
var modeAndHelpProps = new AgentModeAndHelpProps
|
||||
{
|
||||
Mode = state.ModeText,
|
||||
ModeColor = state.ModeColor,
|
||||
HelpText = state.HelpText,
|
||||
};
|
||||
|
||||
// Hide agent status and mode/help during follow-up questions (ListSelection mode)
|
||||
// as they clutter the UI and aren't relevant.
|
||||
bool showStatusAndHelp = state.Mode != BottomPanelMode.ListSelection;
|
||||
int agentStatusHeight = showStatusAndHelp ? AgentStatus.CalculateHeight(agentStatusProps) : 0;
|
||||
int modeAndHelpHeight = showStatusAndHelp ? AgentModeAndHelp.CalculateHeight(modeAndHelpProps) : 0;
|
||||
|
||||
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
|
||||
int nonScrollHeight = ruleHeight + agentStatusHeight + queuedPanelHeight + modeAndHelpHeight + 1; // +1 for bottom padding
|
||||
int scrollBottom = Math.Max(1, state.ConsoleHeight - nonScrollHeight);
|
||||
|
||||
// If scroll region changed or a clear is needed, reset everything
|
||||
if (this._resizedSinceLastRender || (this._scrollRegionBottom != 0 && scrollBottom != this._scrollRegionBottom))
|
||||
{
|
||||
// Reset scroll region to full screen before erasing so the erase covers all rows —
|
||||
// some terminals only erase within the active DECSTBM region.
|
||||
System.Console.Write(AnsiEscapes.ResetScrollRegion);
|
||||
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
|
||||
// Invalidate all children so they re-render even if props haven't changed
|
||||
this._rule.Invalidate();
|
||||
this._textScrollPanel.Invalidate();
|
||||
this._queuedPanel.Invalidate();
|
||||
this._agentStatus.Invalidate();
|
||||
this._modeAndHelp.Invalidate();
|
||||
this._textInput.Invalidate();
|
||||
this._listSelection.Invalidate();
|
||||
|
||||
this._resizedSinceLastRender = false;
|
||||
}
|
||||
|
||||
this._scrollRegionBottom = scrollBottom;
|
||||
|
||||
System.Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom));
|
||||
|
||||
// Render text scroll panel in the scroll area
|
||||
this._textScrollPanel.Props = new TextScrollPanelProps
|
||||
{
|
||||
X = 1,
|
||||
Y = 1,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = scrollBottom,
|
||||
Items = state.ScrollAreaContentItems,
|
||||
};
|
||||
this._textScrollPanel.Render();
|
||||
|
||||
// Render queued input items between scroll area and agent status
|
||||
int queuedPanelY = scrollBottom + 1;
|
||||
this._queuedPanel.Props = new TextPanelProps
|
||||
{
|
||||
X = 1,
|
||||
Y = queuedPanelY,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = queuedPanelHeight,
|
||||
Items = state.QueuedItems,
|
||||
};
|
||||
this._queuedPanel.Render();
|
||||
|
||||
// Render the agent status line between queued items and rule
|
||||
int agentStatusY = queuedPanelY + queuedPanelHeight;
|
||||
if (showStatusAndHelp)
|
||||
{
|
||||
this._agentStatus.Props = agentStatusProps with
|
||||
{
|
||||
X = 1,
|
||||
Y = agentStatusY,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = agentStatusHeight,
|
||||
};
|
||||
this._agentStatus.Render();
|
||||
}
|
||||
|
||||
// Render the bottom rule + child below the agent status
|
||||
this._rule.Props = ruleProps with
|
||||
{
|
||||
X = 1,
|
||||
Y = agentStatusY + agentStatusHeight,
|
||||
};
|
||||
this._rule.Render();
|
||||
|
||||
// Render the mode-and-help line below the bottom rule
|
||||
if (showStatusAndHelp)
|
||||
{
|
||||
int modeAndHelpY = agentStatusY + agentStatusHeight + ruleHeight;
|
||||
this._modeAndHelp.Props = modeAndHelpProps with
|
||||
{
|
||||
X = 1,
|
||||
Y = modeAndHelpY,
|
||||
Width = state.ConsoleWidth,
|
||||
Height = modeAndHelpHeight,
|
||||
};
|
||||
this._modeAndHelp.Render();
|
||||
}
|
||||
|
||||
// Clear the bottom padding line
|
||||
System.Console.Write(AnsiEscapes.MoveAndEraseLine(state.ConsoleHeight));
|
||||
|
||||
// Position cursor for natural typing appearance
|
||||
this.PositionCursor(state);
|
||||
}
|
||||
|
||||
private void PositionCursor(HarnessAppComponentState state)
|
||||
{
|
||||
if (state.Mode == BottomPanelMode.TextInput
|
||||
|| (state.Mode == BottomPanelMode.Streaming && state.InputEnabled))
|
||||
{
|
||||
int promptLength = state.Prompt.Length;
|
||||
int textWidth = state.ConsoleWidth - promptLength;
|
||||
int textLength = state.InputText.Length;
|
||||
|
||||
int textInputY = (this._rule.Props?.Y ?? 0) + 1;
|
||||
|
||||
if (textWidth <= 0 || textLength == 0)
|
||||
{
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(textInputY, promptLength + 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
int cursorRow = textLength < textWidth ? 0 : 1 + ((textLength - textWidth) / textWidth);
|
||||
int cursorCol = textLength < textWidth ? textLength : (textLength - textWidth) % textWidth;
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1));
|
||||
}
|
||||
}
|
||||
else if (state.Mode == BottomPanelMode.ListSelection
|
||||
&& state.ListSelectionCustomTextPlaceholder != null
|
||||
&& state.ListSelectionIndex == state.ListSelectionOptions.Count)
|
||||
{
|
||||
int titleLines = state.ListSelectionTitle?.Split('\n').Length ?? 0;
|
||||
int customOptionY = (this._rule.Props?.Y ?? 0) + 1 + titleLines + state.ListSelectionOptions.Count;
|
||||
int cursorCol = 2 + state.ListSelectionCustomInputText.Length + 1;
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Determines which component is shown in the bottom panel.
|
||||
/// </summary>
|
||||
public enum BottomPanelMode
|
||||
{
|
||||
/// <summary>Show the text input component for user input.</summary>
|
||||
TextInput,
|
||||
|
||||
/// <summary>Show the list selection component for interactive prompts.</summary>
|
||||
ListSelection,
|
||||
|
||||
/// <summary>Show a disabled input indicator during agent streaming.</summary>
|
||||
Streaming,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal state for <see cref="HarnessAppComponent"/>. All UI fields that may
|
||||
/// change after construction live here; they are mutated exclusively via
|
||||
/// <see cref="ConsoleReactiveComponent{TProps,TState}.SetState"/> by the
|
||||
/// owning <see cref="HarnessConsoleUXStateDriver"/>.
|
||||
/// </summary>
|
||||
public record HarnessAppComponentState : ConsoleReactiveState
|
||||
{
|
||||
// --- Console dimensions ---
|
||||
|
||||
/// <summary>Gets the current console width in columns.</summary>
|
||||
public int ConsoleWidth { get; init; }
|
||||
|
||||
/// <summary>Gets the current console height in rows.</summary>
|
||||
public int ConsoleHeight { get; init; }
|
||||
|
||||
// --- Bottom panel mode ---
|
||||
|
||||
/// <summary>Gets the bottom panel mode.</summary>
|
||||
public BottomPanelMode Mode { get; init; } = BottomPanelMode.TextInput;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the queue of follow-up questions waiting for user answers. The head
|
||||
/// (<c>[0]</c>) 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.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FollowUpQuestion> PendingQuestions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accumulated follow-up response messages collected during the
|
||||
/// current agent turn — both direct <see cref="FollowUpMessage"/>s emitted
|
||||
/// by observers and continuation results from answered questions. Consumed
|
||||
/// by the runner via <see cref="IUXStateDriver.TakeFollowUpResponses"/>
|
||||
/// before the next agent invocation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChatMessage> AccumulatedFollowUpResponses { get; init; } = [];
|
||||
|
||||
// --- Text input (active in TextInput / Streaming modes) ---
|
||||
|
||||
/// <summary>Gets the prompt string for text input mode.</summary>
|
||||
public string Prompt { get; init; } = "> ";
|
||||
|
||||
/// <summary>Gets the placeholder text shown when the input is empty.</summary>
|
||||
public string Placeholder { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the current input text being typed.</summary>
|
||||
public string InputText { get; init; } = "";
|
||||
|
||||
/// <summary>Gets a value indicating whether input is enabled during streaming.</summary>
|
||||
public bool InputEnabled { get; init; }
|
||||
|
||||
/// <summary>Gets the prompt to show during streaming when input is disabled.</summary>
|
||||
public string StreamingPrompt { get; init; } = "(agent is running...)";
|
||||
|
||||
// --- List selection (active in ListSelection mode) ---
|
||||
|
||||
/// <summary>Gets the title text displayed above the list selection (for interactive prompts).</summary>
|
||||
public string? ListSelectionTitle { get; init; }
|
||||
|
||||
/// <summary>Gets the list selection options.</summary>
|
||||
public IReadOnlyList<string> ListSelectionOptions { get; init; } = [];
|
||||
|
||||
/// <summary>Gets the highlighted option index in list selection mode.</summary>
|
||||
public int ListSelectionIndex { get; init; }
|
||||
|
||||
/// <summary>Gets the placeholder text for the custom text input option in the list.</summary>
|
||||
public string? ListSelectionCustomTextPlaceholder { get; init; }
|
||||
|
||||
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
|
||||
public string ListSelectionCustomInputText { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the highlight color for the active list item.</summary>
|
||||
public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan;
|
||||
|
||||
// --- Scroll / output area ---
|
||||
|
||||
/// <summary>Gets the items rendered in the scroll-area. Each item is a pre-rendered
|
||||
/// console string (may include ANSI escape sequences and newlines).</summary>
|
||||
public IReadOnlyList<string> ScrollAreaContentItems { get; init; } = [];
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public IReadOnlyList<string> QueuedItems { get; init; } = [];
|
||||
|
||||
// --- Agent mode + status display ---
|
||||
|
||||
/// <summary>Gets the foreground color for the rule borders and mode label.</summary>
|
||||
public ConsoleColor? ModeColor { get; init; }
|
||||
|
||||
/// <summary>Gets the current mode name displayed below the bottom rule (e.g. "plan").</summary>
|
||||
public string? ModeText { get; init; }
|
||||
|
||||
/// <summary>Gets the help text displayed below the bottom rule (available commands).</summary>
|
||||
public string? HelpText { get; init; }
|
||||
|
||||
/// <summary>Gets a value indicating whether the agent status spinner is visible.</summary>
|
||||
public bool ShowSpinner { get; init; }
|
||||
|
||||
/// <summary>Gets the formatted token usage text to display in the status bar.</summary>
|
||||
public string? UsageText { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a reusable interactive console loop for running an <see cref="AIAgent"/>
|
||||
/// with streaming output, extensible observers, and mode-aware interaction strategies.
|
||||
/// </summary>
|
||||
public static class HarnessConsole
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs an interactive console session with the specified agent.
|
||||
/// Constructs the reactive UI component and the <see cref="HarnessAgentRunner"/>,
|
||||
/// wires them together, and awaits the component's <see cref="HarnessAppComponent.ShutdownTask"/>
|
||||
/// (which completes when the user types <c>/exit</c>).
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent to interact with.</param>
|
||||
/// <param name="userPrompt">A short prompt to the user, displayed as a placeholder in the input area.</param>
|
||||
/// <param name="options">Optional configuration options for the console session.</param>
|
||||
public static async Task RunAgentAsync(AIAgent agent, string userPrompt, HarnessConsoleOptions? options = null)
|
||||
{
|
||||
options ??= new();
|
||||
|
||||
System.Console.OutputEncoding = Encoding.UTF8;
|
||||
|
||||
// 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 modeProvider = agent.GetService<AgentModeProvider>();
|
||||
var messageInjector = agent.GetService<MessageInjectingChatClient>();
|
||||
|
||||
AgentSession session = options.SessionFactory is not null
|
||||
? await options.SessionFactory(agent)
|
||||
: await agent.CreateSessionAsync();
|
||||
|
||||
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);
|
||||
|
||||
// Trigger the initial render of the component now that state is seeded.
|
||||
component.Render();
|
||||
|
||||
try
|
||||
{
|
||||
await component.ShutdownTask.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
component.Deactivate();
|
||||
}
|
||||
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(AnsiEscapes.ResetScrollRegion);
|
||||
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(1, 1));
|
||||
System.Console.WriteLine("Goodbye!");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="HarnessConsole"/>.
|
||||
/// </summary>
|
||||
public class HarnessConsoleOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see langword="null"/> (the default), a default set of observers is used.
|
||||
/// Set to an empty list to disable all observers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ConsoleObserver>? Observers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of command handlers to check before sending user input to the agent.
|
||||
/// Use <see cref="BuildDefaultCommandHandlers"/> to create the default set.
|
||||
/// When <see langword="null"/> (the default), a default set of handlers is used.
|
||||
/// Set to an empty list to disable all command handlers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CommandHandler>? CommandHandlers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The default mode-to-color mapping used when no custom <see cref="ModeColors"/> are provided.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, ConsoleColor> DefaultModeColors = new ReadOnlyDictionary<string, ConsoleColor>(
|
||||
new Dictionary<string, ConsoleColor>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["plan"] = ConsoleColor.Cyan,
|
||||
["execute"] = ConsoleColor.Green,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a mapping of agent mode names to console colors.
|
||||
/// When a mode is not found in this dictionary, the default color (<see cref="ConsoleColor.Gray"/>) is used.
|
||||
/// </summary>
|
||||
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(DefaultModeColors, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional factory for creating the <see cref="AgentSession"/>.
|
||||
/// When <see langword="null"/> (the default), <see cref="AIAgent.CreateSessionAsync"/> is used.
|
||||
/// </summary>
|
||||
public Func<AIAgent, Task<AgentSession>>? SessionFactory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default set of observers without planning support.
|
||||
/// Includes tool call display, tool approval, error display, reasoning display,
|
||||
/// usage display, and text output.
|
||||
/// </summary>
|
||||
/// <param name="maxContextWindowTokens">Optional maximum context window size in tokens for usage display.</param>
|
||||
/// <param name="maxOutputTokens">Optional maximum output tokens for usage display.</param>
|
||||
/// <param name="toolFormatters">Optional tool call formatters. When <see langword="null"/>,
|
||||
/// each observer uses the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/>.</param>
|
||||
/// <returns>A list of observers for a standard (non-planning) console session.</returns>
|
||||
public static List<ConsoleObserver> BuildDefaultObservers(
|
||||
int? maxContextWindowTokens = null,
|
||||
int? maxOutputTokens = null,
|
||||
IReadOnlyList<ToolCallFormatter>? toolFormatters = null)
|
||||
{
|
||||
return
|
||||
[
|
||||
new ToolCallDisplayObserver(toolFormatters),
|
||||
new ToolApprovalObserver(toolFormatters),
|
||||
new ErrorDisplayObserver(),
|
||||
new ReasoningDisplayObserver(),
|
||||
new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens),
|
||||
new TextOutputObserver(),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default set of observers with planning support.
|
||||
/// Includes a <see cref="PlanningOutputObserver"/> instead of <see cref="TextOutputObserver"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent, used to resolve <see cref="AgentModeProvider"/>.</param>
|
||||
/// <param name="planModeName">The mode name that represents the planning mode.</param>
|
||||
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
|
||||
/// <param name="modeColors">Optional mode-to-color mapping for display.
|
||||
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
|
||||
/// <param name="maxContextWindowTokens">Optional maximum context window size in tokens for usage display.</param>
|
||||
/// <param name="maxOutputTokens">Optional maximum output tokens for usage display.</param>
|
||||
/// <param name="toolFormatters">Optional tool call formatters. When <see langword="null"/>,
|
||||
/// each observer uses the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/>.</param>
|
||||
/// <returns>A list of observers for a planning-enabled console session.</returns>
|
||||
public static List<ConsoleObserver> BuildObserversWithPlanning(
|
||||
AIAgent agent,
|
||||
string planModeName,
|
||||
string executionModeName,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null,
|
||||
int? maxContextWindowTokens = null,
|
||||
int? maxOutputTokens = null,
|
||||
IReadOnlyList<ToolCallFormatter>? toolFormatters = null)
|
||||
{
|
||||
var modeProvider = agent.GetService<AgentModeProvider>()
|
||||
?? 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),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default set of command handlers.
|
||||
/// Includes exit, todo, and mode command handlers.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent, used to resolve <see cref="TodoProvider"/> and <see cref="AgentModeProvider"/>.</param>
|
||||
/// <param name="modeColors">Optional mode-to-color mapping for the mode command display.
|
||||
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
|
||||
/// <returns>A list of command handlers for a standard console session.</returns>
|
||||
public static List<CommandHandler> BuildDefaultCommandHandlers(
|
||||
AIAgent agent,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
var todoProvider = agent.GetService<TodoProvider>();
|
||||
var modeProvider = agent.GetService<AgentModeProvider>();
|
||||
|
||||
return
|
||||
[
|
||||
new ExitCommandHandler(),
|
||||
new TodoCommandHandler(todoProvider),
|
||||
new ModeCommandHandler(modeProvider, modeColors ?? DefaultModeColors),
|
||||
new SessionCommandHandler(agent),
|
||||
];
|
||||
}
|
||||
}
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IUXStateDriver"/> implementation. Owned by
|
||||
/// <see cref="HarnessAppComponent"/>; mutates the component's state via a
|
||||
/// <c>SetState</c>-style callback. Each public operation updates state and lets
|
||||
/// the component's render-skip optimization handle the actual draw.
|
||||
/// </summary>
|
||||
internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
|
||||
{
|
||||
private readonly Func<HarnessAppComponentState> _getState;
|
||||
private readonly Action<HarnessAppComponentState> _setState;
|
||||
private readonly Action _requestShutdown;
|
||||
private readonly Func<AgentSession, Task> _replaceSession;
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
private readonly List<string> _outputItems = [];
|
||||
private readonly object _stateLock = new();
|
||||
|
||||
private OutputEntryType? _lastEntryType;
|
||||
private bool _hasReceivedAnyText;
|
||||
private OutputEntry? _currentStreamingEntry;
|
||||
private int _currentStreamingEntryIndex = -1;
|
||||
private string? _currentMode;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessConsoleUXStateDriver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="getState">Returns the component's current state.</param>
|
||||
/// <param name="setState">Replaces the component's state and triggers a re-render.</param>
|
||||
/// <param name="requestShutdown">Callback invoked when a command handler requests application shutdown.</param>
|
||||
/// <param name="replaceSession">Callback invoked to replace the current agent session (e.g., on import).</param>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public HarnessConsoleUXStateDriver(
|
||||
Func<HarnessAppComponentState> getState,
|
||||
Action<HarnessAppComponentState> setState,
|
||||
Action requestShutdown,
|
||||
Func<AgentSession, Task> replaceSession,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._getState = getState;
|
||||
this._setState = setState;
|
||||
this._requestShutdown = requestShutdown;
|
||||
this._replaceSession = replaceSession;
|
||||
this._modeColors = modeColors;
|
||||
this._currentMode = getState().ModeText;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? CurrentMode
|
||||
{
|
||||
get => this._currentMode;
|
||||
set
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
this._currentMode = value;
|
||||
return s with
|
||||
{
|
||||
ModeColor = ModeColors.Get(value, this._modeColors),
|
||||
ModeText = value,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void BeginStreaming() =>
|
||||
this.UpdateState(s => s with
|
||||
{
|
||||
Mode = BottomPanelMode.Streaming,
|
||||
ShowSpinner = true,
|
||||
});
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void StopSpinner() =>
|
||||
this.UpdateState(s => s with { ShowSpinner = false });
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void EndStreaming() =>
|
||||
this.UpdateState(s => s with
|
||||
{
|
||||
Mode = BottomPanelMode.TextInput,
|
||||
ShowSpinner = false,
|
||||
});
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void BeginStreamingOutput()
|
||||
{
|
||||
lock (this._stateLock)
|
||||
{
|
||||
this._hasReceivedAnyText = false;
|
||||
this._currentStreamingEntry = null;
|
||||
this._currentStreamingEntryIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetUsageText(string usageText) =>
|
||||
this.UpdateState(s => s with { UsageText = usageText });
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetQueuedMessages(IReadOnlyList<ChatMessage> pending)
|
||||
{
|
||||
var newQueued = new List<string>(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 });
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void QueueFollowUpQuestions(IReadOnlyList<FollowUpQuestion> questions)
|
||||
{
|
||||
if (questions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
bool wasEmpty = s.PendingQuestions.Count == 0;
|
||||
|
||||
var combined = new List<FollowUpQuestion>(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;
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AddFollowUpResponse(ChatMessage response)
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
var combined = new List<ChatMessage>(s.AccumulatedFollowUpResponses.Count + 1);
|
||||
combined.AddRange(s.AccumulatedFollowUpResponses);
|
||||
combined.Add(response);
|
||||
return s with { AccumulatedFollowUpResponses = combined };
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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 = "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<ChatMessage> TakeFollowUpResponses()
|
||||
{
|
||||
return this.UpdateState(s =>
|
||||
{
|
||||
IReadOnlyList<ChatMessage> responses = s.AccumulatedFollowUpResponses;
|
||||
if (responses.Count == 0)
|
||||
{
|
||||
return (s, responses);
|
||||
}
|
||||
|
||||
return (s with { AccumulatedFollowUpResponses = [] }, responses);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> 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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void WriteUserInputEcho(string text)
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
||||
OutputEntryType.UserInput,
|
||||
$"\nYou: {text}\n\n",
|
||||
ConsoleColor.Green));
|
||||
return s with { ScrollAreaContentItems = snapshot };
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task WriteInfoAsync(string text, ConsoleColor? color = null) =>
|
||||
this.WriteInfoCoreAsync(text, color, newLine: false);
|
||||
|
||||
/// <inheritdoc/>
|
||||
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<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
||||
OutputEntryType.InfoLine,
|
||||
fullText,
|
||||
color ?? ModeColors.Get(this._currentMode, this._modeColors)));
|
||||
return s with { ScrollAreaContentItems = snapshot };
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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<string>(this._outputItems) };
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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<string>(this._outputItems) };
|
||||
}
|
||||
|
||||
return s;
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task WriteNoTextWarningAsync(bool hasFollowUpActions)
|
||||
{
|
||||
if (!this._hasReceivedAnyText && !hasFollowUpActions)
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
||||
OutputEntryType.StreamFooter,
|
||||
" (no text response from agent)\n",
|
||||
ConsoleColor.DarkYellow));
|
||||
return s with { ScrollAreaContentItems = snapshot };
|
||||
});
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the supplied text with ANSI foreground color escape sequences (or returns
|
||||
/// the text unchanged when no color is specified). Output is appended to
|
||||
/// <see cref="_outputItems"/> and consumed verbatim by <see cref="TextScrollPanel"/>
|
||||
/// and <see cref="TextPanel"/>.
|
||||
/// </summary>
|
||||
private static string RenderEntry(string text, ConsoleColor? color) =>
|
||||
color.HasValue
|
||||
? $"{AnsiEscapes.SetForegroundColor(color.Value)}{text}{AnsiEscapes.ResetAttributes}"
|
||||
: text;
|
||||
|
||||
private void UpdateState(Func<HarnessAppComponentState, HarnessAppComponentState> update)
|
||||
{
|
||||
lock (this._stateLock)
|
||||
{
|
||||
this._setState(update(this._getState()));
|
||||
}
|
||||
}
|
||||
|
||||
private T UpdateState<T>(Func<HarnessAppComponentState, (HarnessAppComponentState State, T Result)> update)
|
||||
{
|
||||
lock (this._stateLock)
|
||||
{
|
||||
var (newState, result) = update(this._getState());
|
||||
this._setState(newState);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends one or more output entries to the output list, updates
|
||||
/// <see cref="_lastEntryType"/> to the last entry's type, and returns a
|
||||
/// snapshot of <see cref="_outputItems"/>. Must be called inside a locked
|
||||
/// context (e.g. within an <see cref="UpdateState"/> callback).
|
||||
/// </summary>
|
||||
private List<string> AppendOutputEntriesAndSnapshot(params OutputEntry[] entries)
|
||||
{
|
||||
this.AppendOutputEntriesCore(entries);
|
||||
return new List<string>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RequestShutdown() => this._requestShutdown();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ReplaceSessionAsync(AgentSession newSession) => this._replaceSession(newSession);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable VSTHRD002 // Synchronous waits are required by OpenTelemetry enrichment callbacks.
|
||||
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Provides factory methods for creating pre-configured OpenTelemetry tracing for harness samples.
|
||||
/// </summary>
|
||||
public static class HarnessTracing
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="TracerProvider"/> that captures spans from the specified source and HTTP client activity,
|
||||
/// enriching HTTP spans with full request/response headers and bodies, and exports all spans to a timestamped
|
||||
/// text file in the application base directory.
|
||||
/// </summary>
|
||||
/// <param name="sourceName">The activity source name to subscribe to (e.g., "Harness.Research").</param>
|
||||
/// <returns>A configured <see cref="TracerProvider"/>, or <see langword="null"/> if the builder returns null.</returns>
|
||||
public static TracerProvider? CreateFileTracerProvider(string sourceName)
|
||||
{
|
||||
var traceLogPath = Path.Combine(AppContext.BaseDirectory, $"traces_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid()}.log");
|
||||
|
||||
return Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddHttpClientInstrumentation((options) =>
|
||||
{
|
||||
options.EnrichWithHttpRequestMessage = (activity, request) =>
|
||||
{
|
||||
activity.SetTag("http.request.headers", request.Headers.ToString());
|
||||
if (request.Content != null)
|
||||
{
|
||||
activity.SetTag("http.request.content.headers", request.Content.Headers.ToString());
|
||||
var content = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
activity.SetTag("http.request.content.body", content);
|
||||
}
|
||||
};
|
||||
|
||||
options.EnrichWithHttpResponseMessage = (activity, response) =>
|
||||
{
|
||||
activity.SetTag("http.response.headers", response.Headers.ToString());
|
||||
if (response.Content != null)
|
||||
{
|
||||
activity.SetTag("http.response.content.headers", response.Content.Headers.ToString());
|
||||
var content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
activity.SetTag("http.response.content.body", content);
|
||||
}
|
||||
};
|
||||
})
|
||||
.AddProcessor(new SimpleActivityExportProcessor(new FileSpanExporter(traceLogPath)))
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\ConsoleReactiveFramework\ConsoleReactiveFramework.csproj" />
|
||||
<ProjectReference Include="..\ConsoleReactiveComponents\ConsoleReactiveComponents.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>SetState</c> call on the underlying
|
||||
/// reactive component.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface is intentionally narrow: it does not expose blocking input methods.
|
||||
/// The agent runner orchestrates input flow via <see cref="FollowUpQuestion"/>
|
||||
/// objects returned from observers.
|
||||
/// </remarks>
|
||||
public interface IUXStateDriver
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
string? CurrentMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Echoes a submitted user input as a regular user-input entry in the output area.
|
||||
/// </summary>
|
||||
void WriteUserInputEcho(string text);
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output as an output entry, without a trailing newline.
|
||||
/// </summary>
|
||||
Task WriteInfoAsync(string text, ConsoleColor? color = null);
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output as an output entry, followed by a newline.
|
||||
/// </summary>
|
||||
Task WriteInfoLineAsync(string text, ConsoleColor? color = null);
|
||||
|
||||
/// <summary>
|
||||
/// Writes streaming text output from the agent. Successive calls accumulate into a
|
||||
/// single streaming entry that is re-rendered by the text panel.
|
||||
/// </summary>
|
||||
Task WriteTextAsync(string text, ConsoleColor? color = null);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a blank-line separator to visually close the streaming output section.
|
||||
/// </summary>
|
||||
Task EndStreamingOutputAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Shows a "(no text response from agent)" warning if no text was received
|
||||
/// and no observer produced follow-up actions.
|
||||
/// </summary>
|
||||
Task WriteNoTextWarningAsync(bool hasFollowUpActions);
|
||||
|
||||
/// <summary>
|
||||
/// Switches the bottom panel to streaming mode and starts the spinner.
|
||||
/// </summary>
|
||||
void BeginStreaming();
|
||||
|
||||
/// <summary>
|
||||
/// Stops the spinner without leaving streaming mode.
|
||||
/// </summary>
|
||||
void StopSpinner();
|
||||
|
||||
/// <summary>
|
||||
/// Switches the bottom panel back to text-input mode and stops the spinner.
|
||||
/// </summary>
|
||||
void EndStreaming();
|
||||
|
||||
/// <summary>
|
||||
/// Resets per-turn streaming bookkeeping in preparation for a new agent turn.
|
||||
/// </summary>
|
||||
void BeginStreamingOutput();
|
||||
|
||||
/// <summary>
|
||||
/// Sets the formatted usage text shown on the agent status bar.
|
||||
/// </summary>
|
||||
void SetUsageText(string usageText);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the queued-message display with one entry per pending message.
|
||||
/// </summary>
|
||||
void SetQueuedMessages(IReadOnlyList<ChatMessage> pending);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
void QueueFollowUpQuestions(IReadOnlyList<FollowUpQuestion> questions);
|
||||
|
||||
/// <summary>
|
||||
/// Appends a message to the accumulated follow-up response list in component state.
|
||||
/// Called by the runner for direct <see cref="FollowUpMessage"/> outputs and by
|
||||
/// the component when a question's continuation produces a response.
|
||||
/// </summary>
|
||||
void AddFollowUpResponse(ChatMessage response);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
void AdvanceFollowUpQuestion();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current accumulated follow-up responses and clears them in state.
|
||||
/// Called by the runner immediately before invoking the next agent turn.
|
||||
/// </summary>
|
||||
IReadOnlyList<ChatMessage> TakeFollowUpResponses();
|
||||
|
||||
/// <summary>
|
||||
/// Signals that the application should shut down. Completes the shutdown task
|
||||
/// on the owning component.
|
||||
/// </summary>
|
||||
void RequestShutdown();
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the current agent session with the specified session (e.g., after importing
|
||||
/// a serialized session from a file).
|
||||
/// </summary>
|
||||
/// <param name="newSession">The new session to use.</param>
|
||||
Task ReplaceSessionAsync(AgentSession newSession);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for resolving console colours associated with agent modes.
|
||||
/// </summary>
|
||||
internal static class ModeColors
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the console color associated with a mode name, using the provided color map.
|
||||
/// Falls back to <see cref="ConsoleColor.Gray"/> when the mode is <see langword="null"/>
|
||||
/// or not present in the map.
|
||||
/// </summary>
|
||||
/// <param name="mode">The mode name, or <see langword="null"/> if no mode is active.</param>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public static ConsoleColor Get(string? mode, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
if (mode is null)
|
||||
{
|
||||
return ConsoleColor.Gray;
|
||||
}
|
||||
|
||||
if (modeColors is not null && modeColors.TryGetValue(mode, out var color))
|
||||
{
|
||||
return color;
|
||||
}
|
||||
|
||||
return ConsoleColor.Gray;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for console observers that participate in the agent response
|
||||
/// streaming lifecycle. Observers can configure run options, observe streamed content,
|
||||
/// and return messages to re-invoke the agent after the stream completes.
|
||||
/// All methods have default no-op implementations so subclasses only override what they need.
|
||||
/// </summary>
|
||||
public abstract class ConsoleObserver
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures <see cref="AgentRunOptions"/> before the agent is invoked.
|
||||
/// Override to set options such as <see cref="AgentRunOptions.ResponseFormat"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">The run options to configure.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
public virtual void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called for each <see cref="AgentResponseUpdate"/> in the response stream, regardless of
|
||||
/// whether it contains content. Override to inspect update-level metadata such as
|
||||
/// <see cref="AgentResponseUpdate.RawRepresentation"/> for provider-specific events.
|
||||
/// </summary>
|
||||
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||
/// <param name="update">The streaming response update.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
public virtual Task OnResponseUpdateAsync(IUXStateDriver ux, AgentResponseUpdate update, AIAgent agent, AgentSession session) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called for each <see cref="AIContent"/> item in the response stream.
|
||||
/// </summary>
|
||||
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||
/// <param name="content">The content item from the stream.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
public virtual Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called for each text update in the response stream.
|
||||
/// </summary>
|
||||
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||
/// <param name="text">The text from the update.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
public virtual Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see langword="null"/> if no follow-up is needed.
|
||||
/// </summary>
|
||||
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
/// <returns>Follow-up actions to process after the stream completes, or <see langword="null"/>.</returns>
|
||||
public virtual Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
|
||||
IUXStateDriver ux,
|
||||
AIAgent agent,
|
||||
AgentSession session) => Task.FromResult<IList<FollowUpAction>?>(null);
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Displays error content (❌) from the response stream.
|
||||
/// </summary>
|
||||
public sealed class ErrorDisplayObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is ErrorContent errorContent)
|
||||
{
|
||||
string errorText = $"❌ Error: {errorContent.Message}";
|
||||
if (!string.IsNullOrWhiteSpace(errorContent.ErrorCode))
|
||||
{
|
||||
errorText += $" (code: {errorContent.ErrorCode})";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorContent.Details))
|
||||
{
|
||||
errorText += $" details: {errorContent.Details}";
|
||||
}
|
||||
|
||||
await ux.WriteInfoLineAsync(errorText, ConsoleColor.Red);
|
||||
}
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Planning observer that is mode-aware: in planning mode it configures structured
|
||||
/// JSON output, collects streamed text, and deserializes it as a <see cref="PlanningResponse"/>;
|
||||
/// in execution mode it passes text straight through to <see cref="IUXStateDriver.WriteTextAsync"/>
|
||||
/// for live streaming display.
|
||||
/// </summary>
|
||||
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<string, ConsoleColor>? _modeColors;
|
||||
private string? _lastResponseId;
|
||||
private string? _lastMessageId;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PlanningOutputObserver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="modeProvider">The mode provider for switching modes on approval.</param>
|
||||
/// <param name="planModeName">The mode name that represents the planning mode.</param>
|
||||
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
|
||||
/// <param name="modeColors">Optional mode-to-color mapping for display.</param>
|
||||
public PlanningOutputObserver(AgentModeProvider modeProvider, string planModeName, string executionModeName, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._modeProvider = modeProvider;
|
||||
this._planModeName = planModeName;
|
||||
this._executionModeName = executionModeName;
|
||||
this._modeColors = modeColors;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (this.IsPlanningMode(this._modeProvider.GetMode(session)))
|
||||
{
|
||||
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnResponseUpdateAsync(IUXStateDriver ux, AgentResponseUpdate update, AIAgent agent, AgentSession session)
|
||||
{
|
||||
// We aren't in planning mode, so we can just stream the output directly.
|
||||
if (!this.IsPlanningMode(ux.CurrentMode))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(update.Text))
|
||||
{
|
||||
await ux.WriteTextAsync(update.Text).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// We are still accumulating the same response/message.
|
||||
if (this._lastResponseId == update.ResponseId && this._lastMessageId == update.MessageId)
|
||||
{
|
||||
this._textCollector.Append(update.Text);
|
||||
return;
|
||||
}
|
||||
|
||||
// New response/message, write the previous response/message and
|
||||
// clear the text collector for the next JSON response/message.
|
||||
string collectedText = this._textCollector.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(collectedText))
|
||||
{
|
||||
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
this._textCollector.Clear();
|
||||
this._textCollector.Append(update.Text);
|
||||
this._lastResponseId = update.ResponseId;
|
||||
this._lastMessageId = update.MessageId;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
|
||||
IUXStateDriver ux,
|
||||
AIAgent agent,
|
||||
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();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(collectedText))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Deserialize the structured response.
|
||||
PlanningResponse? planningResponse;
|
||||
try
|
||||
{
|
||||
planningResponse = JsonSerializer.Deserialize<PlanningResponse>(collectedText);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// JSON parsing failed — fall back to rendering as regular text output.
|
||||
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (planningResponse is null)
|
||||
{
|
||||
// Null result — fall back to rendering as regular text output.
|
||||
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (planningResponse.Type == PlanningResponseType.Clarification)
|
||||
{
|
||||
return BuildClarificationActions(planningResponse);
|
||||
}
|
||||
|
||||
if (planningResponse.Type == PlanningResponseType.Approval)
|
||||
{
|
||||
var question = planningResponse.Questions.FirstOrDefault();
|
||||
if (question is null)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("(approval response had no content)", ConsoleColor.DarkYellow);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
|
||||
}
|
||||
|
||||
// Unexpected type — fall back to rendering as regular text output.
|
||||
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<FollowUpAction> BuildClarificationActions(PlanningResponse response)
|
||||
{
|
||||
var actions = new List<FollowUpAction>(response.Questions.Count);
|
||||
|
||||
foreach (var question in response.Questions)
|
||||
{
|
||||
string prompt = question.Message;
|
||||
|
||||
async Task<ChatMessage?> 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 })
|
||||
{
|
||||
actions.Add(new ChoiceFollowUpQuestion(
|
||||
Prompt: prompt,
|
||||
Choices: question.Choices,
|
||||
AllowCustomText: true,
|
||||
Continuation: Continuation));
|
||||
}
|
||||
else
|
||||
{
|
||||
actions.Add(new TextFollowUpQuestion(
|
||||
Prompt: prompt,
|
||||
Continuation: Continuation));
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private ChoiceFollowUpQuestion BuildApprovalAction(PlanningQuestion question, AgentSession session)
|
||||
{
|
||||
const string ApproveOption = "Approve and switch to execute mode";
|
||||
var choices = new List<string> { ApproveOption };
|
||||
|
||||
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 == 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 new ChatMessage(ChatRole.User, selection);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the current mode matches the configured plan mode name.
|
||||
/// A <see langword="null"/> mode (no mode provider) is also treated as planning mode.
|
||||
/// </summary>
|
||||
private bool IsPlanningMode(string? currentMode) =>
|
||||
currentMode is null || string.Equals(currentMode, this._planModeName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a structured response from the agent while in planning mode.
|
||||
/// Used with structured output to enable consistent rendering of clarification
|
||||
/// questions and approval requests in the console.
|
||||
/// </summary>
|
||||
public class PlanningResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the type of planning response.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required PlanningResponseType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of questions or items to present to the user.
|
||||
/// For clarification, this contains one or more questions (each with choices).
|
||||
/// For approval, this contains exactly one item with the plan summary.
|
||||
/// </summary>
|
||||
[JsonPropertyName("questions")]
|
||||
[Description("For clarifications, this has one or more questions to ask the user (each with choices). For approvals, this has exactly one item containing the plan summary for the user to approve.")]
|
||||
public required List<PlanningQuestion> Questions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single question or item within a <see cref="PlanningResponse"/>.
|
||||
/// </summary>
|
||||
public class PlanningQuestion
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the message to display to the user.
|
||||
/// For clarification, this is the question. For approval, this is the plan summary.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
[Description("For clarifications, this has the question that needs to be clarified with the user. For approvals, this would contain a summary of the execution plan that the user needs to approve.")]
|
||||
public required string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of choices for the user to pick from.
|
||||
/// Only used for clarification questions. Null when no predefined choices are offered.
|
||||
/// </summary>
|
||||
[JsonPropertyName("choices")]
|
||||
[Description("""
|
||||
For clarifications, this has a list of options that the user can choose from.
|
||||
null for approvals.
|
||||
|
||||
Note: for clarifications, the user will always also be presented with a free form input option, so make sure that each choice provided here is a valid input for the next turn.
|
||||
E.g. if the question is "Which stock are you referring to?" then valid choices might be ["AAPL", "MSFT", "GOOG"], and the user could also type their own answer.
|
||||
Invalid choices would be ["Enter tickers directly", "Paste tickers"], since these conflict with the already existing freeform option, and don't directly provide valid inputs for the next turn.
|
||||
""")]
|
||||
public List<string>? Choices { get; set; }
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the type of planning response from the agent.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<PlanningResponseType>))]
|
||||
public enum PlanningResponseType
|
||||
{
|
||||
/// <summary>
|
||||
/// The agent needs clarification and presents options for the user to choose from.
|
||||
/// </summary>
|
||||
[Description("Use this type when you need clarification around the user request and you want to present the user with options to choose from.")]
|
||||
Clarification,
|
||||
|
||||
/// <summary>
|
||||
/// The agent is seeking approval to proceed with execution.
|
||||
/// </summary>
|
||||
[Description("Use this type when you are ready to start execution, but need approval to start executing.")]
|
||||
Approval,
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Displays reasoning content in dark magenta from the response stream.
|
||||
/// </summary>
|
||||
public sealed class ReasoningDisplayObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text))
|
||||
{
|
||||
await ux.WriteTextAsync(reasoning.Text, ConsoleColor.DarkMagenta);
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Streams agent text output directly to the console.
|
||||
/// Used in normal (non-planning) mode.
|
||||
/// </summary>
|
||||
public sealed class TextOutputObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session)
|
||||
{
|
||||
await ux.WriteTextAsync(text);
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Collects <see cref="ToolApprovalRequestContent"/> items during the response stream,
|
||||
/// displays approval-needed notifications inline, and after the stream completes returns
|
||||
/// one <see cref="ChoiceFollowUpQuestion"/> per pending approval request. Each question's
|
||||
/// continuation produces a separate <see cref="ChatMessage"/> carrying the approval
|
||||
/// response content.
|
||||
/// </summary>
|
||||
public sealed class ToolApprovalObserver : ConsoleObserver
|
||||
{
|
||||
private readonly List<ToolApprovalRequestContent> _approvalRequests = [];
|
||||
private readonly IReadOnlyList<ToolCallFormatter> _formatters;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolApprovalObserver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="formatters">Optional list of tool formatters. When <see langword="null"/>,
|
||||
/// the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/> are used.</param>
|
||||
public ToolApprovalObserver(IReadOnlyList<ToolCallFormatter>? formatters = null)
|
||||
{
|
||||
this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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(this._formatters, fc)
|
||||
: approvalRequest.ToolCall?.ToString() ?? "unknown";
|
||||
await ux.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
|
||||
IUXStateDriver ux,
|
||||
AIAgent agent,
|
||||
AgentSession session)
|
||||
{
|
||||
if (this._approvalRequests.Count == 0)
|
||||
{
|
||||
return Task.FromResult<IList<FollowUpAction>?>(null);
|
||||
}
|
||||
|
||||
var actions = new List<FollowUpAction>(this._approvalRequests.Count);
|
||||
foreach (var request in this._approvalRequests)
|
||||
{
|
||||
actions.Add(this.BuildApprovalQuestion(request));
|
||||
}
|
||||
|
||||
this._approvalRequests.Clear();
|
||||
return Task.FromResult<IList<FollowUpAction>?>(actions);
|
||||
}
|
||||
|
||||
private ChoiceFollowUpQuestion BuildApprovalQuestion(ToolApprovalRequestContent request)
|
||||
{
|
||||
string toolName = request.ToolCall is FunctionCallContent fc
|
||||
? ToolCallFormatter.Format(this._formatters, fc)
|
||||
: request.ToolCall?.ToString() ?? "unknown";
|
||||
|
||||
var choices = new List<string>
|
||||
{
|
||||
"Approve this call",
|
||||
"Always approve this tool (any arguments)",
|
||||
"Always approve this tool with these arguments",
|
||||
"Deny",
|
||||
};
|
||||
|
||||
string prompt = $"🔐 Tool approval: {toolName}";
|
||||
|
||||
return new ChoiceFollowUpQuestion(
|
||||
Prompt: prompt,
|
||||
Choices: choices,
|
||||
AllowCustomText: false,
|
||||
Continuation: async (selection, ux) =>
|
||||
{
|
||||
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",
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
return new ChatMessage(ChatRole.User, [response]);
|
||||
});
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Displays tool call notifications (🔧) for <see cref="FunctionCallContent"/>
|
||||
/// and <see cref="ToolCallContent"/> items in the response stream.
|
||||
/// </summary>
|
||||
public sealed class ToolCallDisplayObserver : ConsoleObserver
|
||||
{
|
||||
private readonly IReadOnlyList<ToolCallFormatter> _formatters;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolCallDisplayObserver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="formatters">Optional list of tool formatters. When <see langword="null"/>,
|
||||
/// the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/> are used.</param>
|
||||
public ToolCallDisplayObserver(IReadOnlyList<ToolCallFormatter>? formatters = null)
|
||||
{
|
||||
this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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(this._formatters, functionCall)}...", ConsoleColor.DarkYellow);
|
||||
}
|
||||
else if (content is WebSearchToolCallContent)
|
||||
{
|
||||
// Handled by OpenAIResponsesWebSearchDisplayObserver when present; skip here to avoid duplication.
|
||||
}
|
||||
else if (content is ToolCallContent toolCall)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Displays token usage statistics (📊) from the response stream.
|
||||
/// </summary>
|
||||
public sealed class UsageDisplayObserver : ConsoleObserver
|
||||
{
|
||||
private readonly int? _maxContextWindowTokens;
|
||||
private readonly int? _maxOutputTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UsageDisplayObserver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxContextWindowTokens">Optional max context window size in tokens.</param>
|
||||
/// <param name="maxOutputTokens">Optional max output tokens.</param>
|
||||
public UsageDisplayObserver(int? maxContextWindowTokens, int? maxOutputTokens)
|
||||
{
|
||||
this._maxContextWindowTokens = maxContextWindowTokens;
|
||||
this._maxOutputTokens = maxOutputTokens;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is UsageContent usage)
|
||||
{
|
||||
if (usage.Details is not null)
|
||||
{
|
||||
ux.SetUsageText(this.FormatUsageBreakdown(usage.Details));
|
||||
}
|
||||
else
|
||||
{
|
||||
ux.SetUsageText("📊 Tokens —");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private string FormatUsageBreakdown(UsageDetails details)
|
||||
{
|
||||
int? inputBudget = (this._maxContextWindowTokens is not null && this._maxOutputTokens is not null)
|
||||
? this._maxContextWindowTokens.Value - this._maxOutputTokens.Value
|
||||
: null;
|
||||
|
||||
return $"📊 Tokens — input: {FormatTokenCount(details.InputTokenCount, inputBudget)}"
|
||||
+ $" | output: {FormatTokenCount(details.OutputTokenCount, this._maxOutputTokens)}"
|
||||
+ $" | total: {FormatTokenCount(details.TotalTokenCount, this._maxContextWindowTokens)}";
|
||||
}
|
||||
|
||||
private static string FormatTokenCount(long? count, int? budget)
|
||||
{
|
||||
if (count is null)
|
||||
{
|
||||
return "—";
|
||||
}
|
||||
|
||||
if (budget is not null && budget.Value > 0)
|
||||
{
|
||||
double pct = (double)count.Value / budget.Value * 100;
|
||||
return $"{count.Value:N0}/{budget.Value:N0} ({pct:F1}%)";
|
||||
}
|
||||
|
||||
return $"{count.Value:N0}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the type of an output entry in the console conversation.
|
||||
/// </summary>
|
||||
internal enum OutputEntryType
|
||||
{
|
||||
/// <summary>User input echo (e.g. "You: hello").</summary>
|
||||
UserInput,
|
||||
|
||||
/// <summary>In-progress streaming text from the agent (accumulated chunk by chunk).</summary>
|
||||
StreamingText,
|
||||
|
||||
/// <summary>Informational line (tool calls, errors, usage, approval requests, etc.).</summary>
|
||||
InfoLine,
|
||||
|
||||
/// <summary>Stream footer (e.g. "(no text response from agent)").</summary>
|
||||
StreamFooter,
|
||||
|
||||
/// <summary>Pending injected message notification.</summary>
|
||||
PendingMessage,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single output entry in the console conversation history.
|
||||
/// Used internally by <see cref="HarnessConsoleUXStateDriver"/> to track
|
||||
/// the in-progress streaming entry and last-entry type for spacing decisions.
|
||||
/// </summary>
|
||||
/// <param name="Type">The type of output entry.</param>
|
||||
/// <param name="Text">The text content of the entry.</param>
|
||||
/// <param name="Color">Optional foreground color for rendering.</param>
|
||||
internal sealed record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null);
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>background_agents_*</c> tool calls with human-readable details
|
||||
/// for task start, continue, wait, and result retrieval operations.
|
||||
/// </summary>
|
||||
public sealed class BackgroundAgentToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("background_agents_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"background_agents_start_task" => FormatStartBackgroundTask(call),
|
||||
"background_agents_wait_for_first_completion" => FormatIdList(call, "taskIds", "Wait for"),
|
||||
"background_agents_get_task_results" => FormatSingleId(call, "taskId"),
|
||||
"background_agents_continue_task" => FormatContinueTask(call),
|
||||
"background_agents_clear_completed_task" => FormatSingleId(call, "taskId"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? FormatStartBackgroundTask(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<int>? 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}";
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class FallbackToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call)
|
||||
{
|
||||
if (call.Arguments is null || call.Arguments.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>file_memory_*</c> tool calls, showing file names and search patterns
|
||||
/// with tree-view corners for write and edit operations.
|
||||
/// </summary>
|
||||
public sealed class FileMemoryToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("file_memory_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"file_memory_write" => FormatWriteFile(call),
|
||||
"file_memory_read" => FormatStringArg(call, "fileName"),
|
||||
"file_memory_delete" => FormatStringArg(call, "fileName"),
|
||||
"file_memory_replace" => FormatReplaceFile(call),
|
||||
"file_memory_replace_lines" => FormatReplaceLinesFile(call),
|
||||
"file_memory_grep" => FormatGrep(call),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? FormatWriteFile(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? FormatReplaceFile(FunctionCallContent call)
|
||||
{
|
||||
string? fileName = GetStringArgumentValue(call, "fileName");
|
||||
|
||||
if (fileName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bool replaceAll = string.Equals(GetStringArgumentValue(call, "replaceAll"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return replaceAll
|
||||
? $"\n └─ {fileName} (replace all)"
|
||||
: $"\n └─ {fileName} (replace)";
|
||||
}
|
||||
|
||||
private static string? FormatReplaceLinesFile(FunctionCallContent call)
|
||||
{
|
||||
string? fileName = GetStringArgumentValue(call, "fileName");
|
||||
|
||||
if (fileName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int count = GetEditsCount(call, "edits");
|
||||
|
||||
return $"\n └─ {fileName} ({count} line(s))";
|
||||
}
|
||||
|
||||
private static int GetEditsCount(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) == true &&
|
||||
value is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return je.GetArrayLength();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string? FormatGrep(FunctionCallContent call)
|
||||
{
|
||||
string? pattern = GetStringArgumentValue(call, "regexPattern");
|
||||
string? globPattern = GetStringArgumentValue(call, "globPattern");
|
||||
|
||||
if (pattern is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(globPattern)
|
||||
? $"(/{pattern}/)"
|
||||
: $"(/{pattern}/ in {globPattern})";
|
||||
}
|
||||
|
||||
private static string? FormatStringArg(FunctionCallContent call, string paramName)
|
||||
{
|
||||
string? value = GetStringArgumentValue(call, paramName);
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>mode_*</c> tool calls, showing the target mode for Set operations.
|
||||
/// </summary>
|
||||
public sealed class ModeToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("mode_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"mode_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;
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>todos_*</c> tool calls with tree-view output for added items
|
||||
/// and structured output for complete/remove operations.
|
||||
/// </summary>
|
||||
public sealed class TodoToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("todos_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"todos_add" => FormatAddTodos(call),
|
||||
"todos_complete" => FormatCompleteTodos(call),
|
||||
"todos_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<string>();
|
||||
|
||||
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? FormatCompleteTodos(FunctionCallContent call)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue("items", out object? itemsObj) != true || itemsObj is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var entries = new List<(int Id, string? Reason)>();
|
||||
|
||||
if (itemsObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in jsonArray.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("id", out JsonElement idElement) || !idElement.TryGetInt32(out int id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? reason = item.TryGetProperty("reason", out JsonElement reasonElement)
|
||||
? reasonElement.GetString()
|
||||
: null;
|
||||
entries.Add((id, reason));
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
string connector = i < entries.Count - 1 ? "├─" : "└─";
|
||||
sb.Append($"\n {connector} Complete #{entries[i].Id}");
|
||||
if (!string.IsNullOrEmpty(entries[i].Reason))
|
||||
{
|
||||
sb.Append($" — {Truncate(entries[i].Reason!, 80)}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
|
||||
{
|
||||
List<int>? 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();
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for tool call formatters that produce human-readable display strings
|
||||
/// for <see cref="FunctionCallContent"/> items shown in the console.
|
||||
/// </summary>
|
||||
public abstract class ToolCallFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if this formatter can handle the given function call.
|
||||
/// </summary>
|
||||
/// <param name="call">The function call content to check.</param>
|
||||
/// <returns><see langword="true"/> if this formatter should be used; otherwise <see langword="false"/>.</returns>
|
||||
public abstract bool CanFormat(FunctionCallContent call);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the detail portion of the formatted output for the given tool call,
|
||||
/// or <see langword="null"/> if only the tool name should be displayed.
|
||||
/// </summary>
|
||||
/// <param name="call">The function call content to format.</param>
|
||||
/// <returns>A detail string to append after the tool name, or <see langword="null"/>.</returns>
|
||||
public abstract string? FormatDetail(FunctionCallContent call);
|
||||
|
||||
/// <summary>
|
||||
/// Formats a tool call using the first matching formatter from the provided list.
|
||||
/// Returns <c>"{toolName} {detail}"</c> when a formatter produces detail,
|
||||
/// or just <c>"{toolName}"</c> otherwise.
|
||||
/// </summary>
|
||||
internal static string Format(IReadOnlyList<ToolCallFormatter> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default list of tool call formatters. The <see cref="FallbackToolFormatter"/>
|
||||
/// is always last. Users can call this method and combine the result with their own formatters.
|
||||
/// </summary>
|
||||
/// <returns>A list of all built-in tool call formatters.</returns>
|
||||
public static List<ToolCallFormatter> BuildDefaultToolFormatters()
|
||||
{
|
||||
return
|
||||
[
|
||||
new TodoToolFormatter(),
|
||||
new ModeToolFormatter(),
|
||||
new BackgroundAgentToolFormatter(),
|
||||
new FileMemoryToolFormatter(),
|
||||
new WebSearchToolFormatter(),
|
||||
new FallbackToolFormatter(),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a string argument value from a function call.
|
||||
/// </summary>
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts an integer argument value from a function call.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a list of integer argument values from a function call.
|
||||
/// </summary>
|
||||
protected static List<int>? GetIntListArgumentValue(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new List<int>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates a string to the specified maximum length, appending an ellipsis if truncated.
|
||||
/// </summary>
|
||||
protected static string Truncate(string text, int maxLength)
|
||||
{
|
||||
return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…");
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>web_search</c> tool calls, showing the search query.
|
||||
/// </summary>
|
||||
public sealed class WebSearchToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) =>
|
||||
call.Name is "web_search";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call)
|
||||
{
|
||||
string? value = GetStringArgumentValue(call, "query");
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user