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:
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// "Meet your agent harness and claw" — Post 1 of the "Build your own claw with Microsoft Agent Framework" series.
|
||||
// See: https://devblogs.microsoft.com/agent-framework/meet-your-agent-harness-and-claw.
|
||||
//
|
||||
// This sample builds the foundation of a personal finance / investing assistant on top of a
|
||||
// HarnessAgent. The harness comes pre-configured with function invocation, per-service-call
|
||||
// history persistence, and planning (TodoProvider + AgentModeProvider), plus web search — so
|
||||
// all we add here is:
|
||||
// 1. Finance-focused instructions.
|
||||
// 2. A custom get_stock_price function tool.
|
||||
//
|
||||
// The agent can plan a multi-step request ("Review my watchlist and recommend some stocks to add"), create a todo list, switch
|
||||
// between plan and execute modes, search the web for market news, and call our stock-price tool.
|
||||
//
|
||||
// Special commands (handled by the shared HarnessConsole):
|
||||
// /todos — Display the current todo list without invoking the agent.
|
||||
// /mode — Get or set the current agent mode.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using ClawSample;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4";
|
||||
|
||||
// <instructions>
|
||||
var instructions =
|
||||
"""
|
||||
## Personal Finance Assistant Instructions
|
||||
|
||||
You are a personal finance and investing assistant. You help the user understand their
|
||||
watchlist and the markets. When asked about a stock, look up its current price with the
|
||||
get_stock_price tool, and use web search for recent news, earnings, or analyst commentary.
|
||||
|
||||
### Working style
|
||||
|
||||
- Always verify numbers with a tool rather than relying on memory. Stock prices change.
|
||||
- Cite web sources inline when you use them.
|
||||
- Keep the user's watchlist in a memory file called watchlist.md: read it when reviewing the
|
||||
watchlist, and update it whenever the user adds or removes a ticker.
|
||||
|
||||
### Important
|
||||
|
||||
You provide information and analysis only — you are not a licensed financial advisor and you
|
||||
must not present your output as personalized investment advice. Remind the user to do their
|
||||
own research before making decisions.
|
||||
""";
|
||||
// </instructions>
|
||||
|
||||
// <create_client>
|
||||
// Construct an IChatClient. Here we use a Microsoft Foundry project: the endpoint points at the
|
||||
// project, DefaultAzureCredential handles auth, and the deployment name selects the model.
|
||||
// The harness works with ANY IChatClient — see the AgentProviders samples for OpenAI, Azure
|
||||
// OpenAI, Anthropic, Google Gemini, Ollama, ONNX, and more.
|
||||
IChatClient chatClient =
|
||||
new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
new DefaultAzureCredential(),
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }) // Enable retries to improve resiliency.
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName);
|
||||
// </create_client>
|
||||
|
||||
// <create_agent>
|
||||
// Turn the chat client into a HarnessAgent. AsHarnessAgent pre-configures function invocation,
|
||||
// per-service-call chat history persistence, TodoProvider, AgentModeProvider, and web search.
|
||||
// We add finance instructions and our get_stock_price tool.
|
||||
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools = [StockTools.CreateGetStockPriceTool()],
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
});
|
||||
// </create_agent>
|
||||
|
||||
// <run>
|
||||
// Run the interactive console session using the shared HarnessConsole helper.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
userPrompt: "Ask about a stock or say 'Review my watchlist and recommend some stocks to add' to get started.",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
Observers = [
|
||||
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||
new OpenAIResponsesErrorObserver(),
|
||||
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
toolFormatters: ToolCallFormatter.BuildDefaultToolFormatters())],
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||
});
|
||||
// </run>
|
||||
@@ -0,0 +1,52 @@
|
||||
# Meet your claw (Post 1) — .NET
|
||||
|
||||
The first runnable sample from the [**"Build your own agent harness and claw with Microsoft Agent Framework"** blog](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework)
|
||||
series. It builds the foundation of a personal finance / investing assistant on top of a
|
||||
`HarnessAgent`.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- **`AsHarnessAgent`** — turns an `IChatClient` into a batteries-included agent: function
|
||||
invocation, per-service-call history persistence, planning
|
||||
(`TodoProvider` + `AgentModeProvider`), and web search.
|
||||
- **A custom function tool** — `get_stock_price` (see `StockTools.cs`), exposing local data to the
|
||||
agent. Prices are illustrative mock data, not real quotes.
|
||||
- **Web search** — provided automatically by the harness for market news and commentary.
|
||||
- **Planning & modes** — the agent breaks a multi-step request ("Review my watchlist and recommend some stocks to add") into a todo
|
||||
list and switches between *plan* and *execute* modes.
|
||||
- **Shared harness console** — interactive streaming UI with `/todos`, `/mode`, and `/exit`
|
||||
commands.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Microsoft Foundry project with a deployed model (e.g. `gpt-5.4`).
|
||||
2. Azure CLI installed and authenticated (`az login`).
|
||||
|
||||
## Environment variables
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
|
||||
# Optional (defaults to gpt-5.4)
|
||||
export FOUNDRY_MODEL="gpt-5.4"
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step01_MeetYourClaw
|
||||
```
|
||||
|
||||
## What to expect
|
||||
|
||||
The sample starts an interactive loop. Try these in order:
|
||||
|
||||
1. `/mode execute` — switch out of the default plan mode; quick lookups don't need a plan.
|
||||
2. `What's the price of MSFT?` — the agent calls the `get_stock_price` tool.
|
||||
3. `Any recent news on NVDA?` — the agent uses web search.
|
||||
4. `Add MSFT, NVDA and SPY to my watch list` — saved to `watchlist.md` in the session's memory.
|
||||
5. `/mode plan` — switch back to plan mode for a bigger, multi-step task.
|
||||
6. `Review my watchlist and recommend some stocks to add` — the agent plans, then executes. Type
|
||||
`/todos` to see the list and `/mode` to inspect the current mode.
|
||||
|
||||
Output is colored by mode: **cyan** during planning, **green** during execution.
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawSample;
|
||||
|
||||
/// <summary>
|
||||
/// A custom function tool that gives our "claw" access to (illustrative) stock prices.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The prices returned here are mock data for demonstration purposes only and are not real
|
||||
/// market quotes. In a real assistant you would call a market-data API instead.
|
||||
/// </remarks>
|
||||
internal static class StockTools
|
||||
{
|
||||
// <stock_quote>
|
||||
/// <summary>A delayed, illustrative stock quote.</summary>
|
||||
public sealed record StockQuote(string Symbol, decimal Price, string Currency, DateTimeOffset AsOf);
|
||||
// </stock_quote>
|
||||
|
||||
// A tiny in-memory price book so the sample runs without any external dependency.
|
||||
private static readonly Dictionary<string, decimal> s_priceBook = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["MSFT"] = 462.97m,
|
||||
["AAPL"] = 229.35m,
|
||||
["GOOGL"] = 178.12m,
|
||||
["AMZN"] = 201.45m,
|
||||
["NVDA"] = 134.81m,
|
||||
};
|
||||
|
||||
// <get_stock_price>
|
||||
/// <summary>
|
||||
/// Gets the latest (delayed, illustrative) stock price for a ticker symbol.
|
||||
/// </summary>
|
||||
/// <param name="symbol">The stock ticker symbol, e.g. <c>MSFT</c> or <c>AAPL</c>.</param>
|
||||
[Description("Gets the latest (delayed, illustrative) stock price for a ticker symbol.")]
|
||||
public static StockQuote GetStockPrice(
|
||||
[Description("The stock ticker symbol, e.g. MSFT or AAPL.")] string symbol)
|
||||
{
|
||||
if (!s_priceBook.TryGetValue(symbol, out var price))
|
||||
{
|
||||
// Deterministic pseudo-price for unknown symbols so the sample stays self-contained.
|
||||
// Derive a stable seed from the characters — string.GetHashCode() is randomized per
|
||||
// process and Math.Abs(int.MinValue) throws, so neither is safe for repeatable output.
|
||||
var seed = 0;
|
||||
foreach (var ch in symbol.ToUpperInvariant())
|
||||
{
|
||||
seed = (seed * 31 + ch) % 1_000_000;
|
||||
}
|
||||
|
||||
price = 50m + seed % 45000 / 100m;
|
||||
}
|
||||
|
||||
return new StockQuote(symbol.ToUpperInvariant(), price, "USD", DateTimeOffset.UtcNow);
|
||||
}
|
||||
// </get_stock_price>
|
||||
|
||||
/// <summary>Creates the <see cref="AIFunction"/> wrapper used to expose the tool to the agent.</summary>
|
||||
public static AIFunction CreateGetStockPriceTool() => AIFunctionFactory.Create(GetStockPrice, "get_stock_price");
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="working\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// "Working with your data, safely" — Post 2 of the "Build your own claw and agent harness with Microsoft Agent Framework" series.
|
||||
// See: https://devblogs.microsoft.com/agent-framework/agent-harness-working-with-your-data-safely.
|
||||
//
|
||||
// This sample builds on Post 1's personal finance assistant and adds three abilities:
|
||||
// 1. File access — read the user's portfolio.csv and write report files (file_access_* tools).
|
||||
// 2. Approvals — the place_trade tool is wrapped so it requires human approval before running.
|
||||
// 3. Durable memory — two complementary kinds:
|
||||
// * File memory (coarse-grained, explicit) — the agent reads/writes files like
|
||||
// watchlist.md. Its files live on disk under {cwd}/agent-file-memory/<session-id>/,
|
||||
// so they persist across runs on this machine; /session-export and /session-import
|
||||
// preserve the session id so a relaunched session re-links to its memory files.
|
||||
// * Foundry memory (fine-grained, automatic) — Microsoft Foundry extracts durable facts
|
||||
// (e.g. the user's risk tolerance) from the conversation. Opt-in: enabled
|
||||
// only when FOUNDRY_MEMORY_STORE and FOUNDRY_EMBEDDING_MODEL are set.
|
||||
//
|
||||
// Special commands (handled by the shared HarnessConsole):
|
||||
// /todos — Display the current todo list without invoking the agent.
|
||||
// /mode — Get or set the current agent mode.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using ClawSample;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4";
|
||||
|
||||
// <instructions>
|
||||
var instructions =
|
||||
"""
|
||||
## Personal Finance Assistant Instructions
|
||||
|
||||
You are a personal finance and investing assistant. You help the user understand their
|
||||
portfolio and watchlist, and you can place trades on their behalf.
|
||||
|
||||
### Working style
|
||||
|
||||
- The user's holdings live in a file called portfolio.csv. Read it with the file_access tools
|
||||
before answering questions about their portfolio, and never modify it unless asked.
|
||||
- When asked for a report or analysis, write it to a Markdown file with the file_access tools
|
||||
(e.g. reports/portfolio-review.md) and tell the user where you saved it.
|
||||
- Keep the user's watchlist in a memory file called watchlist.md: read it when reviewing the
|
||||
watchlist, and update it whenever the user adds or removes a ticker.
|
||||
- To buy or sell, use the place_trade tool. This takes a real action, so the user will be
|
||||
asked to approve it before it runs — explain what you are about to do first.
|
||||
- Remember durable facts the user tells you about themselves (risk tolerance, goals,
|
||||
preferences) and take them into account when giving analysis.
|
||||
|
||||
### Important
|
||||
|
||||
You provide information and analysis only — you are not a licensed financial advisor and you
|
||||
must not present your output as personalized investment advice. Remind the user to do their
|
||||
own research before making decisions.
|
||||
""";
|
||||
// </instructions>
|
||||
|
||||
// <create_client>
|
||||
// Construct an IChatClient backed by a Microsoft Foundry project (see Post 1 for details).
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
new DefaultAzureCredential(),
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) });
|
||||
|
||||
IChatClient chatClient = projectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName);
|
||||
// </create_client>
|
||||
|
||||
// <memory>
|
||||
// Fine-grained Foundry memory is opt-in: it needs a memory store and an embedding model. When the
|
||||
// environment is configured we add a FoundryMemoryProvider scoped to a single user, so the facts it
|
||||
// extracts are recalled across sessions. Otherwise we fall back to file memory only.
|
||||
var memoryStoreName = Environment.GetEnvironmentVariable("FOUNDRY_MEMORY_STORE");
|
||||
var embeddingModel = Environment.GetEnvironmentVariable("FOUNDRY_EMBEDDING_MODEL");
|
||||
|
||||
FoundryMemoryProvider? foundryMemory = null;
|
||||
if (!string.IsNullOrWhiteSpace(memoryStoreName) && !string.IsNullOrWhiteSpace(embeddingModel))
|
||||
{
|
||||
foundryMemory = new FoundryMemoryProvider(
|
||||
projectClient,
|
||||
memoryStoreName,
|
||||
// In a real world scenario, "claw-sample-user" should be replaced with a unique identifier
|
||||
// for the active user.
|
||||
// To tie memories to the session, replace "claw-sample-user" with Guid.NewGuid().ToString().
|
||||
// stateInitializer is called once per session to define the scope for the memory provider.
|
||||
stateInitializer: _ => new(new FoundryMemoryProviderScope("claw-sample-user")),
|
||||
new FoundryMemoryProviderOptions()
|
||||
{
|
||||
// For demo purposes, configure the memory provider to extract facts immediately
|
||||
// from each message. In a real-world scenario, you may want to set this to a higher value.
|
||||
UpdateDelay = 0,
|
||||
});
|
||||
|
||||
// Create the memory store on first use (no-op if it already exists).
|
||||
await foundryMemory.EnsureMemoryStoreCreatedAsync(
|
||||
deploymentName,
|
||||
embeddingModel,
|
||||
"Durable memory for the Build-your-own-claw finance assistant.");
|
||||
|
||||
Console.WriteLine($"Foundry memory enabled (store: {memoryStoreName}).");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Foundry memory disabled. Set FOUNDRY_MEMORY_STORE and FOUNDRY_EMBEDDING_MODEL to enable it.");
|
||||
}
|
||||
// </memory>
|
||||
|
||||
// <create_agent>
|
||||
// Turn the chat client into a HarnessAgent. On top of Post 1's defaults we point file access at a
|
||||
// fixed folder, add our approval-gated place_trade tool, and (optionally) wire in the Foundry
|
||||
// memory provider for automatic, fine-grained fact extraction. File memory keeps its on-disk default
|
||||
// store (see below), and we don't point it at a custom folder.
|
||||
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
// File access: read portfolio.csv and write reports under the sample's working/ folder.
|
||||
FileAccessStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "working")),
|
||||
// Auto-approve the read-only file tools so reading portfolio.csv is frictionless, while saving,
|
||||
// deleting, and place_trade still pause for explicit approval.
|
||||
ToolApprovalAgentOptions = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [FileAccessProvider.ReadOnlyToolsAutoApprovalRule],
|
||||
},
|
||||
// Start in "execute" mode: this assistant is mostly quick lookups and single actions, so a plan
|
||||
// would be overkill. (Planning is still available — switch any time with /mode plan.)
|
||||
AgentModeProviderOptions = new AgentModeProviderOptions { DefaultMode = "execute" },
|
||||
// File memory is on by default. Its files live on disk under {cwd}/agent-file-memory/<session-id>/,
|
||||
// so they persist across runs on this machine. A brand-new session gets a new id (and so an empty
|
||||
// memory); /session-export and /session-import preserve the session's identity so a relaunched
|
||||
// session re-links to its existing on-disk memory files. The export file holds session state, not
|
||||
// the memory files themselves.
|
||||
// Fine-grained, automatic memory (when configured).
|
||||
AIContextProviders = foundryMemory is null ? null : [foundryMemory],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools =
|
||||
[
|
||||
StockTools.CreateGetStockPriceTool(),
|
||||
TradingTools.CreatePlaceTradeTool(),
|
||||
],
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
});
|
||||
// </create_agent>
|
||||
|
||||
// <run>
|
||||
// Run the interactive console session. The default planning observers already include a tool
|
||||
// approval observer, so the place_trade approval prompt is surfaced automatically.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
userPrompt: "Ask me to review your portfolio, draft a report, update your watchlist, or place a trade.",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
Observers = [
|
||||
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||
new OpenAIResponsesErrorObserver(),
|
||||
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
toolFormatters: ToolCallFormatter.BuildDefaultToolFormatters())],
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||
});
|
||||
// </run>
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
# Working with your data, safely (Post 2) — .NET
|
||||
|
||||
The second runnable sample from the [**"Build your own claw and agent harness with Microsoft Agent Framework"** blog](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework)
|
||||
series ([Part 2 — Working with your data, safely](https://devblogs.microsoft.com/agent-framework/agent-harness-working-with-your-data-safely)).
|
||||
It builds on Post 1's personal finance assistant and teaches it to work with *your* data safely.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- **File access** — the agent reads a pre-populated `working/portfolio.csv` and writes reports
|
||||
(e.g. `reports/portfolio-review.md`) with the built-in `file_access_*` tools. A custom
|
||||
`FileAccessStore` roots those tools at the sample's `working/` folder.
|
||||
- **Approvals** — file-access tools require approval by default, but the sample wires the built-in
|
||||
`FileAccessProvider.ReadOnlyToolsAutoApprovalRule` so reads/lists/searches are frictionless while
|
||||
saving and deleting still pause for approval. The `place_trade` tool is also wrapped in an
|
||||
`ApprovalRequiredAIFunction` (see `TradingTools.cs`), so the harness surfaces an approval prompt
|
||||
before any trade runs. The trade itself is simulated — no real order is placed.
|
||||
- **Durable memory, two ways:**
|
||||
- **File memory** (coarse-grained, explicit) — the agent reads/writes files such as
|
||||
`watchlist.md`. File memory is on by default; its files live on disk under
|
||||
`{cwd}/agent-file-memory/<session-id>/`, so they persist across runs on this machine. A new
|
||||
session starts empty; use `/session-export` and `/session-import` to preserve the session id so a
|
||||
relaunch re-links to its memory files (no fixed folder required).
|
||||
- **Foundry memory** (fine-grained, automatic) — Microsoft Foundry extracts durable facts (e.g.
|
||||
your risk tolerance) from the conversation. Opt-in; see below.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Microsoft Foundry project with a deployed model (e.g. `gpt-5.4`).
|
||||
2. Azure CLI installed and authenticated (`az login`).
|
||||
3. *(Optional, for Foundry memory)* a deployed embedding model and a memory store name.
|
||||
|
||||
## Environment variables
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
|
||||
# Optional (defaults to gpt-5.4)
|
||||
export FOUNDRY_MODEL="gpt-5.4"
|
||||
|
||||
# Optional — enable fine-grained Foundry memory (both must be set):
|
||||
export FOUNDRY_MEMORY_STORE="claw-finance-memory"
|
||||
export FOUNDRY_EMBEDDING_MODEL="text-embedding-3-small"
|
||||
```
|
||||
|
||||
When the Foundry memory variables are not set, the sample runs with file memory only and prints a
|
||||
note.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step02_WorkingWithData
|
||||
```
|
||||
|
||||
## What to expect
|
||||
|
||||
The sample starts an interactive loop in **execute** mode (quick lookups don't need a plan). Try
|
||||
these in order:
|
||||
|
||||
1. `What's in my portfolio?` — the agent reads `portfolio.csv` with the file_access tools.
|
||||
2. `Write me a short report on my portfolio and save it.` — the agent writes a Markdown file
|
||||
under `working/`; saving is a write, so **you are prompted to approve** before it lands.
|
||||
3. `I'm a conservative investor saving for a house in two years.` — a durable fact (recalled later
|
||||
by Foundry memory when enabled).
|
||||
4. `Buy 10 shares of MSFT.` — the agent calls `place_trade`; **you are prompted to approve or
|
||||
deny** before it runs.
|
||||
5. `Add SPY to my watchlist.` — saved to `watchlist.md` in file memory.
|
||||
|
||||
Restart the app to see memory persist across runs:
|
||||
|
||||
- **Foundry memory** (when enabled) recalls facts about you in any new session — it's scoped to you,
|
||||
not the session.
|
||||
- **File memory** (the watchlist) lives on disk keyed by session id, so `/session-export` before you
|
||||
quit and `/session-import` after relaunching to re-link the relaunched session to its files.
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawSample;
|
||||
|
||||
/// <summary>
|
||||
/// A custom function tool that gives our "claw" access to (illustrative) stock prices.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The prices returned here are mock data for demonstration purposes only and are not real
|
||||
/// market quotes. In a real assistant you would call a market-data API instead.
|
||||
/// </remarks>
|
||||
internal static class StockTools
|
||||
{
|
||||
/// <summary>A delayed, illustrative stock quote.</summary>
|
||||
public sealed record StockQuote(string Symbol, decimal Price, string Currency, DateTimeOffset AsOf);
|
||||
|
||||
// A tiny in-memory price book so the sample runs without any external dependency.
|
||||
private static readonly Dictionary<string, decimal> s_priceBook = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["MSFT"] = 462.97m,
|
||||
["AAPL"] = 229.35m,
|
||||
["GOOGL"] = 178.12m,
|
||||
["AMZN"] = 201.45m,
|
||||
["NVDA"] = 134.81m,
|
||||
["SPY"] = 612.40m,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest (delayed, illustrative) stock price for a ticker symbol.
|
||||
/// </summary>
|
||||
/// <param name="symbol">The stock ticker symbol, e.g. <c>MSFT</c> or <c>AAPL</c>.</param>
|
||||
[Description("Gets the latest (delayed, illustrative) stock price for a ticker symbol.")]
|
||||
public static StockQuote GetStockPrice(
|
||||
[Description("The stock ticker symbol, e.g. MSFT or AAPL.")] string symbol)
|
||||
{
|
||||
if (!s_priceBook.TryGetValue(symbol, out var price))
|
||||
{
|
||||
// Deterministic pseudo-price for unknown symbols so the sample stays self-contained.
|
||||
// Derive a stable seed from the characters — string.GetHashCode() is randomized per
|
||||
// process and Math.Abs(int.MinValue) throws, so neither is safe for repeatable output.
|
||||
var seed = 0;
|
||||
foreach (var ch in symbol.ToUpperInvariant())
|
||||
{
|
||||
seed = (seed * 31 + ch) % 1_000_000;
|
||||
}
|
||||
|
||||
price = 50m + seed % 45000 / 100m;
|
||||
}
|
||||
|
||||
return new StockQuote(symbol.ToUpperInvariant(), price, "USD", DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Creates the <see cref="AIFunction"/> wrapper used to expose the tool to the agent.</summary>
|
||||
public static AIFunction CreateGetStockPriceTool() => AIFunctionFactory.Create(GetStockPrice, "get_stock_price");
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawSample;
|
||||
|
||||
/// <summary>
|
||||
/// Sensitive "claw" tools that take real-world actions and therefore require human approval.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These tools only simulate their effects (no orders are placed, no email is sent). They exist
|
||||
/// to demonstrate how the harness gates risky actions behind an approval prompt.
|
||||
/// </remarks>
|
||||
internal static class TradingTools
|
||||
{
|
||||
// <place_trade>
|
||||
/// <summary>
|
||||
/// Places a (simulated) buy or sell order for a given symbol and quantity.
|
||||
/// </summary>
|
||||
/// <param name="symbol">The stock ticker symbol to trade, e.g. <c>MSFT</c>.</param>
|
||||
/// <param name="action">Either <c>buy</c> or <c>sell</c>.</param>
|
||||
/// <param name="quantity">The number of shares to trade.</param>
|
||||
[Description("Places a buy or sell order for a given symbol and quantity.")]
|
||||
public static string PlaceTrade(
|
||||
[Description("The stock ticker symbol to trade, e.g. MSFT.")] string symbol,
|
||||
[Description("Either 'buy' or 'sell'.")] string action,
|
||||
[Description("The number of shares to trade.")] int quantity)
|
||||
{
|
||||
var isBuy = action.Equals("buy", StringComparison.OrdinalIgnoreCase);
|
||||
var isSell = action.Equals("sell", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isBuy && !isSell)
|
||||
{
|
||||
return $"Invalid action '{action}'. Use 'buy' or 'sell'.";
|
||||
}
|
||||
|
||||
if (quantity <= 0)
|
||||
{
|
||||
return $"Invalid quantity '{quantity}'. Quantity must be a positive whole number of shares.";
|
||||
}
|
||||
|
||||
var verb = isSell ? "Sold" : "Bought";
|
||||
var confirmation = $"TRADE-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||
return $"{verb} {quantity} share(s) of {symbol.ToUpperInvariant()}. Confirmation: {confirmation}.";
|
||||
}
|
||||
// </place_trade>
|
||||
|
||||
/// <summary>
|
||||
/// Creates an approval-required <see cref="AIFunction"/> for <see cref="PlaceTrade"/>.
|
||||
/// Wrapping the function in <see cref="ApprovalRequiredAIFunction"/> tells the harness to
|
||||
/// surface an approval request before the function ever runs.
|
||||
/// </summary>
|
||||
public static AIFunction CreatePlaceTradeTool() =>
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(PlaceTrade, "place_trade"));
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
symbol,shares,cost_basis,purchase_date
|
||||
MSFT,40,312.50,2023-02-14
|
||||
AAPL,75,168.20,2022-11-03
|
||||
NVDA,120,42.80,2021-06-21
|
||||
AMZN,30,142.10,2023-09-12
|
||||
GOOGL,25,128.45,2024-01-30
|
||||
SPY,60,418.90,2024-05-08
|
||||
|
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Hyperlight.HyperlightSandbox.Guest.Python" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
|
||||
<ProjectReference Include="..\..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="working\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using Azure.Core;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
namespace ClawSample;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for wiring centrally-managed <b>Foundry skills</b> into the claw via a Foundry Toolbox
|
||||
/// MCP endpoint. These are opt-in: skills published to the toolbox are discovered at runtime, so
|
||||
/// they can be managed and updated without changing or redeploying the agent.
|
||||
/// </summary>
|
||||
internal static class FoundrySkills
|
||||
{
|
||||
/// <summary>
|
||||
/// Connects to a Foundry Toolbox MCP endpoint and returns a connected <see cref="McpClient"/>.
|
||||
/// The caller owns the returned client and its HTTP client.
|
||||
/// </summary>
|
||||
/// <param name="toolboxMcpServerUrl">The Foundry Toolbox MCP server URL.</param>
|
||||
/// <param name="credential">Credential used to obtain a bearer token for the toolbox.</param>
|
||||
/// <returns>The connected MCP client and the underlying HTTP client; both must be disposed by the caller.</returns>
|
||||
public static async Task<(McpClient McpClient, HttpClient HttpClient)> ConnectAsync(
|
||||
string toolboxMcpServerUrl,
|
||||
TokenCredential credential)
|
||||
{
|
||||
var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
|
||||
{
|
||||
InnerHandler = new HttpClientHandler(),
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
McpClient mcpClient = await McpClient.CreateAsync(
|
||||
new HttpClientTransport(
|
||||
new HttpClientTransportOptions
|
||||
{
|
||||
Endpoint = new Uri(toolboxMcpServerUrl),
|
||||
Name = "foundry_toolbox",
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
AdditionalHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["Foundry-Features"] = "Toolboxes=V1Preview",
|
||||
},
|
||||
},
|
||||
httpClient));
|
||||
|
||||
return (mcpClient, httpClient);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The MCP client never took ownership of the HTTP client, so dispose it here.
|
||||
httpClient.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
|
||||
{
|
||||
private readonly TokenRequestContext _tokenContext = new([scope]);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// "Scaling its capabilities" — Post 3 of the "Build your own claw and agent harness with Microsoft
|
||||
// Agent Framework" series.
|
||||
// See: https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-the-claw-or-harness-capabilities/.
|
||||
//
|
||||
// This sample builds on Post 2's personal finance assistant and makes it *more capable* in four ways:
|
||||
// 1. Skills — package finance know-how (valuation, risk-scoring) as discoverable SKILL.md
|
||||
// files the agent loads on demand. Optionally fold in centrally-managed Foundry
|
||||
// skills from a Foundry Toolbox MCP endpoint (opt-in via FOUNDRY_TOOLBOX_MCP_SERVER_URL).
|
||||
// 2. Shell — a sandboxed shell, confined to the trade-confirmation vault, that the agent
|
||||
// uses to reorganize the accumulated confirmation files (year/month, rename,
|
||||
// archive). Guarded by a deny-list policy and a confined working directory.
|
||||
// 3. CodeAct — the agent writes and runs Python to crunch portfolio numbers, in a sandboxed
|
||||
// Hyperlight micro-VM (needs hardware virtualization).
|
||||
// 4. Background agents — fan out a per-ticker research sub-agent so several tickers are researched
|
||||
// concurrently, then aggregated.
|
||||
//
|
||||
// Special commands (handled by the shared HarnessConsole):
|
||||
// /todos — Display the current todo list without invoking the agent.
|
||||
// /mode — Get or set the current agent mode.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using ClawSample;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using HyperlightSandbox.Guest.Python;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4";
|
||||
|
||||
// The two folders the claw works in: the working folder (portfolio.csv, reports) and the
|
||||
// trade-confirmation "vault" inside it that the shell will reorganize.
|
||||
var workingDir = Path.Combine(AppContext.BaseDirectory, "working");
|
||||
var vaultDir = Path.Combine(workingDir, "confirmations");
|
||||
var skillsDir = Path.Combine(AppContext.BaseDirectory, "skills");
|
||||
|
||||
// <instructions>
|
||||
var instructions =
|
||||
"""
|
||||
## Personal Finance Assistant Instructions
|
||||
|
||||
You are a personal finance and investing assistant. You help the user understand their
|
||||
portfolio and watchlist, value individual stocks, gauge portfolio risk, research the market,
|
||||
and keep their records tidy.
|
||||
|
||||
### Working style
|
||||
|
||||
- The user's holdings live in a file called portfolio.csv. Read it with the file_access tools
|
||||
before answering questions about their portfolio, and never modify it unless asked.
|
||||
- You have skills for valuation and risk-scoring. When a question matches a skill, load it and
|
||||
follow its instructions (read its references, run its scripts) rather than guessing.
|
||||
- When asked to research several tickers, delegate each one to the background research agent so
|
||||
they run concurrently, then summarize the findings together.
|
||||
- The user's trade confirmations accumulate in the working/confirmations folder. When asked to
|
||||
tidy or reorganize them, use the run_shell tool: inspect the folder first, then move files into
|
||||
a year/month layout and rename them to YYYY-MM-DD_TICKER_BUY|SELL.txt. Explain your plan before
|
||||
running commands that change anything.
|
||||
- To buy or sell, use the place_trade tool. This takes a real action, so the user will be asked
|
||||
to approve it before it runs — explain what you are about to do first.
|
||||
|
||||
### Important
|
||||
|
||||
You provide information and analysis only — you are not a licensed financial advisor and you
|
||||
must not present your output as personalized investment advice. Remind the user to do their own
|
||||
research before making decisions.
|
||||
""";
|
||||
// </instructions>
|
||||
|
||||
// <create_client>
|
||||
// Construct an IChatClient backed by a Microsoft Foundry project (see Post 1 for details).
|
||||
var credential = new DefaultAzureCredential();
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
credential,
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) });
|
||||
|
||||
IChatClient chatClient = projectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName);
|
||||
// </create_client>
|
||||
|
||||
// <skills>
|
||||
// The harness turns a skills provider on by default (it discovers SKILL.md files from the working
|
||||
// directory). Here we build our own so we can point it at this sample's skills/ folder and, when
|
||||
// configured, fold in centrally-managed Foundry skills — all behind one provider.
|
||||
var skillsBuilder = new AgentSkillsProviderBuilder()
|
||||
// File-based skills: valuation and risk-scoring. SubprocessScriptRunner runs their Python scripts.
|
||||
.UseFileSkills([skillsDir], scriptRunner: new SubprocessScriptRunner().RunAsync);
|
||||
|
||||
// Foundry skills (opt-in): discovered live from a Foundry Toolbox MCP endpoint, so they can be
|
||||
// managed and updated centrally without changing or redeploying this agent.
|
||||
HttpClient? toolboxHttpClient = null;
|
||||
ModelContextProtocol.Client.McpClient? toolboxMcpClient = null;
|
||||
var toolboxUrl = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_MCP_SERVER_URL");
|
||||
if (!string.IsNullOrWhiteSpace(toolboxUrl))
|
||||
{
|
||||
(toolboxMcpClient, toolboxHttpClient) = await FoundrySkills.ConnectAsync(toolboxUrl, credential);
|
||||
skillsBuilder.UseMcpSkills(toolboxMcpClient);
|
||||
Console.WriteLine("Foundry skills enabled (Toolbox MCP).");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Foundry skills disabled. Set FOUNDRY_TOOLBOX_MCP_SERVER_URL to enable them.");
|
||||
}
|
||||
|
||||
AgentSkillsProvider skillsProvider = skillsBuilder.Build();
|
||||
// </skills>
|
||||
|
||||
// <background>
|
||||
// Background agents: a lean, web-search-only research sub-agent. Passing it to the harness exposes
|
||||
// the background_agents_* tools so the claw can start several research tasks concurrently and
|
||||
// collect the results.
|
||||
AIAgent researchAgent = ResearchAgent.Create(chatClient);
|
||||
// </background>
|
||||
|
||||
// <shell>
|
||||
// A sandboxed shell, confined to the trade-confirmation vault. ConfineWorkingDirectory re-anchors
|
||||
// every command to the vault, and the deny-list policy pre-filters obviously destructive commands.
|
||||
// (Patterns are a UX guardrail, not a security boundary — for hard isolation use DockerShellExecutor.)
|
||||
await using var shell = new LocalShellExecutor(new LocalShellExecutorOptions
|
||||
{
|
||||
WorkingDirectory = vaultDir,
|
||||
ConfineWorkingDirectory = true,
|
||||
Policy = new ShellPolicy(denyList:
|
||||
[
|
||||
@"\brm\s+-rf\b",
|
||||
@"\bsudo\b",
|
||||
@":\(\)\s*\{", // fork-bomb shape
|
||||
@"\bmkfs\b",
|
||||
@">\s*/dev/sd",
|
||||
]),
|
||||
Timeout = TimeSpan.FromSeconds(15),
|
||||
});
|
||||
// </shell>
|
||||
|
||||
// <codeact>
|
||||
// CodeAct: a sandboxed Python interpreter the model can write and run code in to crunch numbers.
|
||||
// It runs on Hyperlight (a micro-VM, so it needs hardware virtualization). The guest module path is
|
||||
// resolved automatically from the Hyperlight.HyperlightSandbox.Guest.Python NuGet package.
|
||||
using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(PythonGuestModule.GetModulePath()));
|
||||
// </codeact>
|
||||
|
||||
// <create_agent>
|
||||
// Turn the chat client into a HarnessAgent. On top of Post 2's file access and approvals we add the
|
||||
// four "scaling" capabilities: skills (our own provider), background agents, a confined shell, and
|
||||
// CodeAct.
|
||||
List<AIContextProvider> contextProviders = [skillsProvider, codeAct];
|
||||
|
||||
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
// File access: portfolio.csv, reports, and the confirmations vault all live under working/.
|
||||
FileAccessStore = new FileSystemAgentFileStore(workingDir),
|
||||
// We supply our own skills provider (file + optional Foundry), so turn off the default one.
|
||||
DisableAgentSkillsProvider = true,
|
||||
// Fan-out research is delegated to this background agent.
|
||||
BackgroundAgents = [researchAgent],
|
||||
// The confined shell, exposed as the approval-gated run_shell tool.
|
||||
ShellExecutor = shell,
|
||||
// Keep reading the portfolio frictionless while writes, trades, and shell commands still prompt.
|
||||
ToolApprovalAgentOptions = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [FileAccessProvider.ReadOnlyToolsAutoApprovalRule],
|
||||
},
|
||||
// Start in "execute" mode for quick lookups and actions; switch any time with /mode plan.
|
||||
AgentModeProviderOptions = new AgentModeProviderOptions { DefaultMode = "execute" },
|
||||
// Our skills provider plus CodeAct.
|
||||
AIContextProviders = contextProviders,
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools =
|
||||
[
|
||||
StockTools.CreateGetStockPriceTool(),
|
||||
TradingTools.CreatePlaceTradeTool(),
|
||||
],
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
});
|
||||
// </create_agent>
|
||||
|
||||
try
|
||||
{
|
||||
// <run>
|
||||
// Run the interactive console session. The default planning observers already include a tool
|
||||
// approval observer, so the place_trade and run_shell approval prompts are surfaced automatically.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
userPrompt: "Ask me to value a stock, score your portfolio risk, research some tickers, or tidy your trade confirmations.",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
Observers = [
|
||||
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||
new OpenAIResponsesErrorObserver(),
|
||||
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
toolFormatters: ToolCallFormatter.BuildDefaultToolFormatters())],
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||
});
|
||||
// </run>
|
||||
}
|
||||
finally
|
||||
{
|
||||
codeAct?.Dispose();
|
||||
if (toolboxMcpClient is not null)
|
||||
{
|
||||
await toolboxMcpClient.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
toolboxHttpClient?.Dispose();
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
# Scaling its capabilities (Post 3) — .NET
|
||||
|
||||
The third runnable sample from the [**"Build your own claw and agent harness with Microsoft Agent Framework"** blog](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework)
|
||||
series ([Part 3 — Scaling its capabilities](https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-the-claw-or-harness-capabilities/)).
|
||||
It builds on Post 2's personal finance assistant and makes it *more capable* along four axes.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- **Skills** — finance know-how (`valuation`, `risk-scoring`) is packaged as discoverable `SKILL.md`
|
||||
files under `skills/`, which the agent loads on demand. The sample builds its own provider with
|
||||
`AgentSkillsProviderBuilder.UseFileSkills([skillsDir], scriptRunner: new SubprocessScriptRunner().RunAsync)`
|
||||
so the skills' Python scripts can run, and sets `DisableAgentSkillsProvider = true` to replace the
|
||||
harness default. Optionally folds in centrally-managed **Foundry skills** discovered live from a
|
||||
Foundry **Toolbox MCP** endpoint via `FoundrySkills.ConnectAsync(...)` + `UseMcpSkills(...)`
|
||||
(opt-in; see below).
|
||||
- **Shell** — a `LocalShellExecutor` confined to the trade-confirmation vault
|
||||
(`working/confirmations/`) lets the agent tidy the accumulated confirmation files (reorganize into
|
||||
`year/month`, rename to `YYYY-MM-DD_TICKER_BUY|SELL.txt`). `ConfineWorkingDirectory` re-anchors
|
||||
every command to the vault and a `ShellPolicy` deny-list pre-filters obviously destructive
|
||||
commands. Exposed as the `run_shell` tool, which prompts for approval before each command runs.
|
||||
(The deny-list is a UX guardrail, not a security boundary — for hard isolation use a
|
||||
`DockerShellExecutor`.)
|
||||
- **CodeAct** — a `HyperlightCodeActProvider` gives the agent a sandboxed Python interpreter to
|
||||
crunch portfolio numbers by writing and running code. It runs on Hyperlight (a micro-VM), so it
|
||||
requires hardware virtualization. The guest module path is resolved automatically from the
|
||||
`Hyperlight.HyperlightSandbox.Guest.Python` NuGet package via `PythonGuestModule.GetModulePath()`.
|
||||
- **Background agents** — a lean, web-search-only `ResearchAgent` is registered via
|
||||
`HarnessAgentOptions.BackgroundAgents`, exposing the `background_agents_*` tools so the main agent
|
||||
can fan out per-ticker research concurrently and aggregate the findings.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Microsoft Foundry project with a deployed model (e.g. `gpt-5.4`).
|
||||
2. Azure CLI installed and authenticated (`az login`).
|
||||
3. *(For CodeAct)* a host with hardware virtualization enabled (Hyperlight runs the Python
|
||||
interpreter in a micro-VM).
|
||||
|
||||
## Environment variables
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
|
||||
# Optional (defaults to gpt-5.4)
|
||||
export FOUNDRY_MODEL="gpt-5.4"
|
||||
|
||||
# Optional — enable centrally-managed Foundry skills (Foundry Toolbox MCP endpoint URL):
|
||||
export FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://your-project.services.ai.azure.com/.../toolboxes/your-toolbox/mcp?api-version=v1"
|
||||
```
|
||||
|
||||
When `FOUNDRY_TOOLBOX_MCP_SERVER_URL` is not set, the sample runs with the local file skills only and
|
||||
prints a note.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities
|
||||
```
|
||||
|
||||
## What to expect
|
||||
|
||||
The sample starts an interactive loop in **execute** mode (quick lookups don't need a plan). Try
|
||||
these in order:
|
||||
|
||||
1. `Value MSFT for me.` — the agent loads the `valuation` skill and follows its instructions
|
||||
(reading references and running its script).
|
||||
2. `Score the risk of my portfolio.` — the agent reads `portfolio.csv` and loads the `risk-scoring`
|
||||
skill.
|
||||
3. `/mode plan`, then `Tidy up my trade confirmations.` — switching to plan mode first makes the
|
||||
agent inspect `working/confirmations/` and propose a reorganization plan before touching anything;
|
||||
once you approve it switches to execute and uses the shell to reorganize and rename the files,
|
||||
**prompting you to approve** each command.
|
||||
4. `Work out the total value of my portfolio.` — the agent writes and runs Python via CodeAct.
|
||||
5. `Research MSFT, NVDA and SPY and summarize the latest news.` — the agent fans the tickers out to
|
||||
the background research agent and aggregates the results.
|
||||
6. `What's the capital of France?` — with a `financial-agent-rules` skill published to your Foundry
|
||||
toolbox and Foundry skills enabled (`FOUNDRY_TOOLBOX_MCP_SERVER_URL`), the agent loads it,
|
||||
recognizes the question is off-topic, and politely declines, steering you back to finance.
|
||||
|
||||
See the [Part 3 blog post](https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-the-claw-or-harness-capabilities/)
|
||||
for more on the `financial-agent-rules` skill — including the SKILL.md to publish to your Foundry toolbox.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawSample;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the background "research" agent that the main claw fans work out to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This sub-agent doesn't need any of the harness machinery, so it's a plain
|
||||
/// <see cref="ChatClientAgent"/> with a single tool: the hosted web search. The parent claw
|
||||
/// delegates a per-ticker research task to one of these and they run concurrently.
|
||||
/// </remarks>
|
||||
internal static class ResearchAgent
|
||||
{
|
||||
/// <summary>Creates a web-search-only background agent for delegated ticker research.</summary>
|
||||
/// <param name="chatClient">The chat client the background agent should use.</param>
|
||||
public static AIAgent Create(IChatClient chatClient) =>
|
||||
chatClient.AsAIAgent(
|
||||
instructions:
|
||||
"You research a single stock ticker. Use the web search tool to find the most " +
|
||||
"recent, relevant news and commentary, then return a short, factual summary " +
|
||||
"(3-4 bullet points) with no preamble.",
|
||||
name: "TickerResearchAgent",
|
||||
description: "Searches the web for recent news and commentary about a single stock ticker.",
|
||||
// The only tool it needs: the same hosted web search the harness would have added.
|
||||
tools: [new HostedWebSearchTool()]);
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawSample;
|
||||
|
||||
/// <summary>
|
||||
/// A custom function tool that gives our "claw" access to (illustrative) stock prices.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The prices and earnings figures returned here are mock data for demonstration purposes only and
|
||||
/// are not real market quotes. In a real assistant you would call a market-data API instead. The
|
||||
/// trailing earnings-per-share value is included so the valuation skill has something to work with.
|
||||
/// </remarks>
|
||||
internal static class StockTools
|
||||
{
|
||||
/// <summary>A delayed, illustrative stock quote, including a trailing earnings-per-share figure.</summary>
|
||||
public sealed record StockQuote(string Symbol, decimal Price, decimal TrailingEps, string Currency, DateTimeOffset AsOf);
|
||||
|
||||
// A tiny in-memory book of (price, trailing EPS) so the sample runs without any external dependency.
|
||||
private static readonly Dictionary<string, (decimal Price, decimal Eps)> s_priceBook = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["MSFT"] = (462.97m, 11.80m),
|
||||
["AAPL"] = (229.35m, 6.13m),
|
||||
["GOOGL"] = (178.12m, 7.54m),
|
||||
["AMZN"] = (201.45m, 4.18m),
|
||||
["NVDA"] = (134.81m, 2.95m),
|
||||
["SPY"] = (612.40m, 23.10m),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest (delayed, illustrative) stock price and trailing EPS for a ticker symbol.
|
||||
/// </summary>
|
||||
/// <param name="symbol">The stock ticker symbol, e.g. <c>MSFT</c> or <c>AAPL</c>.</param>
|
||||
[Description("Gets the latest (delayed, illustrative) stock price and trailing earnings per share for a ticker symbol.")]
|
||||
public static StockQuote GetStockPrice(
|
||||
[Description("The stock ticker symbol, e.g. MSFT or AAPL.")] string symbol)
|
||||
{
|
||||
if (!s_priceBook.TryGetValue(symbol, out var data))
|
||||
{
|
||||
// Deterministic pseudo-values for unknown symbols so the sample stays self-contained.
|
||||
// Derive a stable seed from the characters — string.GetHashCode() is randomized per
|
||||
// process and Math.Abs(int.MinValue) throws, so neither is safe for repeatable output.
|
||||
var seed = 0;
|
||||
foreach (var ch in symbol.ToUpperInvariant())
|
||||
{
|
||||
seed = (seed * 31 + ch) % 1_000_000;
|
||||
}
|
||||
|
||||
var price = 50m + seed % 45000 / 100m;
|
||||
data = (price, Math.Round(price / 20m, 2));
|
||||
}
|
||||
|
||||
return new StockQuote(symbol.ToUpperInvariant(), data.Price, data.Eps, "USD", DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Creates the <see cref="AIFunction"/> wrapper used to expose the tool to the agent.</summary>
|
||||
public static AIFunction CreateGetStockPriceTool() => AIFunctionFactory.Create(GetStockPrice, "get_stock_price");
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Sample subprocess-based skill script runner.
|
||||
// Executes file-based skill scripts as local subprocesses.
|
||||
// This is provided for demonstration purposes only.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Executes file-based skill scripts as local subprocesses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This runner uses the script's absolute path and converts the arguments
|
||||
/// to CLI arguments. When the LLM sends a JSON array, each element is used
|
||||
/// as a positional argument. It is intended for demonstration purposes only.
|
||||
/// </remarks>
|
||||
internal sealed class SubprocessScriptRunner
|
||||
{
|
||||
/// <summary>Maximum time a skill script is allowed to run before it is terminated.</summary>
|
||||
private static readonly TimeSpan s_scriptTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SubprocessScriptRunner"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">
|
||||
/// Optional logger factory. When provided, script outcomes (success output, stderr, non-zero
|
||||
/// exit codes, and failures) are written to the log in addition to being returned to the LLM.
|
||||
/// </param>
|
||||
public SubprocessScriptRunner(ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<SubprocessScriptRunner>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a skill script as a local subprocess.
|
||||
/// </summary>
|
||||
public async Task<object?> RunAsync(
|
||||
AgentFileSkill skill,
|
||||
AgentFileSkillScript script,
|
||||
JsonElement? arguments,
|
||||
IServiceProvider? serviceProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
this._logger.LogDebug("Running script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name);
|
||||
|
||||
if (!File.Exists(script.FullPath))
|
||||
{
|
||||
this._logger.LogError("Script file not found for skill '{SkillName}': {ScriptPath}", skill.Frontmatter.Name, script.FullPath);
|
||||
return $"Error: Script file not found: {script.FullPath}";
|
||||
}
|
||||
|
||||
string extension = Path.GetExtension(script.FullPath);
|
||||
string? interpreter = extension switch
|
||||
{
|
||||
// Windows Python installs commonly expose "python" rather than "python3".
|
||||
".py" => OperatingSystem.IsWindows() ? "python" : "python3",
|
||||
".js" => "node",
|
||||
".sh" => "bash",
|
||||
".ps1" => "pwsh",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".",
|
||||
};
|
||||
|
||||
if (interpreter is not null)
|
||||
{
|
||||
startInfo.FileName = interpreter;
|
||||
startInfo.ArgumentList.Add(script.FullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
startInfo.FileName = script.FullPath;
|
||||
}
|
||||
|
||||
if (arguments is { ValueKind: JsonValueKind.Array } json)
|
||||
{
|
||||
// Positional CLI arguments
|
||||
foreach (var element in json.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"File-based skill scripts only accept string CLI arguments but received a JSON element of kind '{element.ValueKind}'. " +
|
||||
"All array elements must be JSON strings.");
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add(element.GetString()!);
|
||||
}
|
||||
}
|
||||
else if (arguments is not null && arguments.Value.ValueKind != JsonValueKind.Null && arguments.Value.ValueKind != JsonValueKind.Undefined)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Expected a JSON array of CLI arguments but received {arguments.Value.ValueKind}. " +
|
||||
"File-based skill scripts expect positional arguments as a JSON array of strings.");
|
||||
}
|
||||
|
||||
// Bound the script's lifetime: cancel after a timeout, or when the caller cancels.
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(s_scriptTimeout);
|
||||
CancellationToken runToken = timeoutCts.Token;
|
||||
|
||||
Process? process = null;
|
||||
try
|
||||
{
|
||||
process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
this._logger.LogError("Failed to start process for script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name);
|
||||
return $"Error: Failed to start process for script '{script.Name}'.";
|
||||
}
|
||||
|
||||
Task<string> outputTask = process.StandardOutput.ReadToEndAsync(runToken);
|
||||
Task<string> errorTask = process.StandardError.ReadToEndAsync(runToken);
|
||||
|
||||
await process.WaitForExitAsync(runToken).ConfigureAwait(false);
|
||||
|
||||
string output = await outputTask.ConfigureAwait(false);
|
||||
string error = await errorTask.ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
this._logger.LogWarning(
|
||||
"Script '{ScriptName}' from skill '{SkillName}' succeeded but wrote to stderr:\n{Stderr}",
|
||||
script.Name, skill.Frontmatter.Name, error.Trim());
|
||||
}
|
||||
|
||||
output += $"\nStderr:\n{error}";
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
this._logger.LogError(
|
||||
"Script '{ScriptName}' from skill '{SkillName}' exited with code {ExitCode}.{Stderr}",
|
||||
script.Name, skill.Frontmatter.Name, process.ExitCode,
|
||||
string.IsNullOrEmpty(error) ? string.Empty : $"\nStderr:\n{error.Trim()}");
|
||||
|
||||
output += $"\nScript exited with code {process.ExitCode}";
|
||||
}
|
||||
|
||||
string result = string.IsNullOrEmpty(output) ? "(no output)" : output.Trim();
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
this._logger.LogInformation(
|
||||
"Script '{ScriptName}' from skill '{SkillName}' completed successfully. Output:\n{Output}",
|
||||
script.Name, skill.Frontmatter.Name, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The timeout fired (the caller did not cancel). Kill the process and report a timeout.
|
||||
process?.Kill(entireProcessTree: true);
|
||||
this._logger.LogError(
|
||||
"Script '{ScriptName}' from skill '{SkillName}' timed out after {Timeout} seconds.",
|
||||
script.Name, skill.Frontmatter.Name, s_scriptTimeout.TotalSeconds);
|
||||
return $"Error: Script '{script.Name}' timed out after {s_scriptTimeout.TotalSeconds:0} seconds.";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The caller cancelled: kill the process to avoid leaving orphaned subprocesses, then rethrow.
|
||||
process?.Kill(entireProcessTree: true);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Failed to execute script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name);
|
||||
return $"Error: Failed to execute script '{script.Name}': {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
process?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawSample;
|
||||
|
||||
/// <summary>
|
||||
/// Sensitive "claw" tools that take real-world actions and therefore require human approval.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These tools only simulate their effects (no orders are placed, no email is sent). They exist
|
||||
/// to demonstrate how the harness gates risky actions behind an approval prompt.
|
||||
/// </remarks>
|
||||
internal static class TradingTools
|
||||
{
|
||||
// <place_trade>
|
||||
/// <summary>
|
||||
/// Places a (simulated) buy or sell order for a given symbol and quantity.
|
||||
/// </summary>
|
||||
/// <param name="symbol">The stock ticker symbol to trade, e.g. <c>MSFT</c>.</param>
|
||||
/// <param name="action">Either <c>buy</c> or <c>sell</c>.</param>
|
||||
/// <param name="quantity">The number of shares to trade.</param>
|
||||
[Description("Places a buy or sell order for a given symbol and quantity.")]
|
||||
public static string PlaceTrade(
|
||||
[Description("The stock ticker symbol to trade, e.g. MSFT.")] string symbol,
|
||||
[Description("Either 'buy' or 'sell'.")] string action,
|
||||
[Description("The number of shares to trade.")] int quantity)
|
||||
{
|
||||
var isBuy = action.Equals("buy", StringComparison.OrdinalIgnoreCase);
|
||||
var isSell = action.Equals("sell", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isBuy && !isSell)
|
||||
{
|
||||
return $"Invalid action '{action}'. Use 'buy' or 'sell'.";
|
||||
}
|
||||
|
||||
if (quantity <= 0)
|
||||
{
|
||||
return $"Invalid quantity '{quantity}'. Quantity must be a positive whole number of shares.";
|
||||
}
|
||||
|
||||
var verb = isSell ? "Sold" : "Bought";
|
||||
var confirmation = $"TRADE-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||
return $"{verb} {quantity} share(s) of {symbol.ToUpperInvariant()}. Confirmation: {confirmation}.";
|
||||
}
|
||||
// </place_trade>
|
||||
|
||||
/// <summary>
|
||||
/// Creates an approval-required <see cref="AIFunction"/> for <see cref="PlaceTrade"/>.
|
||||
/// Wrapping the function in <see cref="ApprovalRequiredAIFunction"/> tells the harness to
|
||||
/// surface an approval request before the function ever runs.
|
||||
/// </summary>
|
||||
public static AIFunction CreatePlaceTradeTool() =>
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(PlaceTrade, "place_trade"));
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: risk-scoring
|
||||
description: Score how concentrated and risky a portfolio is on a 0-100 scale from its position weights. Use when the user asks how risky their portfolio is, whether it is too concentrated, or for a diversification check.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
When the user asks about portfolio risk or concentration:
|
||||
|
||||
1. Read `references/risk-bands.md` to understand the score bands and what drives them.
|
||||
2. Compute each holding's market value (shares × price) — use the `get_stock_price` tool for current
|
||||
prices if you do not already have them.
|
||||
3. Run `scripts/risk_score.py` with one `--position VALUE` argument per holding,
|
||||
e.g. `--position 18518 --position 17201 --position 16177`.
|
||||
4. Report the 0-100 score, the band it falls in, and the largest single-position weight, then suggest
|
||||
(in general terms) whether the portfolio looks well diversified or concentrated.
|
||||
|
||||
Remind the user this is a crude concentration measure, not a complete risk model, and not advice.
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Risk-scoring guide (illustrative)
|
||||
|
||||
This skill scores **concentration risk** — how much a portfolio depends on its largest positions —
|
||||
on a 0-100 scale, where higher means riskier.
|
||||
|
||||
## How the score is built
|
||||
|
||||
1. Convert each position to a weight: `weight = position_value / total_value`.
|
||||
2. Compute the Herfindahl-Hirschman Index (HHI): `HHI = sum(weight^2)`.
|
||||
- A perfectly even portfolio of *n* holdings has `HHI = 1/n` (low).
|
||||
- A single-stock portfolio has `HHI = 1` (maximum concentration).
|
||||
3. Scale to 0-100: `score = round(HHI * 100)`.
|
||||
|
||||
## Score bands
|
||||
|
||||
| Score | Band | Interpretation |
|
||||
|---------|--------------------|-------------------------------------------------|
|
||||
| 0-20 | Well diversified | No single holding dominates. |
|
||||
| 21-40 | Moderately diversified | Some tilt, but broadly spread. |
|
||||
| 41-60 | Concentrated | A few positions carry most of the risk. |
|
||||
| 61-100 | Highly concentrated| Heavily dependent on one or two positions. |
|
||||
|
||||
Also watch the **largest single-position weight**: above ~25% is usually worth flagging regardless
|
||||
of the overall score.
|
||||
|
||||
This measures concentration only — it ignores volatility, correlation, sector exposure, and leverage,
|
||||
so it is a starting point, not a verdict.
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# Portfolio risk-scoring script
|
||||
# Scores concentration risk on a 0-100 scale using the Herfindahl-Hirschman Index (HHI).
|
||||
#
|
||||
# weight_i = position_i / total
|
||||
# HHI = sum(weight_i ^ 2)
|
||||
# score = round(HHI * 100) # higher = more concentrated = riskier
|
||||
#
|
||||
# Usage:
|
||||
# python scripts/risk_score.py --position 18518 --position 17201 --position 16177
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Score portfolio concentration risk (0-100).")
|
||||
parser.add_argument(
|
||||
"--position",
|
||||
type=float,
|
||||
action="append",
|
||||
required=True,
|
||||
help="Market value of one holding. Pass once per position.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
positions = args.position
|
||||
if any(p <= 0 for p in positions):
|
||||
print(json.dumps({"error": "Each position value must be a positive market value."}))
|
||||
return
|
||||
|
||||
total = sum(positions)
|
||||
if total <= 0:
|
||||
print(json.dumps({"error": "Total portfolio value must be positive."}))
|
||||
return
|
||||
|
||||
weights = [p / total for p in positions]
|
||||
hhi = sum(w * w for w in weights)
|
||||
score = round(hhi * 100)
|
||||
|
||||
if score <= 20:
|
||||
band = "Well diversified"
|
||||
elif score <= 40:
|
||||
band = "Moderately diversified"
|
||||
elif score <= 60:
|
||||
band = "Concentrated"
|
||||
else:
|
||||
band = "Highly concentrated"
|
||||
|
||||
print(json.dumps({
|
||||
"positions": len(positions),
|
||||
"score": score,
|
||||
"band": band,
|
||||
"largest_weight_pct": round(max(weights) * 100, 1),
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: valuation
|
||||
description: Estimate whether a stock looks cheap or expensive using a price-to-earnings (P/E) based fair-value method. Use when the user asks if a stock is over- or under-valued, or for a fair-value / target price.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
When the user asks whether a stock is fairly valued, over-valued, or under-valued:
|
||||
|
||||
1. Read `references/valuation-guide.md` to pick a sensible target P/E for the company's sector.
|
||||
2. Run `scripts/valuation_metrics.py` with the current price, trailing EPS, and the target P/E,
|
||||
e.g. `--price 462.97 --eps 11.80 --target-pe 32`.
|
||||
3. Report the computed P/E, the fair-value estimate, and the percentage upside/downside, then state
|
||||
plainly whether the stock looks cheap or expensive on this measure.
|
||||
|
||||
Always remind the user that a single P/E heuristic is not investment advice and ignores growth,
|
||||
debt, and many other factors.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Valuation guide (illustrative)
|
||||
|
||||
A quick price-to-earnings (P/E) sanity check:
|
||||
|
||||
- **P/E = price ÷ trailing earnings per share (EPS)**
|
||||
- **Fair value = trailing EPS × target P/E**
|
||||
- **Upside/downside = (fair value − price) ÷ price**
|
||||
|
||||
## Typical target P/E by sector
|
||||
|
||||
These are rough, illustrative anchors only — not live market multiples.
|
||||
|
||||
| Sector | Conservative target P/E | Growth target P/E |
|
||||
|-----------------------|-------------------------|-------------------|
|
||||
| Mega-cap technology | 28 | 35 |
|
||||
| Semiconductors | 25 | 40 |
|
||||
| Consumer staples | 18 | 22 |
|
||||
| Financials / banks | 11 | 14 |
|
||||
| Broad market (index) | 19 | 21 |
|
||||
|
||||
## How to read the result
|
||||
|
||||
- Fair value **well above** the current price ⇒ the stock looks **cheap** on this measure.
|
||||
- Fair value **well below** the current price ⇒ the stock looks **expensive** on this measure.
|
||||
- Within ~5% ⇒ roughly **fairly valued**.
|
||||
|
||||
This is one crude lens. It ignores growth rates, balance-sheet strength, and cash flow, so never
|
||||
present it as a recommendation.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Valuation metrics script
|
||||
# Computes a simple price-to-earnings (P/E) based fair-value estimate.
|
||||
#
|
||||
# fair_value = eps * target_pe
|
||||
# pe = price / eps
|
||||
# upside = (fair_value - price) / price
|
||||
#
|
||||
# Usage:
|
||||
# python scripts/valuation_metrics.py --price 462.97 --eps 11.80 --target-pe 32
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Compute a P/E based fair-value estimate.")
|
||||
parser.add_argument("--price", type=float, required=True, help="Current share price.")
|
||||
parser.add_argument("--eps", type=float, required=True, help="Trailing earnings per share.")
|
||||
parser.add_argument("--target-pe", type=float, required=True, help="Target P/E from the guide.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.eps <= 0:
|
||||
print(json.dumps({"error": "EPS must be positive to compute a P/E ratio."}))
|
||||
return
|
||||
|
||||
if args.price <= 0:
|
||||
print(json.dumps({"error": "Price must be positive to compute valuation metrics."}))
|
||||
return
|
||||
|
||||
if args.target_pe <= 0:
|
||||
print(json.dumps({"error": "Target P/E must be positive."}))
|
||||
return
|
||||
|
||||
pe = args.price / args.eps
|
||||
fair_value = args.eps * args.target_pe
|
||||
upside = (fair_value - args.price) / args.price
|
||||
|
||||
if upside > 0.05:
|
||||
verdict = "looks cheap"
|
||||
elif upside < -0.05:
|
||||
verdict = "looks expensive"
|
||||
else:
|
||||
verdict = "roughly fairly valued"
|
||||
|
||||
print(json.dumps({
|
||||
"price": round(args.price, 2),
|
||||
"eps": round(args.eps, 2),
|
||||
"target_pe": round(args.target_pe, 2),
|
||||
"pe": round(pe, 2),
|
||||
"fair_value": round(fair_value, 2),
|
||||
"upside_pct": round(upside * 100, 1),
|
||||
"verdict": verdict,
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-55AA44BB
|
||||
Date: 2025-06-21
|
||||
Symbol: NVDA
|
||||
Action: SELL
|
||||
Quantity: 20
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-77CC88DD
|
||||
Date: 2024-05-08
|
||||
Symbol: SPY
|
||||
Action: SELL
|
||||
Quantity: 15
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-9F8E7D6C
|
||||
Date: 2024-11-03
|
||||
Symbol: AAPL
|
||||
Action: BUY
|
||||
Quantity: 75
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-1234ABCD
|
||||
Date: 2025-09-12
|
||||
Symbol: AMZN
|
||||
Action: BUY
|
||||
Quantity: 30
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-EE11FF22
|
||||
Date: 2025-01-30
|
||||
Symbol: GOOGL
|
||||
Action: BUY
|
||||
Quantity: 25
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-A1B2C3D4
|
||||
Date: 2024-02-14
|
||||
Symbol: MSFT
|
||||
Action: BUY
|
||||
Quantity: 40
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
symbol,shares,cost_basis,purchase_date
|
||||
MSFT,40,312.50,2023-02-14
|
||||
AAPL,75,168.20,2022-11-03
|
||||
NVDA,120,42.80,2021-06-21
|
||||
AMZN,30,142.10,2023-09-12
|
||||
GOOGL,25,128.45,2024-01-30
|
||||
SPY,60,418.90,2024-05-08
|
||||
|
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Provides descriptive helpers for common ANSI/VT100 escape sequences used
|
||||
/// in the split-console layout (DECSTBM scroll regions, cursor movement, line erasure).
|
||||
/// </summary>
|
||||
public static class AnsiEscapes
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the scrollable region to rows 1 through <paramref name="bottom"/> (DECSTBM).
|
||||
/// Content outside this region will not scroll.
|
||||
/// </summary>
|
||||
public static string SetScrollRegion(int bottom) => $"\x1b[1;{bottom}r";
|
||||
|
||||
/// <summary>
|
||||
/// Resets the scroll region to the full terminal height (DECSTBM reset).
|
||||
/// </summary>
|
||||
public static string ResetScrollRegion => "\x1b[r";
|
||||
|
||||
/// <summary>
|
||||
/// Moves the cursor to the specified 1-based <paramref name="row"/> and <paramref name="column"/> (CUP).
|
||||
/// </summary>
|
||||
public static string MoveCursor(int row, int column) => $"\x1b[{row};{column}H";
|
||||
|
||||
/// <summary>
|
||||
/// Erases the current line from the cursor position to the end of the line (EL 0).
|
||||
/// </summary>
|
||||
public static string EraseToEndOfLine => "\x1b[0K";
|
||||
|
||||
/// <summary>
|
||||
/// Erases the entire current line (EL 2).
|
||||
/// </summary>
|
||||
public static string EraseEntireLine => "\x1b[2K";
|
||||
|
||||
/// <summary>
|
||||
/// Erases the entire screen.
|
||||
/// </summary>
|
||||
public static string EraseEntireScreen => "\x1b[2J";
|
||||
|
||||
/// <summary>
|
||||
/// Erases the scrollback buffer (ESC[3J). Use alongside <see cref="EraseEntireScreen"/>
|
||||
/// to fully clear both the visible screen and the scroll history.
|
||||
/// </summary>
|
||||
public static string EraseScrollbackBuffer => "\x1b[3J";
|
||||
|
||||
/// <summary>
|
||||
/// Saves the current cursor position (DECSC / SCP).
|
||||
/// Note: most terminals have a single save slot — nested saves are not supported.
|
||||
/// </summary>
|
||||
public static string SaveCursor => "\x1b[s";
|
||||
|
||||
/// <summary>
|
||||
/// Restores the previously saved cursor position (DECRC / RCP).
|
||||
/// </summary>
|
||||
public static string RestoreCursor => "\x1b[u";
|
||||
|
||||
/// <summary>
|
||||
/// Moves the cursor to the specified 1-based <paramref name="row"/> at column 1, then erases the entire line.
|
||||
/// Convenience combination of <see cref="MoveCursor"/> and <see cref="EraseEntireLine"/>.
|
||||
/// </summary>
|
||||
public static string MoveAndEraseLine(int row) => $"\x1b[{row};1H\x1b[2K";
|
||||
|
||||
/// <summary>
|
||||
/// Sets the foreground text color using a <see cref="ConsoleColor"/> value.
|
||||
/// </summary>
|
||||
public static string SetForegroundColor(ConsoleColor color) => $"\x1b[{ConsoleColorToAnsi(color)}m";
|
||||
|
||||
/// <summary>
|
||||
/// Resets all text attributes (color, bold, etc.) to their defaults.
|
||||
/// </summary>
|
||||
public static string ResetAttributes => "\x1b[0m";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the visible (printed) length of a string after stripping ANSI escape sequences.
|
||||
/// Escape sequences are zero-width on screen but occupy characters in the raw string.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This counts UTF-16 code units (chars) rather than terminal display cells. Emoji,
|
||||
/// combining characters, variation selectors, and East Asian wide characters may be
|
||||
/// measured incorrectly. For the console harness this is acceptable since content is
|
||||
/// predominantly ASCII, and emoji are padded with surrounding spaces.
|
||||
/// </remarks>
|
||||
public static int VisibleLength(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int length = 0;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '[')
|
||||
{
|
||||
// Skip the ESC[ and all characters up to and including the final byte (0x40–0x7E).
|
||||
i += 2;
|
||||
while (i < text.Length && text[i] < 0x40)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
// i now points to the final byte of the escape sequence; the for-loop will advance past it.
|
||||
}
|
||||
else if (text[i] != '\n' && text[i] != '\r')
|
||||
{
|
||||
length++;
|
||||
}
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the number of physical terminal rows a text item will occupy,
|
||||
/// accounting for both explicit newlines and terminal line wrapping.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to measure.</param>
|
||||
/// <param name="terminalWidth">The terminal width in columns. If <= 0, wrapping is ignored (1 row per logical line).</param>
|
||||
/// <returns>The number of physical rows the text occupies.</returns>
|
||||
public static int CountPhysicalLines(string text, int terminalWidth)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int physicalLines = 0;
|
||||
int lineStart = 0;
|
||||
|
||||
for (int i = 0; i <= text.Length; i++)
|
||||
{
|
||||
if (i == text.Length || text[i] == '\n')
|
||||
{
|
||||
if (terminalWidth <= 0)
|
||||
{
|
||||
// No wrapping — each logical line is one physical row
|
||||
physicalLines += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
string logicalLine = text[lineStart..i];
|
||||
int visibleWidth = VisibleLength(logicalLine);
|
||||
|
||||
physicalLines += visibleWidth == 0
|
||||
? 1
|
||||
: (visibleWidth - 1) / terminalWidth + 1;
|
||||
}
|
||||
|
||||
lineStart = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If text ends with a newline, don't count the trailing empty line
|
||||
if (text[text.Length - 1] == '\n')
|
||||
{
|
||||
physicalLines--;
|
||||
}
|
||||
|
||||
return physicalLines;
|
||||
}
|
||||
|
||||
private static int ConsoleColorToAnsi(ConsoleColor color) => color switch
|
||||
{
|
||||
ConsoleColor.Black => 30,
|
||||
ConsoleColor.DarkRed => 31,
|
||||
ConsoleColor.DarkGreen => 32,
|
||||
ConsoleColor.DarkYellow => 33,
|
||||
ConsoleColor.DarkBlue => 34,
|
||||
ConsoleColor.DarkMagenta => 35,
|
||||
ConsoleColor.DarkCyan => 36,
|
||||
ConsoleColor.Gray => 37,
|
||||
ConsoleColor.DarkGray => 90,
|
||||
ConsoleColor.Red => 91,
|
||||
ConsoleColor.Green => 92,
|
||||
ConsoleColor.Yellow => 93,
|
||||
ConsoleColor.Blue => 94,
|
||||
ConsoleColor.Magenta => 95,
|
||||
ConsoleColor.Cyan => 96,
|
||||
ConsoleColor.White => 97,
|
||||
_ => 37
|
||||
};
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a selectable list of items with a cursor indicator.
|
||||
/// The selected item is indicated with a ">" prefix and rendered in the highlight color.
|
||||
/// Optionally includes a title above the list and a custom text input option at the bottom.
|
||||
/// </summary>
|
||||
public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the height (in rows) required to render the list,
|
||||
/// including the optional title and custom text input row.
|
||||
/// </summary>
|
||||
/// <param name="props">The list selection props.</param>
|
||||
/// <returns>The number of rows needed.</returns>
|
||||
public static int CalculateHeight(ListSelectionProps props)
|
||||
{
|
||||
int height = props.Items.Count;
|
||||
if (props.CustomTextPlaceholder != null)
|
||||
{
|
||||
height++;
|
||||
}
|
||||
|
||||
height += GetTitleLineCount(props.Title);
|
||||
return height;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(ListSelectionProps props, ConsoleReactiveState state)
|
||||
{
|
||||
int row = 0;
|
||||
|
||||
// Render the title lines (if any)
|
||||
if (props.Title is not null)
|
||||
{
|
||||
foreach (string line in props.Title.Split('\n'))
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
Console.Write(line);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
row++;
|
||||
}
|
||||
}
|
||||
|
||||
// Render the list items + optional custom text row
|
||||
int totalItems = props.Items.Count + (props.CustomTextPlaceholder != null ? 1 : 0);
|
||||
|
||||
for (int i = 0; i < totalItems; i++)
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
|
||||
bool isSelected = i == props.SelectedIndex;
|
||||
bool isCustomTextOption = props.CustomTextPlaceholder != null && i == props.Items.Count;
|
||||
|
||||
// Cursor indicator
|
||||
Console.Write(isSelected ? "> " : " ");
|
||||
|
||||
if (isCustomTextOption)
|
||||
{
|
||||
this.RenderCustomTextOption(props, isSelected);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.HighlightColor));
|
||||
}
|
||||
|
||||
Console.Write(props.Items[i]);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
row++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of lines the title occupies, or 0 if no title is set.
|
||||
/// </summary>
|
||||
private static int GetTitleLineCount(string? title) =>
|
||||
title is null ? 0 : title.Split('\n').Length;
|
||||
|
||||
private void RenderCustomTextOption(ListSelectionProps props, bool isSelected)
|
||||
{
|
||||
if (props.CustomText.Length > 0)
|
||||
{
|
||||
// User has typed text — render in highlight color if selected
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.HighlightColor));
|
||||
}
|
||||
|
||||
Console.Write(props.CustomText);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(props.CustomTextPlaceholder))
|
||||
{
|
||||
// No text — show placeholder in dark grey (or highlight color if selected)
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.HighlightColor));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||
}
|
||||
|
||||
Console.Write(" ");
|
||||
Console.Write(props.CustomTextPlaceholder);
|
||||
Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="ListSelection"/>.
|
||||
/// </summary>
|
||||
public record ListSelectionProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the title text displayed above the list items. May contain newlines for multi-line titles.</summary>
|
||||
public string? Title { get; init; }
|
||||
|
||||
/// <summary>Gets the items to display in the list.</summary>
|
||||
public IReadOnlyList<string> Items { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Gets the zero-based index of the currently selected item.</summary>
|
||||
public int SelectedIndex { get; init; }
|
||||
|
||||
/// <summary>Gets the highlight color for the active item. Defaults to <see cref="ConsoleColor.Cyan"/>.</summary>
|
||||
public ConsoleColor HighlightColor { get; init; } = ConsoleColor.Cyan;
|
||||
|
||||
/// <summary>Gets the placeholder text for the custom text input option. If <c>null</c>, no custom option is shown.</summary>
|
||||
public string? CustomTextPlaceholder { get; init; }
|
||||
|
||||
/// <summary>Gets the text being typed into the custom text input option.</summary>
|
||||
public string CustomText { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="TextInput"/>.
|
||||
/// </summary>
|
||||
public record TextInputProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the prompt string displayed on the left (e.g. "> " or "user > ").</summary>
|
||||
public string Prompt { get; init; } = "> ";
|
||||
|
||||
/// <summary>Gets the text content to render to the right of the prompt.</summary>
|
||||
public string Text { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the placeholder text shown in dark grey when <see cref="Text"/> is empty.</summary>
|
||||
public string Placeholder { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a prompt with text input. Supports multi-line text
|
||||
/// where continuation lines are indented to align with the text start position
|
||||
/// (i.e. the column after the prompt).
|
||||
/// </summary>
|
||||
public class TextInput : ConsoleReactiveComponent<TextInputProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the height (in rows) required to render the prompt and text
|
||||
/// given the available width.
|
||||
/// </summary>
|
||||
/// <param name="props">The text input props.</param>
|
||||
/// <param name="availableWidth">The total available width in columns.</param>
|
||||
/// <returns>The number of rows needed.</returns>
|
||||
public static int CalculateHeight(TextInputProps props, int availableWidth)
|
||||
{
|
||||
int promptLength = props.Prompt.Length;
|
||||
int textWidth = availableWidth - promptLength;
|
||||
|
||||
if (textWidth <= 0 || props.Text.Length == 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
int lines = 1;
|
||||
int remaining = props.Text.Length - textWidth;
|
||||
while (remaining > 0)
|
||||
{
|
||||
lines++;
|
||||
remaining -= textWidth;
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(TextInputProps props, ConsoleReactiveState state)
|
||||
{
|
||||
int promptLength = props.Prompt.Length;
|
||||
int textWidth = props.Width - promptLength;
|
||||
string indent = new(' ', promptLength);
|
||||
|
||||
// First line: prompt + start of text
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
Console.Write(props.Prompt);
|
||||
|
||||
if (textWidth <= 0 || props.Text.Length == 0)
|
||||
{
|
||||
// Show placeholder if text is empty
|
||||
if (props.Text.Length == 0 && props.Placeholder.Length > 0 && textWidth > 0)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||
Console.Write(" ");
|
||||
Console.Write(props.Placeholder[..Math.Min(props.Placeholder.Length, textWidth - 1)]);
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
int offset = 0;
|
||||
int firstChunk = Math.Min(textWidth, props.Text.Length);
|
||||
Console.Write(props.Text[offset..firstChunk]);
|
||||
offset = firstChunk;
|
||||
|
||||
// Continuation lines: indented to align with text start
|
||||
int row = 1;
|
||||
while (offset < props.Text.Length)
|
||||
{
|
||||
int chunk = Math.Min(textWidth, props.Text.Length - offset);
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
Console.Write(indent);
|
||||
Console.Write(props.Text[offset..(offset + chunk)]);
|
||||
offset += chunk;
|
||||
row++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="TextPanel"/>.
|
||||
/// </summary>
|
||||
public record TextPanelProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the items to render in the panel. Each item is a pre-rendered
|
||||
/// console string (may include ANSI escape sequences and newlines).</summary>
|
||||
public IReadOnlyList<string> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a list of pre-rendered string items vertically.
|
||||
/// Designed for rendering dynamic items in a non-scroll region that may be
|
||||
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveProps.Height"/>
|
||||
/// exceeds the number of output lines, leftover lines are erased.
|
||||
/// </summary>
|
||||
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the height (in lines) needed to render all items,
|
||||
/// accounting for terminal line wrapping at the specified width.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to measure.</param>
|
||||
/// <param name="terminalWidth">The terminal width in columns. When 0 or negative, wrapping is ignored.</param>
|
||||
/// <returns>The total number of physical lines all items will occupy.</returns>
|
||||
public static int CalculateHeight(IReadOnlyList<string> items, int terminalWidth = 0)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
total += AnsiEscapes.CountPhysicalLines(items[i], terminalWidth);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(TextPanelProps props, ConsoleReactiveState state)
|
||||
{
|
||||
int currentRow = 0;
|
||||
|
||||
for (int i = 0; i < props.Items.Count; i++)
|
||||
{
|
||||
string text = props.Items[i];
|
||||
string[] lines = text.Split('\n');
|
||||
int itemLineCount = AnsiEscapes.CountPhysicalLines(text, props.Width);
|
||||
int itemRow = 0;
|
||||
|
||||
for (int j = 0; j < lines.Length && itemRow < itemLineCount; j++)
|
||||
{
|
||||
int linePhysicalRows = props.Width > 0
|
||||
? Math.Max(1, (AnsiEscapes.VisibleLength(lines[j]) - 1) / props.Width + 1)
|
||||
: 1;
|
||||
|
||||
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
|
||||
Console.Write(lines[j]);
|
||||
|
||||
currentRow += linePhysicalRows;
|
||||
itemRow += linePhysicalRows;
|
||||
}
|
||||
}
|
||||
|
||||
// If the component height exceeds the output, erase leftover lines
|
||||
if (props.Height > currentRow)
|
||||
{
|
||||
for (int i = currentRow; i < props.Height; i++)
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="TextScrollPanel"/>.
|
||||
/// </summary>
|
||||
public record TextScrollPanelProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the items to render in the scroll panel. Each item is a pre-rendered
|
||||
/// console string (may include ANSI escape sequences and newlines).</summary>
|
||||
public IReadOnlyList<string> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State for <see cref="TextScrollPanel"/>.
|
||||
/// </summary>
|
||||
public record TextScrollPanelState : ConsoleReactiveState;
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders pre-rendered string items within a scroll area.
|
||||
/// The last rendered item is considered dynamic and will be re-rendered on each call.
|
||||
/// All prior items are considered finalized and are not re-rendered.
|
||||
/// Use <see cref="Invalidate"/> to force a full re-render.
|
||||
/// </summary>
|
||||
public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, TextScrollPanelState>
|
||||
{
|
||||
private int _renderedCount;
|
||||
private int _lastItemOffsetFromBottom;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextScrollPanel"/> class.
|
||||
/// </summary>
|
||||
public TextScrollPanel()
|
||||
{
|
||||
this.State = new TextScrollPanelState();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Invalidate()
|
||||
{
|
||||
this._renderedCount = 0;
|
||||
this._lastItemOffsetFromBottom = 0;
|
||||
base.Invalidate();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(TextScrollPanelProps props, TextScrollPanelState state)
|
||||
{
|
||||
if (props.Items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int bottomRow = props.Y + props.Height - 1;
|
||||
|
||||
// Determine the first item to render. If we previously rendered items,
|
||||
// re-render the last one (it may have changed/grown) from its stored position.
|
||||
int startIndex = this._renderedCount > 0 ? this._renderedCount - 1 : 0;
|
||||
|
||||
if (this._renderedCount > 0 && this._lastItemOffsetFromBottom > 0)
|
||||
{
|
||||
// Reposition cursor to where the last rendered item began
|
||||
Console.Write(AnsiEscapes.MoveCursor(bottomRow - this._lastItemOffsetFromBottom, props.X));
|
||||
}
|
||||
else
|
||||
{
|
||||
// First render — position at the bottom of the scroll area
|
||||
Console.Write(AnsiEscapes.MoveCursor(bottomRow, props.X));
|
||||
}
|
||||
|
||||
// Render from startIndex onwards
|
||||
for (int i = startIndex; i < props.Items.Count; i++)
|
||||
{
|
||||
Console.Write(props.Items[i]);
|
||||
}
|
||||
|
||||
// Calculate the offset from bottom for the start of the new last item,
|
||||
// accounting for terminal line wrapping at the available width.
|
||||
int lastItemLines = AnsiEscapes.CountPhysicalLines(props.Items[^1], props.Width);
|
||||
this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0;
|
||||
|
||||
// Update rendered count
|
||||
this._renderedCount = props.Items.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="TopBottomRule"/>.
|
||||
/// </summary>
|
||||
public record TopBottomRuleProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the foreground color of the horizontal rules. If <c>null</c>, the default terminal color is used.</summary>
|
||||
public ConsoleColor? Color { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a top and bottom horizontal rule (─) with children
|
||||
/// stacked vertically between them.
|
||||
/// </summary>
|
||||
public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the total height including the top rule, children, and bottom rule.
|
||||
/// </summary>
|
||||
/// <param name="props">The component props containing children.</param>
|
||||
/// <returns>2 (for the rules) plus the sum of all children heights.</returns>
|
||||
public static int CalculateHeight(TopBottomRuleProps props)
|
||||
{
|
||||
int childrenHeight = 0;
|
||||
foreach (var child in props.Children)
|
||||
{
|
||||
childrenHeight += child.BaseProps?.Height ?? 0;
|
||||
}
|
||||
|
||||
// Top rule + children + bottom rule
|
||||
return 2 + childrenHeight;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(TopBottomRuleProps props, ConsoleReactiveState state)
|
||||
{
|
||||
int ruleWidth = props.Width;
|
||||
string rule = new('─', ruleWidth);
|
||||
|
||||
if (props.Color.HasValue)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.Color.Value));
|
||||
}
|
||||
|
||||
// Top rule
|
||||
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
|
||||
Console.Write(rule);
|
||||
|
||||
// Render children stacked below the top rule
|
||||
int currentY = props.Y + 1;
|
||||
|
||||
if (props.Color.HasValue)
|
||||
{
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
|
||||
foreach (var child in props.Children)
|
||||
{
|
||||
child.BaseProps = child.BaseProps! with { X = props.X, Y = currentY };
|
||||
child.Render();
|
||||
currentY += child.BaseProps.Height;
|
||||
}
|
||||
|
||||
if (props.Color.HasValue)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.Color.Value));
|
||||
}
|
||||
|
||||
// Bottom rule
|
||||
Console.Write(AnsiEscapes.MoveCursor(currentY, props.X));
|
||||
Console.Write(rule);
|
||||
|
||||
if (props.Color.HasValue)
|
||||
{
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.ConsoleReactiveFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for all console UI components. Provides access to layout
|
||||
/// through <see cref="BaseProps"/> and a <see cref="Render"/> method for drawing to the console.
|
||||
/// Derive from <see cref="ConsoleReactiveComponent{TProps, TState}"/> instead of this class directly.
|
||||
/// </summary>
|
||||
public abstract class ConsoleReactiveComponent
|
||||
{
|
||||
internal ConsoleReactiveComponent()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the shared render lock across all component types to prevent ANSI escape sequence interleaving.
|
||||
/// </summary>
|
||||
protected static object RenderLock { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
|
||||
/// Used by parent components to set layout (X, Y, Width, Height) on children without
|
||||
/// knowing the concrete props type.
|
||||
/// </summary>
|
||||
public abstract ConsoleReactiveProps? BaseProps { get; set; }
|
||||
|
||||
/// <summary>Renders the component to the console at its current position.</summary>
|
||||
public abstract void Render();
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the component's cached render state, causing the next <see cref="Render"/> call
|
||||
/// to proceed even if props and state have not changed. Use after a screen erase to force repaint.
|
||||
/// </summary>
|
||||
public abstract void Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic base class for console UI components with typed props and state.
|
||||
/// Props represent externally supplied configuration; state represents internal mutable data.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProps">The type of the component's props (external configuration).</typeparam>
|
||||
/// <typeparam name="TState">The type of the component's internal state.</typeparam>
|
||||
public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactiveComponent
|
||||
where TProps : ConsoleReactiveProps
|
||||
where TState : ConsoleReactiveState
|
||||
{
|
||||
private TProps? _lastRenderedProps;
|
||||
private TState? _lastRenderedState;
|
||||
|
||||
/// <summary>Gets or sets the component's props (external configuration).</summary>
|
||||
public TProps? Props { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ConsoleReactiveProps? BaseProps
|
||||
{
|
||||
get => this.Props;
|
||||
set => this.Props = (TProps?)value;
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets the component's internal state.</summary>
|
||||
protected TState? State { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Updates the component's state and triggers a re-render.
|
||||
/// </summary>
|
||||
/// <param name="newState">The new state value.</param>
|
||||
public void SetState(TState newState)
|
||||
{
|
||||
this.State = newState;
|
||||
this.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the component using the current props and state.
|
||||
/// Uses a lock to prevent concurrent renders from multiple sources.
|
||||
/// Skips rendering if neither props nor state have changed since the last render.
|
||||
/// </summary>
|
||||
public override void Render()
|
||||
{
|
||||
lock (RenderLock)
|
||||
{
|
||||
if (this.Props is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (EqualityComparer<TProps>.Default.Equals(this.Props, this._lastRenderedProps)
|
||||
&& EqualityComparer<TState>.Default.Equals(this.State, this._lastRenderedState))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.RenderCore(this.Props, this.State!);
|
||||
|
||||
this._lastRenderedProps = this.Props;
|
||||
this._lastRenderedState = this.State;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Invalidate()
|
||||
{
|
||||
lock (RenderLock)
|
||||
{
|
||||
this._lastRenderedProps = default;
|
||||
this._lastRenderedState = default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by <see cref="Render"/> to perform the actual rendering. Override this in derived classes.
|
||||
/// </summary>
|
||||
/// <param name="props">The current props.</param>
|
||||
/// <param name="state">The current state.</param>
|
||||
public abstract void RenderCore(TProps props, TState state);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base record for component props. Provides layout properties (position and size)
|
||||
/// and an optional <see cref="Children"/> collection for composing child components.
|
||||
/// </summary>
|
||||
public record ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the 1-based column position of the component.</summary>
|
||||
public int X { get; init; }
|
||||
|
||||
/// <summary>Gets the 1-based row position of the component.</summary>
|
||||
public int Y { get; init; }
|
||||
|
||||
/// <summary>Gets the width of the component in columns.</summary>
|
||||
public int Width { get; init; }
|
||||
|
||||
/// <summary>Gets the height of the component in rows.</summary>
|
||||
public int Height { get; init; }
|
||||
|
||||
/// <summary>Gets the child components to render within this component.</summary>
|
||||
public IReadOnlyList<ConsoleReactiveComponent> Children { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base record for component state.
|
||||
/// </summary>
|
||||
public record ConsoleReactiveState;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.ConsoleReactiveFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Caches the result of a mapping function and only recomputes when the input changes.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of the input value.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of the mapped output value.</typeparam>
|
||||
public class ConsoleReactiveMemo<TInput, TOutput>
|
||||
{
|
||||
private TInput? _previousInput;
|
||||
private TOutput? _cachedOutput;
|
||||
private bool _hasValue;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached output if <paramref name="input"/> equals the previously stored input;
|
||||
/// otherwise invokes <paramref name="mapper"/> to compute and cache a new output.
|
||||
/// </summary>
|
||||
/// <param name="input">The current input value.</param>
|
||||
/// <param name="mapper">A function that maps the input to an output value.</param>
|
||||
/// <returns>The cached or newly computed output.</returns>
|
||||
public TOutput Map(TInput input, Func<TInput, TOutput> mapper)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mapper);
|
||||
|
||||
if (!this._hasValue || !EqualityComparer<TInput>.Default.Equals(input, this._previousInput))
|
||||
{
|
||||
this._previousInput = input;
|
||||
this._cachedOutput = mapper(input);
|
||||
this._hasValue = true;
|
||||
}
|
||||
|
||||
return this._cachedOutput!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.ConsoleReactiveFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Event args for console resize events, containing the old and new dimensions.
|
||||
/// </summary>
|
||||
public class ConsoleResizeEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>Gets the previous console width.</summary>
|
||||
public int OldWidth { get; }
|
||||
|
||||
/// <summary>Gets the previous console height.</summary>
|
||||
public int OldHeight { get; }
|
||||
|
||||
/// <summary>Gets the new console width.</summary>
|
||||
public int NewWidth { get; }
|
||||
|
||||
/// <summary>Gets the new console height.</summary>
|
||||
public int NewHeight { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConsoleResizeEventArgs"/> class.
|
||||
/// </summary>
|
||||
/// <param name="oldWidth">The previous width.</param>
|
||||
/// <param name="oldHeight">The previous height.</param>
|
||||
/// <param name="newWidth">The new width.</param>
|
||||
/// <param name="newHeight">The new height.</param>
|
||||
public ConsoleResizeEventArgs(int oldWidth, int oldHeight, int newWidth, int newHeight)
|
||||
{
|
||||
this.OldWidth = oldWidth;
|
||||
this.OldHeight = oldHeight;
|
||||
this.NewWidth = newWidth;
|
||||
this.NewHeight = newHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Singleton that polls console dimensions every 16ms and raises the
|
||||
/// <see cref="ConsoleResized"/> event when the window size changes.
|
||||
/// </summary>
|
||||
public sealed class ConsoleResizeListener
|
||||
{
|
||||
#pragma warning disable IDE0052 // Remove unread private members
|
||||
private readonly Task _task;
|
||||
#pragma warning restore IDE0052 // Remove unread private members
|
||||
|
||||
private int _lastWidth;
|
||||
private int _lastHeight;
|
||||
|
||||
private ConsoleResizeListener()
|
||||
{
|
||||
this._lastWidth = Console.WindowWidth;
|
||||
this._lastHeight = Console.WindowHeight;
|
||||
this._task = this.ListenForResizeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Gets the singleton instance of <see cref="ConsoleResizeListener"/>.</summary>
|
||||
public static ConsoleResizeListener Instance { get; } = new ConsoleResizeListener();
|
||||
|
||||
/// <summary>Raised when the console window is resized.</summary>
|
||||
public event EventHandler<ConsoleResizeEventArgs>? ConsoleResized;
|
||||
|
||||
private async Task ListenForResizeAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int currentWidth = Console.WindowWidth;
|
||||
int currentHeight = Console.WindowHeight;
|
||||
|
||||
if (currentWidth != this._lastWidth || currentHeight != this._lastHeight)
|
||||
{
|
||||
int oldWidth = this._lastWidth;
|
||||
int oldHeight = this._lastHeight;
|
||||
this._lastWidth = currentWidth;
|
||||
this._lastHeight = currentHeight;
|
||||
this.ConsoleResized?.Invoke(this, new ConsoleResizeEventArgs(oldWidth, oldHeight, currentWidth, currentHeight));
|
||||
}
|
||||
|
||||
await Task.Delay(16);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.ConsoleReactiveFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Event args for key press events, wrapping a <see cref="ConsoleKeyInfo"/>.
|
||||
/// </summary>
|
||||
public class KeyPressEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>Gets the key information for the pressed key.</summary>
|
||||
public ConsoleKeyInfo KeyInfo { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="KeyPressEventArgs"/> class.
|
||||
/// </summary>
|
||||
/// <param name="keyInfo">The key information.</param>
|
||||
public KeyPressEventArgs(ConsoleKeyInfo keyInfo)
|
||||
{
|
||||
this.KeyInfo = keyInfo;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Singleton that polls for console key presses every 16ms and raises the
|
||||
/// <see cref="KeyPressed"/> event when a key is detected.
|
||||
/// </summary>
|
||||
public sealed class KeyEventListener
|
||||
{
|
||||
#pragma warning disable IDE0052 // Remove unread private members
|
||||
private readonly Task _task;
|
||||
#pragma warning restore IDE0052 // Remove unread private members
|
||||
|
||||
private KeyEventListener()
|
||||
{
|
||||
this._task = this.ListenForKeyPressesAsync();
|
||||
}
|
||||
|
||||
/// <summary>Gets the singleton instance of <see cref="KeyEventListener"/>.</summary>
|
||||
public static KeyEventListener Instance { get; } = new KeyEventListener();
|
||||
|
||||
/// <summary>Raised when a key is pressed in the console.</summary>
|
||||
public event EventHandler<KeyPressEventArgs>? KeyPressed;
|
||||
|
||||
private async Task ListenForKeyPressesAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
while (Console.KeyAvailable)
|
||||
{
|
||||
var keyInfo = Console.ReadKey(intercept: true);
|
||||
this.KeyPressed?.Invoke(this, new KeyPressEventArgs(keyInfo));
|
||||
}
|
||||
|
||||
await Task.Delay(16);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenAI" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
|
||||
using System.Text.Json;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Harness.Shared.Console.OpenAI;
|
||||
|
||||
/// <summary>
|
||||
/// Detects and displays error/incomplete status from OpenAI Responses API streaming updates.
|
||||
/// Handles <see cref="StreamingResponseFailedUpdate"/> and <see cref="StreamingResponseIncompleteUpdate"/>
|
||||
/// which are not surfaced as <see cref="ErrorContent"/> by the chat client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note: <see cref="StreamingResponseErrorUpdate"/> is already handled by the SDK — it produces
|
||||
/// an <see cref="ErrorContent"/> which is displayed by <see cref="ErrorDisplayObserver"/>.
|
||||
/// This observer covers the cases where the SDK does not produce <see cref="ErrorContent"/>.
|
||||
/// </remarks>
|
||||
public sealed class OpenAIResponsesErrorObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnResponseUpdateAsync(IUXStateDriver ux, AgentResponseUpdate update, AIAgent agent, AgentSession session)
|
||||
{
|
||||
// AgentResponseUpdate.RawRepresentation is the ChatResponseUpdate,
|
||||
// whose RawRepresentation is the underlying StreamingResponseUpdate.
|
||||
object? rawUpdate = (update.RawRepresentation as ChatResponseUpdate)?.RawRepresentation
|
||||
?? update.RawRepresentation;
|
||||
|
||||
switch (rawUpdate)
|
||||
{
|
||||
case StreamingResponseFailedUpdate failedUpdate:
|
||||
// Only display if the response has error details populated.
|
||||
// When error is null, a follow-up StreamingResponseErrorUpdate typically
|
||||
// carries the real error — the SDK surfaces that as ErrorContent,
|
||||
// which is displayed by ErrorDisplayObserver.
|
||||
if (failedUpdate.Response?.Error is { } error)
|
||||
{
|
||||
string errorMessage = error.Message ?? "Unknown error";
|
||||
string? errorCode = error.Code.ToString();
|
||||
string errorText = $"❌ Response failed: {errorMessage}";
|
||||
if (!string.IsNullOrEmpty(errorCode))
|
||||
{
|
||||
errorText += $" (code: {errorCode})";
|
||||
}
|
||||
|
||||
await ux.WriteInfoLineAsync(errorText, ConsoleColor.Red);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case StreamingResponseIncompleteUpdate incompleteUpdate:
|
||||
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
|
||||
if (string.Equals(reason, "content_filter", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string detail = GetContentFilterDetails(incompleteUpdate);
|
||||
const string Message = "🛡️ The service's built-in content filter guardrails were triggered and the response was cut short.";
|
||||
await ux.WriteInfoLineAsync(
|
||||
string.IsNullOrEmpty(detail) ? Message : $"{Message}\n{detail}",
|
||||
ConsoleColor.Yellow);
|
||||
}
|
||||
else
|
||||
{
|
||||
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
|
||||
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts content filter details from the serialized response JSON and returns
|
||||
/// a formatted string showing which specific categories were triggered.
|
||||
/// Returns <see cref="string.Empty"/> if details cannot be extracted.
|
||||
/// </summary>
|
||||
private static string GetContentFilterDetails(StreamingResponseIncompleteUpdate incompleteUpdate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(incompleteUpdate);
|
||||
using var doc = JsonDocument.Parse(data.ToString());
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Navigate into the nested response object if present.
|
||||
JsonElement responseElement = root.TryGetProperty("response", out var resp) ? resp : root;
|
||||
|
||||
if (!responseElement.TryGetProperty("content_filters", out var filtersArray)
|
||||
|| filtersArray.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
foreach (var filter in filtersArray.EnumerateArray())
|
||||
{
|
||||
if (!filter.TryGetProperty("content_filter_results", out var results)
|
||||
|| results.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect category data for aligned output.
|
||||
var categories = new List<(string Name, bool Filtered, string? Severity)>();
|
||||
foreach (var category in results.EnumerateObject())
|
||||
{
|
||||
if (category.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool filtered = category.Value.TryGetProperty("filtered", out var f) && f.GetBoolean();
|
||||
string? severity = category.Value.TryGetProperty("severity", out var s) ? s.GetString() : null;
|
||||
categories.Add((category.Name, filtered, severity));
|
||||
}
|
||||
|
||||
// Build all category lines into a single string.
|
||||
int maxNameLen = categories.Count > 0 ? categories.Max(c => c.Name.Length) : 0;
|
||||
var lines = new List<string>();
|
||||
|
||||
foreach (var (name, filtered, severity) in categories)
|
||||
{
|
||||
string paddedName = name.PadRight(maxNameLen);
|
||||
string icon = filtered ? "❌" : "✅";
|
||||
string statusText = filtered ? "Filtered " : "Not Filtered";
|
||||
string severityText = severity is not null ? $" Severity: {severity}" : "";
|
||||
|
||||
lines.Add($" {icon} {paddedName} {statusText}{severityText}");
|
||||
}
|
||||
|
||||
if (lines.Count > 0)
|
||||
{
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Parsing not critical — skip silently if it fails.
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
|
||||
using System.Text;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Harness.Shared.Console.OpenAI;
|
||||
|
||||
/// <summary>
|
||||
/// Displays web search activity in the scroll area. Shows search queries,
|
||||
/// page opens, and find-in-page actions as they stream in from the API.
|
||||
/// </summary>
|
||||
public sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
|
||||
{
|
||||
private const int MaxQueryDisplayLength = 120;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is WebSearchToolResultContent resultContent
|
||||
&& resultContent.RawRepresentation is WebSearchCallResponseItem wscri)
|
||||
{
|
||||
await WriteActionAsync(ux, wscri, resultContent.Outputs);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteActionAsync(IUXStateDriver ux, WebSearchCallResponseItem wscri, IList<AIContent>? outputs)
|
||||
{
|
||||
WebSearchAction? action = wscri.Action;
|
||||
if (action is null)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("🌐 Web Search Tool (no action details)", ConsoleColor.DarkCyan);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case WebSearchFindInPageAction findInPage:
|
||||
await WriteFindInPageAsync(ux, findInPage);
|
||||
break;
|
||||
|
||||
case WebSearchOpenPageAction openPage:
|
||||
await WriteOpenPageAsync(ux, openPage);
|
||||
break;
|
||||
|
||||
case WebSearchSearchAction search:
|
||||
await WriteSearchAsync(ux, search, outputs);
|
||||
break;
|
||||
|
||||
default:
|
||||
await ux.WriteInfoLineAsync("🌐 Web Search Tool (unknown action)", ConsoleColor.DarkCyan);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteSearchAsync(IUXStateDriver ux, WebSearchSearchAction search, IList<AIContent>? outputs)
|
||||
{
|
||||
// Read queries directly from the typed action.
|
||||
IList<string> queries = search.Queries;
|
||||
|
||||
if (queries.Count == 0)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("🌐 Web Search Tool: search", ConsoleColor.DarkCyan);
|
||||
return;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("🌐 Web Search Tool: search");
|
||||
|
||||
// Show the search queries.
|
||||
bool hasResults = outputs is { Count: > 0 };
|
||||
for (int i = 0; i < queries.Count; i++)
|
||||
{
|
||||
string connector = (i < queries.Count - 1 || hasResults) ? "├─" : "└─";
|
||||
string query = Truncate(queries[i], MaxQueryDisplayLength);
|
||||
sb.Append($"\n {connector} \"{query}\"");
|
||||
}
|
||||
|
||||
// Show search result sources (URLs + titles) when available.
|
||||
// Sources come from M.E.AI's Outputs when IncludedResponseProperty.WebSearchCallActionSources is set,
|
||||
// or directly from the SDK's WebSearchSearchAction.Sources.
|
||||
if (hasResults)
|
||||
{
|
||||
sb.Append("\n │");
|
||||
for (int i = 0; i < outputs!.Count; i++)
|
||||
{
|
||||
string connector = i < outputs.Count - 1 ? "├─" : "└─";
|
||||
string line = FormatOutput(outputs[i]);
|
||||
sb.Append($"\n {connector} {line}");
|
||||
}
|
||||
}
|
||||
else if (search.Sources is { Count: > 0 } sources)
|
||||
{
|
||||
sb.Append("\n │");
|
||||
for (int i = 0; i < sources.Count; i++)
|
||||
{
|
||||
string connector = i < sources.Count - 1 ? "├─" : "└─";
|
||||
string line = FormatSource(sources[i]);
|
||||
sb.Append($"\n {connector} {line}");
|
||||
}
|
||||
}
|
||||
|
||||
await ux.WriteInfoLineAsync(sb.ToString(), ConsoleColor.DarkCyan);
|
||||
}
|
||||
|
||||
private static async Task WriteOpenPageAsync(IUXStateDriver ux, WebSearchOpenPageAction openPage)
|
||||
{
|
||||
string url = openPage.Uri?.AbsoluteUri ?? "(unknown)";
|
||||
await ux.WriteInfoLineAsync(
|
||||
$"🌐 Web Search Tool: open page\n └─ {url}",
|
||||
ConsoleColor.DarkCyan);
|
||||
}
|
||||
|
||||
private static async Task WriteFindInPageAsync(IUXStateDriver ux, WebSearchFindInPageAction findInPage)
|
||||
{
|
||||
string url = findInPage.Uri?.AbsoluteUri ?? "(unknown)";
|
||||
string pattern = findInPage.Pattern ?? "(unknown)";
|
||||
|
||||
await ux.WriteInfoLineAsync(
|
||||
$"🌐 Web Search Tool: find in page\n ├─ \"{Truncate(pattern, MaxQueryDisplayLength)}\"\n └─ {url}",
|
||||
ConsoleColor.DarkCyan);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single search result source from the SDK's <see cref="WebSearchActionSource"/> for display.
|
||||
/// </summary>
|
||||
private static string FormatSource(WebSearchActionSource source)
|
||||
{
|
||||
if (source is WebSearchActionUriSource uriSource)
|
||||
{
|
||||
string url = uriSource.Uri?.AbsoluteUri ?? "(unknown)";
|
||||
|
||||
// WebSearchActionUriSource doesn't expose a title property,
|
||||
// but the API may include one in the raw response JSON.
|
||||
string? title = GetTitleFromRawRepresentation(uriSource);
|
||||
|
||||
return title is not null
|
||||
? $"{Truncate(title, MaxQueryDisplayLength)} — {url}"
|
||||
: url;
|
||||
}
|
||||
|
||||
return source.ToString() ?? "(unknown source)";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a single search result output from M.E.AI's <see cref="AIContent"/> for display.
|
||||
/// </summary>
|
||||
private static string FormatOutput(AIContent output)
|
||||
{
|
||||
if (output is UriContent uriContent)
|
||||
{
|
||||
string url = uriContent.Uri?.AbsoluteUri ?? "(unknown)";
|
||||
|
||||
// Try to extract a title from the raw JSON of the source.
|
||||
// The SDK's WebSearchActionUriSource doesn't expose a title property,
|
||||
// but the API may include one in the raw response.
|
||||
string? title = GetTitleFromRawRepresentation(uriContent.RawRepresentation)
|
||||
?? (uriContent.AdditionalProperties?.TryGetValue("title", out var t) is true ? t?.ToString() : null);
|
||||
|
||||
return title is not null
|
||||
? $"{Truncate(title, MaxQueryDisplayLength)} — {url}"
|
||||
: url;
|
||||
}
|
||||
|
||||
return output.ToString() ?? "(unknown output)";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to extract a "title" field from a raw representation object by serializing it to JSON.
|
||||
/// The SDK's <see cref="WebSearchActionUriSource"/> doesn't expose a title property,
|
||||
/// but the API may include one in the raw JSON — this is forward-compatible for when
|
||||
/// the SDK adds title support.
|
||||
/// </summary>
|
||||
private static string? GetTitleFromRawRepresentation(object? rawRepresentation)
|
||||
{
|
||||
if (rawRepresentation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(rawRepresentation);
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(data);
|
||||
if (doc.RootElement.TryGetProperty("title", out var titleEl)
|
||||
&& titleEl.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
return titleEl.GetString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Serialization may not be supported for this object type.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Truncate(string text, int maxLength)
|
||||
=> text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength - 1), "…");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>DownloadUri</c> tool calls, showing the target URI.
|
||||
/// </summary>
|
||||
public sealed class DownloadUriToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) =>
|
||||
call.Name is "DownloadUri";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call)
|
||||
{
|
||||
string? value = GetStringArgumentValue(call, "uri");
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a HarnessAgent for interactive research tasks.
|
||||
// The HarnessAgent comes pre-configured with TodoProvider, AgentModeProvider, FileMemoryProvider,
|
||||
// ToolApproval, WebSearch, and OpenTelemetry — so this sample only needs custom instructions
|
||||
// and a WebBrowsingTool.
|
||||
// The agent plans research tasks, creates a todo list, gets user approval,
|
||||
// and then executes each step — all within an interactive conversation loop.
|
||||
//
|
||||
// Special commands:
|
||||
// /todos — Display the current todo list without invoking the agent.
|
||||
// /mode — Get or set the current agent mode.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using SampleApp;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4";
|
||||
|
||||
const int MaxContextWindowTokens = 1_050_000;
|
||||
const int MaxOutputTokens = 128_000;
|
||||
const string TracingSourceName = "Harness.Research";
|
||||
|
||||
// Set up OpenTelemetry tracing that writes spans to a text file.
|
||||
// This captures all agent activity (tool calls, model invocations, compaction, etc.)
|
||||
// as well as HTTP requests made by the underlying HttpClient transport.
|
||||
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
|
||||
|
||||
// Create a HarnessAgent with the Harness providers (TodoProvider and AgentModeProvider)
|
||||
// and research-focused instructions including the mandatory planning workflow.
|
||||
var instructions =
|
||||
"""
|
||||
## Research Assistant Instructions
|
||||
|
||||
You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing.
|
||||
Use your knowledge to form good search queries and hypotheses, but always verify claims with the tools available to you rather than relying on memory alone.
|
||||
|
||||
### Research quality
|
||||
|
||||
Consult multiple sources when possible and cross-reference key claims.
|
||||
When sources disagree, note the discrepancy and explain which source you consider more reliable and why.
|
||||
If a web page fails to load or a search returns irrelevant results, try alternative search queries or sources before moving on.
|
||||
Track your sources — you will need them when presenting results.
|
||||
|
||||
### Presenting results
|
||||
|
||||
When presenting your final findings:
|
||||
- Use Markdown formatting for clarity.
|
||||
- Use clear sections with headings for each major topic or sub-question.
|
||||
- Cite your sources inline (e.g., "According to [source name](URL), ...").
|
||||
- End with a brief summary of key takeaways.
|
||||
- In addition to returning the results to the user, save the final research report to file memory so it survives compaction and can be referenced later.
|
||||
""";
|
||||
|
||||
// Create the agent using AsHarnessAgent, which pre-configures function invocation,
|
||||
// per-service-call chat history persistence, in-loop compaction, TodoProvider, AgentModeProvider,
|
||||
// FileMemoryProvider, ToolApproval, WebSearch, AgentSkillsProvider, and OpenTelemetry.
|
||||
// Only custom instructions, a WebBrowsingTool, and FileAccess opt-out are needed.
|
||||
AIAgent agent =
|
||||
// Create an OpenAIClient that communicates with the Foundry responses service.
|
||||
new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
new DefaultAzureCredential(),
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }) // Enable retries to improve resiliency.
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "ResearchAgent",
|
||||
Description = "A research assistant that plans and executes research tasks.",
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
OpenTelemetrySourceName = TracingSourceName, // Use our custom source name so spans are captured by the TracerProvider above.
|
||||
FileMemoryStore = new FileSystemAgentFileStore( // Configure the file memory provider to store files in a local folder called "agent-files".
|
||||
Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||
// The built in ModeProvider has two default modes: "plan" and "execute".
|
||||
// Adding a loop evaluator so that in "execute" mode, the harness keeps re-invoking itself until every todo item is complete.
|
||||
LoopEvaluators =
|
||||
[
|
||||
new TodoCompletionLoopEvaluator(new TodoCompletionLoopEvaluatorOptions { Modes = ["execute"] }),
|
||||
],
|
||||
LoopAgentOptions = new LoopAgentOptions { MaxIterations = 10 }, // Safety cap on the number of autonomous passes per turn.
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools =
|
||||
[
|
||||
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
|
||||
new WebBrowsingToolOptions { AllowPublicNetworks = true }),
|
||||
],
|
||||
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
});
|
||||
|
||||
// Run the interactive console session using the shared HarnessConsole helper.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
userPrompt: "Enter a research topic to get started.",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
Observers = [
|
||||
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||
new OpenAIResponsesErrorObserver(),
|
||||
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens,
|
||||
toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()])],
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to use a `HarnessAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Azure AI Foundry. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and context-window compaction.
|
||||
|
||||
Key features showcased:
|
||||
|
||||
- **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction
|
||||
- **ToolApproval** — the agent is wrapped with `UseToolApproval()` to allow auto-approving tools once confirmed
|
||||
- **Web Search** — the agent can search the web for current information via `ResponseTool.CreateWebSearchTool()`
|
||||
- **TodoProvider** — the agent creates and manages a todo list to track research questions
|
||||
- **AgentModeProvider** — the agent switches between "plan" mode (breaking down the topic) and "execute" mode (answering each research question)
|
||||
- **TodoCompletionLoopEvaluator** — in "execute" mode the agent loops automatically, re-invoking itself until every todo item is complete (capped by `LoopAgentOptions.MaxIterations`). The loop is scoped to "execute" mode, so "plan" mode stays interactive. The `HarnessAgent` wraps itself in a `LoopAgent` automatically whenever `LoopEvaluators` is supplied.
|
||||
- **Interactive conversation** — you can review the agent's plan, provide feedback, and approve before execution begins
|
||||
- **Streaming output** — responses are streamed token-by-token for a natural experience
|
||||
- **`/todos` command** — view the current todo list at any time without invoking the agent
|
||||
- **Mode-based coloring** — console output is colored based on the agent's current mode (cyan for plan, green for execute)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`)
|
||||
2. Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```bash
|
||||
# Required: Your Azure AI Foundry OpenAI endpoint
|
||||
export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/"
|
||||
|
||||
# Optional: Model deployment name (defaults to gpt-5.4)
|
||||
export FOUNDRY_MODEL="gpt-5.4"
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet run --project samples/02-agents/Harness/Harness_Step01_Research
|
||||
```
|
||||
|
||||
## What to Expect
|
||||
|
||||
The sample starts an interactive conversation loop. You can:
|
||||
|
||||
1. **Enter a research topic** — the agent will analyze it and create a plan with todos
|
||||
2. **Review and adjust** — provide feedback on the plan, ask for changes, or approve it
|
||||
3. **Type `/todos`** — to see the current todo list at any time
|
||||
4. **Watch execution** — once approved, the agent will switch to "execute" mode and process each todo autonomously until the whole plan is complete
|
||||
5. **Type `exit`** — to end the session
|
||||
|
||||
The prompt and agent output are colored by the current mode: **cyan** during planning, **green** during execution.
|
||||
@@ -0,0 +1,439 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// An AI function that downloads HTML pages and converts them to markdown.
|
||||
/// Access is controlled by <see cref="WebBrowsingToolOptions"/> — by default, no hosts are accessible.
|
||||
/// </summary>
|
||||
internal sealed partial class WebBrowsingTool : AIFunction
|
||||
{
|
||||
private static readonly HttpClient s_httpClient = new();
|
||||
private readonly AIFunction _inner;
|
||||
private readonly WebBrowsingToolOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="WebBrowsingTool"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">Options controlling which URLs are permitted. By default, no hosts are accessible.</param>
|
||||
public WebBrowsingTool(WebBrowsingToolOptions options)
|
||||
{
|
||||
this._options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this._inner = AIFunctionFactory.Create(this.DownloadUriAsync);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Name => this._inner.Name;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Description => this._inner.Description;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override JsonElement JsonSchema => this._inner.JsonSchema;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<object?> InvokeCoreAsync(
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken) =>
|
||||
this._inner.InvokeAsync(arguments, cancellationToken);
|
||||
|
||||
[Description("Fetch the html from the given url as markdown")]
|
||||
private async Task<string> DownloadUriAsync(
|
||||
[Description("The URL to download")] string uri,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Uri.TryCreate(uri, UriKind.Absolute, out Uri? parsedUri))
|
||||
{
|
||||
return $"Error: '{uri}' is not a valid URL.";
|
||||
}
|
||||
|
||||
if (parsedUri.Scheme is not "http" and not "https")
|
||||
{
|
||||
return $"Error: Only HTTP and HTTPS URLs are supported. Got: '{parsedUri.Scheme}'.";
|
||||
}
|
||||
|
||||
// Check access policy.
|
||||
string? accessError = await this.CheckAccessAsync(parsedUri, cancellationToken);
|
||||
if (accessError is not null)
|
||||
{
|
||||
return accessError;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string html = await s_httpClient.GetStringAsync(parsedUri, cancellationToken);
|
||||
return HtmlToMarkdownConverter.Convert(html);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return $"Error downloading {uri}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the given URI is permitted by the configured access policy.
|
||||
/// Returns null if allowed, or an error message string if blocked.
|
||||
/// </summary>
|
||||
private async Task<string?> CheckAccessAsync(Uri uri, CancellationToken cancellationToken)
|
||||
{
|
||||
string host = uri.Host;
|
||||
|
||||
// 1. Check AllowedHosts.
|
||||
if (this._options.AllowedHosts is { Count: > 0 } allowedHosts)
|
||||
{
|
||||
foreach (string pattern in allowedHosts)
|
||||
{
|
||||
if (HostMatchesPattern(host, pattern))
|
||||
{
|
||||
return null; // Allowed by explicit host list.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Short-circuit when the policy is guaranteed to block.
|
||||
if (!this._options.AllowPublicNetworks &&
|
||||
!this._options.AllowPrivateNetworks &&
|
||||
!this._options.AllowAllHosts)
|
||||
{
|
||||
return $"Error: Access to '{host}' is blocked by the current access policy. Configure WebBrowsingToolOptions to allow access.";
|
||||
}
|
||||
|
||||
// 3. Resolve DNS to determine if the host is public or private.
|
||||
IPAddress[] addresses;
|
||||
try
|
||||
{
|
||||
addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return $"Error: Could not resolve host '{host}'.";
|
||||
}
|
||||
|
||||
if (addresses.Length == 0)
|
||||
{
|
||||
return $"Error: Could not resolve host '{host}'.";
|
||||
}
|
||||
|
||||
bool isPrivate = Array.Exists(addresses, IsPrivateAddress);
|
||||
|
||||
// 4. If public and AllowPublicNetworks is true → allow.
|
||||
if (!isPrivate && this._options.AllowPublicNetworks)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5. If private and AllowPrivateNetworks is true → allow.
|
||||
if (isPrivate && this._options.AllowPrivateNetworks)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 6. If AllowAllHosts is true → allow.
|
||||
if (this._options.AllowAllHosts)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 7. Block.
|
||||
string networkType = isPrivate ? "private/internal network" : "public network";
|
||||
return $"Error: Access to '{host}' is blocked. The host resolves to a {networkType} address and the current access policy does not permit this. " +
|
||||
"Configure WebBrowsingToolOptions to allow access.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a host matches a pattern. Supports exact match and wildcard prefix (e.g., "*.example.com").
|
||||
/// </summary>
|
||||
private static bool HostMatchesPattern(string host, string pattern)
|
||||
{
|
||||
if (string.Equals(host, pattern, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard prefix: "*.example.com" matches "sub.example.com" and "a.b.example.com".
|
||||
if (pattern.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
string suffix = pattern[1..]; // ".example.com"
|
||||
return host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an IP address is private, loopback, or link-local.
|
||||
/// </summary>
|
||||
private static bool IsPrivateAddress(IPAddress address)
|
||||
{
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
{
|
||||
address = address.MapToIPv4();
|
||||
}
|
||||
|
||||
if (IPAddress.IsLoopback(address))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
return bytes[0] switch
|
||||
{
|
||||
10 => true, // 10.0.0.0/8
|
||||
172 => bytes[1] >= 16 && bytes[1] <= 31, // 172.16.0.0/12
|
||||
192 => bytes[1] == 168, // 192.168.0.0/16
|
||||
169 => bytes[1] == 254, // 169.254.0.0/16 (link-local + metadata)
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
{
|
||||
// fe80::/10 (link-local) or fc00::/7 (unique local).
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
if (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80)
|
||||
{
|
||||
return true; // Link-local
|
||||
}
|
||||
|
||||
if ((bytes[0] & 0xfe) == 0xfc)
|
||||
{
|
||||
return true; // Unique local
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple HTML to Markdown converter using regex-based transformations.
|
||||
/// Handles the most common HTML elements without requiring external dependencies.
|
||||
/// </summary>
|
||||
private static partial class HtmlToMarkdownConverter
|
||||
{
|
||||
public static string Convert(string html)
|
||||
{
|
||||
// Extract body content if present, otherwise use the full HTML.
|
||||
var bodyMatch = BodyRegex().Match(html);
|
||||
string content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
|
||||
|
||||
// Remove script, style, and head blocks.
|
||||
content = ScriptRegex().Replace(content, string.Empty);
|
||||
content = StyleRegex().Replace(content, string.Empty);
|
||||
content = HeadRegex().Replace(content, string.Empty);
|
||||
content = CommentRegex().Replace(content, string.Empty);
|
||||
|
||||
// Convert block elements before inline elements.
|
||||
content = ConvertHeadings(content);
|
||||
content = ConvertCodeBlocks(content);
|
||||
content = ConvertBlockquotes(content);
|
||||
content = ConvertLists(content);
|
||||
content = ConvertHorizontalRules(content);
|
||||
|
||||
// Convert inline elements.
|
||||
content = ConvertLinks(content);
|
||||
content = ConvertImages(content);
|
||||
content = ConvertBold(content);
|
||||
content = ConvertItalic(content);
|
||||
content = ConvertInlineCode(content);
|
||||
|
||||
// Convert structural elements.
|
||||
content = ConvertParagraphs(content);
|
||||
content = ConvertLineBreaks(content);
|
||||
|
||||
// Strip remaining HTML tags.
|
||||
content = StripTagsRegex().Replace(content, string.Empty);
|
||||
|
||||
// Decode HTML entities.
|
||||
content = WebUtility.HtmlDecode(content);
|
||||
|
||||
// Clean up excessive whitespace.
|
||||
content = ExcessiveNewlinesRegex().Replace(content, "\n\n");
|
||||
|
||||
return content.Trim();
|
||||
}
|
||||
|
||||
private static string ConvertHeadings(string html)
|
||||
{
|
||||
html = H1Regex().Replace(html, m => $"\n# {StripInnerTags(m.Groups[1].Value).Trim()}\n");
|
||||
html = H2Regex().Replace(html, m => $"\n## {StripInnerTags(m.Groups[1].Value).Trim()}\n");
|
||||
html = H3Regex().Replace(html, m => $"\n### {StripInnerTags(m.Groups[1].Value).Trim()}\n");
|
||||
html = H4Regex().Replace(html, m => $"\n#### {StripInnerTags(m.Groups[1].Value).Trim()}\n");
|
||||
html = H5Regex().Replace(html, m => $"\n##### {StripInnerTags(m.Groups[1].Value).Trim()}\n");
|
||||
html = H6Regex().Replace(html, m => $"\n###### {StripInnerTags(m.Groups[1].Value).Trim()}\n");
|
||||
return html;
|
||||
}
|
||||
|
||||
private static string ConvertLinks(string html) =>
|
||||
LinkRegex().Replace(html, m =>
|
||||
{
|
||||
string href = m.Groups[1].Value;
|
||||
string text = StripInnerTags(m.Groups[2].Value).Trim();
|
||||
|
||||
// Skip javascript and data links.
|
||||
if (href.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) ||
|
||||
href.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(text) ? string.Empty : $"[{text}]({href})";
|
||||
});
|
||||
|
||||
private static string ConvertImages(string html) =>
|
||||
ImageRegex().Replace(html, m =>
|
||||
{
|
||||
string src = m.Groups[1].Value;
|
||||
string alt = m.Groups[2].Value;
|
||||
|
||||
// Truncate data URIs.
|
||||
if (src.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
src = src.Split(',')[0] + "...";
|
||||
}
|
||||
|
||||
return $"";
|
||||
});
|
||||
|
||||
private static string ConvertBold(string html) =>
|
||||
BoldRegex().Replace(html, m => $"**{m.Groups[2].Value}**");
|
||||
|
||||
private static string ConvertItalic(string html) =>
|
||||
ItalicRegex().Replace(html, m => $"*{m.Groups[2].Value}*");
|
||||
|
||||
private static string ConvertInlineCode(string html) =>
|
||||
InlineCodeRegex().Replace(html, m => $"`{m.Groups[1].Value}`");
|
||||
|
||||
private static string ConvertCodeBlocks(string html) =>
|
||||
CodeBlockRegex().Replace(html, m => $"\n```\n{StripInnerTags(m.Groups[1].Value).Trim()}\n```\n");
|
||||
|
||||
private static string ConvertBlockquotes(string html) =>
|
||||
BlockquoteRegex().Replace(html, m =>
|
||||
{
|
||||
string inner = StripInnerTags(m.Groups[1].Value).Trim();
|
||||
// Prefix each line with "> ".
|
||||
string quoted = string.Join("\n", inner.Split('\n').Select(line => $"> {line.Trim()}"));
|
||||
return $"\n{quoted}\n";
|
||||
});
|
||||
|
||||
private static string ConvertLists(string html)
|
||||
{
|
||||
// Unordered lists.
|
||||
html = UlRegex().Replace(html, m =>
|
||||
{
|
||||
string items = LiRegex().Replace(m.Groups[1].Value, li => $"- {StripInnerTags(li.Groups[1].Value).Trim()}\n");
|
||||
return $"\n{items}";
|
||||
});
|
||||
|
||||
// Ordered lists.
|
||||
html = OlRegex().Replace(html, m =>
|
||||
{
|
||||
int index = 1;
|
||||
string items = LiRegex().Replace(m.Groups[1].Value, li => $"{index++}. {StripInnerTags(li.Groups[1].Value).Trim()}\n");
|
||||
return $"\n{items}";
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private static string ConvertHorizontalRules(string html) =>
|
||||
HrRegex().Replace(html, "\n---\n");
|
||||
|
||||
private static string ConvertParagraphs(string html) =>
|
||||
ParagraphRegex().Replace(html, m => $"\n\n{m.Groups[1].Value}\n\n");
|
||||
|
||||
private static string ConvertLineBreaks(string html) =>
|
||||
BrRegex().Replace(html, "\n");
|
||||
|
||||
private static string StripInnerTags(string html) =>
|
||||
StripTagsRegex().Replace(html, string.Empty);
|
||||
|
||||
// Source-generated regex patterns for performance and AOT compatibility.
|
||||
|
||||
[GeneratedRegex(@"<body[^>]*>(.*?)</body>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BodyRegex();
|
||||
|
||||
[GeneratedRegex(@"<script[^>]*>.*?</script>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ScriptRegex();
|
||||
|
||||
[GeneratedRegex(@"<style[^>]*>.*?</style>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex StyleRegex();
|
||||
|
||||
[GeneratedRegex(@"<head[^>]*>.*?</head>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex HeadRegex();
|
||||
|
||||
[GeneratedRegex(@"<!--.*?-->", RegexOptions.Singleline)]
|
||||
private static partial Regex CommentRegex();
|
||||
|
||||
[GeneratedRegex(@"<h1[^>]*>(.*?)</h1>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex H1Regex();
|
||||
|
||||
[GeneratedRegex(@"<h2[^>]*>(.*?)</h2>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex H2Regex();
|
||||
|
||||
[GeneratedRegex(@"<h3[^>]*>(.*?)</h3>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex H3Regex();
|
||||
|
||||
[GeneratedRegex(@"<h4[^>]*>(.*?)</h4>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex H4Regex();
|
||||
|
||||
[GeneratedRegex(@"<h5[^>]*>(.*?)</h5>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex H5Regex();
|
||||
|
||||
[GeneratedRegex(@"<h6[^>]*>(.*?)</h6>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex H6Regex();
|
||||
|
||||
[GeneratedRegex(@"<a\s[^>]*href=[""']([^""']*)[""'][^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LinkRegex();
|
||||
|
||||
[GeneratedRegex(@"<img\s[^>]*src=[""']([^""']*)[""'][^>]*?(?:alt=[""']([^""']*)[""'])?[^>]*/?>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ImageRegex();
|
||||
|
||||
[GeneratedRegex(@"<(strong|b)\b[^>]*>(.*?)</\1>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BoldRegex();
|
||||
|
||||
[GeneratedRegex(@"<(em|i)\b[^>]*>(.*?)</\1>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ItalicRegex();
|
||||
|
||||
[GeneratedRegex(@"<code[^>]*>(.*?)</code>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex InlineCodeRegex();
|
||||
|
||||
[GeneratedRegex(@"<pre[^>]*>(.*?)</pre>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CodeBlockRegex();
|
||||
|
||||
[GeneratedRegex(@"<blockquote[^>]*>(.*?)</blockquote>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BlockquoteRegex();
|
||||
|
||||
[GeneratedRegex(@"<ul[^>]*>(.*?)</ul>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex UlRegex();
|
||||
|
||||
[GeneratedRegex(@"<ol[^>]*>(.*?)</ol>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex OlRegex();
|
||||
|
||||
[GeneratedRegex(@"<li[^>]*>(.*?)</li>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LiRegex();
|
||||
|
||||
[GeneratedRegex(@"<hr\s*/?>", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex HrRegex();
|
||||
|
||||
[GeneratedRegex(@"<p[^>]*>(.*?)</p>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ParagraphRegex();
|
||||
|
||||
[GeneratedRegex(@"<br\s*/?>", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BrRegex();
|
||||
|
||||
[GeneratedRegex(@"<[^>]+>")]
|
||||
private static partial Regex StripTagsRegex();
|
||||
|
||||
[GeneratedRegex(@"\n{3,}")]
|
||||
private static partial Regex ExcessiveNewlinesRegex();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// Options that control which URLs the <see cref="WebBrowsingTool"/> is permitted to access.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// By default, <b>no hosts are accessible</b>. You must explicitly opt in to one or more
|
||||
/// of the access modes below. The validation order is:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description>If the host matches an entry in <see cref="AllowedHosts"/>, the request is allowed.</description></item>
|
||||
/// <item><description>If the resolved IP is a public address and <see cref="AllowPublicNetworks"/> is <see langword="true"/>, the request is allowed.</description></item>
|
||||
/// <item><description>If the resolved IP is a private/loopback/link-local address and <see cref="AllowPrivateNetworks"/> is <see langword="true"/>, the request is allowed.</description></item>
|
||||
/// <item><description>If <see cref="AllowAllHosts"/> is <see langword="true"/>, the request is allowed.</description></item>
|
||||
/// <item><description>Otherwise, the request is blocked.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal sealed class WebBrowsingToolOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a list of host patterns that are always permitted, regardless of other settings.
|
||||
/// Patterns support wildcard prefix matching (e.g., <c>"*.example.com"</c> matches <c>"docs.example.com"</c>).
|
||||
/// Exact host names (e.g., <c>"docs.microsoft.com"</c>) are also supported.
|
||||
/// </summary>
|
||||
/// <remarks>This has the highest priority — if a host matches, it is allowed immediately.</remarks>
|
||||
public IReadOnlyList<string>? AllowedHosts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether public internet hosts (non-private, non-loopback, non-link-local IPs) are permitted.
|
||||
/// Default is <see langword="false"/>.
|
||||
/// </summary>
|
||||
public bool AllowPublicNetworks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether private network hosts are permitted.
|
||||
/// This includes RFC 1918 addresses (10.x.x.x, 172.16-31.x.x, 192.168.x.x),
|
||||
/// loopback (127.x.x.x, ::1), link-local (169.254.x.x, fe80::),
|
||||
/// and cloud metadata endpoints (169.254.169.254).
|
||||
/// Default is <see langword="false"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Warning:</b> Enabling this allows the agent to make requests to internal services,
|
||||
/// localhost, and cloud metadata endpoints. Only enable this if you understand the SSRF risks.
|
||||
/// </remarks>
|
||||
public bool AllowPrivateNetworks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether all hosts are permitted without any restriction.
|
||||
/// Default is <see langword="false"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>⚠️ UNSAFE:</b> Enabling this disables all network boundary checks and allows the agent
|
||||
/// to access any URL, including internal services, cloud metadata endpoints, and localhost.
|
||||
/// Only use this for trusted, isolated environments where SSRF is not a concern.
|
||||
/// </remarks>
|
||||
public bool AllowAllHosts { get; set; }
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use the BackgroundAgentsProvider to delegate work to background agents.
|
||||
// A parent agent is given a list of stock tickers and instructed to find the closing price
|
||||
// for each ticker on December 31, 2025. It delegates the web searches to a background agent.
|
||||
// The HarnessAgent provides built-in WebSearch (HostedWebSearchTool) so no manual web search
|
||||
// tool configuration is needed on the background agent.
|
||||
//
|
||||
// Special commands:
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4";
|
||||
|
||||
const int MaxContextWindowTokens = 1_050_000;
|
||||
const int MaxOutputTokens = 128_000;
|
||||
const string TracingSourceName = "Harness.SubAgents";
|
||||
|
||||
// Set up OpenTelemetry tracing that writes spans to a text file.
|
||||
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
// Create the AIProjectClient for communicating with the Foundry responses service.
|
||||
var projectClient = new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential(),
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) });
|
||||
|
||||
// --- Background agent: Web Search Agent ---
|
||||
// This agent uses the HarnessAgent's built-in HostedWebSearchTool to search the web.
|
||||
// Features not needed by this sub-agent are disabled.
|
||||
AIAgent webSearchAgent =
|
||||
projectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "WebSearchAgent",
|
||||
Description = "An agent that can search the web to find information.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
DisableToolAutoApproval = true, // If true, this disables the don't-ask-again approval functionality.
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.",
|
||||
},
|
||||
});
|
||||
|
||||
// --- Parent agent: Stock Price Researcher ---
|
||||
// This agent orchestrates the background agent to look up stock prices in parallel.
|
||||
var parentInstructions =
|
||||
"""
|
||||
You are a stock price research assistant. You have access to a web search background agent that can look up information on the web.
|
||||
|
||||
When given a list of stock tickers, your job is to find the closing price for each ticker on December 31, 2025.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. For each ticker, start a background task on the WebSearchAgent asking it to find the closing price on December 31, 2025.
|
||||
- Start all background tasks before waiting for any of them to complete, so they run concurrently.
|
||||
2. Wait for all background tasks to complete.
|
||||
3. Retrieve the results from each background task.
|
||||
4. Present a summary table with the ticker symbol and closing price for each stock.
|
||||
5. Clear all completed tasks to free memory.
|
||||
|
||||
## Important
|
||||
|
||||
- Always delegate web searches to the WebSearchAgent background agent. Do not try to answer from memory.
|
||||
- If a background task fails or returns unclear results, continue the task with a more specific query.
|
||||
- Present results in a clean markdown table format.
|
||||
""";
|
||||
|
||||
// --- Parent agent: Stock Price Researcher ---
|
||||
// This agent orchestrates the sub-agent to look up stock prices in parallel.
|
||||
// Most features are disabled since the parent only needs SubAgentsProvider.
|
||||
AIAgent parentAgent =
|
||||
projectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "StockPriceResearcher",
|
||||
Description = "An agent that researches stock prices using background agents.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
DisableToolAutoApproval = true, // If true, this disables the don't-ask-again approval functionality.
|
||||
DisableWebSearch = true,
|
||||
BackgroundAgents = [webSearchAgent],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = parentInstructions,
|
||||
MaxOutputTokens = 16_000,
|
||||
},
|
||||
});
|
||||
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
parentAgent,
|
||||
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):",
|
||||
options: new HarnessConsoleOptions
|
||||
{
|
||||
Observers = [new OpenAIResponsesErrorObserver(), .. HarnessConsoleOptions.BuildDefaultObservers()],
|
||||
});
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Harness Step 02 — BackgroundAgents (Stock Price Research)
|
||||
|
||||
This sample demonstrates how to use the **BackgroundAgentsProvider** to delegate work from a parent agent to background agents. Both agents use `HarnessAgent` for pre-configured function invocation, per-service-call persistence, and context-window compaction.
|
||||
|
||||
## What It Does
|
||||
|
||||
A parent agent receives a list of stock tickers and uses a web-search background agent to find the closing price for each ticker on December 31, 2025. The background tasks run concurrently, and results are presented in a summary table.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ StockPriceResearcher │
|
||||
│ (Parent Agent) │
|
||||
│ │
|
||||
│ BackgroundAgentsProvider │
|
||||
│ ├─ background_agents_start_task │
|
||||
│ ├─ background_agents_wait_for_first_completion│
|
||||
│ ├─ background_agents_get_task_results │
|
||||
│ └─ ... │
|
||||
└─────────────┬────────────────────────────────────┘
|
||||
│ delegates to
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ WebSearchAgent │
|
||||
│ (Sub-Agent) │
|
||||
│ │
|
||||
│ Tools: │
|
||||
│ └─ web_search (Foundry) │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An Azure AI Foundry endpoint with an OpenAI model deployment
|
||||
- Set the following environment variables:
|
||||
- `AZURE_FOUNDRY_OPENAI_ENDPOINT` — Your Foundry OpenAI endpoint URL
|
||||
- `FOUNDRY_MODEL` — Model deployment name (defaults to `gpt-5.4`)
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents
|
||||
dotnet run
|
||||
```
|
||||
|
||||
When prompted, enter a list of stock tickers such as:
|
||||
|
||||
```
|
||||
BAC, MSFT, BA
|
||||
```
|
||||
|
||||
The parent agent will delegate each ticker lookup to the web search background agent concurrently and present the results in a table.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
`BackgroundAgentsProvider` delegates work to the agents you supply — the parent sends them text input
|
||||
and receives back whatever they produce. A compromised or malicious background agent could exfiltrate
|
||||
data it receives, or return adversarial output designed to influence the parent agent via indirect
|
||||
prompt injection once its result is retrieved. Only supply background agents you have vetted and trust
|
||||
with the data the parent may pass to them.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="working\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a HarnessAgent with the default FileAccessProvider
|
||||
// to give an agent access to a folder of CSV data files. The agent can read, analyze,
|
||||
// and extract information from the data, then write results back as new files.
|
||||
//
|
||||
// The sample includes a pre-populated `working/` folder with sales transaction data.
|
||||
// The HarnessAgent's default FileAccessProvider uses `{cwd}/working` as its working directory,
|
||||
// which matches this sample's folder layout.
|
||||
// Ask the agent to analyze the data, produce summaries, or create new output files.
|
||||
//
|
||||
// Special commands:
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4";
|
||||
|
||||
const int MaxContextWindowTokens = 1_050_000;
|
||||
const int MaxOutputTokens = 128_000;
|
||||
const string TracingSourceName = "Harness.DataProcessing";
|
||||
|
||||
// Set up OpenTelemetry tracing that writes spans to a text file.
|
||||
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
|
||||
|
||||
var instructions =
|
||||
"""
|
||||
You are a data analyst assistant. You have access to a folder of data files via the file_access_* tools.
|
||||
|
||||
## Getting started
|
||||
- Start by listing available files with file_access_ls to see what data is available.
|
||||
- Read the files to understand their structure and contents.
|
||||
|
||||
## Working with data
|
||||
- When asked to analyze data, read the relevant files first, then perform the analysis.
|
||||
- Show your analysis clearly with tables, summaries, and key insights.
|
||||
- When calculations are needed, work through them step by step and show your reasoning.
|
||||
|
||||
## Writing output
|
||||
- When asked to produce output files (e.g., reports, summaries, filtered data), use file_access_write to write them.
|
||||
- Use appropriate file formats: CSV for tabular data, Markdown for reports.
|
||||
- Confirm what you wrote and where.
|
||||
|
||||
## Important
|
||||
- Never modify or delete the original input data files unless explicitly asked to do so.
|
||||
- If asked about data you haven't read yet, read it first before answering.
|
||||
- Always explain your reasoning and thought process as you work through tasks.
|
||||
- Always explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process.
|
||||
""";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
// Create the agent using AsHarnessAgent. The FileAccessStore is explicitly set to the
|
||||
// sample's working/ folder (copied to the output directory) so it works regardless of cwd.
|
||||
// Unused features are disabled.
|
||||
AIAgent agent =
|
||||
new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential(),
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) })
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "DataAnalyst",
|
||||
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
FileAccessStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "working")),
|
||||
ToolApprovalAgentOptions = new ToolApprovalAgentOptions()
|
||||
{
|
||||
// The HarnessAgent's FileAccessProvider requires approval for all file access operations.
|
||||
// Add an auto-approval rule to skip prompts for specific operations (e.g., read-only access).
|
||||
// You can also supply your own rule to implement custom approval logic.
|
||||
AutoApprovalRules = [FileAccessProvider.ReadOnlyToolsAutoApprovalRule]
|
||||
},
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
|
||||
DisableWebSearch = true,
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
},
|
||||
});
|
||||
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
userPrompt: "Ask me to analyze the data files, produce summaries, or create output files.");
|
||||
@@ -0,0 +1,66 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to use a `HarnessAgent` with the default `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, in-loop compaction, tool approval, and OpenTelemetry — so the sample only needs to supply the chat client, token limits, custom instructions, and opt out of unused features.
|
||||
|
||||
Key features showcased:
|
||||
|
||||
- **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction
|
||||
- **FileAccessProvider** — the HarnessAgent's default file access provider uses `{cwd}/working` as its working directory, matching this sample's `working/` folder
|
||||
- **CSV data processing** — the agent reads sales transaction data and performs analysis on demand
|
||||
- **Output file creation** — the agent can write summaries, filtered data, or reports back to the data folder
|
||||
- **Streaming output** — responses are streamed token-by-token for a natural experience
|
||||
- **No planning mode** — this is a simple conversational sample focused on data interaction
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`)
|
||||
2. Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```bash
|
||||
# Required: Your Azure AI Foundry OpenAI endpoint
|
||||
export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/"
|
||||
|
||||
# Optional: Model deployment name (defaults to gpt-5.4)
|
||||
export FOUNDRY_MODEL="gpt-5.4"
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet run --project samples/02-agents/Harness/Harness_Step03_DataProcessing
|
||||
```
|
||||
|
||||
## What to Expect
|
||||
|
||||
The sample starts an interactive conversation with a data analyst agent. The `working/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson).
|
||||
|
||||
You can ask the agent to:
|
||||
|
||||
1. **List available files** — "What files do you have?"
|
||||
2. **Analyze the data** — "What are the total sales by region?" or "Which salesperson has the highest revenue?"
|
||||
3. **Create output files** — "Create a summary report as a markdown file" or "Write a CSV with monthly totals"
|
||||
4. **Search for patterns** — "Find all transactions over $1000"
|
||||
5. **Type `exit`** — to end the session
|
||||
|
||||
E.g. try the following prompt `Please process the sales.csv file by first filtering it to only North region sales, and then calculating the sum of sales by person. I'd like to write the results of the processing to north_region_totals.csv`.
|
||||
|
||||
## Sample Data
|
||||
|
||||
The included `working/sales.csv` contains sales transactions from January to March 2025 with the following columns:
|
||||
|
||||
| Column | Description |
|
||||
| --- | --- |
|
||||
| `date` | Transaction date (YYYY-MM-DD) |
|
||||
| `product` | Product name |
|
||||
| `category` | Product category (Electronics, Furniture, Stationery) |
|
||||
| `quantity` | Units sold |
|
||||
| `unit_price` | Price per unit |
|
||||
| `region` | Sales region (North, South, West) |
|
||||
| `salesperson` | Name of the salesperson |
|
||||
@@ -0,0 +1,50 @@
|
||||
date,product,category,quantity,unit_price,region,salesperson
|
||||
2025-01-03,Laptop Pro 15,Electronics,2,1299.99,North,Alice
|
||||
2025-01-05,Ergonomic Chair,Furniture,5,349.50,South,Bob
|
||||
2025-01-07,Wireless Mouse,Electronics,12,24.99,North,Alice
|
||||
2025-01-08,Standing Desk,Furniture,1,599.00,West,Carol
|
||||
2025-01-10,USB-C Hub,Electronics,8,45.99,North,David
|
||||
2025-01-12,Monitor 27in,Electronics,3,429.00,South,Bob
|
||||
2025-01-14,Desk Lamp,Furniture,6,79.95,West,Carol
|
||||
2025-01-15,Keyboard Mech,Electronics,4,149.99,North,Alice
|
||||
2025-01-17,Filing Cabinet,Furniture,2,189.00,South,David
|
||||
2025-01-20,Webcam HD,Electronics,10,89.99,West,Bob
|
||||
2025-01-22,Laptop Pro 15,Electronics,1,1299.99,South,Carol
|
||||
2025-01-24,Ergonomic Chair,Furniture,3,349.50,North,Alice
|
||||
2025-01-25,Notebook Pack,Stationery,20,12.99,South,David
|
||||
2025-01-27,Wireless Mouse,Electronics,15,24.99,West,Carol
|
||||
2025-01-28,Whiteboard,Stationery,4,129.00,North,Bob
|
||||
2025-01-30,Standing Desk,Furniture,2,599.00,South,Alice
|
||||
2025-02-02,USB-C Hub,Electronics,6,45.99,West,David
|
||||
2025-02-04,Monitor 27in,Electronics,2,429.00,North,Carol
|
||||
2025-02-05,Desk Lamp,Furniture,8,79.95,South,Bob
|
||||
2025-02-07,Keyboard Mech,Electronics,5,149.99,West,Alice
|
||||
2025-02-09,Filing Cabinet,Furniture,1,189.00,North,David
|
||||
2025-02-11,Webcam HD,Electronics,7,89.99,South,Carol
|
||||
2025-02-13,Laptop Pro 15,Electronics,3,1299.99,West,Bob
|
||||
2025-02-15,Notebook Pack,Stationery,30,12.99,North,Alice
|
||||
2025-02-17,Ergonomic Chair,Furniture,4,349.50,South,David
|
||||
2025-02-19,Wireless Mouse,Electronics,20,24.99,North,Carol
|
||||
2025-02-20,Whiteboard,Stationery,2,129.00,West,Bob
|
||||
2025-02-22,Standing Desk,Furniture,1,599.00,North,Alice
|
||||
2025-02-24,USB-C Hub,Electronics,10,45.99,South,David
|
||||
2025-02-26,Monitor 27in,Electronics,4,429.00,West,Carol
|
||||
2025-02-28,Desk Lamp,Furniture,3,79.95,North,Bob
|
||||
2025-03-02,Keyboard Mech,Electronics,6,149.99,South,Alice
|
||||
2025-03-04,Filing Cabinet,Furniture,3,189.00,West,David
|
||||
2025-03-06,Webcam HD,Electronics,9,89.99,North,Carol
|
||||
2025-03-08,Laptop Pro 15,Electronics,2,1299.99,South,Bob
|
||||
2025-03-10,Notebook Pack,Stationery,25,12.99,West,Alice
|
||||
2025-03-12,Ergonomic Chair,Furniture,6,349.50,North,David
|
||||
2025-03-14,Wireless Mouse,Electronics,18,24.99,South,Carol
|
||||
2025-03-15,Whiteboard,Stationery,5,129.00,North,Bob
|
||||
2025-03-17,Standing Desk,Furniture,3,599.00,West,Alice
|
||||
2025-03-19,USB-C Hub,Electronics,7,45.99,North,David
|
||||
2025-03-21,Monitor 27in,Electronics,5,429.00,South,Carol
|
||||
2025-03-23,Desk Lamp,Furniture,4,79.95,West,Bob
|
||||
2025-03-25,Keyboard Mech,Electronics,3,149.99,North,Alice
|
||||
2025-03-27,Filing Cabinet,Furniture,2,189.00,South,David
|
||||
2025-03-28,Webcam HD,Electronics,11,89.99,West,Carol
|
||||
2025-03-29,Laptop Pro 15,Electronics,1,1299.99,North,Bob
|
||||
2025-03-30,Notebook Pack,Stationery,15,12.99,South,Alice
|
||||
2025-03-31,Ergonomic Chair,Furniture,2,349.50,West,David
|
||||
|
+29
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Hyperlight.HyperlightSandbox.Guest.Python" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates a HarnessAgent with ALL features enabled, plus:
|
||||
// - Hyperlight CodeAct (HyperlightCodeActProvider) for sandboxed Python code execution
|
||||
// - Skills (AgentSkillsProvider) discovering a local "regex-tester" skill
|
||||
//
|
||||
// The agent can plan tasks with todos, manage modes, store memories, read/write files,
|
||||
// search the web, approve sensitive tools, discover and use skills, and execute arbitrary
|
||||
// Python code in a Hyperlight sandbox — all pre-configured by the HarnessAgent.
|
||||
//
|
||||
// Try asking: "Help me write a regex that matches valid email addresses, then test it."
|
||||
//
|
||||
// Special commands:
|
||||
// /todos — Display the current todo list without invoking the agent.
|
||||
// /mode — Get or set the current agent mode.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using HyperlightSandbox.Guest.Python;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4";
|
||||
|
||||
const int MaxContextWindowTokens = 1_050_000;
|
||||
const int MaxOutputTokens = 128_000;
|
||||
const string TracingSourceName = "Harness.CodeExecution";
|
||||
|
||||
// Set up OpenTelemetry tracing that writes spans to a text file.
|
||||
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
|
||||
|
||||
// Create the HyperlightCodeActProvider with the Python/Wasm backend.
|
||||
// The guest module path is resolved automatically from the Hyperlight.HyperlightSandbox.Guest.Python NuGet package.
|
||||
using var codeAct = new HyperlightCodeActProvider(
|
||||
HyperlightCodeActProviderOptions.CreateForWasm(PythonGuestModule.GetModulePath()));
|
||||
|
||||
var instructions =
|
||||
"""
|
||||
## Technical Assistant Instructions
|
||||
|
||||
You are a code-powered technical assistant. You can execute Python code in a sandboxed environment
|
||||
to solve problems precisely rather than guessing. You also have access to skills that provide
|
||||
structured workflows for specific technical tasks.
|
||||
|
||||
### Code Execution
|
||||
|
||||
When a problem requires computation, validation, or testing:
|
||||
- Write Python code and use `execute_code` to run it in the sandbox.
|
||||
- Always verify results by running the code rather than reasoning about what would happen.
|
||||
- If code fails, read the error message carefully, fix the issue, and retry.
|
||||
|
||||
### Skills
|
||||
|
||||
You have access to discoverable skills. When a task matches a skill's description:
|
||||
- Follow the skill's instructions carefully.
|
||||
- Use the skill's reference materials for context.
|
||||
- Combine the skill's workflow with code execution when appropriate.
|
||||
|
||||
### Planning and Research
|
||||
|
||||
For complex tasks:
|
||||
- Break the problem into steps using your todo list.
|
||||
- Research background information using web search when needed.
|
||||
- Save important findings to file memory for later reference.
|
||||
|
||||
### Presenting Results
|
||||
|
||||
- Show your work: include the code you ran and its output.
|
||||
- Explain what each part of your solution does.
|
||||
- If applicable, save final results to file memory.
|
||||
""";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
// Create the agent with ALL HarnessAgent features enabled plus Hyperlight CodeAct.
|
||||
// No Disable* flags are set — TodoProvider, AgentModeProvider, FileMemory, FileAccess,
|
||||
// ToolApproval, WebSearch, and AgentSkillsProvider are all active.
|
||||
AIAgent agent =
|
||||
new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential(),
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) })
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName)
|
||||
.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "CodeExecutionAgent",
|
||||
Description = "A technical assistant with sandboxed code execution and skill-based workflows.",
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
// Point the file memory at a local folder for persistent memory across sessions.
|
||||
FileMemoryStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||
// Add the HyperlightCodeActProvider so the agent can execute Python code in a sandbox.
|
||||
AIContextProviders = [codeAct],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
});
|
||||
|
||||
// Run the interactive console session using the shared HarnessConsole helper.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
userPrompt: "Ask me a technical question, or try: \"Help me write a regex that matches valid email addresses.\"",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
Observers = HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens),
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
# Harness Step 04 — Code Execution (Hyperlight + Skills)
|
||||
|
||||
This sample demonstrates a HarnessAgent with **all features enabled**, plus:
|
||||
|
||||
- **Hyperlight CodeAct** — sandboxed Python code execution via `execute_code` (requires KVM)
|
||||
- **Skills** — file-based skill discovery (a `regex-tester` skill is included)
|
||||
|
||||
The agent can plan tasks, manage modes, store memories, read/write files, search the web, approve sensitive operations, discover and use skills, and execute arbitrary Python code — all pre-configured by the HarnessAgent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK
|
||||
- An Azure AI Foundry project endpoint
|
||||
- KVM-capable host (the Hyperlight sandbox runs code in micro-VMs)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `FOUNDRY_PROJECT_ENDPOINT` | Your Azure AI Foundry project endpoint |
|
||||
| `FOUNDRY_MODEL` | Model deployment name (default: `gpt-5.4`) |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## What to Try
|
||||
|
||||
- **Regex testing**: "Help me write a regex that matches valid email addresses, then test it against some examples."
|
||||
- **Code execution**: "Calculate the first 20 prime numbers using the Sieve of Eratosthenes."
|
||||
- **Skill + code combo**: "I need a regex for ISO 8601 dates — test it thoroughly with edge cases."
|
||||
|
||||
## Included Skill
|
||||
|
||||
The `skills/regex-tester/` skill instructs the agent to validate regex patterns by executing Python test code in the Hyperlight sandbox. It includes a regex cheatsheet as reference material.
|
||||
|
||||
## Features Enabled
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| TodoProvider | Task planning and tracking (`/todos` command) |
|
||||
| AgentModeProvider | Mode switching (`/mode` command) |
|
||||
| FileMemoryProvider | Persistent memory stored as files |
|
||||
| FileAccessProvider | Read/write files in a working directory |
|
||||
| ToolApproval | Don't-ask-again approval for sensitive tools |
|
||||
| WebSearch | Built-in hosted web search |
|
||||
| AgentSkillsProvider | Discovers and uses skills from the `skills/` folder |
|
||||
| HyperlightCodeActProvider | Sandboxed Python execution via `execute_code` |
|
||||
| OpenTelemetry | Trace logging to a text file |
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: regex-tester
|
||||
description: Validate, test, and debug regular expressions by executing them against sample inputs. Use when asked to build, verify, or explain a regex pattern.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
When the user asks you to create, validate, or debug a regular expression:
|
||||
|
||||
1. **Understand the requirement** — clarify what the pattern should match and what it should reject.
|
||||
2. **Consult the cheatsheet** — review `references/regex-cheatsheet.md` for syntax reminders if needed.
|
||||
3. **Write and execute test code** — use the `execute_code` tool to run Python code that:
|
||||
- Compiles the regex with `re.compile()`
|
||||
- Tests it against a set of positive examples (should match) and negative examples (should not match)
|
||||
- Extracts and displays any capturing groups
|
||||
- Reports pass/fail for each test case
|
||||
4. **Iterate** — if any test fails, refine the pattern and re-run until all cases pass.
|
||||
5. **Present the result** — give the user the final pattern, explain what each part does, and show the test results.
|
||||
|
||||
## Example Test Script
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
pattern = re.compile(r'^[\w.+-]+@[\w-]+\.[\w.-]+$')
|
||||
|
||||
positives = ["user@example.com", "first.last+tag@sub.domain.org"]
|
||||
negatives = ["@missing.com", "no-at-sign", "spaces in@address.com"]
|
||||
|
||||
for s in positives:
|
||||
assert pattern.match(s), f"FAIL: expected match for '{s}'"
|
||||
for s in negatives:
|
||||
assert not pattern.match(s), f"FAIL: expected no match for '{s}'"
|
||||
|
||||
print("All tests passed!")
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user