Merge remote-tracking branch 'upstream/main' into dotnet-split-integration-tests

# Conflicts:
#	dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicSkillsIntegrationTests.cs
This commit is contained in:
Giles Odigwe
2026-05-12 09:54:58 -07:00
146 changed files with 11687 additions and 2980 deletions
+2 -1
View File
@@ -25,6 +25,7 @@
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.1" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
@@ -193,4 +194,4 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
</Project>
+12 -3
View File
@@ -1,4 +1,4 @@
<Solution>
<Solution>
<Configurations>
<BuildType Name="Debug" />
<BuildType Name="Publish" />
@@ -65,6 +65,7 @@
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment/Agent_Step21_ShellWithEnvironment.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
@@ -123,6 +124,8 @@
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
@@ -167,7 +170,7 @@
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Agent_Step25_FoundryToolboxMcp.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Evaluation/">
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
@@ -328,6 +331,9 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj" />
</Folder>
@@ -370,7 +376,7 @@
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
</Folder>
</Folder>
<Folder Name="/Samples/05-end-to-end/">
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
@@ -587,6 +593,7 @@
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
<Project Path="src/Microsoft.Agents.AI.Tools.Shell/Microsoft.Agents.AI.Tools.Shell.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
@@ -609,6 +616,7 @@
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Tools.Shell.IntegrationTests/Microsoft.Agents.AI.Tools.Shell.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj" />
@@ -637,6 +645,7 @@
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/Microsoft.Agents.AI.Tools.Shell.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
@@ -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.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,130 @@
// Copyright (c) Microsoft. All rights reserved.
// Shell tool with environment-aware system prompt
//
// WARNING: This sample uses LocalShellExecutor, which executes real commands
// against the shell on this machine. Approval gating is disabled here so
// the demo runs unattended; in any real application keep approval on
// (the default), or use DockerShellExecutor for container isolation. The
// commands the model emits below are read-only or scoped (echo, cd into
// a temp folder, set a process-local env var) but a different model or
// prompt could choose to do something destructive. Run this only in an
// environment where you are comfortable with the agent typing into your
// terminal.
//
// Demonstrates LocalShellExecutor in both modes paired with
// ShellEnvironmentProvider, an AIContextProvider that probes the live
// shell (OS, family, version, CWD, common CLIs) and injects authoritative
// system-prompt instructions so the agent emits commands in the right
// idiom (PowerShell vs POSIX).
//
// Two runs:
// 1) Stateless mode: each tool call runs in a fresh shell. Useful when
// commands are independent (read-only scripts, version checks, file
// listings) and you want strong isolation between calls. Side
// effects in one call (cd, exported variables) do NOT carry to the
// next.
// 2) Persistent mode: a single long-lived shell is reused across calls,
// so working directory and exported environment variables are
// preserved. Useful for multi-step workflows that build state
// (cd into a folder and run a sequence of commands there; set a
// token in one step and read it in the next).
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Tools.Shell;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetChatClient(deploymentName);
const string Instructions = """
You are an agent with a single tool: run_shell. Use it to satisfy the
user's request. Do not describe what you would do actually run the
commands. Reply with the final answer derived from real output.
""";
// --------------------------------------------------------------------
// 1. Stateless mode — each call gets a fresh shell.
// --------------------------------------------------------------------
Console.WriteLine("### Stateless mode\n");
await using (var statelessShell = new LocalShellExecutor(new() { Mode = ShellMode.Stateless, AcknowledgeUnsafe = true }))
{
var envProvider = new ShellEnvironmentProvider(statelessShell);
var statelessAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new()
{
Instructions = Instructions,
Tools = [statelessShell.AsAIFunction(requireApproval: false)],
},
AIContextProviders = [envProvider],
});
var statelessSession = await statelessAgent.CreateSessionAsync();
Console.WriteLine(await statelessAgent.RunAsync("Print the current working directory.", statelessSession));
Console.WriteLine();
// Show that side effects do NOT carry between stateless calls: ask the
// agent to cd into the system temp directory in one call, then ask
// for the CWD in a second call. Stateless mode means the cd is gone.
Console.WriteLine(await statelessAgent.RunAsync("Change directory into the system temp folder, then print the current working directory.", statelessSession));
Console.WriteLine();
Console.WriteLine(await statelessAgent.RunAsync("In a NEW shell call, print the current working directory again. Tell me whether it matches the temp folder from the previous call.", statelessSession));
Console.WriteLine();
PrintSnapshot(envProvider.CurrentSnapshot!);
}
// --------------------------------------------------------------------
// 2. Persistent mode — one shell, reused across calls. State carries.
// --------------------------------------------------------------------
Console.WriteLine("\n### Persistent mode\n");
await using (var persistentShell = new LocalShellExecutor(new() { Mode = ShellMode.Persistent, AcknowledgeUnsafe = true }))
{
var envProvider = new ShellEnvironmentProvider(persistentShell);
var persistentAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new()
{
Instructions = Instructions,
Tools = [persistentShell.AsAIFunction(requireApproval: false)],
},
AIContextProviders = [envProvider],
});
var persistentSession = await persistentAgent.CreateSessionAsync();
// State carries across calls in persistent mode: cd into temp, then
// verify the next call sees the new CWD.
Console.WriteLine(await persistentAgent.RunAsync("Change directory into the system temp folder, then print the current working directory.", persistentSession));
Console.WriteLine();
Console.WriteLine(await persistentAgent.RunAsync("In a NEW shell call, print the current working directory again. Tell me whether it still matches the temp folder.", persistentSession));
Console.WriteLine();
// Same idea with an exported variable: set in one call, read in the next.
Console.WriteLine(await persistentAgent.RunAsync("Set the environment variable DEMO_TOKEN to the value 'hello-world'.", persistentSession));
Console.WriteLine();
Console.WriteLine(await persistentAgent.RunAsync("Print the current value of DEMO_TOKEN. Tell me exactly what value the shell reports.", persistentSession));
Console.WriteLine();
PrintSnapshot(envProvider.CurrentSnapshot!);
}
static void PrintSnapshot(ShellEnvironmentSnapshot snap)
{
Console.WriteLine("--- Captured environment snapshot ---");
Console.WriteLine($" Family: {snap.Family}");
Console.WriteLine($" OS: {snap.OSDescription}");
Console.WriteLine($" Shell: {snap.ShellVersion ?? "(unknown)"}");
Console.WriteLine($" CWD: {snap.WorkingDirectory}");
foreach (var (tool, version) in snap.ToolVersions)
{
Console.WriteLine($" {tool,-8} {version ?? "(not installed)"}");
}
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -6,12 +6,16 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -1,93 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to load a Foundry toolbox and pass its tools as server-side
// tools when creating an agent. The Foundry platform handles tool execution — the agent
// process does not invoke tools locally.
// Foundry Toolbox via MCP (Streamable HTTP).
//
// Point an `McpClient` at a Foundry Toolbox's MCP endpoint. The agent
// discovers the toolbox's tools at runtime and invokes them locally.
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using OpenAI.Responses;
#pragma warning disable OPENAI001 // Experimental API
#pragma warning disable AAIP001 // AgentToolboxes is experimental
#pragma warning disable CS8321 // Local functions may be commented-out alternatives
// Replace with your own Foundry toolbox name.
// Must match the `<name>` segment of FOUNDRY_TOOLBOX_ENDPOINT.
const string ToolboxName = "research_toolbox";
// Used only by CombineToolboxes — swap in a second toolbox you own.
const string SecondToolboxName = "analysis_toolbox";
// Replace with any question that exercises the tools configured in your toolbox.
const string Query = "Introduce yourself and briefly describe the tools you can use to help me.";
const string Query = "What tools do you have access to?";
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint.");
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
string toolboxEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_ENDPOINT")
?? throw new InvalidOperationException(
"FOUNDRY_TOOLBOX_ENDPOINT is not set. Example: " +
"https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=2025-05-01-preview");
TokenCredential credential = new DefaultAzureCredential();
// Comment out if the toolbox already exists in your Foundry project.
await CreateSampleToolboxAsync(ToolboxName, endpoint, credential);
// Inject a fresh Azure AI bearer token on every MCP request.
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
{
InnerHandler = new HttpClientHandler(),
});
Console.WriteLine($"Connecting to toolbox MCP endpoint: {toolboxEndpoint}");
await using McpClient mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(toolboxEndpoint),
Name = "foundry_toolbox",
},
httpClient));
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Toolbox MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}");
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
await Main(projectClient, model, endpoint);
// await CombineToolboxes(projectClient, model, endpoint);
AIAgent agent = aiProjectClient.AsAIAgent(
model: deploymentName,
instructions: "You are a helpful assistant. Use the available toolbox tools to answer the user.",
name: "ToolboxMcpAgent",
tools: [.. mcpTools.Cast<AITool>()]);
Console.WriteLine($"\nUser: {Query}\n");
Console.WriteLine($"Assistant: {await agent.RunAsync(Query)}");
// ---------------------------------------------------------------------------
// Main: single toolbox
// Helper: create (or replace) a sample toolbox so the sample runs end-to-end
// ---------------------------------------------------------------------------
static async Task Main(AIProjectClient projectClient, string model, string endpoint)
{
Console.WriteLine("=== Foundry Toolbox Server-Side Tools Example ===");
// Comment out if the toolbox already exists in your Foundry project.
await CreateSampleToolboxAsync(ToolboxName, endpoint);
// Omit the version to resolve the toolbox's current default version at runtime.
var tools = await projectClient.GetToolboxToolsAsync(ToolboxName);
AIAgent agent = projectClient
.AsAIAgent(
model: model,
instructions: "You are a research assistant. Use the available tools to answer questions.",
tools: tools.ToList());
Console.WriteLine($"User: {Query}");
Console.WriteLine($"Result: {await agent.RunAsync(Query)}\n");
}
// ---------------------------------------------------------------------------
// Alternative: combine tools from multiple toolboxes
// ---------------------------------------------------------------------------
static async Task CombineToolboxes(AIProjectClient projectClient, string model, string endpoint)
{
Console.WriteLine("=== Combine Toolboxes Example ===");
// Comment out if the toolboxes already exist in your Foundry project.
await CreateSampleToolboxAsync(ToolboxName, endpoint);
await CreateSampleToolboxAsync(SecondToolboxName, endpoint);
var toolboxA = await projectClient.GetToolboxToolsAsync(ToolboxName);
var toolboxB = await projectClient.GetToolboxToolsAsync(SecondToolboxName);
var allTools = toolboxA.Concat(toolboxB).ToList();
AIAgent agent = projectClient
.AsAIAgent(
model: model,
instructions: "You are a research assistant. Use all available tools to answer questions.",
tools: allTools);
Console.WriteLine($"User: {Query}");
Console.WriteLine($"Combined-toolbox result: {await agent.RunAsync(Query)}\n");
}
// ---------------------------------------------------------------------------
// Helper: create (or replace) a sample toolbox so the sample works out-of-the-box
// ---------------------------------------------------------------------------
static async Task CreateSampleToolboxAsync(string name, string endpoint)
static async Task CreateSampleToolboxAsync(string name, string endpoint, TokenCredential credential)
{
// Toolboxes are normally configured in the Foundry portal or a deployment
// script, not the application itself. This helper exists so the sample can
@@ -96,10 +83,7 @@ static async Task CreateSampleToolboxAsync(string name, string endpoint)
// The Foundry-Features header is currently required for toolbox CRUD operations.
var options = new AgentAdministrationClientOptions();
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
var adminClient = new AgentAdministrationClient(
new Uri(endpoint),
new DefaultAzureCredential(),
options);
var adminClient = new AgentAdministrationClient(new Uri(endpoint), credential, options);
var toolboxClient = adminClient.GetAgentToolboxes();
// Delete existing toolbox if present (ignore 404).
@@ -128,7 +112,7 @@ static async Task CreateSampleToolboxAsync(string name, string endpoint)
}
// ---------------------------------------------------------------------------
// Pipeline policy that adds the Foundry-Features header for toolbox CRUD
// Pipeline policy: adds the Foundry-Features header for toolbox CRUD calls
// ---------------------------------------------------------------------------
internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
{
@@ -146,3 +130,18 @@ internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
// ---------------------------------------------------------------------------
// DelegatingHandler: attaches a fresh Azure AI bearer token to every request
// ---------------------------------------------------------------------------
internal 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);
}
}
@@ -0,0 +1,31 @@
# Foundry Toolbox via MCP
This sample shows how to use a Foundry Toolbox by pointing an `McpClient` at the toolbox's MCP endpoint. The agent discovers the toolbox's tools at runtime and invokes them locally over MCP.
## What this sample demonstrates
- Connecting to a Foundry toolbox's MCP endpoint via Streamable HTTP transport
- Injecting a fresh Azure AI bearer token (`https://ai.azure.com/.default`) on every MCP request
- Passing the discovered MCP tools to `AIProjectClient.AsAIAgent(...)`
- Optional helper to create (or replace) a sample toolbox in the project so the sample is runnable end-to-end
## Prerequisites
- A Microsoft Foundry project with a toolbox configured (or let the sample create one for you)
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini"
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project/toolsets/research_toolbox/mcp?api-version=2025-05-01-preview"
```
The `<name>` segment of `FOUNDRY_TOOLBOX_ENDPOINT` must match the `ToolboxName` constant in `Program.cs`.
## Run the sample
```powershell
dotnet run
```
@@ -1,46 +0,0 @@
# Agent_Step25_ToolboxServerSideTools
This sample demonstrates loading a named Foundry toolbox and passing its tools as
**server-side tools** when creating an agent via `AsAIAgent()`.
When tools from a toolbox are passed this way, they are sent as tool definitions in
the Responses API request. The Foundry platform handles tool execution — the agent
process does not invoke tools locally.
This is the dotnet equivalent of the Python sample:
`python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
## Prerequisites
- A Microsoft Foundry project
- `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable set (defaults to `gpt-5.4-mini`)
The sample recreates the toolbox on each run, replacing any existing toolbox with
the same name. Comment out the `CreateSampleToolboxAsync` call if you want to keep
an existing toolbox unchanged.
## How it works
1. `projectClient.GetToolboxVersionAsync(name)` fetches the toolbox definition from the
Foundry project API (resolving the default version if none is specified)
2. `ToolboxVersion.ToAITools()` converts each tool definition to an `AITool` instance
3. The tools are passed to `AsAIAgent(tools: ...)` which includes them in the Responses
API request as server-side tool definitions
For a one-liner, use `projectClient.GetToolboxToolsAsync(name)` to fetch and convert in one call.
## Sample flows
| Flow | Description |
|------|-------------|
| `Main` (default) | Loads a single toolbox and runs an agent with its tools |
| `CombineToolboxes` | Loads two toolboxes and merges their tools into one agent |
Uncomment the desired flow in the top-level statements to try each one.
## Running the sample
```bash
dotnet run
```
@@ -73,6 +73,7 @@ Some samples require extra tool-specific environment variables. See each sample
| [Memory search](./Agent_Step22_MemorySearch/) | Memory search tool |
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
| [Foundry toolbox via MCP](./Agent_Step25_FoundryToolboxMcp/) | Use a Foundry Toolbox from a non-hosted agent via its MCP endpoint |
## Running the samples
@@ -0,0 +1,90 @@
// 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 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";
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
};
}
@@ -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,151 @@
// 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 "&gt;" 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(this.Y + row, this.X));
Console.Write(AnsiEscapes.EraseEntireLine);
Console.Write(line);
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(this.Y + row, this.X));
Console.Write(AnsiEscapes.EraseEntireLine);
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]);
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);
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.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. "&gt; " or "user &gt; ").</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 = this.Width - promptLength;
string indent = new(' ', promptLength);
// First line: prompt + start of text
Console.Write(AnsiEscapes.MoveCursor(this.Y, this.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(this.Y + row, this.X));
Console.Write(AnsiEscapes.EraseEntireLine);
Console.Write(indent);
Console.Write(props.Text[offset..(offset + chunk)]);
offset += chunk;
row++;
}
}
}
@@ -0,0 +1,106 @@
// 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.</summary>
public IReadOnlyList<object> Items { get; init; } = [];
}
/// <summary>
/// A component that renders a list of items vertically using a custom render delegate.
/// Designed for rendering dynamic items in a non-scroll region that may be
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveComponent.Height"/>
/// exceeds the number of output lines, leftover lines are erased.
/// </summary>
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
{
private readonly Func<object, string> _renderItem;
/// <summary>
/// Initializes a new instance of the <see cref="TextPanel"/> class.
/// </summary>
/// <param name="renderItem">A delegate that renders an item and returns the text to display (may contain newlines).</param>
public TextPanel(Func<object, string> renderItem)
{
this._renderItem = renderItem;
}
/// <summary>
/// Calculates the height (in lines) needed to render all items.
/// </summary>
/// <param name="items">The items to measure.</param>
/// <param name="renderItem">The render delegate to use for measuring.</param>
/// <returns>The total number of lines all items will occupy.</returns>
public static int CalculateHeight(IReadOnlyList<object> items, Func<object, string> renderItem)
{
int total = 0;
for (int i = 0; i < items.Count; i++)
{
string text = renderItem(items[i]);
total += CountLines(text);
}
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 = this._renderItem(props.Items[i]);
string[] lines = text.Split('\n');
int lineCount = CountLines(text);
for (int j = 0; j < lineCount; j++)
{
Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + currentRow));
Console.Write(lines[j]);
currentRow++;
}
}
// If the component height exceeds the output, erase leftover lines
if (this.Height > currentRow)
{
for (int i = currentRow; i < this.Height; i++)
{
Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + i));
}
}
}
private static int CountLines(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int count = 1;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\n')
{
count++;
}
}
// If text ends with a newline, don't count the trailing empty line
if (text[text.Length - 1] == '\n')
{
count--;
}
return count;
}
}
@@ -0,0 +1,70 @@
// 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.</summary>
public IReadOnlyList<object> Items { get; init; } = [];
}
/// <summary>
/// State for <see cref="TextScrollPanel"/>.
/// </summary>
/// <param name="RenderedCount">The number of items already rendered.</param>
public record TextScrollPanelState(int RenderedCount = 0) : ConsoleReactiveState;
/// <summary>
/// A component that renders items within a scroll area using a custom render delegate.
/// All items are considered finalized — only new items since the last render are output.
/// Use <see cref="Reset"/> to force a full re-render.
/// </summary>
public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, TextScrollPanelState>
{
private readonly Func<object, string> _renderItem;
/// <summary>
/// Initializes a new instance of the <see cref="TextScrollPanel"/> class.
/// </summary>
/// <param name="renderItem">A delegate that renders a single item and returns the text to display (may contain newlines).</param>
public TextScrollPanel(Func<object, string> renderItem)
{
this._renderItem = renderItem;
this.State = new TextScrollPanelState();
}
/// <summary>
/// Resets the panel so all items will be re-rendered on the next Render call.
/// </summary>
public void Reset()
{
this.State = new TextScrollPanelState();
}
/// <inheritdoc />
public override void RenderCore(TextScrollPanelProps props, TextScrollPanelState state)
{
if (props.Items.Count == 0)
{
return;
}
// Move cursor to the bottom of the scroll area
Console.Write(AnsiEscapes.MoveCursor(this.Y + this.Height - 1, this.X));
// Output only new items since last rendered
for (int i = state.RenderedCount; i < props.Items.Count; i++)
{
string text = this._renderItem(props.Items[i]);
Console.Write(text);
}
// Update state to track what we've rendered
this.State = new TextScrollPanelState(props.Items.Count);
}
}
@@ -0,0 +1,87 @@
// 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 width of the horizontal rules in characters.</summary>
public int Width { get; init; }
/// <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.Height;
}
// 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(this.Y, this.X));
Console.Write(rule);
// Render children stacked below the top rule
int currentY = this.Y + 1;
if (props.Color.HasValue)
{
Console.Write(AnsiEscapes.ResetAttributes);
}
foreach (var child in props.Children)
{
child.X = this.X;
child.Y = currentY;
child.Render();
currentY += child.Height;
}
if (props.Color.HasValue)
{
Console.Write(AnsiEscapes.SetForegroundColor(props.Color.Value));
}
// Bottom rule
Console.Write(AnsiEscapes.MoveCursor(currentY, this.X));
Console.Write(rule);
if (props.Color.HasValue)
{
Console.Write(AnsiEscapes.ResetAttributes);
}
}
}
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Harness.ConsoleReactiveFramework;
/// <summary>
/// Abstract base class for all console UI components. Provides layout properties
/// (position and size) 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 or sets the 1-based column position of the component.</summary>
public int X { get; set; }
/// <summary>Gets or sets the 1-based row position of the component.</summary>
public int Y { get; set; }
/// <summary>Gets or sets the width of the component in columns.</summary>
public int Width { get; set; }
/// <summary>Gets or sets the height of the component in rows.</summary>
public int Height { get; set; }
/// <summary>Renders the component to the console at its current position.</summary>
public abstract void Render();
}
/// <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 readonly object _renderLock = new();
private TProps? _lastRenderedProps;
private TState? _lastRenderedState;
/// <summary>Gets or sets the component's props (external configuration).</summary>
public TProps? Props { get; set; }
/// <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 (this._renderLock)
{
if (this.Props is null)
{
return;
}
if (ReferenceEquals(this.Props, this._lastRenderedProps)
&& ReferenceEquals(this.State, this._lastRenderedState))
{
return;
}
this.RenderCore(this.Props, this.State!);
this._lastRenderedProps = this.Props;
this._lastRenderedState = this.State;
}
}
/// <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 an optional <see cref="Children"/> collection
/// for composing child components.
/// </summary>
public record ConsoleReactiveProps
{
/// <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;
@@ -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,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,315 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Harness.ConsoleReactiveFramework;
namespace Harness.ConsoleSandbox;
/// <summary>
/// Determines which component is shown in the bottom panel.
/// </summary>
public enum BottomPanelMode
{
/// <summary>Show the list selection component.</summary>
ListSelection,
/// <summary>Show the text input component.</summary>
TextInput
}
public record AppComponentProps : ConsoleReactiveProps
{
public IReadOnlyList<string> Items { get; init; } = Array.Empty<string>();
public IReadOnlyList<object> ScrollItems { get; init; } = [];
/// <summary>Gets the bottom panel mode.</summary>
public BottomPanelMode Mode { get; init; } = BottomPanelMode.ListSelection;
/// <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 highlight color for the active list item. Defaults to <see cref="ConsoleColor.Cyan"/>.</summary>
public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan;
/// <summary>Gets the placeholder text for the custom text input option in the list. If <c>null</c>, no custom option is shown.</summary>
public string? ListCustomTextPlaceholder { get; init; }
/// <summary>Gets the foreground color for the rule borders. If <c>null</c>, uses the default terminal color.</summary>
public ConsoleColor? RuleColor { get; init; }
}
/// <summary>
/// Internal state for the <see cref="AppComponent"/>.
/// </summary>
public record AppComponentState : ConsoleReactiveState
{
/// <summary>Gets the selected index in list selection mode.</summary>
public int SelectedIndex { get; init; }
/// <summary>Gets the current input text being typed in text input mode.</summary>
public string InputText { get; init; } = "";
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
public string ListInputText { get; init; } = "";
}
public class AppComponent : ConsoleReactiveComponent<AppComponentProps, AppComponentState>
{
private readonly TopBottomRule _rule = new();
private readonly ListSelection _listSelection = new();
private readonly TextInput _textInput = new();
private readonly TextScrollPanel _textScrollPanel;
private readonly TextPanel _textPanel;
private readonly Func<object, string> _renderItem;
private readonly Action<string> _onTextInputSubmit;
private readonly Action<string> _onListInputSubmit;
private bool _resizedSinceLastRender;
private int _lastScrollBottom;
/// <summary>
/// Initializes a new instance of the <see cref="AppComponent"/> class.
/// </summary>
/// <param name="renderScrollItem">A delegate that renders a single scroll panel item and returns the text to display.</param>
/// <param name="onTextInputSubmit">A callback invoked with the input text when the user presses Enter in text input mode.</param>
/// <param name="onListInputSubmit">A callback invoked with the selected or typed text when the user presses Enter in list selection mode.</param>
public AppComponent(Func<object, string> renderScrollItem, Action<string> onTextInputSubmit, Action<string> onListInputSubmit)
{
this._renderItem = renderScrollItem;
this._onTextInputSubmit = onTextInputSubmit;
this._onListInputSubmit = onListInputSubmit;
this._textScrollPanel = new TextScrollPanel(renderScrollItem);
this._textPanel = new TextPanel(renderScrollItem);
this.State = new AppComponentState();
KeyEventListener.Instance.KeyPressed += this.OnKeyPressed;
ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized;
}
private void OnKeyPressed(object? sender, KeyPressEventArgs e)
{
if (this.Props!.Mode == BottomPanelMode.TextInput)
{
this.HandleTextInputKey(e);
}
else
{
this.HandleListSelectionKey(e);
}
}
private void HandleTextInputKey(KeyPressEventArgs e)
{
if (e.KeyInfo.Key == ConsoleKey.Enter)
{
string text = this.State!.InputText;
this.SetState(this.State with { InputText = "" });
this._onTextInputSubmit(text);
}
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
{
if (this.State!.InputText.Length > 0)
{
this.SetState(this.State with { InputText = this.State.InputText[..^1] });
}
}
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
{
this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar });
}
}
private void HandleListSelectionKey(KeyPressEventArgs e)
{
int maxIndex = this.Props!.Items.Count - 1;
if (this.Props.ListCustomTextPlaceholder != null)
{
maxIndex = this.Props.Items.Count; // extra option at the end
}
bool isOnCustomTextOption = this.Props.ListCustomTextPlaceholder != null
&& this.State!.SelectedIndex == this.Props.Items.Count;
if (e.KeyInfo.Key == ConsoleKey.UpArrow)
{
this.SetState(this.State! with { SelectedIndex = Math.Max(0, this.State.SelectedIndex - 1) });
}
else if (e.KeyInfo.Key == ConsoleKey.DownArrow)
{
this.SetState(this.State! with { SelectedIndex = Math.Min(maxIndex, this.State.SelectedIndex + 1) });
}
else if (e.KeyInfo.Key == ConsoleKey.Enter)
{
if (isOnCustomTextOption)
{
string text = this.State!.ListInputText;
this.SetState(this.State with { ListInputText = "" });
this._onListInputSubmit(text);
}
else
{
this._onListInputSubmit(this.Props.Items[this.State!.SelectedIndex]);
}
}
else if (isOnCustomTextOption)
{
// Typing only works when on the custom text option
if (e.KeyInfo.Key == ConsoleKey.Backspace)
{
if (this.State!.ListInputText.Length > 0)
{
this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] });
}
}
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
{
this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar });
}
}
}
private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e)
{
this._resizedSinceLastRender = true;
this.Render();
}
public override void RenderCore(AppComponentProps props, AppComponentState state)
{
// Determine the text panel height for the last scroll item
object? lastItem = props.ScrollItems.Count > 0 ? props.ScrollItems[^1] : null;
IReadOnlyList<object> lastItems = lastItem != null ? [lastItem] : [];
int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem);
if (textPanelHeight > 0)
{
textPanelHeight++; // Extra line for spacing between text panel and rule
}
// Build the bottom panel child based on mode
ConsoleReactiveComponent bottomChild;
int bottomChildHeight;
if (props.Mode == BottomPanelMode.TextInput)
{
var textInputProps = new TextInputProps
{
Prompt = props.Prompt,
Text = state.InputText,
Placeholder = props.Placeholder
};
bottomChildHeight = TextInput.CalculateHeight(textInputProps, Console.WindowWidth);
this._textInput.Width = Console.WindowWidth;
this._textInput.Height = bottomChildHeight;
this._textInput.Props = textInputProps;
bottomChild = this._textInput;
}
else
{
var listProps = new ListSelectionProps
{
Items = props.Items,
SelectedIndex = state.SelectedIndex,
HighlightColor = props.ListHighlightColor,
CustomTextPlaceholder = props.ListCustomTextPlaceholder,
CustomText = state.ListInputText
};
bottomChildHeight = ListSelection.CalculateHeight(listProps);
this._listSelection.Height = bottomChildHeight;
this._listSelection.Props = listProps;
bottomChild = this._listSelection;
}
var ruleProps = new TopBottomRuleProps
{
Width = Console.WindowWidth,
Color = props.RuleColor,
Children = [bottomChild]
};
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
int scrollBottom = Console.WindowHeight - ruleHeight - textPanelHeight;
// If scroll region changed or a clear is needed, reset everything
if (this._resizedSinceLastRender || (this._lastScrollBottom != 0 && scrollBottom != this._lastScrollBottom))
{
Console.Write(AnsiEscapes.EraseEntireScreen);
Console.Write(AnsiEscapes.EraseScrollbackBuffer);
this._textScrollPanel.Reset();
this._resizedSinceLastRender = false;
}
this._lastScrollBottom = scrollBottom;
Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom));
// Render text scroll panel in the scroll area (all items except the last)
IReadOnlyList<object> scrollItems = props.ScrollItems.Count > 1
? props.ScrollItems.Take(props.ScrollItems.Count - 1).ToList()
: [];
this._textScrollPanel.X = 1;
this._textScrollPanel.Y = 1;
this._textScrollPanel.Width = Console.WindowWidth;
this._textScrollPanel.Height = scrollBottom;
this._textScrollPanel.Props = new TextScrollPanelProps
{
Items = scrollItems
};
this._textScrollPanel.Render();
// Render the text panel for the last (dynamic) item just below the scroll region
this._textPanel.X = 1;
this._textPanel.Y = scrollBottom + 1;
this._textPanel.Width = Console.WindowWidth;
this._textPanel.Height = textPanelHeight;
this._textPanel.Props = new TextPanelProps
{
Items = lastItems,
};
this._textPanel.Render();
// Render the bottom rule + child below the text panel
this._rule.X = 1;
this._rule.Y = scrollBottom + textPanelHeight + 1;
this._rule.Props = ruleProps;
this._rule.Render();
// Position cursor for natural typing appearance
if (props.Mode == BottomPanelMode.TextInput)
{
int promptLength = props.Prompt.Length;
int textWidth = Console.WindowWidth - promptLength;
int textLength = state.InputText.Length;
// The TextInput starts at rule.Y + 1 (first row inside the rule)
int textInputY = this._rule.Y + 1;
if (textWidth <= 0 || textLength == 0)
{
// Cursor right after the prompt
Console.Write(AnsiEscapes.MoveCursor(textInputY, promptLength + 1));
}
else
{
// Calculate which row and column the cursor lands on
int cursorRow = textLength < textWidth ? 0 : 1 + ((textLength - textWidth) / textWidth);
int cursorCol = textLength < textWidth ? textLength : (textLength - textWidth) % textWidth;
Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1));
}
}
else if (props.Mode == BottomPanelMode.ListSelection
&& props.ListCustomTextPlaceholder != null
&& state.SelectedIndex == props.Items.Count)
{
// Cursor after the typed text in the custom text option
// The custom text option is at rule.Y + 1 + Items.Count (0-based row inside rule)
int customOptionY = this._rule.Y + 1 + props.Items.Count;
// "> " prefix is 2 chars, then the typed text
int cursorCol = 2 + state.ListInputText.Length + 1;
Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
}
}
}
@@ -5,24 +5,25 @@ using Microsoft.Agents.AI;
namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles a console command (e.g., /todos, /mode). Command handlers are checked
/// in order before user input is sent to the agent. The first handler that
/// accepts the input prevents further handlers from being checked.
/// 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 interface ICommandHandler
public abstract class CommandHandler
{
/// <summary>
/// Gets the help text for this command, displayed in the console header.
/// 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>
string? GetHelpText();
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 container for rendering output.</param>
/// <returns><see langword="true"/> if this handler handled the input; <see langword="false"/> otherwise.</returns>
ValueTask<bool> TryHandleAsync(string input, AgentSession session);
public abstract ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux);
}
@@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles the <c>/mode</c> command to display or switch the current agent mode.
/// </summary>
internal sealed class ModeCommandHandler : ICommandHandler
internal sealed class ModeCommandHandler : CommandHandler
{
private readonly AgentModeProvider? _modeProvider;
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
@@ -24,28 +24,28 @@ internal sealed class ModeCommandHandler : ICommandHandler
}
/// <inheritdoc/>
public string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
public override string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
/// <inheritdoc/>
public ValueTask<bool> TryHandleAsync(string input, AgentSession session)
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux)
{
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult(false);
return false;
}
if (this._modeProvider is null)
{
System.Console.WriteLine("AgentModeProvider is not available.");
return ValueTask.FromResult(true);
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);
System.Console.WriteLine($"\n Current mode: {current}\n");
return ValueTask.FromResult(true);
await ux.WriteInfoLineAsync($"Current mode: {current}").ConfigureAwait(false);
return true;
}
string newMode = parts[1];
@@ -53,17 +53,14 @@ internal sealed class ModeCommandHandler : ICommandHandler
try
{
this._modeProvider.SetMode(session, newMode);
System.Console.ForegroundColor = ConsoleWriter.GetModeColor(newMode, this._modeColors);
System.Console.WriteLine($"\n Switched to {newMode} mode.\n");
System.Console.ResetColor();
ux.CurrentMode = newMode;
await ux.WriteInfoLineAsync($"Switched to {newMode} mode.", ModeColors.Get(newMode, this._modeColors)).ConfigureAwait(false);
}
catch (ArgumentException ex)
{
System.Console.ForegroundColor = ConsoleColor.Red;
System.Console.WriteLine($"\n {ex}\n");
System.Console.ResetColor();
await ux.WriteInfoLineAsync(ex.Message, ConsoleColor.Red).ConfigureAwait(false);
}
return ValueTask.FromResult(true);
return true;
}
}
@@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles the <c>/todos</c> command to display the current todo list.
/// </summary>
internal sealed class TodoCommandHandler : ICommandHandler
internal sealed class TodoCommandHandler : CommandHandler
{
private readonly TodoProvider? _todoProvider;
@@ -21,10 +21,10 @@ internal sealed class TodoCommandHandler : ICommandHandler
}
/// <inheritdoc/>
public string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
public override string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
/// <inheritdoc/>
public async ValueTask<bool> TryHandleAsync(string input, AgentSession session)
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux)
{
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
{
@@ -33,34 +33,28 @@ internal sealed class TodoCommandHandler : ICommandHandler
if (this._todoProvider is null)
{
System.Console.WriteLine("TodoProvider is not available.");
await ux.WriteInfoLineAsync("TodoProvider is not available.").ConfigureAwait(false);
return true;
}
var todos = await this._todoProvider.GetAllTodosAsync(session).ConfigureAwait(false);
if (todos.Count == 0)
{
System.Console.WriteLine("\n No todos yet.\n");
await ux.WriteInfoLineAsync("No todos yet.").ConfigureAwait(false);
return true;
}
System.Console.WriteLine();
System.Console.WriteLine(" ── Todo List ──");
await ux.WriteInfoLineAsync("── Todo List ──").ConfigureAwait(false);
foreach (var item in todos)
{
string status = item.IsComplete ? "✓" : "○";
System.Console.ForegroundColor = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White;
System.Console.Write($" [{status}] #{item.Id} {item.Title}");
if (!string.IsNullOrWhiteSpace(item.Description))
{
System.Console.Write($" — {item.Description}");
}
System.Console.WriteLine();
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);
}
System.Console.ResetColor();
System.Console.WriteLine();
return true;
}
}
@@ -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(this.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,120 @@
// 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;
/// <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.MoveAndEraseLine(this.Y));
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));
}
}
}
@@ -1,278 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Spectre.Console;
namespace Harness.Shared.Console;
/// <summary>
/// Centralizes all console output and spinner management for the harness console.
/// Observers write through this class so the spinner is automatically paused before output.
/// </summary>
public sealed class ConsoleWriter : IDisposable
{
private readonly Spinner _spinner = new();
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
private bool _lastWasText;
private bool _hasReceivedAnyText;
/// <summary>
/// Initializes a new instance of the <see cref="ConsoleWriter"/> class.
/// </summary>
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
public ConsoleWriter(IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
this._modeColors = modeColors;
}
/// <summary>
/// Gets or sets the current agent mode (e.g., "plan", "execute").
/// Used to determine the console color for mode-prefixed output.
/// </summary>
public string? CurrentMode { get; set; }
/// <summary>
/// Writes the agent response header (e.g., "[plan] Agent: ") and starts the spinner.
/// </summary>
public void WriteResponseHeader()
{
if (this.CurrentMode is not null)
{
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
System.Console.Write($"\n[{this.CurrentMode}] Agent: ");
}
else
{
System.Console.Write("\nAgent: ");
}
this._lastWasText = true;
this._hasReceivedAnyText = false;
this._spinner.Start();
}
/// <summary>
/// Writes informational output with automatic prefix spacing, without a trailing newline.
/// Use when continuation content will be appended on the same line.
/// </summary>
/// <param name="text">The informational text to write (without leading newline/indent — added automatically).</param>
/// <param name="color">Optional console color for the text.</param>
public async Task WriteInfoAsync(string text, ConsoleColor? color = null)
{
await this.WriteInfoCoreAsync(text, color, newLine: false);
}
/// <summary>
/// Writes informational output with automatic prefix spacing, followed by a newline.
/// </summary>
/// <param name="text">The informational text to write (without leading newline/indent — added automatically).</param>
/// <param name="color">Optional console color for the text.</param>
public async Task WriteInfoLineAsync(string text, ConsoleColor? color = null)
{
await this.WriteInfoCoreAsync(text, color, newLine: true);
}
private async Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
{
await this._spinner.StopAsync();
string prefix = this._lastWasText ? "\n\n " : " ";
this._lastWasText = false;
System.Console.ForegroundColor = color ?? GetModeColor(this.CurrentMode, this._modeColors);
if (newLine)
{
System.Console.WriteLine(prefix + text);
}
else
{
System.Console.Write(prefix + text);
}
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
this._spinner.Start();
}
/// <summary>
/// Writes text output from the agent, managing line break state.
/// Ensures a newline is written before the first text output.
/// </summary>
/// <param name="text">The text to write.</param>
/// <param name="color">Optional console color override for this text.</param>
public async Task WriteTextAsync(string text, ConsoleColor? color = null)
{
await this._spinner.StopAsync();
if (!this._lastWasText)
{
System.Console.Write("\n");
this._lastWasText = true;
}
this._hasReceivedAnyText = true;
if (color.HasValue)
{
System.Console.ForegroundColor = color.Value;
}
System.Console.Write(text);
if (color.HasValue)
{
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
}
this._spinner.Start();
}
/// <summary>
/// Reads a line of input from the console, pausing the spinner while waiting for input.
/// Optionally displays a prompt before reading. The prompt is rendered between
/// two horizontal rules for visual clarity.
/// </summary>
/// <param name="prompt">Optional prompt text to display before reading input.</param>
/// <param name="promptColor">Optional console color for the prompt text.</param>
/// <returns>The line read from the console, or <c>null</c> if no input is available.</returns>
public async Task<string?> ReadLineAsync(string? prompt = null, ConsoleColor? promptColor = null)
{
await this._spinner.StopAsync();
if (prompt is not null)
{
System.Console.WriteLine();
AnsiConsole.Write(this.CreateModeRule());
if (promptColor.HasValue)
{
System.Console.ForegroundColor = promptColor.Value;
}
System.Console.Write($" {prompt}");
if (promptColor.HasValue)
{
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
}
}
string? input = System.Console.ReadLine();
if (prompt is not null)
{
AnsiConsole.Write(this.CreateModeRule());
}
this._lastWasText = false;
return input;
}
/// <summary>
/// Presents a selection prompt with the given choices, plus an option to type a custom response.
/// Uses Spectre.Console <see cref="SelectionPrompt{T}"/> for interactive arrow-key selection.
/// </summary>
/// <param name="title">The title/question displayed above the selection list.</param>
/// <param name="choices">The list of choices to present.</param>
/// <returns>The selected choice text, or the custom-typed response.</returns>
public async Task<string> ReadSelectionAsync(string title, IList<string> choices)
{
await this._spinner.StopAsync();
AnsiConsole.Write(this.CreateModeRule());
const string FreeformOption = "✏️ Type a custom response...";
var allChoices = choices.Concat([FreeformOption]).ToList();
var prompt = new SelectionPrompt<string>()
.Title($" [bold]{Markup.Escape(title)}[/]")
.PageSize(10)
.AddChoices(allChoices);
string selection = AnsiConsole.Prompt(prompt);
if (selection == FreeformOption)
{
var textPrompt = new TextPrompt<string>(" [grey]Response:[/]");
selection = AnsiConsole.Prompt(textPrompt);
}
AnsiConsole.MarkupLine($" [dim]→ {Markup.Escape(selection)}[/]");
AnsiConsole.Write(this.CreateModeRule());
this._lastWasText = false;
return selection;
}
/// <summary>
/// Writes the stream-complete footer (handles "no text response" fallback, resets color).
/// </summary>
public async Task WriteStreamFooterAsync(bool hasFollowUpMessages)
{
await this._spinner.StopAsync();
if (!this._hasReceivedAnyText && !hasFollowUpMessages)
{
System.Console.ForegroundColor = ConsoleColor.DarkYellow;
System.Console.Write("\n (no text response from agent)");
}
System.Console.ResetColor();
System.Console.WriteLine();
}
/// <inheritdoc/>
public void Dispose()
{
this._spinner.Dispose();
}
/// <summary>
/// Gets the console color associated with a mode name, using the provided color map.
/// </summary>
internal static ConsoleColor GetModeColor(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;
}
/// <summary>
/// Creates a <see cref="Rule"/> styled with the current mode color.
/// </summary>
internal Rule CreateModeRule()
{
var spectreColor = ToSpectreColor(GetModeColor(this.CurrentMode, this._modeColors));
return new Rule().RuleStyle(new Style(spectreColor));
}
internal static Color ToSpectreColor(ConsoleColor consoleColor) => consoleColor switch
{
ConsoleColor.Black => Color.Black,
ConsoleColor.DarkBlue => Color.Blue,
ConsoleColor.DarkGreen => Color.Green,
ConsoleColor.DarkCyan => Color.Teal,
ConsoleColor.DarkRed => Color.Red,
ConsoleColor.DarkMagenta => Color.Purple,
ConsoleColor.DarkYellow => Color.Olive,
ConsoleColor.Gray => Color.Silver,
ConsoleColor.DarkGray => Color.Grey,
ConsoleColor.Blue => Color.Blue1,
ConsoleColor.Green => Color.Green1,
ConsoleColor.Cyan => Color.Aqua,
ConsoleColor.Red => Color.Red1,
ConsoleColor.Magenta => Color.Fuchsia,
ConsoleColor.Yellow => Color.Yellow,
ConsoleColor.White => Color.White,
_ => Color.Silver,
};
}
@@ -0,0 +1,553 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Harness.ConsoleReactiveFramework;
using Harness.Shared.Console.Components;
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>
/// Event arguments for the <see cref="HarnessAppComponent.InputSubmitted"/> event.
/// </summary>
public sealed class InputSubmittedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="InputSubmittedEventArgs"/> class.
/// </summary>
/// <param name="text">The submitted text.</param>
/// <param name="mode">The bottom panel mode in which the input was submitted.</param>
public InputSubmittedEventArgs(string text, BottomPanelMode mode)
{
this.Text = text;
this.Mode = mode;
}
/// <summary>Gets the submitted text.</summary>
public string Text { get; }
/// <summary>Gets the bottom panel mode in which the input was submitted.</summary>
public BottomPanelMode Mode { get; }
}
/// <summary>
/// Props for <see cref="HarnessAppComponent"/>.
/// </summary>
public record HarnessAppComponentProps : ConsoleReactiveProps
{
/// <summary>Gets or sets the list selection choices (for ListSelection mode).</summary>
public IReadOnlyList<string> Items { get; set; } = Array.Empty<string>();
/// <summary>Gets or sets the scroll items (output entries) to render in the scroll panel.</summary>
public IReadOnlyList<object> ScrollItems { get; set; } = [];
/// <summary>Gets or sets the bottom panel mode.</summary>
public BottomPanelMode Mode { get; set; } = BottomPanelMode.TextInput;
/// <summary>Gets or sets the prompt string for text input mode.</summary>
public string Prompt { get; set; } = "You: ";
/// <summary>Gets or sets the placeholder text shown when the input is empty.</summary>
public string Placeholder { get; set; } = "";
/// <summary>Gets or sets the highlight color for the active list item.</summary>
public ConsoleColor ListHighlightColor { get; set; } = ConsoleColor.Cyan;
/// <summary>Gets or sets the placeholder text for the custom text input option in the list.</summary>
public string? ListCustomTextPlaceholder { get; set; }
/// <summary>Gets or sets the foreground color for the rule borders and mode label.</summary>
public ConsoleColor? ModeColor { get; set; }
/// <summary>Gets or sets the current mode name displayed below the bottom rule (e.g. "plan").</summary>
public string? ModeText { get; set; }
/// <summary>Gets or sets the help text displayed below the bottom rule (available commands).</summary>
public string? HelpText { get; set; }
/// <summary>Gets or sets the title text displayed above the list selection (for interactive prompts).</summary>
public string? ListTitle { get; set; }
/// <summary>Gets or sets a value indicating whether input is enabled during streaming.</summary>
public bool InputEnabled { get; set; }
/// <summary>Gets or sets the prompt to show during streaming when input is disabled.</summary>
public string StreamingPrompt { get; set; } = "(agent is running...)";
/// <summary>Gets or sets a value indicating whether the agent status spinner is visible.</summary>
public bool ShowSpinner { get; set; }
/// <summary>Gets or sets the formatted token usage text to display in the status bar.</summary>
public string? UsageText { get; set; }
/// <summary>Gets or sets the queued input items to display above the rule.</summary>
public IReadOnlyList<object> QueuedItems { get; set; } = [];
}
/// <summary>
/// Internal state for <see cref="HarnessAppComponent"/>.
/// </summary>
public record HarnessAppComponentState : ConsoleReactiveState
{
/// <summary>Gets the selected index in list selection mode.</summary>
public int SelectedIndex { get; init; }
/// <summary>Gets the current input text being typed.</summary>
public string InputText { get; init; } = "";
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
public string ListInputText { get; init; } = "";
/// <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; }
}
/// <summary>
/// The main application component for the Harness console. Manages the scroll region
/// and bottom panel (text input, list selection, or streaming indicator), and emits
/// an <see cref="InputSubmitted"/> event when the user submits text in any mode.
/// </summary>
public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentProps, HarnessAppComponentState>, IDisposable
{
private readonly TopBottomRule _rule = new();
private readonly ListSelection _listSelection = new();
private readonly TextInput _textInput = new();
private readonly TextScrollPanel _textScrollPanel;
private readonly TextPanel _textPanel;
private readonly TextPanel _queuedPanel;
private readonly AgentStatus _agentStatus = new();
private readonly AgentModeAndHelp _modeAndHelp = new();
private readonly Func<object, string> _renderItem;
private bool _resizedSinceLastRender;
private bool _deactivated;
/// <summary>
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
/// </summary>
/// <param name="renderScrollItem">A delegate that renders a single output entry and returns the text to display.</param>
public HarnessAppComponent(Func<object, string> renderScrollItem)
{
this._renderItem = renderScrollItem;
this._textScrollPanel = new TextScrollPanel(renderScrollItem);
this._textPanel = new TextPanel(renderScrollItem);
this._queuedPanel = new TextPanel(renderScrollItem);
this.State = new HarnessAppComponentState
{
ConsoleWidth = System.Console.WindowWidth,
ConsoleHeight = System.Console.WindowHeight,
};
KeyEventListener.Instance.KeyPressed += this.OnKeyPressed;
ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized;
}
/// <summary>
/// Gets the 1-based row number of the last row in the output scroll region.
/// </summary>
public int ScrollRegionBottom { get; private set; }
/// <summary>
/// Occurs when the user submits input via Enter, in any mode (text input, list selection,
/// or streaming injection). Consumers inspect <see cref="InputSubmittedEventArgs.Mode"/>
/// to decide how to handle the submission.
/// </summary>
public event EventHandler<InputSubmittedEventArgs>? InputSubmitted;
/// <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;
System.Console.Write(AnsiEscapes.ResetScrollRegion);
System.Console.Write(AnsiEscapes.MoveCursor(System.Console.WindowHeight, 1));
System.Console.WriteLine();
}
/// <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();
}
}
private void OnKeyPressed(object? sender, KeyPressEventArgs e)
{
if (this.Props!.Mode == BottomPanelMode.TextInput)
{
this.HandleTextInputKey(e);
}
else if (this.Props.Mode == BottomPanelMode.ListSelection)
{
this.HandleListSelectionKey(e);
}
else if (this.Props.Mode == BottomPanelMode.Streaming && this.Props.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.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(text, BottomPanelMode.TextInput));
}
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
{
if (this.State!.InputText.Length > 0)
{
this.SetState(this.State with { InputText = this.State.InputText[..^1] });
}
}
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
{
this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar });
}
}
private void HandleListSelectionKey(KeyPressEventArgs e)
{
int maxIndex = this.Props!.Items.Count - 1;
if (this.Props.ListCustomTextPlaceholder != null)
{
maxIndex = this.Props.Items.Count;
}
bool isOnCustomTextOption = this.Props.ListCustomTextPlaceholder != null
&& this.State!.SelectedIndex == this.Props.Items.Count;
if (e.KeyInfo.Key == ConsoleKey.UpArrow)
{
this.SetState(this.State! with { SelectedIndex = Math.Max(0, this.State.SelectedIndex - 1) });
}
else if (e.KeyInfo.Key == ConsoleKey.DownArrow)
{
this.SetState(this.State! with { SelectedIndex = Math.Min(maxIndex, this.State.SelectedIndex + 1) });
}
else if (e.KeyInfo.Key == ConsoleKey.Enter)
{
string result = isOnCustomTextOption
? this.State!.ListInputText
: this.Props.Items[this.State!.SelectedIndex];
this.SetState(this.State with { ListInputText = "", SelectedIndex = 0 });
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(result, BottomPanelMode.ListSelection));
}
else if (isOnCustomTextOption)
{
if (e.KeyInfo.Key == ConsoleKey.Backspace)
{
if (this.State!.ListInputText.Length > 0)
{
this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] });
}
}
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
{
this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar });
}
}
}
private void HandleStreamingInputKey(KeyPressEventArgs e)
{
// During streaming with input enabled, capture text for message injection
if (e.KeyInfo.Key == ConsoleKey.Enter)
{
string text = this.State!.InputText;
if (string.IsNullOrWhiteSpace(text))
{
return;
}
this.SetState(this.State with { InputText = "" });
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(text, BottomPanelMode.Streaming));
}
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 OnConsoleResized(object? sender, ConsoleResizeEventArgs e)
{
this._resizedSinceLastRender = true;
this.SetState(this.State! with
{
ConsoleWidth = e.NewWidth,
ConsoleHeight = e.NewHeight,
});
}
/// <inheritdoc />
public override void RenderCore(HarnessAppComponentProps props, HarnessAppComponentState state)
{
// Determine the text panel height for the last scroll item
IReadOnlyList<object> lastItems = props.ScrollItems.Count > 0
? [props.ScrollItems[^1]]
: [];
int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem);
if (textPanelHeight > 0)
{
textPanelHeight++; // Extra line for spacing between text panel and rule
}
// Calculate queued items panel height
int queuedPanelHeight = TextPanel.CalculateHeight(props.QueuedItems, this._renderItem);
// Build the bottom panel child based on mode
ConsoleReactiveComponent bottomChild;
int bottomChildHeight;
if (props.Mode == BottomPanelMode.ListSelection)
{
var listProps = new ListSelectionProps
{
Title = props.ListTitle,
Items = props.Items,
SelectedIndex = state.SelectedIndex,
HighlightColor = props.ListHighlightColor,
CustomTextPlaceholder = props.ListCustomTextPlaceholder,
CustomText = state.ListInputText,
};
bottomChildHeight = ListSelection.CalculateHeight(listProps);
this._listSelection.Height = bottomChildHeight;
this._listSelection.Props = listProps;
bottomChild = this._listSelection;
}
else if (props.Mode == BottomPanelMode.Streaming)
{
TextInputProps textInputProps;
if (props.InputEnabled)
{
textInputProps = new TextInputProps
{
Prompt = props.Prompt,
Text = state.InputText,
Placeholder = props.Placeholder,
};
}
else
{
textInputProps = new TextInputProps
{
Prompt = props.Prompt,
Text = "",
Placeholder = props.StreamingPrompt,
};
}
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
this._textInput.Width = state.ConsoleWidth;
this._textInput.Height = bottomChildHeight;
this._textInput.Props = textInputProps;
bottomChild = this._textInput;
}
else
{
var textInputProps = new TextInputProps
{
Prompt = props.Prompt,
Text = state.InputText,
Placeholder = props.Placeholder,
};
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
this._textInput.Width = state.ConsoleWidth;
this._textInput.Height = bottomChildHeight;
this._textInput.Props = textInputProps;
bottomChild = this._textInput;
}
var ruleProps = new TopBottomRuleProps
{
Width = state.ConsoleWidth,
Color = props.ModeColor,
Children = [bottomChild],
};
// Calculate the agent status height
var agentStatusProps = new AgentStatusProps
{
ShowSpinner = props.ShowSpinner,
UsageText = props.UsageText,
};
int agentStatusHeight = AgentStatus.CalculateHeight(agentStatusProps);
// Calculate the mode-and-help height
var modeAndHelpProps = new AgentModeAndHelpProps
{
Mode = props.ModeText,
ModeColor = props.ModeColor,
HelpText = props.HelpText,
};
int modeAndHelpHeight = AgentModeAndHelp.CalculateHeight(modeAndHelpProps);
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
int scrollBottom = Math.Max(1, state.ConsoleHeight - ruleHeight - textPanelHeight - agentStatusHeight - queuedPanelHeight - modeAndHelpHeight);
// If scroll region changed or a clear is needed, reset everything
if (this._resizedSinceLastRender || (this.ScrollRegionBottom != 0 && scrollBottom != this.ScrollRegionBottom))
{
System.Console.Write(AnsiEscapes.EraseEntireScreen);
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
this._textScrollPanel.Reset();
this._resizedSinceLastRender = false;
}
this.ScrollRegionBottom = scrollBottom;
System.Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom));
// Render text scroll panel in the scroll area (all items except the last)
IReadOnlyList<object> scrollItems = props.ScrollItems.Count > 1
? props.ScrollItems.Take(props.ScrollItems.Count - 1).ToList()
: [];
this._textScrollPanel.X = 1;
this._textScrollPanel.Y = 1;
this._textScrollPanel.Width = state.ConsoleWidth;
this._textScrollPanel.Height = scrollBottom;
this._textScrollPanel.Props = new TextScrollPanelProps
{
Items = scrollItems,
};
this._textScrollPanel.Render();
// Render the text panel for the last (dynamic) item just below the scroll region
this._textPanel.X = 1;
this._textPanel.Y = scrollBottom + 1;
this._textPanel.Width = state.ConsoleWidth;
this._textPanel.Height = textPanelHeight;
this._textPanel.Props = new TextPanelProps
{
Items = lastItems,
};
this._textPanel.Render();
// Render queued input items between text panel and agent status
int queuedPanelY = scrollBottom + textPanelHeight + 1;
this._queuedPanel.X = 1;
this._queuedPanel.Y = queuedPanelY;
this._queuedPanel.Width = state.ConsoleWidth;
this._queuedPanel.Height = queuedPanelHeight;
this._queuedPanel.Props = new TextPanelProps
{
Items = props.QueuedItems,
};
this._queuedPanel.Render();
// Render the agent status line between queued items and rule
int agentStatusY = queuedPanelY + queuedPanelHeight;
this._agentStatus.X = 1;
this._agentStatus.Y = agentStatusY;
this._agentStatus.Width = state.ConsoleWidth;
this._agentStatus.Height = agentStatusHeight;
this._agentStatus.Props = agentStatusProps;
this._agentStatus.Render();
// Render the bottom rule + child below the agent status
this._rule.X = 1;
this._rule.Y = agentStatusY + agentStatusHeight;
this._rule.Props = ruleProps;
this._rule.Render();
// Render the mode-and-help line below the bottom rule
int modeAndHelpY = this._rule.Y + ruleHeight;
this._modeAndHelp.X = 1;
this._modeAndHelp.Y = modeAndHelpY;
this._modeAndHelp.Width = state.ConsoleWidth;
this._modeAndHelp.Height = modeAndHelpHeight;
this._modeAndHelp.Props = modeAndHelpProps;
this._modeAndHelp.Render();
// Position cursor for natural typing appearance
this.PositionCursor(props, state);
}
private void PositionCursor(HarnessAppComponentProps props, HarnessAppComponentState state)
{
if (props.Mode == BottomPanelMode.TextInput
|| (props.Mode == BottomPanelMode.Streaming && props.InputEnabled))
{
int promptLength = props.Prompt.Length;
int textWidth = state.ConsoleWidth - promptLength;
int textLength = state.InputText.Length;
int textInputY = this._rule.Y + 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 (props.Mode == BottomPanelMode.ListSelection
&& props.ListCustomTextPlaceholder != null
&& state.SelectedIndex == props.Items.Count)
{
int titleLines = props.ListTitle?.Split('\n').Length ?? 0;
int customOptionY = this._rule.Y + 1 + titleLines + props.Items.Count;
int cursorCol = 2 + state.ListInputText.Length + 1;
System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
}
}
}
@@ -34,42 +34,55 @@ public static class HarnessConsole
nameof(options));
}
System.Console.WriteLine($"=== {title} ===");
System.Console.WriteLine(userPrompt);
var todoProvider = agent.GetService<TodoProvider>();
var modeProvider = agent.GetService<AgentModeProvider>();
var messageInjector = agent.GetService<MessageInjectingChatClient>();
// Build command handlers.
var commandHandlers = new List<ICommandHandler>
var commandHandlers = new List<CommandHandler>
{
new TodoCommandHandler(todoProvider),
new ModeCommandHandler(modeProvider, options.ModeColors),
};
var commands = commandHandlers
AgentSession session = await agent.CreateSessionAsync();
using var ux = new HarnessUXContainer(
placeholder: userPrompt,
initialMode: modeProvider?.GetMode(session),
inputEnabled: messageInjector is not null,
modeColors: options.ModeColors);
// Streaming-mode submissions are enqueued for injection; the queued display
// is then refreshed from the injector's current pending list.
ux.StreamingInputReceived += (sender, e) =>
{
if (messageInjector is null)
{
return;
}
messageInjector.EnqueueMessages(session, [new ChatMessage(ChatRole.User, e.Text)]);
ux.ShowQueuedMessages(messageInjector.GetPendingMessages(session));
};
var commandHelp = commandHandlers
.Select(h => h.GetHelpText())
.Where(t => t is not null)
.Append("exit (quit)");
.Append("exit (quit)")!;
System.Console.WriteLine($"Commands: {string.Join(", ", commands)}");
System.Console.WriteLine();
ux.Initialize(title, commandHelp!, messageInjector is not null);
AgentSession session = await agent.CreateSessionAsync();
using var writer = new ConsoleWriter(options.ModeColors);
writer.CurrentMode = modeProvider?.GetMode(session);
string userInput = await ux.WaitForInputAsync();
string prompt = BuildUserPrompt(modeProvider, session);
string? userInput = await writer.ReadLineAsync(prompt);
// Main loop to run a command or agent and get the next user command/input.
while (!string.IsNullOrWhiteSpace(userInput) && !userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
ux.WriteUserInputEcho(userInput);
// Check command handlers first — first one to handle wins.
bool handled = false;
foreach (var handler in commandHandlers)
{
if (await handler.TryHandleAsync(userInput, session).ConfigureAwait(false))
if (await handler.TryHandleAsync(userInput, session, ux).ConfigureAwait(false))
{
handled = true;
break;
@@ -78,14 +91,14 @@ public static class HarnessConsole
if (!handled)
{
await RunAgentTurnAsync(agent, session, modeProvider, options, writer, userInput);
await RunAgentTurnAsync(agent, session, modeProvider, messageInjector, options, ux, userInput);
}
writer.CurrentMode = modeProvider?.GetMode(session);
prompt = BuildUserPrompt(modeProvider, session);
userInput = await writer.ReadLineAsync(prompt);
ux.CurrentMode = modeProvider?.GetMode(session);
userInput = await ux.WaitForInputAsync();
}
ux.Deactivate();
System.Console.ResetColor();
System.Console.WriteLine("Goodbye!");
}
@@ -99,27 +112,27 @@ public static class HarnessConsole
AIAgent agent,
AgentSession session,
AgentModeProvider? modeProvider,
MessageInjectingChatClient? messageInjector,
HarnessConsoleOptions options,
ConsoleWriter writer,
HarnessUXContainer ux,
string userInput)
{
IList<ChatMessage>? nextMessages = [new ChatMessage(ChatRole.User, userInput)];
IReadOnlyList<ChatMessage> lastPendingMessages = messageInjector?.GetPendingMessages(session) ?? [];
while (nextMessages is not null)
{
// Build observers for this invocation (may change between iterations due to mode changes).
var observers = CreateObservers(options, modeProvider, session);
// Build run options — observers may inject ResponseFormat, etc.
var runOptions = new AgentRunOptions();
foreach (var observer in observers)
{
observer.ConfigureRunOptions(runOptions);
}
// Stream the response, fanning out to all observers.
writer.CurrentMode = modeProvider?.GetMode(session);
writer.WriteResponseHeader();
ux.CurrentMode = modeProvider?.GetMode(session);
ux.BeginStreaming();
ux.BeginStreamingOutput();
try
{
@@ -129,9 +142,9 @@ public static class HarnessConsole
if (modeProvider is not null)
{
string currentMode = modeProvider.GetMode(session);
if (currentMode != writer.CurrentMode)
if (currentMode != ux.CurrentMode)
{
writer.CurrentMode = currentMode;
ux.CurrentMode = currentMode;
}
}
@@ -139,7 +152,7 @@ public static class HarnessConsole
{
foreach (var observer in observers)
{
await observer.OnContentAsync(writer, content);
await observer.OnContentAsync(ux, content);
}
}
@@ -147,22 +160,32 @@ public static class HarnessConsole
{
foreach (var observer in observers)
{
await observer.OnTextAsync(writer, update.Text);
await observer.OnTextAsync(ux, update.Text);
}
}
SyncQueuedMessageDisplay(messageInjector, session, ux, ref lastPendingMessages);
}
}
catch (Exception ex)
{
await writer.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red);
await ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red);
}
// Collect messages from all observers.
// Final sync after streaming — messages may have been consumed during the last iteration.
SyncQueuedMessageDisplay(messageInjector, session, ux, ref lastPendingMessages);
// Stop spinner before observer completions (which may prompt for input).
ux.StopSpinner();
// Close the streaming output to provide visual separation from observer output.
await ux.EndStreamingOutputAsync();
var combinedMessages = new List<ChatMessage>();
bool hasObserverMessages = false;
foreach (var observer in observers)
{
var messages = await observer.OnStreamCompleteAsync(writer, agent, session, options);
var messages = await observer.OnStreamCompleteAsync(ux, agent, session, options);
if (messages is { Count: > 0 })
{
combinedMessages.AddRange(messages);
@@ -170,11 +193,44 @@ public static class HarnessConsole
}
}
await writer.WriteStreamFooterAsync(hasFollowUpMessages: hasObserverMessages);
await ux.WriteNoTextWarningAsync(hasFollowUpMessages: hasObserverMessages);
ux.EndStreaming();
nextMessages = combinedMessages.Count > 0 ? combinedMessages : null;
}
}
/// <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 static void SyncQueuedMessageDisplay(
MessageInjectingChatClient? messageInjector,
AgentSession session,
HarnessUXContainer ux,
ref IReadOnlyList<ChatMessage> lastPendingMessages)
{
if (messageInjector is null)
{
return;
}
var pending = messageInjector.GetPendingMessages(session);
// If previously pending messages exceed current pending count, some were consumed.
int consumedCount = lastPendingMessages.Count - pending.Count;
for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++)
{
string text = lastPendingMessages[i].Text ?? string.Empty;
ux.WriteUserInputEcho(text);
}
lastPendingMessages = pending;
ux.ShowQueuedMessages(pending);
}
private static List<ConsoleObserver> CreateObservers(HarnessConsoleOptions options, AgentModeProvider? modeProvider, AgentSession session)
{
var observers = new List<ConsoleObserver>
@@ -186,7 +242,6 @@ public static class HarnessConsole
new UsageDisplayObserver(options.MaxContextWindowTokens, options.MaxOutputTokens),
};
// Add the appropriate output observer based on the current mode.
if (options.EnablePlanningUx
&& modeProvider is not null
&& string.Equals(modeProvider.GetMode(session), options.PlanningModeName, StringComparison.OrdinalIgnoreCase))
@@ -200,15 +255,4 @@ public static class HarnessConsole
return observers;
}
private static string BuildUserPrompt(AgentModeProvider? modeProvider, AgentSession session)
{
if (modeProvider is not null)
{
string mode = modeProvider.GetMode(session);
return $"[{mode}] You: ";
}
return "You: ";
}
}
@@ -0,0 +1,478 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Event arguments raised when the user submits text while the bottom panel is in
/// streaming mode (i.e. an agent turn is in progress).
/// </summary>
public sealed class StreamingInputReceivedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="StreamingInputReceivedEventArgs"/> class.
/// </summary>
/// <param name="text">The submitted text.</param>
public StreamingInputReceivedEventArgs(string text)
{
this.Text = text;
}
/// <summary>
/// Gets the submitted text.
/// </summary>
public string Text { get; }
}
/// <summary>
/// Façade over the harness UI: owns the <see cref="HarnessAppComponent"/>, manages
/// its props, dispatches input submissions, and provides the high-level read/write
/// operations used by observers, command handlers, and the harness loop.
/// </summary>
/// <remarks>
/// All callers interact with the UI exclusively through this class. The underlying
/// <see cref="HarnessAppComponent"/> and its props are an implementation detail and
/// must not be exposed.
/// </remarks>
public sealed class HarnessUXContainer : IDisposable
{
/// <summary>
/// The prompt displayed in the bottom-panel input area.
/// </summary>
private const string UserPrompt = "> ";
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
private readonly List<object> _outputItems = [];
private readonly HarnessAppComponent _appComponent;
private readonly object _outputLock = new();
private TaskCompletionSource<string>? _pendingInputTcs;
private OutputEntryType? _lastEntryType;
private bool _hasReceivedAnyText;
private OutputEntry? _currentStreamingEntry;
private string? _currentMode;
/// <summary>
/// Initializes a new instance of the <see cref="HarnessUXContainer"/> 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="modeColors">Optional mapping of mode names to console colors.</param>
public HarnessUXContainer(
string placeholder,
string? initialMode,
bool inputEnabled,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
this._modeColors = modeColors;
this._currentMode = initialMode;
this._appComponent = new HarnessAppComponent(RenderOutputEntry)
{
Props = new HarnessAppComponentProps
{
ScrollItems = this._outputItems,
Mode = BottomPanelMode.TextInput,
Prompt = UserPrompt,
Placeholder = placeholder,
ModeColor = ModeColors.Get(initialMode, modeColors),
ModeText = initialMode,
InputEnabled = inputEnabled,
},
};
this._appComponent.InputSubmitted += this.OnInputSubmitted;
}
/// <summary>
/// Raised when the user submits text while the bottom panel is in streaming mode.
/// Subscribers typically enqueue the text into a message-injecting chat client.
/// </summary>
public event EventHandler<StreamingInputReceivedEventArgs>? StreamingInputReceived;
/// <summary>
/// Gets or sets the current agent mode (e.g. "plan", "execute"). Updating this
/// also refreshes the rule colour and bottom-panel prompt to match the new mode.
/// </summary>
public string? CurrentMode
{
get => this._currentMode;
set
{
this._currentMode = value;
this._appComponent.Props = this._appComponent.Props! with
{
ModeColor = ModeColors.Get(value, this._modeColors),
ModeText = value,
};
this._appComponent.Render();
}
}
/// <summary>
/// Performs the initial screen clear, sets the help text in the mode-and-help bar,
/// and adds the title to the output area.
/// </summary>
/// <param name="title">The title displayed in the console header.</param>
/// <param name="commandHelpTexts">The command help strings displayed in the mode-and-help bar.</param>
/// <param name="messageInjectionActive">Whether streaming-time message injection is enabled.</param>
public void Initialize(string title, IEnumerable<string> commandHelpTexts, bool messageInjectionActive)
{
// Set the help text on the mode-and-help bar (persists below the rule).
this._appComponent.Props = this._appComponent.Props! with
{
HelpText = string.Join(", ", commandHelpTexts),
ModeText = this._currentMode,
};
System.Console.Write(AnsiEscapes.EraseEntireScreen);
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
this._appComponent.Render();
this.AppendOutputEntries(
new OutputEntry(OutputEntryType.InfoLine, $"=== {title} ===\n", ConsoleColor.White),
new OutputEntry(OutputEntryType.InfoLine, "\n"));
}
/// <summary>
/// Restores the cursor and exits the alternate screen, ending the interactive UI.
/// </summary>
public void Deactivate() => this._appComponent.Deactivate();
/// <summary>
/// Switches the bottom panel to streaming mode and starts the spinner.
/// </summary>
public void BeginStreaming()
{
this._appComponent.Props = this._appComponent.Props! with
{
Mode = BottomPanelMode.Streaming,
ShowSpinner = true,
};
this._appComponent.Render();
}
/// <summary>
/// Stops the spinner without leaving streaming mode. Use between the end of the
/// stream and any observer-driven prompts (e.g. tool approvals).
/// </summary>
public void StopSpinner()
{
this._appComponent.Props = this._appComponent.Props! with { ShowSpinner = false };
this._appComponent.Render();
}
/// <summary>
/// Switches the bottom panel back to text-input mode and stops the spinner.
/// </summary>
public void EndStreaming()
{
this._appComponent.Props = this._appComponent.Props! with
{
Mode = BottomPanelMode.TextInput,
ShowSpinner = false,
};
this._appComponent.Render();
}
/// <summary>
/// Resets per-turn streaming bookkeeping in preparation for a new agent turn.
/// </summary>
public void BeginStreamingOutput()
{
this._hasReceivedAnyText = false;
this._currentStreamingEntry = null;
}
/// <summary>
/// Sets the formatted usage text shown on the agent status bar.
/// </summary>
public void SetUsageText(string usageText)
{
this._appComponent.Props = this._appComponent.Props! with { UsageText = usageText };
this._appComponent.Render();
}
/// <summary>
/// Clears the usage text from the agent status bar.
/// </summary>
public void ClearUsageText()
{
this._appComponent.Props = this._appComponent.Props! with { UsageText = null };
this._appComponent.Render();
}
/// <summary>
/// Replaces the queued-message display with one entry per pending message.
/// </summary>
public void ShowQueuedMessages(IReadOnlyList<ChatMessage> pending)
{
var newQueued = new List<object>(pending.Count);
foreach (var msg in pending)
{
string text = msg.Text ?? string.Empty;
newQueued.Add(new OutputEntry(OutputEntryType.UserInput, $" 💬 {text}\n", ConsoleColor.DarkGray));
}
this._appComponent.Props = this._appComponent.Props! with { QueuedItems = newQueued };
this._appComponent.Render();
}
/// <summary>
/// Echoes a submitted user input as a regular user-input entry in the output area,
/// using the current mode-aware prompt prefix.
/// </summary>
/// <param name="text">The user-entered text.</param>
public void WriteUserInputEcho(string text)
{
this.AppendOutputEntries(new OutputEntry(
OutputEntryType.UserInput,
$"\nYou: {text}\n",
ConsoleColor.Green));
}
/// <summary>
/// Writes informational output as an output entry, without a trailing newline.
/// </summary>
public Task WriteInfoAsync(string text, ConsoleColor? color = null) =>
this.WriteInfoCoreAsync(text, color, newLine: false);
/// <summary>
/// Writes informational output as an output entry, followed by a newline.
/// </summary>
public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) =>
this.WriteInfoCoreAsync(text, color, newLine: true);
private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
{
// Add a blank line separator when transitioning from streaming text or user input.
string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter
? "\n\n "
: " ";
string fullText = newLine ? prefix + text + "\n" : prefix + text;
this.AppendOutputEntries(new OutputEntry(
OutputEntryType.InfoLine,
fullText,
color ?? ModeColors.Get(this.CurrentMode, this._modeColors)));
return Task.CompletedTask;
}
/// <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>
public Task WriteTextAsync(string text, ConsoleColor? color = null)
{
lock (this._outputLock)
{
this._lastEntryType = OutputEntryType.StreamingText;
this._hasReceivedAnyText = true;
ConsoleColor effectiveColor = color ?? ModeColors.Get(this.CurrentMode, this._modeColors);
if (this._currentStreamingEntry is not null)
{
this._currentStreamingEntry = this._currentStreamingEntry with
{
Text = this._currentStreamingEntry.Text + text,
};
this._outputItems[^1] = this._currentStreamingEntry;
}
else
{
const string Prefix = "\n";
this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor);
this._outputItems.Add(this._currentStreamingEntry);
}
this._appComponent.Props = this._appComponent.Props! with
{
ScrollItems = new List<object>(this._outputItems),
};
}
this._appComponent.Render();
return Task.CompletedTask;
}
/// <summary>
/// Writes a blank-line separator to visually close the streaming output section.
/// Call before observer completions so their output is visually separated.
/// </summary>
public Task EndStreamingOutputAsync()
{
lock (this._outputLock)
{
this._outputItems.Add(new OutputEntry(OutputEntryType.StreamFooter, "\n"));
this._currentStreamingEntry = null;
this._lastEntryType = OutputEntryType.StreamFooter;
this._appComponent.Props = this._appComponent.Props! with
{
ScrollItems = new List<object>(this._outputItems),
};
}
this._appComponent.Render();
return Task.CompletedTask;
}
/// <summary>
/// Shows a "(no text response from agent)" warning if no text was received
/// and no observer produced follow-up messages. Call after observer completions.
/// </summary>
/// <param name="hasFollowUpMessages">Whether any observer produced follow-up messages.</param>
public Task WriteNoTextWarningAsync(bool hasFollowUpMessages)
{
if (!this._hasReceivedAnyText && !hasFollowUpMessages)
{
this.AppendOutputEntries(new OutputEntry(
OutputEntryType.StreamFooter,
" (no text response from agent)\n",
ConsoleColor.DarkYellow));
}
return Task.CompletedTask;
}
/// <summary>
/// Reads a line of input from the user. If <paramref name="prompt"/> is supplied
/// it is rendered as an info line above the input row before reading.
/// </summary>
public async Task<string?> ReadLineAsync(string? prompt = null, ConsoleColor? promptColor = null)
{
if (prompt is not null)
{
ConsoleColor ruleColor = ModeColors.Get(this.CurrentMode, this._modeColors);
this.AppendOutputEntries(
new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor),
new OutputEntry(OutputEntryType.InfoLine, $" {prompt}", promptColor ?? ruleColor));
}
this._appComponent.Props = this._appComponent.Props! with { Mode = BottomPanelMode.TextInput };
this._appComponent.Render();
string input = await this.WaitForInputAsync();
this.AppendOutputEntries(new OutputEntry(
OutputEntryType.UserInput,
$"\nYou: {input}\n",
ConsoleColor.Green));
return input;
}
/// <summary>
/// Presents a selection prompt with the given choices and waits for the user's
/// selection. The title is displayed above the list in the bottom panel. After
/// selection the bottom panel is restored to text-input mode and both the question
/// and selection are echoed in the output area.
/// </summary>
public async Task<string> ReadSelectionAsync(string title, IList<string> choices)
{
this._appComponent.Props = this._appComponent.Props! with
{
Mode = BottomPanelMode.ListSelection,
Items = choices.ToList(),
ListTitle = title,
ListCustomTextPlaceholder = "✏️ Type a custom response...",
};
this._appComponent.Render();
string selection = await this.WaitForInputAsync();
this._appComponent.Props = this._appComponent.Props with { Mode = BottomPanelMode.TextInput };
this.AppendOutputEntries(
new OutputEntry(
OutputEntryType.InfoLine,
$"\n {title}\n",
ModeColors.Get(this.CurrentMode, this._modeColors)),
new OutputEntry(
OutputEntryType.UserInput,
$"\nYou: {selection}\n",
ConsoleColor.Green));
return selection;
}
/// <summary>
/// Awaits the next non-streaming user input submission.
/// </summary>
public Task<string> WaitForInputAsync()
{
this._pendingInputTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
return this._pendingInputTcs.Task;
}
private void OnInputSubmitted(object? sender, InputSubmittedEventArgs e)
{
if (e.Mode == BottomPanelMode.Streaming)
{
this.StreamingInputReceived?.Invoke(this, new StreamingInputReceivedEventArgs(e.Text));
}
else
{
var waiter = this._pendingInputTcs;
this._pendingInputTcs = null;
waiter?.TrySetResult(e.Text);
}
}
/// <inheritdoc/>
public void Dispose()
{
this._appComponent.InputSubmitted -= this.OnInputSubmitted;
this._appComponent.Deactivate();
this._appComponent.Dispose();
}
/// <summary>
/// Renders an <see cref="OutputEntry"/> to a string with ANSI color codes.
/// Used as the render delegate for the <see cref="HarnessAppComponent"/>.
/// </summary>
private static string RenderOutputEntry(object item)
{
if (item is not OutputEntry entry)
{
return item?.ToString() ?? string.Empty;
}
if (entry.Color.HasValue)
{
return $"{AnsiEscapes.SetForegroundColor(entry.Color.Value)}{entry.Text}{AnsiEscapes.ResetAttributes}";
}
return entry.Text;
}
/// <summary>
/// Appends one or more output entries to the output list under lock,
/// updates <see cref="_lastEntryType"/> to the last entry's type, and renders.
/// </summary>
private void AppendOutputEntries(params OutputEntry[] entries)
{
lock (this._outputLock)
{
foreach (OutputEntry entry in entries)
{
this._outputItems.Add(entry);
}
if (entries.Length > 0)
{
this._lastEntryType = entries[^1].Type;
}
this._appComponent.Props = this._appComponent.Props! with
{
ScrollItems = new List<object>(this._outputItems),
};
}
this._appComponent.Render();
}
}
@@ -7,12 +7,10 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console" />
</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,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;
}
}
@@ -25,28 +25,28 @@ public abstract class ConsoleObserver
/// <summary>
/// Called for each <see cref="AIContent"/> item in the response stream.
/// </summary>
/// <param name="writer">The console writer for rendering output.</param>
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
/// <param name="content">The content item from the stream.</param>
public virtual Task OnContentAsync(ConsoleWriter writer, AIContent content) => Task.CompletedTask;
public virtual Task OnContentAsync(HarnessUXContainer ux, AIContent content) => Task.CompletedTask;
/// <summary>
/// Called for each text update in the response stream.
/// </summary>
/// <param name="writer">The console writer for rendering output.</param>
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
/// <param name="text">The text from the update.</param>
public virtual Task OnTextAsync(ConsoleWriter writer, string text) => Task.CompletedTask;
public virtual Task OnTextAsync(HarnessUXContainer ux, string text) => Task.CompletedTask;
/// <summary>
/// Called after the response stream completes. Returns messages to include in the
/// next agent invocation, or <see langword="null"/> if no re-invocation is needed.
/// </summary>
/// <param name="writer">The console writer for rendering output.</param>
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
/// <param name="options">The console options.</param>
/// <returns>Messages to send to the agent, or <see langword="null"/> if no action is needed.</returns>
public virtual Task<IList<ChatMessage>?> OnStreamCompleteAsync(
ConsoleWriter writer,
HarnessUXContainer ux,
AIAgent agent,
AgentSession session,
HarnessConsoleOptions options) => Task.FromResult<IList<ChatMessage>?>(null);
@@ -10,7 +10,7 @@ namespace Harness.Shared.Console.Observers;
internal sealed class ErrorDisplayObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
{
if (content is ErrorContent errorContent)
{
@@ -25,7 +25,7 @@ internal sealed class ErrorDisplayObserver : ConsoleObserver
errorText += $" details: {errorContent.Details}";
}
await writer.WriteInfoLineAsync(errorText, ConsoleColor.Red);
await ux.WriteInfoLineAsync(errorText, ConsoleColor.Red);
}
}
}
@@ -33,7 +33,7 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
}
/// <inheritdoc/>
public override Task OnTextAsync(ConsoleWriter writer, string text)
public override Task OnTextAsync(HarnessUXContainer ux, string text)
{
// Collect text silently instead of displaying it.
this._textCollector.Append(text);
@@ -42,7 +42,7 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
/// <inheritdoc/>
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
ConsoleWriter writer,
HarnessUXContainer ux,
AIAgent agent,
AgentSession session,
HarnessConsoleOptions options)
@@ -64,21 +64,21 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
}
catch (JsonException ex)
{
await writer.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
await writer.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
return null;
}
if (planningResponse is null)
{
await writer.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
return null;
}
// Render based on response type.
if (planningResponse.Type == PlanningResponseType.Clarification)
{
return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(writer, planningResponse));
return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(ux, planningResponse));
}
if (planningResponse.Type == PlanningResponseType.Approval)
@@ -86,48 +86,45 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
var question = planningResponse.Questions.FirstOrDefault();
if (question is null)
{
await writer.WriteInfoLineAsync("(approval response had no content)", ConsoleColor.DarkYellow);
await ux.WriteInfoLineAsync("(approval response had no content)", ConsoleColor.DarkYellow);
return null;
}
string response = await this.RenderApprovalAndCollectResponseAsync(writer, question, options);
string response = await this.RenderApprovalAndCollectResponseAsync(ux, question, options);
if (response == "Approved")
{
this._modeProvider.SetMode(session, options.ExecutionModeName!);
await writer.WriteInfoLineAsync($"✅ Switched to {options.ExecutionModeName} mode.",
ConsoleWriter.GetModeColor(options.ExecutionModeName, options.ModeColors));
await ux.WriteInfoLineAsync($"✅ Switched to {options.ExecutionModeName} mode.",
ModeColors.Get(options.ExecutionModeName, options.ModeColors));
}
return AsUserMessages(response);
}
await writer.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
return null;
}
private static IList<ChatMessage>? AsUserMessages(string? text) =>
text is not null ? [new ChatMessage(ChatRole.User, text)] : null;
private async Task<string?> RenderClarificationsAndCollectResponsesAsync(ConsoleWriter writer, PlanningResponse response)
private async Task<string?> RenderClarificationsAndCollectResponsesAsync(HarnessUXContainer ux, PlanningResponse response)
{
var answers = new List<string>();
foreach (var question in response.Questions)
{
await writer.WriteInfoLineAsync(string.Empty);
await writer.WriteInfoLineAsync(question.Message);
string? answer;
if (question.Choices is { Count: > 0 })
{
answer = await writer.ReadSelectionAsync(
"Choose an option:",
answer = await ux.ReadSelectionAsync(
question.Message,
question.Choices);
}
else
{
answer = (await writer.ReadLineAsync("Response: "))?.Trim();
answer = (await ux.ReadLineAsync(question.Message))?.Trim();
}
if (!string.IsNullOrWhiteSpace(answer))
@@ -139,38 +136,20 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
return answers.Count > 0 ? string.Join("\n\n", answers) : null;
}
private async Task<string> RenderApprovalAndCollectResponseAsync(ConsoleWriter writer, PlanningQuestion question, HarnessConsoleOptions options)
private async Task<string> RenderApprovalAndCollectResponseAsync(HarnessUXContainer ux, PlanningQuestion question, HarnessConsoleOptions options)
{
await writer.WriteInfoLineAsync(question.Message);
var choices = new List<string>
{
"Approve and switch to execute mode",
"Suggest changes",
};
string selection = await writer.ReadSelectionAsync("What would you like to do?", choices);
string selection = await ux.ReadSelectionAsync(question.Message, choices);
if (selection == choices[0])
{
return "Approved";
}
if (selection == choices[1])
{
string? feedback = await writer.ReadLineAsync(
"Your feedback: ",
ConsoleWriter.GetModeColor(options.PlanningModeName, options.ModeColors));
if (string.IsNullOrWhiteSpace(feedback))
{
// Treat empty feedback as no changes — re-prompt the agent with the plan.
return "No changes suggested. Please re-present the plan for approval.";
}
return feedback;
}
// Custom freeform input — treat as suggested changes.
return selection;
}
@@ -10,11 +10,11 @@ namespace Harness.Shared.Console.Observers;
internal sealed class ReasoningDisplayObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
{
if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text))
{
await writer.WriteTextAsync(reasoning.Text, ConsoleColor.DarkMagenta);
await ux.WriteTextAsync(reasoning.Text, ConsoleColor.DarkMagenta);
}
}
}
@@ -9,8 +9,8 @@ namespace Harness.Shared.Console.Observers;
internal sealed class TextOutputObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnTextAsync(ConsoleWriter writer, string text)
public override async Task OnTextAsync(HarnessUXContainer ux, string text)
{
await writer.WriteTextAsync(text);
await ux.WriteTextAsync(text);
}
}
@@ -15,7 +15,7 @@ internal sealed class ToolApprovalObserver : ConsoleObserver
private readonly List<ToolApprovalRequestContent> _approvalRequests = [];
/// <inheritdoc/>
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
{
if (content is ToolApprovalRequestContent approvalRequest)
{
@@ -23,13 +23,13 @@ internal sealed class ToolApprovalObserver : ConsoleObserver
string toolName = approvalRequest.ToolCall is FunctionCallContent fc
? ToolCallFormatter.Format(fc)
: approvalRequest.ToolCall?.ToString() ?? "unknown";
await writer.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
await ux.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
}
}
/// <inheritdoc/>
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
ConsoleWriter writer,
HarnessUXContainer ux,
AIAgent agent,
AgentSession session,
HarnessConsoleOptions options)
@@ -39,12 +39,12 @@ internal sealed class ToolApprovalObserver : ConsoleObserver
return null;
}
var messages = await PromptForApprovalsAsync(writer, this._approvalRequests);
var messages = await PromptForApprovalsAsync(ux, this._approvalRequests);
this._approvalRequests.Clear();
return messages;
}
private static async Task<List<ChatMessage>?> PromptForApprovalsAsync(ConsoleWriter writer, List<ToolApprovalRequestContent> approvalRequests)
private static async Task<List<ChatMessage>?> PromptForApprovalsAsync(HarnessUXContainer ux, List<ToolApprovalRequestContent> approvalRequests)
{
if (approvalRequests.Count == 0)
{
@@ -66,7 +66,7 @@ internal sealed class ToolApprovalObserver : ConsoleObserver
"Deny",
};
string selection = await writer.ReadSelectionAsync($"🔐 Tool approval: {toolName}", choices);
string selection = await ux.ReadSelectionAsync($"🔐 Tool approval: {toolName}", choices);
AIContent response = selection switch
{
"Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
@@ -82,7 +82,7 @@ internal sealed class ToolApprovalObserver : ConsoleObserver
"Deny" => "❌ Denied",
_ => "✅ Approved",
};
await writer.WriteInfoLineAsync($" {action}", ConsoleColor.DarkGray);
await ux.WriteInfoLineAsync($" {action}", ConsoleColor.DarkGray);
responses.Add(response);
}
@@ -11,15 +11,15 @@ namespace Harness.Shared.Console.Observers;
internal sealed class ToolCallDisplayObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
{
if (content is FunctionCallContent functionCall)
{
await writer.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow);
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow);
}
else if (content is ToolCallContent toolCall)
{
await writer.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
await ux.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
}
}
}
@@ -24,19 +24,21 @@ internal sealed class UsageDisplayObserver : ConsoleObserver
}
/// <inheritdoc/>
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
public override Task OnContentAsync(HarnessUXContainer ux, AIContent content)
{
if (content is UsageContent usage)
{
if (usage.Details is not null)
{
await writer.WriteInfoLineAsync(this.FormatUsageBreakdown(usage.Details), ConsoleColor.DarkGray);
ux.SetUsageText(this.FormatUsageBreakdown(usage.Details));
}
else
{
await writer.WriteInfoLineAsync("📊 Tokens —", ConsoleColor.DarkGray);
ux.SetUsageText("📊 Tokens —");
}
}
return Task.CompletedTask;
}
private string FormatUsageBreakdown(UsageDetails details)
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Harness.Shared.Console;
/// <summary>
/// Represents the type of an output entry in the console conversation.
/// </summary>
public 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.
/// These entries are rendered by the <see cref="HarnessAppComponent"/> via its render delegate.
/// </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>
public record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null);
@@ -1,77 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Harness.Shared.Console;
/// <summary>
/// A restartable spinner that can be started and stopped multiple times.
/// </summary>
internal sealed class Spinner : IDisposable
{
private static readonly string[] s_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
private CancellationTokenSource? _cts;
private Task? _task;
public void Start()
{
if (this._task is not null)
{
return;
}
this._cts = new CancellationTokenSource();
this._task = RunAsync(this._cts.Token);
}
public async Task StopAsync()
{
if (this._cts is null || this._task is null)
{
return;
}
this._cts.Cancel();
await this._task;
this._cts.Dispose();
this._cts = null;
this._task = null;
}
public void Dispose()
{
if (this._cts is not null && this._task is not null)
{
this._cts.Cancel();
// Block briefly to let the spinner task clean up.
// This prevents the background task from writing to the console after disposal.
#pragma warning disable VSTHRD002 // Synchronous wait in Dispose is acceptable here — the spinner task completes quickly on cancellation.
this._task.Wait();
#pragma warning restore VSTHRD002
}
this._cts?.Dispose();
this._cts = null;
this._task = null;
}
private static async Task RunAsync(CancellationToken cancellationToken)
{
int i = 0;
try
{
while (!cancellationToken.IsCancellationRequested)
{
System.Console.Write(s_frames[i % s_frames.Length]);
await Task.Delay(80, cancellationToken);
System.Console.Write("\b \b");
i++;
}
}
catch (OperationCanceledException)
{
// Clear the last spinner frame left on screen.
System.Console.Write("\b \b");
}
}
}
@@ -135,6 +135,7 @@ AIAgent agent =
// Build a ChatClient Pipeline
.AsBuilder()
.UseFunctionInvocation() // We are building our own stack from scratch so we need to include Function Invocation ourselves.
.UseMessageInjection() // Allow message injection during the function call loop.
.UsePerServiceCallChatHistoryPersistence() // Save chat history updates to the session after each service call, rather than only at the end of the run.
.UseAIContextProviders(new CompactionProvider(compactionStrategy)) // Add Compaction before each service call to responses so that long function invocation loops don't overflow the context.
@@ -0,0 +1,8 @@
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AZURE_SEARCH_ENDPOINT=<your-azure-search-endpoint>
AZURE_SEARCH_INDEX_NAME=contoso-outdoors
AZURE_BEARER_TOKEN_FOUNDRY=DefaultAzureCredential
AZURE_BEARER_TOKEN_SEARCH=DefaultAzureCredential
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
@@ -0,0 +1,17 @@
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedAzureSearchRag.dll"]
@@ -0,0 +1,23 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
# which means a standard multi-stage Docker build cannot resolve dependencies outside
# this folder. Instead, pre-publish the app targeting the container runtime and copy
# the output into the container:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-azure-search-rag .
# docker run --rm -p 8088:8088 \
# -e AGENT_NAME=hosted-azure-search-rag \
# -e AZURE_BEARER_TOKEN_FOUNDRY=$AZURE_BEARER_TOKEN_FOUNDRY \
# -e AZURE_BEARER_TOKEN_SEARCH=$AZURE_BEARER_TOKEN_SEARCH \
# --env-file .env hosted-azure-search-rag
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedAzureSearchRag.dll"]
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedAzureSearchRag</RootNamespace>
<AssemblyName>HostedAzureSearchRag</AssemblyName>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<!-- For contributors: uses ProjectReference to build against local source -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
</ItemGroup>
-->
</Project>
@@ -0,0 +1,171 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to add Retrieval Augmented Generation (RAG) capabilities to a hosted
// agent using Azure AI Search. The sample assumes the search index has already been provisioned
// and populated out of band (see README.md for the required schema and example seed content).
// A SearchClient-backed adapter is plugged into TextSearchProvider, which runs a keyword search
// against the index before each model invocation and injects the matching documents into the
// model context.
using Azure;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using DotNetEnv;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
// Load .env file if present (for local development)
Env.TraversePath().Load();
string projectEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
string searchEndpoint = Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT")
?? throw new InvalidOperationException("AZURE_SEARCH_ENDPOINT is not set.");
string searchIndexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME")
?? throw new InvalidOperationException("AZURE_SEARCH_INDEX_NAME is not set.");
// Use a chained credential. Try a temporary dev token first (for local Docker debugging),
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in
// production). The dev credential is scope aware so a single instance serves both Foundry and
// Azure AI Search clients (each Azure SDK client requests a token for its own audience).
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
// Connect to the pre-provisioned search index. The caller is expected to have created the
// index and populated it with documents matching the schema (id / content / sourceName /
// sourceLink) before running this sample. See README.md for an example provisioning script.
var searchClient = new SearchClient(new Uri(searchEndpoint), searchIndexName, credential);
TextSearchProviderOptions textSearchOptions = new()
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 6,
};
AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
.AsAIAgent(new ChatClientAgentOptions
{
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-azure-search-rag",
ChatOptions = new ChatOptions
{
ModelId = deploymentName,
Instructions = "You are a helpful support specialist for Contoso Outdoors. " +
"Answer questions using the provided context and cite the source document when available.",
},
AIContextProviders = [new TextSearchProvider(CreateSearchAdapter(searchClient), textSearchOptions)]
});
// Host the agent as a Foundry Hosted Agent using the Responses API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
var app = builder.Build();
app.MapFoundryResponses();
if (app.Environment.IsDevelopment())
{
app.MapFoundryResponses("openai/v1");
}
app.Run();
// ── Search adapter ───────────────────────────────────────────────────────────
// Wraps a SearchClient as the delegate TextSearchProvider expects. Keyword/full-text only;
// no embeddings. Returns the top results and projects them into TextSearchResult entries
// the provider will inject into the model context.
static Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>>
CreateSearchAdapter(SearchClient client, int top = 3) =>
async (query, cancellationToken) =>
{
var options = new SearchOptions { Size = top };
Response<SearchResults<SearchDocument>> response =
await client.SearchAsync<SearchDocument>(query, options, cancellationToken).ConfigureAwait(false);
var results = new List<TextSearchProvider.TextSearchResult>();
await foreach (SearchResult<SearchDocument> hit in response.Value.GetResultsAsync().WithCancellation(cancellationToken).ConfigureAwait(false))
{
results.Add(new TextSearchProvider.TextSearchResult
{
SourceName = hit.Document.TryGetValue("sourceName", out var name) ? name?.ToString() ?? string.Empty : string.Empty,
SourceLink = hit.Document.TryGetValue("sourceLink", out var link) ? link?.ToString() ?? string.Empty : string.Empty,
Text = hit.Document.TryGetValue("content", out var content) ? content?.ToString() ?? string.Empty : string.Empty,
RawRepresentation = hit
});
}
return results;
};
/// <summary>
/// A scope aware <see cref="TokenCredential"/> for local Docker debugging only.
/// Reads pre-fetched bearer tokens from environment variables, dispensing the right token
/// based on the requested scope:
/// <list type="bullet">
/// <item><description><c>ai.azure.com</c> scopes -> <c>AZURE_BEARER_TOKEN_FOUNDRY</c></description></item>
/// <item><description><c>search.azure.com</c> scopes -> <c>AZURE_BEARER_TOKEN_SEARCH</c></description></item>
/// </list>
/// For any other scope, throws <see cref="CredentialUnavailableException"/> so a chained
/// credential will fall through. This should NOT be used in production: tokens expire (~1 hour)
/// and cannot be refreshed.
///
/// Generate the tokens on your host and pass them to the container:
/// <code>
/// export AZURE_BEARER_TOKEN_FOUNDRY=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
/// export AZURE_BEARER_TOKEN_SEARCH=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)
/// docker run -e AZURE_BEARER_TOKEN_FOUNDRY -e AZURE_BEARER_TOKEN_SEARCH ...
/// </code>
/// </summary>
internal sealed class DevTemporaryTokenCredential : TokenCredential
{
private const string FoundryEnvironmentVariable = "AZURE_BEARER_TOKEN_FOUNDRY";
private const string SearchEnvironmentVariable = "AZURE_BEARER_TOKEN_SEARCH";
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> Resolve(requestContext.Scopes);
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
=> new(Resolve(requestContext.Scopes));
private static AccessToken Resolve(IReadOnlyList<string> scopes)
{
string? envVar = null;
foreach (var scope in scopes)
{
if (scope.Contains("search.azure.com", StringComparison.OrdinalIgnoreCase))
{
envVar = SearchEnvironmentVariable;
break;
}
if (scope.Contains("ai.azure.com", StringComparison.OrdinalIgnoreCase))
{
envVar = FoundryEnvironmentVariable;
break;
}
}
if (envVar is null)
{
throw new CredentialUnavailableException(
$"DevTemporaryTokenCredential cannot serve scopes [{string.Join(", ", scopes)}]; falling through.");
}
var token = Environment.GetEnvironmentVariable(envVar);
if (string.IsNullOrEmpty(token) || string.Equals(token, "DefaultAzureCredential", StringComparison.Ordinal))
{
throw new CredentialUnavailableException(
$"{envVar} environment variable is not set; falling through to next credential.");
}
return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1));
}
}
@@ -0,0 +1,179 @@
# Hosted-AzureSearchRag
A hosted agent with **Retrieval Augmented Generation (RAG)** capabilities backed by **Azure AI Search**. The agent grounds its answers in product documentation by running a keyword search against an Azure AI Search index before each model invocation, then citing the source in its response.
This sample is the Azure AI Search counterpart to `Hosted-TextRag`. Where `Hosted-TextRag` uses a mock in-process search function, this sample talks to a real Azure AI Search index that is provisioned out of band (see "Provisioning the search index" below).
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
- An Azure AI Search service ([create one](https://learn.microsoft.com/azure/search/search-create-service-portal))
- **A pre-provisioned search index** with the schema and content described in the next section
- Azure CLI logged in (`az login`)
### Required RBAC
Your identity (or the Managed Identity running the container in production) needs:
- **Azure AI User** on the Foundry project scope
- **Search Index Data Reader** on the Azure AI Search service (the sample only reads from the index)
## Provisioning the search index (one time)
The sample assumes the search index already exists and contains documents the agent can retrieve from. Provision it once via the Azure Portal, the [REST API](https://learn.microsoft.com/azure/search/search-how-to-create-search-index), or the snippet below.
### Index schema
| Field | Type | Attributes |
|---|---|---|
| `id` | `Edm.String` | key, filterable |
| `content` | `Edm.String` | searchable (full-text) |
| `sourceName` | `Edm.String` | retrievable, filterable |
| `sourceLink` | `Edm.String` | retrievable |
### Example: provision and seed via Azure CLI + REST
```bash
SEARCH_ENDPOINT="https://<your-search>.search.windows.net"
INDEX_NAME="contoso-outdoors"
TOKEN=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)
# 1. Create the index.
curl -X PUT "$SEARCH_ENDPOINT/indexes/$INDEX_NAME?api-version=2024-07-01" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "contoso-outdoors",
"fields": [
{ "name": "id", "type": "Edm.String", "key": true, "filterable": true, "searchable": false, "retrievable": true },
{ "name": "content", "type": "Edm.String", "key": false, "filterable": false, "searchable": true, "retrievable": true, "analyzer": "standard.lucene" },
{ "name": "sourceName", "type": "Edm.String", "key": false, "filterable": true, "searchable": false, "retrievable": true },
{ "name": "sourceLink", "type": "Edm.String", "key": false, "filterable": false, "searchable": false, "retrievable": true }
]
}'
# 2. Upload three Contoso Outdoors documents matching the queries below.
curl -X POST "$SEARCH_ENDPOINT/indexes/$INDEX_NAME/docs/index?api-version=2024-07-01" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"value": [
{ "@search.action": "mergeOrUpload", "id": "return-policy", "sourceName": "Contoso Outdoors Return Policy", "sourceLink": "https://contoso.com/policies/returns", "content": "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection. As a thank you, every accepted return ships back with a complimentary Contoso TrailRunner sticker pack, item code TR-CANARY-7821." },
{ "@search.action": "mergeOrUpload", "id": "shipping-guide", "sourceName": "Contoso Outdoors Shipping Guide", "sourceLink": "https://contoso.com/help/shipping", "content": "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout. Use promo code SHIP-CANARY-4493 at checkout for a one-time free overnight upgrade on your first order." },
{ "@search.action": "mergeOrUpload", "id": "tent-care", "sourceName": "TrailRunner Tent Care Instructions", "sourceLink": "https://contoso.com/manuals/trailrunner-tent", "content": "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating. Replacement waterproofing kits are stocked under SKU TENT-CANARY-9067." }
]
}'
```
You can also point the sample at any existing index that exposes the four fields above; the sample reads `content`, `sourceName`, and `sourceLink` as projected by the search results.
## Configuration
Copy the template and fill in your endpoints:
```bash
cp .env.example .env
```
Edit `.env`:
```env
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
AZURE_SEARCH_ENDPOINT=https://<your-search>.search.windows.net
AZURE_SEARCH_INDEX_NAME=contoso-outdoors
AZURE_BEARER_TOKEN_FOUNDRY=DefaultAzureCredential
AZURE_BEARER_TOKEN_SEARCH=DefaultAzureCredential
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
```
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
## Running directly (contributors)
This project uses `ProjectReference` to build against the local Agent Framework source.
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag
AGENT_NAME=hosted-azure-search-rag dotnet run
```
The agent will start on `http://localhost:8088`. The sample assumes the search index has already been provisioned and seeded (see "Provisioning the search index" above).
### Test it
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "What is your return policy?"
azd ai agent invoke --local "How long does shipping take?"
azd ai agent invoke --local "How do I clean my tent?"
```
Or with curl:
```bash
curl -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "What is your return policy?", "model": "hosted-azure-search-rag"}'
```
## Running with Docker
Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output.
### 1. Publish for the container runtime (Linux Alpine)
```bash
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
```
### 2. Build the Docker image
```bash
docker build -f Dockerfile.contributor -t hosted-azure-search-rag .
```
### 3. Run the container
Generate two bearer tokens on your host (one per audience) and pass them to the container. A single Azure AD token has only one `aud` claim, so Foundry and Azure AI Search require separate tokens.
```bash
# Generate tokens (each expires in ~1 hour)
export AZURE_BEARER_TOKEN_FOUNDRY=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
export AZURE_BEARER_TOKEN_SEARCH=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)
# Run with both tokens
docker run --rm -p 8088:8088 \
-e AGENT_NAME=hosted-azure-search-rag \
-e AZURE_BEARER_TOKEN_FOUNDRY=$AZURE_BEARER_TOKEN_FOUNDRY \
-e AZURE_BEARER_TOKEN_SEARCH=$AZURE_BEARER_TOKEN_SEARCH \
--env-file .env \
hosted-azure-search-rag
```
### 4. Test it
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "What is your return policy?"
```
## How RAG works in this sample
The `TextSearchProvider` runs a keyword search against the configured Azure AI Search index **before each model invocation**. When the index is seeded with the three Contoso Outdoors documents from the provisioning section above:
| User query mentions | Search result injected |
|---|---|
| "return", "refund" | Contoso Outdoors Return Policy (canary token: `TR-CANARY-7821`) |
| "shipping", "promo" | Contoso Outdoors Shipping Guide (canary token: `SHIP-CANARY-4493`) |
| "tent", "fabric" | TrailRunner Tent Care Instructions (canary token: `TENT-CANARY-9067`) |
The model receives the top three search results as additional context and cites the source in its response. Each seeded document includes a unique `*-CANARY-*` token that does not exist in any model training data, so the integration tests can prove an answer was grounded in retrieved content (not fabricated from training) by asking for the canary and asserting it appears in the response.
Replace the seed documents (or point the sample at an existing index with your own content) to ground the agent in your own knowledge base.
## NuGet package users
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedAzureSearchRag.csproj` for the `PackageReference` alternative.
@@ -0,0 +1,31 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-azure-search-rag
displayName: "Hosted Azure AI Search RAG Agent"
description: >
A support specialist agent for Contoso Outdoors with RAG capabilities backed by
Azure AI Search. Uses TextSearchProvider with a SearchClient adapter to ground
answers in product documentation indexed in Azure AI Search before each model
invocation.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- RAG
- Azure AI Search
- Agent Framework
template:
name: hosted-azure-search-rag
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
parameters:
properties: []
resources: []
@@ -0,0 +1,9 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: hosted-azure-search-rag
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
+29 -14
View File
@@ -93,26 +93,26 @@ public sealed class A2AAgent : AIAgent
/// <inheritdoc/>
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false);
this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name);
if (GetContinuationToken(messages, options) is { } token)
if (GetContinuationToken(inputMessages, options) is { } token)
{
AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = token.TaskId }, cancellationToken).ConfigureAwait(false);
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id, agentTask.Status.State);
return this.ConvertToAgentResponse(agentTask);
}
SendMessageRequest sendParams = new()
{
Message = CreateA2AMessage(typedSession, messages),
Message = CreateA2AMessage(typedSession, inputMessages),
Metadata = options?.AdditionalProperties?.ToA2AMetadata(),
Configuration = new SendMessageConfiguration { ReturnImmediately = options?.AllowBackgroundResponses is true }
};
@@ -134,7 +134,7 @@ public sealed class A2AAgent : AIAgent
{
var agentTask = a2aResponse.Task!;
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id, agentTask.Status.State);
return this.ConvertToAgentResponse(agentTask);
}
@@ -145,7 +145,7 @@ public sealed class A2AAgent : AIAgent
/// <inheritdoc/>
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false);
@@ -153,7 +153,7 @@ public sealed class A2AAgent : AIAgent
ConfiguredCancelableAsyncEnumerable<StreamResponse> streamEvents;
if (GetContinuationToken(messages, options) is { } token)
if (GetContinuationToken(inputMessages, options) is { } token)
{
streamEvents = this.SubscribeToTaskWithFallbackAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
}
@@ -161,7 +161,7 @@ public sealed class A2AAgent : AIAgent
{
SendMessageRequest sendParams = new()
{
Message = CreateA2AMessage(typedSession, messages),
Message = CreateA2AMessage(typedSession, inputMessages),
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
};
@@ -172,6 +172,7 @@ public sealed class A2AAgent : AIAgent
string? contextId = null;
string? taskId = null;
TaskState? taskState = null;
await foreach (var streamResponse in streamEvents)
{
@@ -187,6 +188,7 @@ public sealed class A2AAgent : AIAgent
var task = streamResponse.Task!;
contextId = task.ContextId;
taskId = task.Id;
taskState = task.Status.State;
yield return this.ConvertToAgentResponseUpdate(task);
break;
@@ -194,6 +196,7 @@ public sealed class A2AAgent : AIAgent
var statusUpdate = streamResponse.StatusUpdate!;
contextId = statusUpdate.ContextId;
taskId = statusUpdate.TaskId;
taskState = statusUpdate.Status.State;
yield return this.ConvertToAgentResponseUpdate(statusUpdate);
break;
@@ -209,7 +212,7 @@ public sealed class A2AAgent : AIAgent
}
}
UpdateSession(typedSession, contextId, taskId);
UpdateSession(typedSession, contextId, taskId, taskState);
}
/// <inheritdoc/>
@@ -317,7 +320,7 @@ public sealed class A2AAgent : AIAgent
}
}
private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null)
private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null, TaskState? taskState = null)
{
if (session is null)
{
@@ -335,9 +338,10 @@ public sealed class A2AAgent : AIAgent
// Assign a server-generated context Id to the session if it's not already set.
session.ContextId ??= contextId;
session.TaskId = taskId;
session.TaskState = taskState;
}
private static Message CreateA2AMessage(A2AAgentSession typedSession, IEnumerable<ChatMessage> messages)
private static Message CreateA2AMessage(A2AAgentSession typedSession, IReadOnlyCollection<ChatMessage> messages)
{
var a2aMessage = messages.ToA2AMessage();
@@ -345,9 +349,19 @@ public sealed class A2AAgent : AIAgent
// See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#group-related-interactions
a2aMessage.ContextId = typedSession.ContextId;
// Link the message as a follow-up to an existing task, if any.
// See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#task-refinements
a2aMessage.ReferenceTaskIds = typedSession.TaskId is null ? null : [typedSession.TaskId];
if (typedSession.TaskState == TaskState.InputRequired)
{
// If the session indicates the task is waiting for user input,
// link the response to the existing task so it is treated as input
// for that task.
a2aMessage.TaskId = typedSession.TaskId;
}
else
{
// Link the message as a follow-up to an existing task, if any.
// See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#task-refinements
a2aMessage.ReferenceTaskIds = typedSession.TaskId is not null ? [typedSession.TaskId] : null;
}
return a2aMessage;
}
@@ -444,6 +458,7 @@ public sealed class A2AAgent : AIAgent
Role = ChatRole.Assistant,
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
Contents = statusUpdateEvent.Status.GetUserInputRequests(),
};
}
@@ -5,6 +5,8 @@ using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using TaskState = A2A.TaskState;
namespace Microsoft.Agents.AI.A2A;
/// <summary>
@@ -18,10 +20,11 @@ public sealed class A2AAgentSession : AgentSession
}
[JsonConstructor]
internal A2AAgentSession(string? contextId, string? taskId, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
internal A2AAgentSession(string? contextId, string? taskId, TaskState? taskState, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
{
this.ContextId = contextId;
this.TaskId = taskId;
this.TaskState = taskState;
}
/// <summary>
@@ -36,6 +39,12 @@ public sealed class A2AAgentSession : AgentSession
[JsonPropertyName("taskId")]
public string? TaskId { get; internal set; }
/// <summary>
/// Gets the state of the task the agent is currently working on.
/// </summary>
[JsonPropertyName("taskState")]
public TaskState? TaskState { get; internal set; }
/// <inheritdoc/>
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
@@ -57,5 +66,5 @@ public sealed class A2AAgentSession : AgentSession
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay =>
$"ContextId = {this.ContextId}, TaskId = {this.TaskId}, StateBag Count = {this.StateBag.Count}";
$"ContextId = {this.ContextId}, TaskId = {this.TaskId}, TaskState = {this.TaskState}, StateBag Count = {this.StateBag.Count}";
}
@@ -13,7 +13,7 @@ internal static class A2AAIContentExtensions
/// <summary>
/// Converts a collection of <see cref="AIContent"/> to a list of <see cref="Part"/> objects.
/// </summary>
/// <param name="contents">The collection of AI contents to convert.</param>"
/// <param name="contents">The collection of AI contents to convert.</param>
/// <returns>The list of A2A <see cref="Part"/> objects.</returns>
internal static List<Part>? ToParts(this IEnumerable<AIContent> contents)
{
@@ -21,8 +21,7 @@ internal static class A2AAIContentExtensions
foreach (var content in contents)
{
var part = content.ToPart();
if (part is not null)
if (content.ToPart() is { } part)
{
(parts ??= []).Add(part);
}
@@ -17,7 +17,7 @@ internal static class A2AAgentTaskExtensions
List<ChatMessage>? messages = null;
if (agentTask?.Artifacts is { Count: > 0 })
if (agentTask.Artifacts is { Count: > 0 })
{
foreach (var artifact in agentTask.Artifacts)
{
@@ -25,6 +25,14 @@ internal static class A2AAgentTaskExtensions
}
}
if (agentTask.Status?.GetUserInputRequests() is { } userInputRequests)
{
(messages ??= []).Add(new(ChatRole.Assistant, userInputRequests)
{
RawRepresentation = agentTask.Status,
});
}
return messages;
}
@@ -42,6 +50,11 @@ internal static class A2AAgentTaskExtensions
}
}
if (agentTask.Status?.GetUserInputRequests() is { } userInputRequests)
{
(aiContents ??= []).AddRange(userInputRequests);
}
return aiContents;
}
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace A2A;
/// <summary>
/// Extension methods for the <see cref="TaskStatus"/> class.
/// </summary>
internal static class AgentTaskStatusExtensions
{
internal static IList<AIContent>? GetUserInputRequests(this TaskStatus status)
{
_ = Throw.IfNull(status);
List<AIContent>? contents = null;
if (status.Message is null || status.State is not TaskState.InputRequired)
{
return contents;
}
foreach (var part in status.Message.Parts)
{
var aiContent = part.ToAIContent();
aiContent.RawRepresentation = part;
aiContent.AdditionalProperties = part.Metadata.ToAdditionalProperties();
(contents ??= []).Add(aiContent);
}
return contents;
}
}
@@ -105,7 +105,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
State state = this._sessionState.GetOrInitializeState(context.Session);
// Add request and response messages to the provider
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
var allNewMessages = (context.RequestMessages ?? []).Concat(context.ResponseMessages ?? []);
state.Messages.AddRange(allNewMessages);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
@@ -0,0 +1,106 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace Microsoft.Agents.AI.DevUI;
/// <summary>
/// Endpoint filter that enforces the DevUI security posture: loopback-only
/// access by default, plus optional bearer-token authentication.
/// </summary>
internal sealed class DevUIAuthFilter : IEndpointFilter
{
private const string BearerScheme = "Bearer";
private readonly DevUIOptions _options;
private readonly byte[]? _expectedTokenBytes;
private readonly ILogger<DevUIAuthFilter> _logger;
/// <summary>
/// Gets a value indicating whether a bearer token is required by this filter
/// (either via <see cref="DevUIOptions.AuthToken"/> or the
/// <c>DEVUI_AUTH_TOKEN</c> environment variable).
/// </summary>
public bool TokenRequired => this._expectedTokenBytes is { Length: > 0 };
public DevUIAuthFilter(IOptions<DevUIOptions> options, ILogger<DevUIAuthFilter> logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
this._options = options.Value;
this._logger = logger;
var configuredToken = !string.IsNullOrEmpty(this._options.AuthToken)
? this._options.AuthToken
: Environment.GetEnvironmentVariable(DevUIOptions.AuthTokenEnvironmentVariable);
this._expectedTokenBytes = !string.IsNullOrEmpty(configuredToken)
? Encoding.UTF8.GetBytes(configuredToken)
: null;
}
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var httpContext = context.HttpContext;
var remoteIp = httpContext.Connection.RemoteIpAddress;
var isLoopback = remoteIp is not null && IPAddress.IsLoopback(remoteIp);
if (!isLoopback && !this._options.AllowRemoteAccess)
{
this._logger.LogWarning(
"Rejected non-loopback DevUI request from {RemoteIp}. Set DevUIOptions.AllowRemoteAccess to permit remote callers.",
remoteIp);
return Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "DevUI access denied",
detail: "DevUI is restricted to loopback callers by default. Enable AllowRemoteAccess to permit remote access.");
}
if (this._expectedTokenBytes is { Length: > 0 } expected && !TokenIsValid(httpContext.Request, expected))
{
httpContext.Response.Headers[HeaderNames.WWWAuthenticate] = BearerScheme;
return Results.Problem(
statusCode: StatusCodes.Status401Unauthorized,
title: "DevUI authentication required",
detail: "Provide a valid bearer token via the Authorization header.");
}
return await next(context).ConfigureAwait(false);
}
private static bool TokenIsValid(HttpRequest request, byte[] expected)
{
if (!request.Headers.TryGetValue(HeaderNames.Authorization, out var headerValues))
{
return false;
}
foreach (var header in headerValues)
{
if (string.IsNullOrEmpty(header))
{
continue;
}
const int PrefixLength = 7; // "Bearer "
if (header.Length <= PrefixLength ||
!header.StartsWith(BearerScheme, StringComparison.OrdinalIgnoreCase) ||
header[BearerScheme.Length] != ' ')
{
continue;
}
var presented = Encoding.UTF8.GetBytes(header.AsSpan(PrefixLength).Trim().ToString());
if (CryptographicOperations.FixedTimeEquals(presented, expected))
{
return true;
}
}
return false;
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Options;
namespace Microsoft.Agents.AI.DevUI;
@@ -13,12 +14,19 @@ public static class DevUIExtensions
/// Maps an endpoint that serves the DevUI from the '/devui' path.
/// </summary>
/// <remarks>
/// <para>
/// DevUI requires the OpenAI Responses and Conversations services to be registered with
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIResponses(IServiceCollection)"/> and
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIConversations(IServiceCollection)"/>,
/// and the corresponding endpoints to be mapped using
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses(IEndpointRouteBuilder)"/> and
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIConversations(IEndpointRouteBuilder)"/>.
/// </para>
/// <para>
/// DevUI is restricted to loopback callers unless
/// <see cref="DevUIOptions.AllowRemoteAccess"/> is set. See <see cref="DevUIOptions"/>
/// for the available authentication and authorization hooks.
/// </para>
/// </remarks>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
@@ -30,11 +38,29 @@ public static class DevUIExtensions
public static IEndpointConventionBuilder MapDevUI(
this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("");
group.MapDevUI(pattern: "/devui");
group.MapMeta();
group.MapEntities();
return group;
ArgumentNullException.ThrowIfNull(endpoints);
var authFilter = endpoints.ServiceProvider.GetRequiredService<DevUIAuthFilter>();
var options = endpoints.ServiceProvider.GetRequiredService<IOptions<DevUIOptions>>().Value;
var startupLogger = endpoints.ServiceProvider.GetRequiredService<ILogger<DevUIAuthFilter>>();
WarnIfInsecurelyExposed(startupLogger, options);
// /meta must remain reachable without authentication so the frontend can
// discover whether a bearer token is required before prompting for one.
endpoints.MapMeta(authRequired: authFilter.TokenRequired);
var protectedGroup = endpoints.MapGroup("");
// Conventions must be applied before endpoints are added to the group so
// they reliably attach to every protected DevUI endpoint.
options.ConfigureEndpoints?.Invoke(protectedGroup);
protectedGroup.AddEndpointFilter(authFilter);
protectedGroup.MapDevUI(pattern: "/devui");
protectedGroup.MapEntities();
return protectedGroup;
}
/// <summary>
@@ -66,4 +92,18 @@ public static class DevUIExtensions
.WithName($"DevUI at {cleanPattern}")
.WithDescription("Interactive developer interface for Microsoft Agent Framework");
}
private static void WarnIfInsecurelyExposed(ILogger logger, DevUIOptions options)
{
var tokenConfigured = !string.IsNullOrEmpty(options.AuthToken)
|| !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(DevUIOptions.AuthTokenEnvironmentVariable));
if (options.AllowRemoteAccess && !tokenConfigured && options.ConfigureEndpoints is null)
{
logger.LogWarning(
"DevUI is configured with AllowRemoteAccess=true and no authentication. " +
"Set DevUIOptions.AuthToken, the {EnvVar} environment variable, or attach an authorization policy via ConfigureEndpoints.",
DevUIOptions.AuthTokenEnvironmentVariable);
}
}
}
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DevUI;
/// <summary>
/// Options that control the security posture of the DevUI HTTP surface.
/// </summary>
/// <remarks>
/// DevUI exposes agent metadata that is sensitive in production contexts:
/// system instructions, tool definitions, model identifiers, and workflow
/// structure. By default, DevUI rejects any request whose remote endpoint
/// is not a loopback address. Hosts that intentionally expose DevUI on a
/// non-loopback interface must opt in via <see cref="AllowRemoteAccess"/>
/// and should also configure <see cref="AuthToken"/> or
/// <see cref="ConfigureEndpoints"/> to attach an authorization policy.
/// </remarks>
public sealed class DevUIOptions
{
/// <summary>
/// Environment variable inspected for a default bearer token when
/// <see cref="AuthToken"/> is not explicitly set.
/// </summary>
public const string AuthTokenEnvironmentVariable = "DEVUI_AUTH_TOKEN";
/// <summary>
/// Gets or sets a value indicating whether DevUI may be served to
/// non-loopback callers. Defaults to <see langword="false"/>.
/// </summary>
/// <remarks>
/// When <see langword="false"/>, any request whose
/// <see cref="ConnectionInfo.RemoteIpAddress"/> is
/// not a loopback address (or is missing) is rejected with HTTP 403 before
/// reaching the DevUI handlers. Enable only when the host is responsible
/// for fronting DevUI with its own authentication, network policy, or both.
/// </remarks>
public bool AllowRemoteAccess { get; set; }
/// <summary>
/// Gets or sets a shared bearer token required on every DevUI request.
/// When <see langword="null"/> or empty, the value of the
/// <c>DEVUI_AUTH_TOKEN</c> environment variable is used instead.
/// </summary>
/// <remarks>
/// When a token is configured, requests must include the header
/// <c>Authorization: Bearer &lt;token&gt;</c>. Comparison is performed
/// in constant time. This is a convenience for development scenarios.
/// Production hosts should prefer a real ASP.NET Core authentication
/// scheme attached via <see cref="ConfigureEndpoints"/>.
/// </remarks>
public string? AuthToken { get; set; }
/// <summary>
/// Gets or sets a callback invoked with the DevUI endpoint group so the
/// host can attach authorization, rate limiting, or other endpoint
/// conventions (for example
/// <c>group.RequireAuthorization("DevUIPolicy")</c>).
/// </summary>
public Action<IEndpointConventionBuilder>? ConfigureEndpoints { get; set; }
}
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DevUI;
namespace Microsoft.Extensions.Hosting;
/// <summary>
@@ -13,10 +15,19 @@ public static class MicrosoftAgentAIDevUIHostApplicationBuilderExtensions
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
=> AddDevUI(builder, configure: null);
/// <summary>
/// Adds DevUI services to the host application builder.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <param name="configure">Optional callback used to configure <see cref="DevUIOptions"/>.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder, Action<DevUIOptions>? configure)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddDevUI();
builder.Services.AddDevUI(configure);
return builder;
}
@@ -13,6 +13,7 @@ internal static class MetaApiExtensions
/// Maps the HTTP API endpoint for retrieving server metadata.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="authRequired">Value reported via <c>auth_required</c> in the meta response so the frontend can decide whether to prompt for a bearer token.</param>
/// <returns>The <see cref="IEndpointConventionBuilder"/> for method chaining.</returns>
/// <remarks>
/// This extension method registers the following endpoint:
@@ -22,16 +23,16 @@ internal static class MetaApiExtensions
/// The endpoint is compatible with the Python DevUI frontend and provides essential
/// configuration information needed for proper frontend initialization.
/// </remarks>
public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints)
public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints, bool authRequired = false)
{
return endpoints.MapGet("/meta", GetMeta)
return endpoints.MapGet("/meta", () => GetMeta(authRequired))
.WithName("GetMeta")
.WithSummary("Get server metadata and configuration")
.WithDescription("Returns server metadata including UI mode, version, framework identifier, capabilities, and authentication requirements. Used by the frontend for initialization and feature detection.")
.Produces<MetaResponse>(StatusCodes.Status200OK, contentType: "application/json");
}
private static IResult GetMeta()
private static IResult GetMeta(bool authRequired)
{
// TODO: Consider making these configurable via IOptions<DevUIOptions>
// For now, using sensible defaults that match Python DevUI behavior
@@ -53,7 +54,7 @@ internal static class MetaApiExtensions
// Deployment capability - not currently supported in .NET DevUI
["deployment"] = false
},
AuthRequired = false // Could be made configurable based on authentication middleware
AuthRequired = authRequired
};
return Results.Json(meta, EntitiesJsonContext.Default.MetaResponse);
@@ -2,6 +2,9 @@
This package provides a web interface for testing and debugging AI agents during development.
> [!WARNING]
> DevUI is intended for development only. Its endpoints surface agent system instructions, tool definitions, model identifiers, and workflow structure. Do not expose DevUI to untrusted callers. By default, DevUI rejects any request whose remote endpoint is not a loopback address; see [Security](#security) below for the available options.
## Installation
```bash
@@ -48,3 +51,30 @@ if (builder.Environment.IsDevelopment())
app.Run();
```
## Security
DevUI exposes `/v1/entities` and `/v1/entities/{id}/info`, which return agent metadata including the system prompt (`ChatClientAgent.Instructions`). To prevent accidental disclosure, the DevUI route group is wrapped in a small endpoint filter that:
- Rejects requests from any non-loopback `RemoteIpAddress` with HTTP 403 by default.
- Optionally requires a shared bearer token on every request.
Configure via `DevUIOptions`:
```csharp
builder.AddDevUI(options =>
{
// Allow non-loopback callers. Set this only when the host fronts DevUI with
// its own authentication or network policy.
options.AllowRemoteAccess = true;
// Optional: require Authorization: Bearer <token> on every request.
// Falls back to the DEVUI_AUTH_TOKEN environment variable when null.
options.AuthToken = builder.Configuration["DevUI:AuthToken"];
// Optional: attach a real authorization policy or rate limiting.
options.ConfigureEndpoints = group => group.RequireAuthorization("DevUIPolicy");
});
```
The bundled bearer-token check uses constant-time comparison and is intended as a convenience for development scenarios. Production hosts should prefer a real ASP.NET Core authentication scheme via `ConfigureEndpoints`.
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DevUI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Shared.Diagnostics;
@@ -17,9 +18,26 @@ public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
public static IServiceCollection AddDevUI(this IServiceCollection services)
=> AddDevUI(services, configure: null);
/// <summary>
/// Adds services required for DevUI integration.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <param name="configure">Optional callback used to configure <see cref="DevUIOptions"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
public static IServiceCollection AddDevUI(this IServiceCollection services, Action<DevUIOptions>? configure)
{
ArgumentNullException.ThrowIfNull(services);
var optionsBuilder = services.AddOptions<DevUIOptions>();
if (configure is not null)
{
optionsBuilder.Configure(configure);
}
services.AddSingleton<DevUIAuthFilter>();
// a factory that tries to construct an AIAgent from Workflow,
// even if workflow was not explicitly registered as an AIAgent.
@@ -1,56 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
namespace Azure.AI.Projects;
/// <summary>
/// Provides extension methods on <see cref="AIProjectClient"/> for fetching
/// Foundry toolbox definitions as server-side tools.
/// </summary>
/// <remarks>
/// Provides a single call on the project client to retrieve tools ready for use
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class AIProjectClientToolboxExtensions
{
/// <summary>
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
/// ready for use as server-side tools in the Responses API.
/// </summary>
/// <param name="projectClient">The <see cref="AIProjectClient"/> to use. Cannot be <see langword="null"/>.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically.
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
/// <exception cref="System.ArgumentNullException">
/// Thrown when <paramref name="projectClient"/> or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
public static async Task<IReadOnlyList<AITool>> GetToolboxToolsAsync(
this AIProjectClient projectClient,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(projectClient);
Throw.IfNullOrWhitespace(name);
var toolboxClient = projectClient.AgentAdministrationClient.GetAgentToolboxes();
var toolboxVersion = await FoundryToolbox.GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
return toolboxVersion.ToAITools();
}
}
@@ -1,220 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
#pragma warning disable IL2026 // ModelReaderWriter.Read<ResponseTool> uses reflection; suppressed for Azure SDK model types.
#pragma warning disable IL3050 // ModelReaderWriter.Read<ResponseTool> requires dynamic code; suppressed for Azure SDK model types.
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Provides methods for fetching Foundry toolbox definitions and converting their tools
/// to <see cref="AITool"/> instances for use as server-side tools in the Responses API.
/// </summary>
/// <remarks>
/// <para>
/// When tools from a toolbox are passed to a Foundry agent (e.g. via <c>AsAIAgent(model, instructions, tools: ...)</c>),
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
/// handles tool execution — the agent process does not invoke tools locally.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryToolbox
{
/// <summary>
/// Fetches a toolbox version from the Foundry project and returns the raw SDK <see cref="ToolboxVersion"/>.
/// </summary>
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically (requires an additional API call).
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>The <see cref="ToolboxVersion"/> containing tool definitions.</returns>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
public static async Task<ToolboxVersion> GetToolboxVersionAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(projectEndpoint);
Throw.IfNull(credential);
Throw.IfNullOrWhitespace(name);
var toolboxClient = CreateToolboxClient(projectEndpoint, credential);
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
/// ready for use as server-side tools in the Responses API.
/// </summary>
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically.
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
public static async Task<IReadOnlyList<AITool>> GetToolsAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
var toolboxVersion = await GetToolboxVersionAsync(projectEndpoint, credential, name, version, cancellationToken).ConfigureAwait(false);
return toolboxVersion.ToAITools();
}
/// <summary>
/// Converts the tools in a <see cref="ToolboxVersion"/> to <see cref="AITool"/> instances
/// suitable for use as server-side tools in the Responses API.
/// </summary>
/// <param name="toolboxVersion">The toolbox version whose tools to convert.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="toolboxVersion"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// Each <see cref="ProjectsAgentTool"/> in the toolbox is cast to <see cref="ResponseTool"/>
/// and converted via <c>AsAITool()</c>. Non-function hosted tools (MCP, web_search,
/// code_interpreter, etc.) are included as server-side tool definitions — the Foundry
/// platform handles their execution.
/// </para>
/// <para>
/// Non-function tools are sanitized to remove decoration fields (<c>name</c>, <c>description</c>)
/// that the toolbox API returns but the Responses API rejects.
/// </para>
/// </remarks>
public static IReadOnlyList<AITool> ToAITools(this ToolboxVersion toolboxVersion)
{
Throw.IfNull(toolboxVersion);
if (toolboxVersion.Tools?.Any() != true)
{
return [];
}
return toolboxVersion.Tools
.Select(SanitizeAndConvert)
.ToList();
}
#region Internal helpers (visible to unit tests via InternalsVisibleTo)
/// <summary>
/// Sanitizes a <see cref="ProjectsAgentTool"/> by removing decoration fields that the
/// toolbox API returns but the Responses API rejects, then converts to <see cref="AITool"/>.
/// </summary>
/// <remarks>
/// The Azure AI Projects toolbox API may return <c>name</c> and <c>description</c> on
/// hosted tool objects (MCP, code_interpreter, file_search, etc.). The Responses API
/// rejects at least <c>name</c> with "Unknown parameter: 'tools[0].name'". We strip
/// these decoration fields for non-function tools. Function tools keep them since
/// <c>name</c> and <c>description</c> are expected parts of the function schema.
/// </remarks>
internal static AITool SanitizeAndConvert(ProjectsAgentTool tool)
{
var toolJson = ModelReaderWriter.Write(tool, new ModelReaderWriterOptions("J"));
var node = JsonNode.Parse(toolJson.ToString());
if (node is not JsonObject obj)
{
return ((ResponseTool)tool).AsAITool();
}
var toolType = obj["type"]?.GetValue<string>();
// Function tools need name/description — don't strip
if (toolType is "function" or "custom")
{
return ((ResponseTool)tool).AsAITool();
}
// Strip decoration fields that the Responses API rejects
bool modified = false;
modified |= obj.Remove("name");
modified |= obj.Remove("description");
if (!modified)
{
return ((ResponseTool)tool).AsAITool();
}
var sanitizedJson = obj.ToJsonString();
var sanitizedTool = ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString(sanitizedJson))!;
return sanitizedTool.AsAITool();
}
internal static async Task<ToolboxVersion> GetToolboxVersionAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version,
AgentAdministrationClientOptions? clientOptions,
CancellationToken cancellationToken)
{
Throw.IfNull(projectEndpoint);
Throw.IfNull(credential);
Throw.IfNullOrWhitespace(name);
var toolboxClient = CreateToolboxClient(projectEndpoint, credential, clientOptions);
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
}
internal static AgentToolboxes CreateToolboxClient(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
AgentAdministrationClientOptions? clientOptions = null)
{
clientOptions ??= new AgentAdministrationClientOptions();
var adminClient = new AgentAdministrationClient(projectEndpoint, credential, clientOptions);
return adminClient.GetAgentToolboxes();
}
internal static async Task<ToolboxVersion> GetToolboxVersionCoreAsync(
AgentToolboxes toolboxClient,
string name,
string? version,
CancellationToken cancellationToken)
{
if (version is null)
{
var record = await toolboxClient.GetToolboxAsync(name, cancellationToken).ConfigureAwait(false);
version = record.Value.DefaultVersion
?? throw new InvalidOperationException($"Toolbox '{name}' does not have a default version. Specify an explicit version.");
}
var result = await toolboxClient.GetToolboxVersionAsync(name, version, cancellationToken).ConfigureAwait(false);
return result.Value;
}
#endregion
}
@@ -42,23 +42,15 @@ internal sealed class ClientHeadersAgent : DelegatingAIAgent
CancellationToken cancellationToken = default)
{
var snapshot = TrySnapshot(options);
if (snapshot is null)
if (snapshot is not null)
{
return this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
// AsyncLocal mutations made inside an awaited async method do not leak back to the
// caller after the method returns, so we do not need an explicit restore step here.
// See ClientHeadersScope remarks.
ClientHeadersScope.Current = snapshot;
}
return RunAsyncCoreAsync(messages, session, options, snapshot, cancellationToken);
async Task<AgentResponse> RunAsyncCoreAsync(
IEnumerable<ChatMessage> innerMessages,
AgentSession? innerSession,
AgentRunOptions? innerOptions,
Dictionary<string, string> innerSnapshot,
CancellationToken innerCt)
{
using var _ = ClientHeadersScope.Push(innerSnapshot);
return await this.InnerAgent.RunAsync(innerMessages, innerSession, innerOptions, innerCt).ConfigureAwait(false);
}
return this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
}
/// <inheritdoc/>
@@ -69,7 +61,10 @@ internal sealed class ClientHeadersAgent : DelegatingAIAgent
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var snapshot = TrySnapshot(options);
using var _ = snapshot is null ? default : ClientHeadersScope.Push(snapshot);
if (snapshot is not null)
{
ClientHeadersScope.Current = snapshot;
}
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
{
@@ -11,39 +11,31 @@ namespace Microsoft.Agents.AI.Foundry;
/// <see cref="ClientHeadersPolicy"/> running inside the SCM transport pipeline.
/// </summary>
/// <remarks>
/// AsyncLocal flows the value into downstream awaits but does not roll the value back when the
/// setting method returns. This type pairs each <see cref="Push(IReadOnlyDictionary{string, string}?)"/>
/// with a disposable that explicitly restores the prior value, giving stack-style LIFO semantics
/// for nested or sequential per-call scopes on the same async flow.
/// <para>
/// <see cref="AsyncLocal{T}"/> propagates the value forward into every <c>await</c> on the same
/// async flow, but mutations made inside an awaited <c>async</c> method do <em>not</em> leak back
/// to the caller after the method returns. This means a method that assigns
/// <see cref="Current"/> at the top and then awaits inner work does not need any explicit
/// restoration step: the runtime restores the caller's view of the AsyncLocal automatically when
/// the method's task completes.
/// </para>
/// <para>
/// Setting <see cref="Current"/> from synchronous code, however, will leak to the caller because
/// no async-method boundary is crossed. All Agent Framework call sites of this carrier are
/// inside <c>async</c> methods (<see cref="ClientHeadersAgent"/>), so the natural restoration
/// suffices for our needs.
/// </para>
/// </remarks>
internal static class ClientHeadersScope
{
private static readonly AsyncLocal<IReadOnlyDictionary<string, string>?> s_current = new();
/// <summary>Gets the dictionary captured by the most recent <see cref="Push(IReadOnlyDictionary{string, string}?)"/> on this async flow.</summary>
public static IReadOnlyDictionary<string, string>? Current => s_current.Value;
/// <summary>
/// Pushes a new value as the current scope. Disposing the returned token restores the previous value.
/// Gets or sets the per-async-flow client-header snapshot read by <see cref="ClientHeadersPolicy"/>.
/// </summary>
/// <param name="headers">The header dictionary to surface to the policy. May be <see langword="null"/>.</param>
public static Scope Push(IReadOnlyDictionary<string, string>? headers)
public static IReadOnlyDictionary<string, string>? Current
{
var previous = s_current.Value;
s_current.Value = headers;
return new Scope(previous);
}
/// <summary>Disposable token that restores the previous scope on <see cref="Dispose"/>.</summary>
internal readonly struct Scope : System.IDisposable
{
private readonly IReadOnlyDictionary<string, string>? _previous;
internal Scope(IReadOnlyDictionary<string, string>? previous)
{
this._previous = previous;
}
public void Dispose() => s_current.Value = this._previous;
get => s_current.Value;
set => s_current.Value = value;
}
}
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Globalization;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// UID/GID pair passed to <c>docker run --user</c>.
/// </summary>
/// <param name="Uid">User ID (numeric string, e.g. <c>"65534"</c>; <c>"root"</c> or <c>"0"</c> selects the container's root user).</param>
/// <param name="Gid">Group ID (numeric string).</param>
public sealed record ContainerUser(string Uid, string Gid)
{
/// <summary>
/// Default unprivileged user (<c>nobody:nogroup</c> on most distros, UID/GID 65534).
/// </summary>
public static ContainerUser Default { get; } = new("65534", "65534");
/// <summary>
/// Container root (UID/GID 0). Avoid in production; use only for diagnostics.
/// </summary>
public static ContainerUser Root { get; } = new("0", "0");
/// <summary>Render as the <c>uid:gid</c> string Docker expects.</summary>
public override string ToString() => $"{this.Uid}:{this.Gid}";
/// <summary>
/// Returns <see langword="true"/> when this user maps to UID 0 (root).
/// </summary>
public bool IsRoot =>
this.Uid.Equals("root", StringComparison.OrdinalIgnoreCase)
|| (int.TryParse(this.Uid, NumberStyles.Integer, CultureInfo.InvariantCulture, out var uid) && uid == 0);
}
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// Well-known values for the <c>network</c> parameter on
/// <see cref="DockerShellExecutor"/>. The parameter type stays
/// <see langword="string"/> so callers can supply user-defined networks
/// (e.g. <c>"my-private-net"</c>) — these constants exist for
/// discoverability and to avoid stringly-typed defaults.
/// </summary>
public static class DockerNetworkMode
{
/// <summary>No network — the container has no network interfaces. The default.</summary>
public const string None = "none";
/// <summary>Docker's default bridge network — egress to the host network.</summary>
public const string Bridge = "bridge";
/// <summary>Share the host's network namespace — strongly discouraged for untrusted code.</summary>
public const string Host = "host";
}
@@ -0,0 +1,636 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// Sandboxed shell tool backed by a Docker (or compatible) container runtime.
/// </summary>
/// <remarks>
/// <para>
/// Exposes the same public surface as <see cref="LocalShellExecutor"/> but executes
/// commands inside a container. The container is intended to be the
/// security boundary, and the defaults bias toward a restrictive baseline
/// (<c>--network none</c>, non-root user, <c>--read-only</c> root filesystem,
/// <c>--cap-drop=ALL</c>, <c>--security-opt=no-new-privileges</c>, memory and
/// pids limits, <c>--tmpfs /tmp</c>). These are a best-effort starting point,
/// NOT a guarantee: the actual isolation you get depends on the host kernel,
/// the container runtime, the image, and any caller-supplied
/// <c>ExtraRunArgs</c>. Do not rely on this tool as your sole defense against
/// untrusted input. Approval gating via <see cref="AsAIFunction"/> is the
/// primary safety control; pair it with the precautions you would normally
/// apply when running adversarial code: review the model's output before
/// acting on it, run on a host you can afford to lose, monitor for resource
/// exhaustion, and consider stronger isolation (a dedicated VM, gVisor/Kata,
/// network segmentation) when stakes are high.
/// </para>
/// <para>
/// Persistent mode reuses <see cref="ShellSession"/> by launching
/// <c>docker exec -i &lt;container&gt; bash --noprofile --norc</c> as the
/// long-lived shell — the sentinel protocol works unchanged because the
/// host process is still a bash REPL connected over pipes. Stateless mode
/// runs each call in a fresh <c>docker run --rm</c>.
/// </para>
/// <para>
/// <b>Single-session ownership.</b> In persistent mode the executor owns a long-lived
/// container plus the bash REPL inside it. That container's filesystem, environment,
/// working directory, and any artifacts the agent has produced are visible to every
/// subsequent command, and a single stdin/stdout pipe serializes every call. A
/// persistent-mode <see cref="DockerShellExecutor"/> is therefore intended to be owned by
/// exactly one conversation / agent session — i.e., one user. Do not share one instance
/// across users, tenants, or concurrent conversations: their state leaks together inside
/// the container and commands queue behind each other. Create one executor per session,
/// dispose it when the session ends (disposal stops and removes the container), and in DI
/// scenarios register it with a per-session scope. If a shared instance is genuinely
/// required, use <see cref="ShellMode.Stateless"/>, which gives each call its own
/// throwaway <c>docker run --rm</c>.
/// </para>
/// </remarks>
public sealed class DockerShellExecutor : ShellExecutor
{
/// <summary>Default container image. A small Microsoft-maintained Linux base.</summary>
public const string DefaultImage = "mcr.microsoft.com/azurelinux/base/core:3.0";
/// <summary>Default Docker network mode (no network).</summary>
internal const string DefaultNetwork = DockerNetworkMode.None;
/// <summary>Default container memory limit, in bytes (512 MiB).</summary>
internal const long DefaultMemoryBytes = 512L * 1024 * 1024;
/// <summary>Default pids limit.</summary>
public const int DefaultPidsLimit = 256;
/// <summary>Default container working directory.</summary>
public const string DefaultContainerWorkdir = "/workspace";
/// <summary>
/// Recommended default per-command timeout (30 seconds). Pass this
/// explicitly via <see cref="DockerShellExecutorOptions.Timeout"/> to
/// opt in. Note that <see langword="null"/> (the property default) means
/// <em>no timeout</em>.
/// </summary>
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
private readonly string _image;
private readonly ShellMode _mode;
private readonly string? _hostWorkdir;
private readonly string _containerWorkdir;
private readonly bool _mountReadonly;
private readonly string _network;
private readonly long _memoryBytes;
private readonly int _pidsLimit;
private readonly ContainerUser _user;
private readonly bool _readOnlyRoot;
private readonly IReadOnlyList<string> _extraRunArgs;
private readonly IReadOnlyDictionary<string, string> _env;
private readonly ShellPolicy _policy;
private readonly TimeSpan? _timeout;
private readonly int _maxOutputBytes;
private ShellSession? _session;
private bool _containerStarted;
private readonly SemaphoreSlim _lifecycleLock = new(1, 1);
/// <summary>
/// Initializes a new instance of the <see cref="DockerShellExecutor"/>
/// class with default options.
/// </summary>
public DockerShellExecutor() : this(new DockerShellExecutorOptions())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="DockerShellExecutor"/> class.
/// </summary>
/// <param name="options">Configuration. <see langword="null"/> selects defaults.</param>
public DockerShellExecutor(DockerShellExecutorOptions options)
{
_ = Throw.IfNull(options);
_ = Throw.IfNull(options.Image);
if (options.MaxOutputBytes <= 0)
{
throw new ArgumentOutOfRangeException(nameof(options), $"{nameof(options.MaxOutputBytes)} must be positive.");
}
if (options.MemoryBytes is <= 0)
{
throw new ArgumentOutOfRangeException(nameof(options), $"{nameof(options.MemoryBytes)} must be positive.");
}
this._image = options.Image;
this.ContainerName = options.ContainerName ?? GenerateContainerName();
this._mode = options.Mode;
this._hostWorkdir = options.HostWorkdir;
this._containerWorkdir = options.ContainerWorkdir ?? DefaultContainerWorkdir;
this._mountReadonly = options.MountReadonly;
this._network = options.Network ?? DefaultNetwork;
this._memoryBytes = options.MemoryBytes ?? DefaultMemoryBytes;
this._pidsLimit = options.PidsLimit;
this._user = options.User ?? ContainerUser.Default;
this._readOnlyRoot = options.ReadOnlyRoot;
this._extraRunArgs = options.ExtraRunArgs ?? Array.Empty<string>();
this._env = options.Environment ?? new Dictionary<string, string>();
this._policy = options.Policy ?? new ShellPolicy();
this._timeout = options.Timeout;
this._maxOutputBytes = options.MaxOutputBytes;
this.DockerBinary = options.DockerBinary ?? "docker";
}
/// <summary>Gets the container name (auto-generated when not specified at construction).</summary>
public string ContainerName { get; }
/// <summary>Gets the docker binary path.</summary>
public string DockerBinary { get; }
/// <summary>Eagerly start the container (and inner shell session in persistent mode).</summary>
public override async Task InitializeAsync(CancellationToken cancellationToken = default)
{
await this._lifecycleLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (this._containerStarted)
{
return;
}
await this.StartContainerAsync(cancellationToken).ConfigureAwait(false);
this._containerStarted = true;
if (this._mode == ShellMode.Persistent)
{
var execArgv = BuildExecArgv(this.DockerBinary, this.ContainerName);
// BuildExecArgv already includes the bash flags
// (--noprofile --norc) at the end of the argv. We pass
// ShellKind.Sh here (not Bash) because Sh's
// PersistentArgv() returns an empty suffix and forwards
// ExtraArgv unchanged; Bash would re-append
// --noprofile/--norc and produce a duplicated argv.
var inner = new ResolvedShell(execArgv[0], ShellKind.Sh, ExtraArgv: execArgv.Skip(1).ToArray());
this._session = new ShellSession(
inner,
workingDirectory: null, // workdir is set on the container itself
confineWorkingDirectory: false,
environment: null,
cleanEnvironment: false,
maxOutputBytes: this._maxOutputBytes);
}
}
finally
{
_ = this._lifecycleLock.Release();
}
}
/// <inheritdoc />
public override async ValueTask DisposeAsync()
{
await this._lifecycleLock.WaitAsync().ConfigureAwait(false);
try
{
if (this._session is not null)
{
try { await this._session.DisposeAsync().ConfigureAwait(false); }
finally { this._session = null; }
}
if (this._containerStarted)
{
await this.StopContainerAsync().ConfigureAwait(false);
this._containerStarted = false;
}
}
finally
{
_ = this._lifecycleLock.Release();
}
this._lifecycleLock.Dispose();
}
/// <summary>Run a single command inside the container.</summary>
/// <exception cref="ShellCommandRejectedException">Thrown when the policy denies the command.</exception>
public override async Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default)
{
if (command is null)
{
throw new ArgumentNullException(nameof(command));
}
var decision = this._policy.Evaluate(new ShellRequest(command, this._containerWorkdir));
if (!decision.Allowed)
{
throw new ShellCommandRejectedException(
$"Command rejected by policy: {decision.Reason ?? "(unspecified)"}");
}
if (this._mode == ShellMode.Persistent)
{
if (this._session is null)
{
await this.InitializeAsync(cancellationToken).ConfigureAwait(false);
}
return await this._session!.RunAsync(command, this._timeout, cancellationToken).ConfigureAwait(false);
}
return await this.RunStatelessAsync(command, cancellationToken).ConfigureAwait(false);
}
/// <summary>Format a byte count into the value passed to <c>docker --memory</c> (e.g. <c>536870912b</c>).</summary>
internal static string FormatMemoryBytes(long memoryBytes) =>
memoryBytes.ToString(System.Globalization.CultureInfo.InvariantCulture) + "b";
/// <summary>
/// Build the AIFunction for this tool.
/// </summary>
/// <remarks>
/// When <paramref name="requireApproval"/> is <see langword="null"/>
/// (the default), the returned function is wrapped in
/// <see cref="ApprovalRequiredAIFunction"/>. The caller must
/// explicitly pass <see langword="false"/> to opt out of approval
/// gating. Container configuration alone is not a sufficient signal
/// to safely auto-execute model-generated commands — the
/// approval/policy decision belongs to the agent author.
/// </remarks>
/// <param name="name">Function name surfaced to the model.</param>
/// <param name="description">Function description for the model.</param>
/// <param name="requireApproval">
/// <see langword="true"/> or <see langword="null"/> (the default)
/// wraps the function in <see cref="ApprovalRequiredAIFunction"/>;
/// <see langword="false"/> opts out and returns the raw function.
/// </param>
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool? requireApproval = null)
{
var effectiveRequireApproval = requireApproval ?? true;
description ??=
"Execute a single shell command inside an isolated Docker container and return its " +
"stdout, stderr, and exit code. The container has no network, no host filesystem access " +
"(except an optional read-only workspace mount), and runs as a non-root user. " +
(this._mode == ShellMode.Persistent
? "PERSISTENT MODE: a single long-lived container handles every call; cd and exported variables persist."
: "STATELESS MODE: each call runs in a fresh container.");
var fn = AIFunctionFactory.Create(
async ([Description("The shell command to execute.")] string command,
CancellationToken cancellationToken) =>
{
try
{
var result = await this.RunAsync(command, cancellationToken).ConfigureAwait(false);
return result.FormatForModel();
}
catch (ShellCommandRejectedException ex)
{
// ex.Message already starts with "Command rejected by policy: ...".
return ex.Message;
}
},
new AIFunctionFactoryOptions { Name = name, Description = description });
return effectiveRequireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
}
/// <summary>
/// Probe whether the configured docker binary can be reached. Returns
/// <see langword="true"/> only if the binary exists on PATH and
/// <c>docker version</c> succeeds within ~5 seconds.
/// </summary>
public static async Task<bool> IsAvailableAsync(string binary = "docker", CancellationToken cancellationToken = default)
{
try
{
var psi = new ProcessStartInfo
{
FileName = binary,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add("version");
psi.ArgumentList.Add("--format");
psi.ArgumentList.Add("{{.Server.Version}}");
using var proc = new Process { StartInfo = psi };
if (!proc.Start())
{
return false;
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));
try
{
await proc.WaitForExitAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
try { proc.Kill(entireProcessTree: true); } catch { }
return false;
}
return proc.ExitCode == 0;
}
catch (Win32Exception)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
}
// ------------------------------------------------------------------
// Pure argv builders — kept side-effect-free so tests don't need Docker.
// ------------------------------------------------------------------
/// <summary>Build the <c>docker run -d</c> argv that starts the long-lived container.</summary>
public static IReadOnlyList<string> BuildRunArgv(
string binary,
string image,
string containerName,
ContainerUser user,
string network,
long memoryBytes,
int pidsLimit,
string workdir,
string? hostWorkdir,
bool mountReadonly,
bool readOnlyRoot,
IReadOnlyDictionary<string, string>? extraEnv,
IReadOnlyList<string>? extraArgs)
{
_ = Throw.IfNull(user);
var argv = new List<string>
{
binary,
"run",
"-d",
"--rm",
"--name", containerName,
"--user", user.ToString(),
"--network", network,
"--memory", FormatMemoryBytes(memoryBytes),
"--pids-limit", pidsLimit.ToString(System.Globalization.CultureInfo.InvariantCulture),
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--tmpfs", "/tmp:rw,nosuid,nodev,size=64m",
"--workdir", workdir,
};
if (readOnlyRoot)
{
argv.Add("--read-only");
}
if (hostWorkdir is not null)
{
var ro = mountReadonly ? "ro" : "rw";
argv.Add("-v");
argv.Add($"{hostWorkdir}:{workdir}:{ro}");
}
if (extraEnv is not null)
{
foreach (var kv in extraEnv)
{
argv.Add("-e");
argv.Add($"{kv.Key}={kv.Value}");
}
}
if (extraArgs is not null)
{
foreach (var a in extraArgs) { argv.Add(a); }
}
argv.Add(image);
argv.Add("sleep");
argv.Add("infinity");
return argv;
}
/// <summary>
/// Build the <c>docker exec -i &lt;container&gt; bash --noprofile --norc</c> argv for
/// the persistent inner shell. Stateless callers should use
/// <see cref="BuildRunArgvStateless"/>; this method intentionally does
/// not produce a stand-alone command argv.
/// </summary>
public static IReadOnlyList<string> BuildExecArgv(string binary, string containerName)
{
return new List<string> { binary, "exec", "-i", containerName, "bash", "--noprofile", "--norc" };
}
private async Task StartContainerAsync(CancellationToken cancellationToken)
{
var argv = BuildRunArgv(
this.DockerBinary, this._image, this.ContainerName, this._user, this._network,
this._memoryBytes, this._pidsLimit, this._containerWorkdir, this._hostWorkdir,
this._mountReadonly, this._readOnlyRoot, this._env, this._extraRunArgs);
var (exit, _, stderr) = await RunDockerCommandAsync(argv, cancellationToken).ConfigureAwait(false);
if (exit != 0)
{
throw new DockerNotAvailableException(
$"Failed to start container ({exit}): {stderr.Trim()}");
}
}
private async Task StopContainerAsync()
{
var argv = new[] { this.DockerBinary, "rm", "-f", this.ContainerName };
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
_ = await RunDockerCommandAsync(argv, cts.Token).ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException || ex is Win32Exception || ex is InvalidOperationException)
{
// Best-effort teardown.
}
}
private async Task<ShellResult> RunStatelessAsync(string command, CancellationToken cancellationToken)
{
var perCallName = GenerateContainerName();
var argv = new List<string>(this.BuildRunArgvStateless(perCallName));
argv.Add(this._image);
argv.Add("bash");
argv.Add("-c");
argv.Add(command);
var stopwatch = Stopwatch.StartNew();
var stdoutBuf = new HeadTailBuffer(this._maxOutputBytes);
var stderrBuf = new HeadTailBuffer(this._maxOutputBytes);
var psi = new ProcessStartInfo
{
FileName = argv[0],
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
for (var i = 1; i < argv.Count; i++) { psi.ArgumentList.Add(argv[i]); }
using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
proc.OutputDataReceived += (_, e) => { if (e.Data is not null) { stdoutBuf.AppendLine(e.Data); } };
proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) { stderrBuf.AppendLine(e.Data); } };
try { _ = proc.Start(); }
catch (Win32Exception ex)
{
throw new IOException($"Failed to launch '{this.DockerBinary}': {ex.Message}", ex);
}
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
var timedOut = false;
using var timeoutCts = this._timeout is null
? new CancellationTokenSource()
: new CancellationTokenSource(this._timeout.Value);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
try
{
await proc.WaitForExitAsync(linkedCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
timedOut = true;
// Kill the running container by name; --rm reaps it.
await this.BestEffortKillContainerAsync(perCallName).ConfigureAwait(false);
try { await proc.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); }
catch (Exception ex) when (ex is InvalidOperationException || ex is Win32Exception) { }
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Caller-driven cancellation: --rm only fires when PID 1 exits, so
// if we just propagate, the container keeps running indefinitely.
// Kill it explicitly before rethrowing so we don't leak containers.
await this.BestEffortKillContainerAsync(perCallName).ConfigureAwait(false);
try { await proc.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); }
catch (Exception ex) when (ex is InvalidOperationException || ex is Win32Exception) { }
throw;
}
proc.WaitForExit();
stopwatch.Stop();
var (sout, soutT) = stdoutBuf.ToFinalString();
var (serr, serrT) = stderrBuf.ToFinalString();
return new ShellResult(
Stdout: sout,
Stderr: serr,
ExitCode: timedOut ? 124 : proc.ExitCode,
Duration: stopwatch.Elapsed,
Truncated: soutT || serrT,
TimedOut: timedOut);
}
private List<string> BuildRunArgvStateless(string perCallName)
{
var argv = new List<string>
{
this.DockerBinary,
"run", "--rm", "-i",
"--name", perCallName,
"--user", this._user.ToString(),
"--network", this._network,
"--memory", FormatMemoryBytes(this._memoryBytes),
"--pids-limit", this._pidsLimit.ToString(System.Globalization.CultureInfo.InvariantCulture),
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--tmpfs", "/tmp:rw,nosuid,nodev,size=64m",
"--workdir", this._containerWorkdir,
};
if (this._readOnlyRoot) { argv.Add("--read-only"); }
if (this._hostWorkdir is not null)
{
var ro = this._mountReadonly ? "ro" : "rw";
argv.Add("-v");
argv.Add($"{this._hostWorkdir}:{this._containerWorkdir}:{ro}");
}
foreach (var kv in this._env)
{
argv.Add("-e");
argv.Add($"{kv.Key}={kv.Value}");
}
foreach (var a in this._extraRunArgs) { argv.Add(a); }
return argv;
}
private async Task BestEffortKillContainerAsync(string containerName)
{
try
{
using var killCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
_ = await RunDockerCommandAsync(
new[] { this.DockerBinary, "kill", "--signal", "KILL", containerName }, killCts.Token).ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException || ex is Win32Exception || ex is InvalidOperationException)
{
// best-effort: container may already be gone
}
}
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunDockerCommandAsync(
IReadOnlyList<string> argv, CancellationToken cancellationToken)
{
var psi = new ProcessStartInfo
{
FileName = argv[0],
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
for (var i = 1; i < argv.Count; i++) { psi.ArgumentList.Add(argv[i]); }
// Cap helper-command output at 1 MiB. These commands (`docker version`,
// `docker kill`, `docker pull`) shouldn't produce more than that, but a
// chatty `docker pull` progress stream can easily run into hundreds of
// KiB; bound the buffer so we never exhaust memory on misbehaviour.
const int HelperOutputCap = 1 * 1024 * 1024;
var stdoutBuf = new HeadTailBuffer(HelperOutputCap);
var stderrBuf = new HeadTailBuffer(HelperOutputCap);
using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
proc.OutputDataReceived += (_, e) => { if (e.Data is not null) { stdoutBuf.AppendLine(e.Data); } };
proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) { stderrBuf.AppendLine(e.Data); } };
_ = proc.Start();
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
await proc.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
proc.WaitForExit();
return (proc.ExitCode, stdoutBuf.ToFinalString().text, stderrBuf.ToFinalString().text);
}
private static string GenerateContainerName()
{
var bytes = new byte[6];
#if NET6_0_OR_GREATER
RandomNumberGenerator.Fill(bytes);
#else
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
#endif
#pragma warning disable CA1308
return "af-shell-" + Convert.ToHexString(bytes).ToLowerInvariant();
#pragma warning restore CA1308
}
}
/// <summary>
/// Thrown when the configured docker (or compatible) binary cannot start a
/// container — typically because the daemon isn't running, the image
/// can't be pulled, or the binary isn't on PATH.
/// </summary>
public sealed class DockerNotAvailableException : Exception
{
/// <summary>Initializes a new instance of the <see cref="DockerNotAvailableException"/> class.</summary>
public DockerNotAvailableException() { }
/// <summary>Initializes a new instance of the <see cref="DockerNotAvailableException"/> class.</summary>
/// <param name="message">The exception message.</param>
public DockerNotAvailableException(string message) : base(message) { }
/// <summary>Initializes a new instance of the <see cref="DockerNotAvailableException"/> class.</summary>
/// <param name="message">The exception message.</param>
/// <param name="inner">The inner exception.</param>
public DockerNotAvailableException(string message, Exception inner) : base(message, inner) { }
}
@@ -0,0 +1,78 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// Configuration for <see cref="DockerShellExecutor"/>. New knobs will be
/// added as properties here so the constructor surface stays binary-stable.
/// </summary>
public sealed class DockerShellExecutorOptions
{
/// <summary>OCI image to run. Must include <c>bash</c> and (for persistent mode) <c>sleep</c>.</summary>
public string Image { get; set; } = DockerShellExecutor.DefaultImage;
/// <summary>Optional container name. When <see langword="null"/>, a unique name is generated.</summary>
public string? ContainerName { get; set; }
/// <summary>
/// Execution mode. Defaults to <see cref="ShellMode.Persistent"/>.
/// <para>
/// In <see cref="ShellMode.Persistent"/> the resulting executor instance owns a
/// long-lived container plus the bash REPL inside it, and is intended to be owned
/// by a single conversation / agent session; do not share it across users or
/// concurrent sessions. See <see cref="DockerShellExecutor"/> remarks.
/// </para>
/// </summary>
public ShellMode Mode { get; set; } = ShellMode.Persistent;
/// <summary>Optional host directory mounted at <see cref="ContainerWorkdir"/>.</summary>
public string? HostWorkdir { get; set; }
/// <summary>Path inside the container. Defaults to <c>/workspace</c>.</summary>
public string ContainerWorkdir { get; set; } = DockerShellExecutor.DefaultContainerWorkdir;
/// <summary>When <see langword="true"/> (the default), the host workdir is mounted read-only.</summary>
public bool MountReadonly { get; set; } = true;
/// <summary>Docker network mode. Defaults to <see cref="DockerNetworkMode.None"/>.</summary>
public string Network { get; set; } = DockerNetworkMode.None;
/// <summary>Container memory limit, in bytes. <see langword="null"/> selects 512 MiB.</summary>
public long? MemoryBytes { get; set; }
/// <summary>Max processes inside the container.</summary>
public int PidsLimit { get; set; } = DockerShellExecutor.DefaultPidsLimit;
/// <summary>Container user. Defaults to <see cref="ContainerUser.Default"/> (nobody).</summary>
public ContainerUser User { get; set; } = ContainerUser.Default;
/// <summary>When <see langword="true"/> (the default), the container root filesystem is read-only.</summary>
public bool ReadOnlyRoot { get; set; } = true;
/// <summary>Additional args appended to <c>docker run</c>.</summary>
public IReadOnlyList<string>? ExtraRunArgs { get; set; }
/// <summary>Environment variables passed via <c>-e</c> to every command.</summary>
public IReadOnlyDictionary<string, string>? Environment { get; set; }
/// <summary>
/// Optional <see cref="ShellPolicy"/>. When <see langword="null"/>,
/// a default (empty) policy is used that allows any non-empty command.
/// Container isolation is the security boundary for Docker mode; a
/// <see cref="ShellPolicy"/> here is a UX pre-filter for shapes you
/// would rather see rejected with a clear error than run.
/// </summary>
public ShellPolicy? Policy { get; set; }
/// <summary>Per-command timeout. <see langword="null"/> disables timeouts.</summary>
public TimeSpan? Timeout { get; set; }
/// <summary>Per-stream cap before head+tail truncation. Defaults to 64 KiB.</summary>
public int MaxOutputBytes { get; set; } = 64 * 1024;
/// <summary>Override (e.g. <c>podman</c>).</summary>
public string DockerBinary { get; set; } = "docker";
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// Helpers shared by <see cref="LocalShellExecutor"/> and <see cref="ShellSession"/> for
/// the <c>cleanEnvironment</c> mode where the spawned shell does not inherit the parent
/// process environment — except for a small allowlist of variables that the shell needs
/// to locate itself and basic tools.
/// </summary>
internal static class EnvironmentSanitizer
{
/// <summary>
/// Variables propagated from the host environment when <c>cleanEnvironment</c> is true.
/// Add new entries here only — both the stateless and persistent code paths consume this list.
/// </summary>
public static readonly IReadOnlyList<string> PreservedVariables = new[]
{
"PATH",
"HOME",
"USER",
"USERNAME",
"USERPROFILE",
"SystemRoot",
"TEMP",
"TMP",
};
/// <summary>
/// Strip everything from <paramref name="environment"/> except the entries named by
/// <see cref="PreservedVariables"/>. Lookup is case-insensitive so it works on both
/// Windows (case-insensitive env vars) and POSIX (case-sensitive but typed in the
/// expected case). Variables that aren't present in the input dictionary are skipped.
/// </summary>
/// <param name="environment">The environment dictionary to sanitize in-place.</param>
public static void RemoveNonPreserved(IDictionary<string, string?> environment)
{
if (environment is null)
{
return;
}
var keep = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var name in PreservedVariables)
{
if (environment.TryGetValue(name, out var v) && v is not null)
{
keep[name] = v;
}
}
environment.Clear();
foreach (var kv in keep)
{
environment[kv.Key] = kv.Value;
}
}
}
@@ -0,0 +1,120 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// Bounded accumulator that keeps the first half of the input and the most recent
/// half (rolling tail), summing to <c>cap</c> UTF-8 bytes total. When the input fits
/// in <c>cap</c> bytes, the result is the original concatenation. Otherwise the middle
/// is dropped and the result includes a "[... truncated N bytes ...]" marker.
/// </summary>
/// <remarks>
/// <para>
/// Used by <see cref="LocalShellExecutor"/> and <see cref="DockerShellExecutor"/> when
/// streaming stdout / stderr from a long-running subprocess. Memory usage is bounded
/// at roughly <c>cap</c> bytes regardless of how much is appended.
/// </para>
/// <para>
/// The buffer counts UTF-8 bytes (matching the public <c>maxOutputBytes</c> contract
/// and <see cref="ShellSession.TruncateHeadTail"/>). Append happens one rune at a time
/// — when the head fills, the next rune's UTF-8 bytes go to the tail as an indivisible
/// unit, and the oldest rune is dropped from the tail. This guarantees the final
/// string never contains a split rune (no orphan surrogates, no invalid UTF-8).
/// </para>
/// </remarks>
internal sealed class HeadTailBuffer
{
private readonly int _cap;
private readonly int _headCap;
private readonly int _tailCap;
private readonly List<byte> _head = new();
// Tail is a queue of complete rune-byte-sequences so we can drop oldest rune
// atomically when capacity is exceeded.
private readonly Queue<byte[]> _tail = new();
private int _tailBytes;
private long _totalBytes;
public HeadTailBuffer(int cap)
{
this._cap = cap < 0 ? 0 : cap;
// Split the budget so head and tail sum to exactly _cap. With odd caps,
// the extra byte goes to the tail. This guarantees that any input whose
// UTF-8 size is <= _cap round-trips losslessly (no silent data drop).
this._headCap = this._cap / 2;
this._tailCap = this._cap - this._headCap;
}
public void AppendLine(string line)
{
this.AppendInternal(line);
this.AppendInternal("\n");
}
private void AppendInternal(string s)
{
Span<byte> scratch = stackalloc byte[4];
foreach (var rune in s.EnumerateRunes())
{
// Encode this rune to its UTF-8 bytes (1-4 bytes).
var n = rune.EncodeToUtf8(scratch);
this._totalBytes += n;
if (this._head.Count + n <= this._headCap)
{
for (var i = 0; i < n; i++) { this._head.Add(scratch[i]); }
continue;
}
// Head is full — append to tail as a single rune-sized chunk.
var bytes = scratch[..n].ToArray();
this._tail.Enqueue(bytes);
this._tailBytes += n;
// Evict whole runes from the front of the tail until we fit.
while (this._tailBytes > this._tailCap && this._tail.Count > 0)
{
var dropped = this._tail.Dequeue();
this._tailBytes -= dropped.Length;
}
}
}
public (string text, bool truncated) ToFinalString()
{
if (this._totalBytes <= this._cap)
{
var combinedBytes = new byte[this._head.Count + this._tailBytes];
this._head.CopyTo(combinedBytes, 0);
var offset = this._head.Count;
foreach (var chunk in this._tail)
{
Array.Copy(chunk, 0, combinedBytes, offset, chunk.Length);
offset += chunk.Length;
}
return (Encoding.UTF8.GetString(combinedBytes), false);
}
var dropped = this._totalBytes - this._head.Count - this._tailBytes;
var headStr = Encoding.UTF8.GetString(this._head.ToArray());
var tailBytes = new byte[this._tailBytes];
var tailOffset = 0;
foreach (var chunk in this._tail)
{
Array.Copy(chunk, 0, tailBytes, tailOffset, chunk.Length);
tailOffset += chunk.Length;
}
var tailStr = Encoding.UTF8.GetString(tailBytes);
var sb = new StringBuilder(headStr.Length + tailStr.Length + 64);
_ = sb.Append(headStr);
_ = sb.Append('\n');
_ = sb.Append("[... truncated ").Append(dropped).Append(" bytes ...]");
_ = sb.Append('\n');
_ = sb.Append(tailStr);
return (sb.ToString(), true);
}
}
@@ -0,0 +1,489 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// Cross-platform shell tool. <b>Approval-in-the-loop is the security boundary.</b>
/// </summary>
/// <remarks>
/// <para>
/// <c>LocalShellExecutor</c> launches a real shell (bash/sh on POSIX, pwsh/powershell/cmd on Windows)
/// to execute commands emitted by an agent. Output is captured, optionally truncated, and a
/// timeout terminates the process tree.
/// </para>
/// <para>
/// Both <see cref="ShellMode.Stateless"/> (every call spawns a fresh shell) and
/// <see cref="ShellMode.Persistent"/> (a long-lived shell that preserves <c>cd</c>, exported
/// variables, etc. across calls via a sentinel protocol) are supported. Persistent mode is the
/// recommended default for coding agents because it eliminates a class of "agent runs cd and
/// then runs the wrong path" failures.
/// </para>
/// <para>
/// <b>Single-session ownership.</b> A persistent-mode executor is owned by a single
/// conversation / agent session — i.e., a single user. The backing shell process carries
/// mutable state (working directory, exported variables, shell history, background jobs)
/// that is visible to every command run through it, and a single stdin/stdout pipe
/// serializes every call. Do not share one instance across users, tenants, or concurrent
/// conversations: state leaks between them and commands queue behind each other. Create
/// one <see cref="LocalShellExecutor"/> per session, dispose it when the session ends, and
/// in DI scenarios register it with a per-session scope (not as a singleton). If a shared
/// instance is genuinely required, use <see cref="ShellMode.Stateless"/>.
/// </para>
/// <para>
/// <b>Threat model.</b> The deny list is a guardrail, not a security boundary. Real isolation
/// requires either (a) approval-in-the-loop, where every command is reviewed by a human via the
/// harness <c>ToolApprovalAgent</c> (this is the default; see
/// <see cref="AsAIFunction(string, string?, bool)"/>), or (b) container isolation
/// (<c>DockerShellExecutor</c>). To produce an unapproved <see cref="AIFunction"/> you must pass
/// <c>acknowledgeUnsafe: true</c> at construction; otherwise <see cref="AsAIFunction"/> will
/// refuse to return a non-approval-gated function.
/// </para>
/// </remarks>
public sealed class LocalShellExecutor : ShellExecutor
{
/// <summary>
/// Recommended default per-command timeout (30 seconds). Pass this
/// explicitly via <see cref="LocalShellExecutorOptions.Timeout"/> to opt
/// in. Note that <see langword="null"/> (the property default) means
/// <em>no timeout</em>.
/// </summary>
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
private readonly ShellMode _mode;
private readonly ShellPolicy _policy;
private readonly ResolvedShell _shell;
private readonly TimeSpan? _timeout;
private readonly int _maxOutputBytes;
private readonly string? _workingDirectory;
private readonly bool _confineWorkingDirectory;
private readonly IReadOnlyDictionary<string, string?>? _environment;
private readonly bool _cleanEnvironment;
private readonly bool _acknowledgeUnsafe;
private ShellSession? _session;
private readonly object _sessionGate = new();
/// <summary>
/// Initializes a new instance of the <see cref="LocalShellExecutor"/>
/// class with default options.
/// </summary>
public LocalShellExecutor() : this(new LocalShellExecutorOptions())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="LocalShellExecutor"/> class.
/// </summary>
/// <param name="options">Configuration. <see langword="null"/> selects defaults.</param>
public LocalShellExecutor(LocalShellExecutorOptions options)
{
options ??= new LocalShellExecutorOptions();
if (options.MaxOutputBytes <= 0)
{
throw new ArgumentOutOfRangeException(nameof(options), $"{nameof(options.MaxOutputBytes)} must be positive.");
}
if (options.Shell is not null && options.ShellArgv is not null)
{
throw new ArgumentException($"Pass either {nameof(options.Shell)} or {nameof(options.ShellArgv)}, not both.", nameof(options));
}
this._mode = options.Mode;
this._policy = options.Policy ?? new ShellPolicy();
this._shell = options.ShellArgv is not null ? ShellResolver.ResolveArgv(options.ShellArgv) : ShellResolver.Resolve(options.Shell);
this._timeout = options.Timeout;
this._maxOutputBytes = options.MaxOutputBytes;
this._workingDirectory = options.WorkingDirectory;
this._confineWorkingDirectory = options.ConfineWorkingDirectory;
this._environment = options.Environment;
this._cleanEnvironment = options.CleanEnvironment;
this._acknowledgeUnsafe = options.AcknowledgeUnsafe;
if (this._mode == ShellMode.Persistent && this._shell.Kind == ShellKind.Cmd)
{
throw new NotSupportedException(
"Persistent mode is not supported for cmd.exe — use pwsh/powershell or override the shell with AGENT_FRAMEWORK_SHELL.");
}
}
/// <summary>Gets the resolved shell binary that will host commands.</summary>
public string ResolvedShellBinary => this._shell.Binary;
/// <summary>
/// Run a single command and return its result.
/// </summary>
/// <param name="command">The command to execute.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The captured <see cref="ShellResult"/>.</returns>
/// <exception cref="ShellCommandRejectedException">Thrown when the policy denies the command.</exception>
public override async Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default)
{
if (command is null)
{
throw new ArgumentNullException(nameof(command));
}
var decision = this._policy.Evaluate(new ShellRequest(command, this._workingDirectory));
if (!decision.Allowed)
{
throw new ShellCommandRejectedException(
$"Command rejected by policy: {decision.Reason ?? "(unspecified)"}");
}
return this._mode == ShellMode.Persistent
? await this.RunPersistentAsync(command, cancellationToken).ConfigureAwait(false)
: await this.RunStatelessAsync(command, cancellationToken).ConfigureAwait(false);
}
private async Task<ShellResult> RunPersistentAsync(string command, CancellationToken cancellationToken)
{
ShellSession session;
lock (this._sessionGate)
{
this._session ??= new ShellSession(
this._shell,
this._workingDirectory,
this._confineWorkingDirectory,
this._environment,
this._cleanEnvironment,
this._maxOutputBytes);
session = this._session;
}
return await session.RunAsync(command, this._timeout, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public override Task InitializeAsync(CancellationToken cancellationToken = default)
{
if (this._mode != ShellMode.Persistent)
{
return Task.CompletedTask;
}
ShellSession session;
lock (this._sessionGate)
{
this._session ??= new ShellSession(
this._shell,
this._workingDirectory,
this._confineWorkingDirectory,
this._environment,
this._cleanEnvironment,
this._maxOutputBytes);
session = this._session;
}
// Force a tiny no-op so the session spawns now rather than lazily.
return session.RunAsync(this._shell.Kind == ShellKind.PowerShell ? "$null" : ":", this._timeout, cancellationToken);
}
private async Task<ShellResult> RunStatelessAsync(string command, CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = this._shell.Binary,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = false,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = this._workingDirectory ?? Directory.GetCurrentDirectory(),
};
foreach (var arg in this._shell.StatelessArgvForCommand(command))
{
startInfo.ArgumentList.Add(arg);
}
if (this._cleanEnvironment)
{
EnvironmentSanitizer.RemoveNonPreserved(startInfo.Environment);
}
if (this._environment is not null)
{
foreach (var kv in this._environment)
{
if (kv.Value is null)
{
_ = startInfo.Environment.Remove(kv.Key);
}
else
{
startInfo.Environment[kv.Key] = kv.Value;
}
}
}
// PowerShell defaults to non-UTF8 output redirection; force UTF-8 to avoid mojibake.
if (this._shell.Kind == ShellKind.PowerShell)
{
startInfo.Environment["PSDefaultParameterValues"] = "Out-File:Encoding=utf8";
}
using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
var stdoutBuf = new HeadTailBuffer(this._maxOutputBytes);
var stderrBuf = new HeadTailBuffer(this._maxOutputBytes);
process.OutputDataReceived += (_, e) =>
{
if (e.Data is null) { return; }
stdoutBuf.AppendLine(e.Data);
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is null) { return; }
stderrBuf.AppendLine(e.Data);
};
var stopwatch = Stopwatch.StartNew();
try
{
_ = process.Start();
}
catch (Win32Exception ex)
{
throw new IOException(
$"Failed to launch shell '{this._shell.Binary}': {ex.Message}", ex);
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
var timedOut = false;
using var timeoutCts = this._timeout is null
? new CancellationTokenSource()
: new CancellationTokenSource(this._timeout.Value);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, timeoutCts.Token);
try
{
await process.WaitForExitAsync(linkedCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
timedOut = true;
}
catch (OperationCanceledException)
{
KillProcessTree(process);
throw;
}
if (timedOut)
{
KillProcessTree(process);
try
{
await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex) when (ex is InvalidOperationException || ex is Win32Exception)
{
// Best-effort shutdown after timeout — process may already be reaped.
}
}
stopwatch.Stop();
// Drain the async readers — WaitForExit doesn't guarantee the
// OutputDataReceived/ErrorDataReceived events have all fired.
process.WaitForExit();
var (stdout, soutTrunc) = stdoutBuf.ToFinalString();
var (stderr, serrTrunc) = stderrBuf.ToFinalString();
return new ShellResult(
Stdout: stdout,
Stderr: stderr,
ExitCode: timedOut ? 124 : process.ExitCode,
Duration: stopwatch.Elapsed,
Truncated: soutTrunc || serrTrunc,
TimedOut: timedOut);
}
/// <summary>
/// Build an <see cref="AIFunction"/> bound to this tool, suitable for
/// adding to <see cref="ChatOptions.Tools"/>.
/// </summary>
/// <param name="name">Function name surfaced to the model. Defaults to <c>run_shell</c>.</param>
/// <param name="description">Function description for the model.</param>
/// <param name="requireApproval">
/// When <see langword="true"/> (the default) the returned function is wrapped in
/// <see cref="ApprovalRequiredAIFunction"/>, so any agent built with
/// <c>UseFunctionInvocation()</c> + <c>UseToolApproval()</c> will surface a
/// <see cref="ToolApprovalRequestContent"/> that the harness can present to the user
/// before the command runs. This is the security boundary for the local shell tool —
/// disable only if you are intentionally running unattended (e.g. in a sandboxed
/// container where the tool itself is the boundary).
/// </param>
/// <returns>An <see cref="AIFunction"/> wrapping <see cref="RunAsync"/>.</returns>
public AIFunction AsAIFunction(string name = "run_shell", string? description = null, bool requireApproval = true)
{
if (!requireApproval && !this._acknowledgeUnsafe)
{
throw new InvalidOperationException(
"Refusing to produce an AIFunction without approval gating. " +
"Pass `acknowledgeUnsafe: true` to the LocalShellExecutor constructor to opt out, " +
"or leave `requireApproval: true` (the default).");
}
description ??= this.BuildDefaultDescription();
var fn = AIFunctionFactory.Create(
async ([Description("The shell command to execute.")] string command,
CancellationToken cancellationToken) =>
{
try
{
var result = await this.RunAsync(command, cancellationToken).ConfigureAwait(false);
return result.FormatForModel();
}
catch (ShellCommandRejectedException ex)
{
// ex.Message already starts with "Command rejected by policy: ...".
return ex.Message;
}
},
new AIFunctionFactoryOptions
{
Name = name,
Description = description,
});
return requireApproval ? new ApprovalRequiredAIFunction(fn) : fn;
}
/// <inheritdoc />
public override async ValueTask DisposeAsync()
{
ShellSession? session;
lock (this._sessionGate)
{
session = this._session;
this._session = null;
}
if (session is not null)
{
await session.DisposeAsync().ConfigureAwait(false);
}
}
private string BuildDefaultDescription()
{
var sb = new StringBuilder();
_ = sb.Append("Execute a single shell command on the local machine and return its stdout, stderr, and exit code.");
_ = sb.Append(' ');
var os = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows) ? "Windows"
: System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX) ? "macOS"
: System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Linux) ? "Linux"
: "POSIX";
_ = sb.Append("Operating system: ").Append(os).Append(". ");
var shellName = this._shell.Kind switch
{
ShellKind.PowerShell => "PowerShell (pwsh)",
ShellKind.Cmd => "cmd.exe",
ShellKind.Bash => "bash",
ShellKind.Sh => "POSIX sh (dash/ash)",
_ => "POSIX shell",
};
_ = sb.Append("Shell: ").Append(shellName).Append(" (binary: '").Append(this._shell.Binary).Append("'). ");
if (this._shell.Kind == ShellKind.PowerShell)
{
_ = sb.Append(
"Use PowerShell syntax — NOT bash/sh. Equivalents: ");
_ = sb.Append("`cd $env:TEMP` (NOT `cd /tmp`); ");
_ = sb.Append("`$env:VAR = 'x'` (NOT `VAR=x` or `export VAR=x`); ");
_ = sb.Append("`$env:VAR` (NOT `$VAR`); ");
_ = sb.Append("`Get-ChildItem` or `dir` (NOT `ls -la`); ");
_ = sb.Append("`Get-Content` or `cat` (built-in alias works); ");
_ = sb.Append("`Where-Object` / `Select-String` (NOT `grep`). ");
}
else if (this._shell.Kind is ShellKind.Bash or ShellKind.Sh)
{
_ = sb.Append("Use POSIX shell syntax. ");
if (this._shell.Kind == ShellKind.Sh)
{
_ = sb.Append("This is a minimal POSIX sh (likely dash/ash) — avoid bash-only features like `[[ ... ]]`, arrays, `<<<` here-strings, or `set -o pipefail`. ");
}
}
if (this._mode == ShellMode.Persistent)
{
_ = sb.Append(
"PERSISTENT MODE: a single long-lived shell handles every call. " +
"`cd`, exported / `$env:` variables, and function definitions DO persist across calls. " +
"Use this to your advantage: change directory once, then run subsequent commands without re-cd'ing.");
}
else
{
_ = sb.Append(
"STATELESS MODE: each call runs in a fresh shell. " +
"Working directory and environment variables DO NOT carry across calls — combine related steps into one command if state matters.");
}
_ = sb.Append(' ');
if (this._timeout is { } t)
{
_ = sb.Append("Per-call timeout: ").Append((int)t.TotalSeconds).Append("s. ");
}
_ = sb.Append("Output is truncated to ").Append(this._maxOutputBytes).Append(" bytes (head + tail). ");
_ = sb.Append("The user reviews and approves every call.");
return sb.ToString();
}
private static void KillProcessTree(Process process)
{
try
{
#if NET5_0_OR_GREATER
process.Kill(entireProcessTree: true);
#else
process.Kill();
#endif
}
catch (InvalidOperationException)
{
// Process already exited.
}
catch (Win32Exception)
{
// Best-effort tree-kill — child has likely already exited.
}
}
}
/// <summary>
/// Thrown when <see cref="LocalShellExecutor"/> rejects a command via its policy.
/// </summary>
public sealed class ShellCommandRejectedException : Exception
{
/// <summary>Initializes a new instance of the <see cref="ShellCommandRejectedException"/> class.</summary>
/// <param name="message">The exception message.</param>
public ShellCommandRejectedException(string message) : base(message)
{
}
/// <summary>Initializes a new instance of the <see cref="ShellCommandRejectedException"/> class.</summary>
/// <param name="message">The exception message.</param>
/// <param name="inner">The inner exception.</param>
public ShellCommandRejectedException(string message, Exception inner) : base(message, inner)
{
}
/// <summary>Initializes a new instance of the <see cref="ShellCommandRejectedException"/> class.</summary>
public ShellCommandRejectedException()
{
}
}
@@ -0,0 +1,91 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// Configuration for <see cref="LocalShellExecutor"/>. New knobs will be
/// added as properties here so the constructor surface stays binary-stable.
/// </summary>
public sealed class LocalShellExecutorOptions
{
/// <summary>
/// Execution mode. Defaults to <see cref="ShellMode.Persistent"/>.
/// <para>
/// In <see cref="ShellMode.Persistent"/> the resulting executor instance is owned by
/// a single conversation / agent session; do not share it across users or concurrent
/// sessions. See <see cref="LocalShellExecutor"/> remarks.
/// </para>
/// </summary>
public ShellMode Mode { get; set; } = ShellMode.Persistent;
/// <summary>
/// Override path to the shell binary. Falls back to the
/// <c>AGENT_FRAMEWORK_SHELL</c> environment variable, then OS defaults.
/// Mutually exclusive with <see cref="ShellArgv"/>.
/// </summary>
public string? Shell { get; set; }
/// <summary>
/// Override argv for the shell launch. The first element is the binary;
/// subsequent elements are passed as a launch-time prefix. Mutually
/// exclusive with <see cref="Shell"/>.
/// </summary>
public IReadOnlyList<string>? ShellArgv { get; set; }
/// <summary>
/// Working directory for the spawned shell. Defaults to the current
/// process directory. Required when <see cref="ConfineWorkingDirectory"/>
/// is <see langword="true"/>.
/// </summary>
public string? WorkingDirectory { get; set; }
/// <summary>
/// When <see langword="true"/> (the default), every command in
/// persistent mode is prefixed with a <c>cd</c> back into
/// <see cref="WorkingDirectory"/> so a wandering <c>cd</c> in one call
/// doesn't leak to the next.
/// </summary>
public bool ConfineWorkingDirectory { get; set; } = true;
/// <summary>
/// Extra environment variables. Pass a <see langword="null"/> value to
/// remove an inherited variable.
/// </summary>
public IReadOnlyDictionary<string, string?>? Environment { get; set; }
/// <summary>
/// When <see langword="true"/>, the spawned shell does not inherit the
/// parent process environment.
/// </summary>
public bool CleanEnvironment { get; set; }
/// <summary>
/// Optional <see cref="ShellPolicy"/>. When <see langword="null"/>,
/// a default (empty) policy is used that allows any non-empty command.
/// Supply a <see cref="ShellPolicy"/> with explicit deny/allow
/// patterns if you want pre-execution rejection of specific command
/// shapes; note that pattern matching is a UX pre-filter, not a
/// security control (see <see cref="ShellPolicy"/> remarks).
/// </summary>
public ShellPolicy? Policy { get; set; }
/// <summary>
/// Per-command timeout. <see langword="null"/> (the default) disables
/// timeouts. See <see cref="LocalShellExecutor.DefaultTimeout"/> for the
/// recommended value.
/// </summary>
public TimeSpan? Timeout { get; set; }
/// <summary>Per-stream cap before head+tail truncation. Defaults to 64 KiB.</summary>
public int MaxOutputBytes { get; set; } = 64 * 1024;
/// <summary>
/// Set to <see langword="true"/> to allow
/// <see cref="LocalShellExecutor.AsAIFunction"/> to produce an
/// AIFunction without an <c>ApprovalRequiredAIFunction</c> wrapper.
/// </summary>
public bool AcknowledgeUnsafe { get; set; }
}
@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Modern targets only; the underlying P/Invokes (setsid) and
async patterns are not validated against netstandard2.0/net472. -->
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<RootNamespace>Microsoft.Agents.AI.Tools.Shell</RootNamespace>
<VersionSuffix>preview</VersionSuffix>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<!-- These must appear AFTER the nuget-package.props import so they
override the shared defaults rather than being overwritten by them. -->
<PropertyGroup>
<Title>Microsoft Agent Framework - Shell Tools</Title>
<Description>Cross-platform shell tools for the Microsoft Agent Framework. Includes LocalShellExecutor and DockerShellExecutor with approval-in-the-loop semantics, plus ShellEnvironmentProvider for environment-aware system prompts.</Description>
</PropertyGroup>
<!-- Disable package validation baseline until the first release -->
<PropertyGroup>
<PackageValidationBaselineVersion />
<EnablePackageValidation>false</EnablePackageValidation>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Tools.Shell.UnitTests" />
</ItemGroup>
</Project>
@@ -0,0 +1,299 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// An <see cref="AIContextProvider"/> that probes the underlying shell
/// (OS, shell family/version, working directory, available CLI tools)
/// once per session and injects an authoritative instructions block so
/// the agent emits commands in the correct shell idiom.
/// </summary>
/// <remarks>
/// <para>
/// This addresses a common failure mode where a model defaults to bash
/// syntax while talking to a PowerShell session (or vice versa). Probes
/// run through the supplied <see cref="ShellExecutor"/>, so the same
/// provider works for both <see cref="LocalShellExecutor"/> (host shell) and
/// <see cref="DockerShellExecutor"/> (container shell).
/// </para>
/// <para>
/// The provider does not expose any new tools; it augments the system
/// prompt only (<see cref="AIContext.Instructions"/>). Probe failures
/// are swallowed in a narrow set of cases — per-probe timeout
/// (<see cref="TimeoutException"/>, or an
/// <see cref="OperationCanceledException"/> caused by the
/// <see cref="ShellEnvironmentProviderOptions.ProbeTimeout"/> linked
/// token), policy rejection (<see cref="ShellCommandRejectedException"/>),
/// and process spawn / pipe failures (<see cref="IOException"/>) —
/// and surfaced as <see langword="null"/> entries in the snapshot.
/// Caller-requested cancellation (a <see cref="CancellationToken"/>
/// passed in by the host) is NOT swallowed and propagates as an
/// <see cref="OperationCanceledException"/> so shutdown paths work.
/// Other exceptions (e.g. argument errors, internal bugs) propagate
/// normally. A missing CLI never fails the agent: the model simply
/// sees fewer hints in its system prompt.
/// </para>
/// <para>
/// <b>Why <see cref="AIContext.Instructions"/> rather than
/// <see cref="AIContext.Messages"/>?</b> The shell environment
/// (OS, family, version, CWD, available CLIs) is stable runtime
/// metadata, not per-turn retrieved data. The framework's
/// <c>AgentSkillsProvider</c> uses <c>Instructions</c> for the same
/// reason; <c>TextSearchProvider</c> and <c>ChatHistoryMemoryProvider</c>
/// use <c>Messages</c> for retrieval payloads that are <em>about</em>
/// the user's question. System-prompt steering also has higher weight
/// in major providers (OpenAI, Anthropic) and benefits from prompt
/// caching, so injecting the env block as a fake user message would
/// be both weaker and more expensive.
/// </para>
/// </remarks>
public sealed class ShellEnvironmentProvider : AIContextProvider
{
private readonly ShellExecutor _executor;
private readonly ShellEnvironmentProviderOptions _options;
private Task<ShellEnvironmentSnapshot>? _snapshotTask;
/// <summary>
/// Initializes a new instance of the <see cref="ShellEnvironmentProvider"/> class.
/// </summary>
/// <param name="executor">The shell executor used to run probe commands.</param>
/// <param name="options">Optional configuration; defaults are used when <see langword="null"/>.</param>
/// <exception cref="ArgumentNullException"><paramref name="executor"/> is <see langword="null"/>.</exception>
public ShellEnvironmentProvider(ShellExecutor executor, ShellEnvironmentProviderOptions? options = null)
{
this._executor = executor ?? throw new ArgumentNullException(nameof(executor));
this._options = options ?? new ShellEnvironmentProviderOptions();
}
/// <summary>
/// Gets the most recently captured snapshot, or <see langword="null"/>
/// if no probe has completed yet.
/// </summary>
public ShellEnvironmentSnapshot? CurrentSnapshot { get; private set; }
/// <summary>
/// Force a re-probe and refresh the cached snapshot. Useful when the
/// agent has changed something the snapshot depends on (e.g., installed
/// a new CLI mid-session).
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The freshly captured snapshot.</returns>
public async Task<ShellEnvironmentSnapshot> RefreshAsync(CancellationToken cancellationToken = default)
{
var snapshot = await this.ProbeAsync(cancellationToken).ConfigureAwait(false);
this.CurrentSnapshot = snapshot;
this._snapshotTask = Task.FromResult(snapshot);
return snapshot;
}
/// <inheritdoc />
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
// First-call wins: subsequent concurrent callers await the same Task.
// If the cached task faults or is cancelled, clear it so the next call
// re-probes instead of permanently poisoning the provider.
var task = this._snapshotTask;
if (task is null)
{
var fresh = this.ProbeAsync(cancellationToken);
task = Interlocked.CompareExchange(ref this._snapshotTask, fresh, null) ?? fresh;
}
ShellEnvironmentSnapshot snapshot;
try
{
snapshot = await task.ConfigureAwait(false);
}
catch
{
// Replace the cached failed task with null only if no other thread
// has already done so. Concurrent waiters will all observe the
// failure once, but the next call starts a fresh probe.
_ = Interlocked.CompareExchange(ref this._snapshotTask, null, task);
throw;
}
this.CurrentSnapshot = snapshot;
var formatter = this._options.InstructionsFormatter ?? DefaultInstructionsFormatter;
return new AIContext { Instructions = formatter(snapshot) };
}
private async Task<ShellEnvironmentSnapshot> ProbeAsync(CancellationToken cancellationToken)
{
var family = this._options.OverrideFamily ?? DetectFamily();
await this._executor.InitializeAsync(cancellationToken).ConfigureAwait(false);
var (shellVersion, workingDir) = await this.ProbeShellAndCwdAsync(family, cancellationToken).ConfigureAwait(false);
var toolVersions = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var tool in this._options.ProbeTools)
{
// ProbeTools is user-supplied. Skip duplicates that differ only by
// case (e.g., "git" and "GIT") so we don't probe the same CLI twice
// and don't depend on dictionary insertion order for the result.
if (toolVersions.ContainsKey(tool))
{
continue;
}
toolVersions[tool] = await this.ProbeToolVersionAsync(tool, cancellationToken).ConfigureAwait(false);
}
return new ShellEnvironmentSnapshot(
Family: family,
OSDescription: RuntimeInformation.OSDescription,
ShellVersion: shellVersion,
WorkingDirectory: workingDir,
ToolVersions: toolVersions);
}
private async Task<(string? Version, string Cwd)> ProbeShellAndCwdAsync(ShellFamily family, CancellationToken cancellationToken)
{
var probe = family == ShellFamily.PowerShell
? "Write-Output (\"VERSION=\" + $PSVersionTable.PSVersion.ToString()); Write-Output (\"CWD=\" + (Get-Location).Path)"
: "echo \"VERSION=${BASH_VERSION:-${ZSH_VERSION:-unknown}}\"; echo \"CWD=$PWD\"";
var result = await this.RunProbeAsync(probe, cancellationToken).ConfigureAwait(false);
if (result is null)
{
return (null, string.Empty);
}
string? version = null;
string cwd = string.Empty;
foreach (var line in result.Stdout.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("VERSION=", StringComparison.Ordinal))
{
var v = line.Substring("VERSION=".Length).Trim();
version = string.IsNullOrEmpty(v) || v == "unknown" ? null : v;
}
else if (line.StartsWith("CWD=", StringComparison.Ordinal))
{
cwd = line.Substring("CWD=".Length).Trim();
}
}
return (version, cwd);
}
private static readonly System.Text.RegularExpressions.Regex s_toolNamePattern =
new("^[A-Za-z0-9._-]+$", System.Text.RegularExpressions.RegexOptions.Compiled);
private async Task<string?> ProbeToolVersionAsync(string tool, CancellationToken cancellationToken)
{
// The tool name is interpolated into a shell command, so reject anything that
// isn't a plain identifier. Whitespace, quotes, $, ;, |, &, etc. are not valid
// in any real CLI binary name and would otherwise allow shell injection if the
// configured tool list is sourced from untrusted input.
if (string.IsNullOrEmpty(tool) || !s_toolNamePattern.IsMatch(tool))
{
return null;
}
var probe = $"{tool} --version";
var result = await this.RunProbeAsync(probe, cancellationToken).ConfigureAwait(false);
if (result is null || result.ExitCode != 0)
{
return null;
}
// Some CLIs (java, gcc on older versions) emit `--version` to stderr.
var firstLine = FirstNonEmptyLine(result.Stdout) ?? FirstNonEmptyLine(result.Stderr);
return string.IsNullOrWhiteSpace(firstLine) ? null : firstLine!.Trim();
static string? FirstNonEmptyLine(string text) =>
text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
}
private async Task<ShellResult?> RunProbeAsync(string command, CancellationToken cancellationToken)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(this._options.ProbeTimeout);
try
{
return await this._executor.RunAsync(command, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Probe-timeout-driven cancellation: surface as a null snapshot field.
// Caller-driven cancellation is allowed to propagate.
return null;
}
catch (Exception ex) when (ex is ShellCommandRejectedException || ex is IOException || ex is TimeoutException)
{
return null;
}
}
private static ShellFamily DetectFamily() =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? ShellFamily.PowerShell
: ShellFamily.Posix;
/// <summary>
/// Default formatter for the instructions block. Public so callers
/// who want to wrap or augment the default can call it directly.
/// </summary>
/// <param name="snapshot">The snapshot to render.</param>
/// <returns>A multi-line markdown-style instructions block.</returns>
public static string DefaultInstructionsFormatter(ShellEnvironmentSnapshot snapshot)
{
var sb = new StringBuilder();
_ = sb.AppendLine("## Shell environment");
if (snapshot.Family == ShellFamily.PowerShell)
{
var version = snapshot.ShellVersion is null ? string.Empty : $" {snapshot.ShellVersion}";
_ = sb.Append("You are operating a PowerShell").Append(version).Append(" session on ").Append(snapshot.OSDescription).AppendLine(".");
_ = sb.AppendLine("Use PowerShell idioms, NOT bash:");
_ = sb.AppendLine("- Set environment variables with `$env:NAME = 'value'` (NOT `NAME=value`).");
_ = sb.AppendLine("- Change directory with `Set-Location` or `cd`. Paths use `\\` separators.");
_ = sb.AppendLine("- Reference environment variables as `$env:NAME` (NOT `$NAME`).");
_ = sb.AppendLine("- The system temp directory is `[System.IO.Path]::GetTempPath()` (NOT `/tmp`).");
_ = sb.AppendLine("- Pipe to `Out-Null` to suppress output (NOT `> /dev/null`).");
}
else
{
var version = snapshot.ShellVersion is null ? string.Empty : $" {snapshot.ShellVersion}";
_ = sb.Append("You are operating a POSIX shell").Append(version).Append(" session on ").Append(snapshot.OSDescription).AppendLine(".");
_ = sb.AppendLine("Use POSIX shell idioms (bash/sh).");
_ = sb.AppendLine("- Set environment variables for the next command with `export NAME=value`.");
_ = sb.AppendLine("- Reference environment variables as `$NAME` or `${NAME}`.");
_ = sb.AppendLine("- Paths use `/` separators.");
}
if (!string.IsNullOrEmpty(snapshot.WorkingDirectory))
{
_ = sb.Append("Working directory: ").AppendLine(snapshot.WorkingDirectory);
}
var installed = snapshot.ToolVersions
.Where(kv => kv.Value is not null)
.Select(kv => $"{kv.Key} ({kv.Value})")
.ToList();
var missing = snapshot.ToolVersions
.Where(kv => kv.Value is null)
.Select(kv => kv.Key)
.ToList();
if (installed.Count > 0)
{
_ = sb.Append("Available CLIs: ").AppendLine(string.Join(", ", installed));
}
if (missing.Count > 0)
{
_ = sb.Append("Not installed: ").AppendLine(string.Join(", ", missing));
}
return sb.ToString().TrimEnd();
}
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// Configuration knobs for <see cref="ShellEnvironmentProvider"/>.
/// </summary>
public sealed class ShellEnvironmentProviderOptions
{
/// <summary>
/// CLI tools whose <c>--version</c> output is probed and surfaced in
/// the agent context. Defaults to a small, common set.
/// </summary>
public IReadOnlyList<string> ProbeTools { get; init; } =
["git", "dotnet", "node", "python", "docker"];
/// <summary>
/// Optional override for the auto-detected shell family. When
/// <see langword="null"/>, the family is inferred from
/// <see cref="RuntimeInformation"/> (Windows -> PowerShell, otherwise
/// POSIX). Set this when running against a non-default shell (e.g.,
/// bash on Windows via WSL, or pwsh on Linux).
/// </summary>
public ShellFamily? OverrideFamily { get; init; }
/// <summary>
/// Per-probe execution timeout. Failed or timed-out probes are
/// recorded as missing rather than thrown to the agent.
/// </summary>
public TimeSpan ProbeTimeout { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Optional formatter for the instructions block. When
/// <see langword="null"/>, a built-in formatter is used.
/// </summary>
public Func<ShellEnvironmentSnapshot, string>? InstructionsFormatter { get; init; }
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// A point-in-time snapshot of the shell environment the agent is using.
/// </summary>
/// <param name="Family">Shell family (PowerShell vs POSIX).</param>
/// <param name="OSDescription"><see cref="RuntimeInformation.OSDescription"/>.</param>
/// <param name="ShellVersion">Reported shell version, or <see langword="null"/> if probing failed.</param>
/// <param name="WorkingDirectory">CWD at probe time, or empty if probing failed.</param>
/// <param name="ToolVersions">Map of probed CLI tool name to reported version (or <see langword="null"/> when not installed).</param>
public sealed record ShellEnvironmentSnapshot(
ShellFamily Family,
string OSDescription,
string? ShellVersion,
string WorkingDirectory,
IReadOnlyDictionary<string, string?> ToolVersions);
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// Pluggable backend that runs shell commands on behalf of a tool.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="LocalShellExecutor"/> runs commands directly on the host (no
/// isolation; approval-in-the-loop is the security boundary).
/// <see cref="DockerShellExecutor"/> runs them inside a container with resource
/// limits, network isolation, and a non-root user.
/// </para>
/// <para>
/// This is an abstract class rather than an interface so the surface can be
/// extended in future versions (e.g., adding new lifecycle hooks) without
/// breaking existing third-party implementations. Mirrors the Python
/// <c>ShellExecutor</c> Protocol in
/// <c>agent_framework_tools.shell._executor_base</c>.
/// </para>
/// <para>
/// Lifetime: <see cref="InitializeAsync"/> is invoked at most once per
/// instance (idempotent); <see cref="DisposeAsync"/> tears the executor down
/// at the end of its life. There is no public Shutdown step — disposal is the
/// teardown.
/// </para>
/// <para>
/// <b>Concurrency and session ownership.</b> A single executor instance is
/// intended to serve a single conversation / agent session — i.e., a single
/// user. Stateless mode is safe to share across concurrent callers (each
/// <c>RunAsync</c> spawns a fresh process or container, so there is no
/// shared mutable state). Persistent mode is <em>not</em> shareable: a
/// single long-lived shell process backs every call, it carries mutable
/// state (working directory, exported variables, history, in-flight
/// background jobs) that is visible to every subsequent command, and
/// concurrent commands would interleave on its stdin/stdout. The framework
/// does not isolate one caller's state from another's. Build one executor
/// per session, treat it as owned by that session for its lifetime, and
/// dispose it when the session ends. If you register an executor with a DI
/// container, use a per-request / per-conversation scope, not a singleton.
/// </para>
/// </remarks>
public abstract class ShellExecutor : IAsyncDisposable
{
/// <summary>
/// Eagerly initialize the backend. Idempotent; subsequent calls are
/// no-ops once the executor is started. For stateless executors this is
/// typically a no-op (the default implementation returns
/// <see cref="Task.CompletedTask"/>).
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public virtual Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
/// <summary>
/// Run a single command and return its result. Implementations are
/// expected to apply the configured per-command timeout and surface it
/// via <see cref="ShellResult.TimedOut"/> + <c>ExitCode = 124</c>.
/// </summary>
/// <param name="command">The shell command to execute.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public abstract Task<ShellResult> RunAsync(string command, CancellationToken cancellationToken = default);
/// <inheritdoc />
public abstract ValueTask DisposeAsync();
}
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// Identifies the shell family the agent is talking to.
/// </summary>
public enum ShellFamily
{
/// <summary>POSIX-style shell (bash, sh, zsh).</summary>
Posix,
/// <summary>PowerShell (pwsh or Windows PowerShell).</summary>
PowerShell,
}
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// Specifies how a shell executor dispatches commands to the underlying shell.
/// </summary>
public enum ShellMode
{
/// <summary>
/// Each command runs in a fresh shell subprocess. State (working directory,
/// environment variables) is reset between calls.
/// </summary>
Stateless,
/// <summary>
/// A single long-lived shell subprocess is reused across calls so
/// <c>cd</c> and exported / <c>$env:</c> variables persist between
/// invocations. Commands are executed via a sentinel protocol that
/// brackets stdout to determine completion. This is the recommended
/// default for coding agents because it eliminates the "agent runs cd
/// and then runs the wrong path" failure class.
/// <para>
/// <b>Single-session ownership.</b> Because the underlying shell carries
/// mutable state (working directory, exported variables, function
/// definitions, shell history) that is intentionally visible to every
/// command run through it, a persistent-mode executor instance is meant
/// to be owned by exactly one conversation / agent session. Sharing one
/// instance across users, tenants, or concurrent conversations leaks
/// state between them and serializes their commands behind a single
/// stdin/stdout pipe. If you need multiple sessions, create one
/// executor per session (and dispose it when the session ends), or use
/// <see cref="Stateless"/>.
/// </para>
/// </summary>
Persistent,
}
@@ -0,0 +1,210 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// A shell command awaiting a policy decision.
/// </summary>
/// <remarks>
/// Plain <see langword="readonly struct"/> rather than a record struct: the
/// type carries no equality semantics that callers care about, and the
/// minimal POCO is cheaper than the synthesized record machinery.
/// </remarks>
public readonly struct ShellRequest : IEquatable<ShellRequest>
{
/// <summary>Initializes a new instance of the <see cref="ShellRequest"/> struct.</summary>
/// <param name="command">The full command line that the agent wants to run.</param>
/// <param name="workingDirectory">Optional working directory the command will execute in, if known.</param>
public ShellRequest(string command, string? workingDirectory = null)
{
this.Command = command;
this.WorkingDirectory = workingDirectory;
}
/// <summary>Gets the full command line that the agent wants to run.</summary>
public string Command { get; }
/// <summary>Gets the optional working directory the command will execute in, if known.</summary>
public string? WorkingDirectory { get; }
/// <inheritdoc />
public bool Equals(ShellRequest other) =>
string.Equals(this.Command, other.Command, StringComparison.Ordinal)
&& string.Equals(this.WorkingDirectory, other.WorkingDirectory, StringComparison.Ordinal);
/// <inheritdoc />
public override bool Equals(object? obj) => obj is ShellRequest r && this.Equals(r);
/// <inheritdoc />
public override int GetHashCode() => HashCode.Combine(this.Command, this.WorkingDirectory);
/// <summary>Equality operator.</summary>
public static bool operator ==(ShellRequest left, ShellRequest right) => left.Equals(right);
/// <summary>Inequality operator.</summary>
public static bool operator !=(ShellRequest left, ShellRequest right) => !left.Equals(right);
}
/// <summary>
/// The outcome of a <see cref="ShellPolicy"/> evaluation.
/// </summary>
public readonly struct ShellPolicyOutcome : IEquatable<ShellPolicyOutcome>
{
/// <summary>Initializes a new instance of the <see cref="ShellPolicyOutcome"/> struct.</summary>
/// <param name="allowed"><see langword="true"/> when the command may run.</param>
/// <param name="reason">Human-readable rationale; populated for both allow and deny when applicable.</param>
public ShellPolicyOutcome(bool allowed, string? reason = null)
{
this.Allowed = allowed;
this.Reason = reason;
}
/// <summary>Gets a value indicating whether the command may run.</summary>
public bool Allowed { get; }
/// <summary>Gets the human-readable rationale; populated for both allow and deny when applicable.</summary>
public string? Reason { get; }
/// <summary>Gets a default-allow outcome.</summary>
public static ShellPolicyOutcome Allow { get; } = new(true);
/// <summary>Build a deny outcome with a human-readable reason.</summary>
/// <param name="reason">The rationale to surface to the caller.</param>
/// <returns>A new <see cref="ShellPolicyOutcome"/>.</returns>
public static ShellPolicyOutcome Deny(string reason) => new(false, reason);
/// <inheritdoc />
public bool Equals(ShellPolicyOutcome other) =>
this.Allowed == other.Allowed
&& string.Equals(this.Reason, other.Reason, StringComparison.Ordinal);
/// <inheritdoc />
public override bool Equals(object? obj) => obj is ShellPolicyOutcome o && this.Equals(o);
/// <inheritdoc />
public override int GetHashCode() => HashCode.Combine(this.Allowed, this.Reason);
/// <summary>Equality operator.</summary>
public static bool operator ==(ShellPolicyOutcome left, ShellPolicyOutcome right) => left.Equals(right);
/// <summary>Inequality operator.</summary>
public static bool operator !=(ShellPolicyOutcome left, ShellPolicyOutcome right) => !left.Equals(right);
}
/// <summary>
/// Layered allow/deny pattern filter for shell commands.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is not a security control.</b> It is a regex-based pre-filter
/// that operators can use to fast-fail literal commands they would rather
/// see rejected with a clear error than run (e.g. site-specific patterns
/// like a production hostname, or obviously-destructive shapes like
/// <c>rm -rf /</c>). Pattern-based filters are trivially bypassed by
/// variable expansion (<c>${RM:=rm} -rf /</c>), interpreter escapes
/// (<c>python -c "…"</c>), command substitution
/// (<c>$(base64 -d &lt;&lt;&lt; …)</c>, <c>$(echo -e "\xNN…")</c>),
/// envvar splicing (<c>$(A=r B=m; echo $A$B)</c>), alternative tools
/// (<c>find / -delete</c>), or PowerShell-native verbs
/// (<c>Remove-Item -Recurse -Force</c>). The real security boundary is
/// approval-in-the-loop (see <see cref="LocalShellExecutor"/>,
/// <see cref="DockerShellExecutor"/>) and container isolation (Docker).
/// No major agent framework relies on pattern matching as a primary
/// shell-command defense for these reasons.
/// </para>
/// <para>
/// <b>No default patterns.</b> A <see cref="ShellPolicy"/> constructed
/// with no arguments has an empty deny list and an empty allow list —
/// it will allow any non-empty command. Operators who want pre-execution
/// rejection of specific shapes must supply their own
/// <paramref>denyList</paramref>.
/// </para>
/// <para>
/// <b>Evaluation order — allow short-circuits deny.</b> Allow patterns are
/// checked first; a match returns immediately without consulting the deny
/// list. Use allow patterns sparingly (and prefer narrowly anchored regexes
/// like <c>^git\s+status$</c> rather than substring matches), because an
/// over-broad allow pattern can re-enable a command that the deny list was
/// supposed to block.
/// </para>
/// </remarks>
public sealed class ShellPolicy
{
private readonly IReadOnlyList<Regex> _denies;
private readonly IReadOnlyList<Regex> _allows;
/// <summary>
/// Initializes a new instance of the <see cref="ShellPolicy"/> class.
/// </summary>
/// <param name="denyList">
/// Patterns that trigger a deny outcome. <see langword="null"/> or an
/// empty collection disables the deny list entirely.
/// </param>
/// <param name="allowList">
/// Optional explicit-allow patterns. A match here short-circuits the
/// deny list and is useful when the caller knows the command is safe.
/// </param>
public ShellPolicy(IEnumerable<string>? denyList = null, IEnumerable<string>? allowList = null)
{
var deny = new List<Regex>();
if (denyList is not null)
{
foreach (var pattern in denyList)
{
deny.Add(new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase));
}
}
this._denies = deny;
var allow = new List<Regex>();
if (allowList is not null)
{
foreach (var pattern in allowList)
{
allow.Add(new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase));
}
}
this._allows = allow;
}
/// <summary>
/// Evaluate <paramref name="request"/> and return an outcome.
/// </summary>
/// <remarks>
/// Order of operations: empty-command guard → explicit allow patterns
/// (a match short-circuits with <see cref="ShellPolicyOutcome.Allow"/>)
/// → deny patterns (first match wins) → default allow.
/// </remarks>
/// <param name="request">The request to evaluate.</param>
/// <returns>An allow or deny outcome.</returns>
public ShellPolicyOutcome Evaluate(ShellRequest request)
{
var command = request.Command?.Trim() ?? string.Empty;
if (command.Length == 0)
{
return ShellPolicyOutcome.Deny("empty command");
}
foreach (var allow in this._allows)
{
if (allow.IsMatch(command))
{
return new ShellPolicyOutcome(true, "matched allow pattern");
}
}
foreach (var deny in this._denies)
{
if (deny.IsMatch(command))
{
return ShellPolicyOutcome.Deny($"matched deny pattern: {deny}");
}
}
return ShellPolicyOutcome.Allow;
}
}
@@ -0,0 +1,208 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// Resolves which shell binary and which argv to launch for the current OS.
/// </summary>
/// <remarks>
/// Resolution order:
/// <list type="bullet">
/// <item><description>Windows: prefer <c>pwsh</c>, fall back to <c>powershell.exe</c>, then <c>cmd.exe</c>.</description></item>
/// <item><description>Linux / macOS: prefer <c>/bin/bash</c>, fall back to <c>/bin/sh</c>.</description></item>
/// <item><description>Override via the constructor argument or the <c>AGENT_FRAMEWORK_SHELL</c> environment variable.</description></item>
/// </list>
/// </remarks>
internal static class ShellResolver
{
/// <summary>
/// The environment variable consulted by <see cref="Resolve"/> to override
/// the default shell selection (e.g. <c>AGENT_FRAMEWORK_SHELL=/usr/bin/bash</c>).
/// </summary>
public const string EnvVarName = "AGENT_FRAMEWORK_SHELL";
/// <summary>Resolve the shell binary and the per-command argv prefix.</summary>
public static ResolvedShell Resolve(string? overrideShell = null)
{
var requested = overrideShell ?? Environment.GetEnvironmentVariable(EnvVarName);
if (!string.IsNullOrWhiteSpace(requested))
{
return ClassifyExplicit(requested!);
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
if (TryFindOnPath("pwsh", out var pwsh))
{
return new ResolvedShell(pwsh, ShellKind.PowerShell);
}
if (TryFindOnPath("powershell", out var winps))
{
return new ResolvedShell(winps, ShellKind.PowerShell);
}
return new ResolvedShell(Path.Combine(SystemRoot(), "System32", "cmd.exe"), ShellKind.Cmd);
}
if (File.Exists("/bin/bash"))
{
return new ResolvedShell("/bin/bash", ShellKind.Bash);
}
return new ResolvedShell("/bin/sh", ShellKind.Sh);
}
/// <summary>
/// Resolve from an explicit argv list. The first element is treated as
/// the binary; the rest are passed as a launch-time prefix preceding
/// the standard <c>-c</c> / <c>-Command</c> / persistent suffix.
/// </summary>
public static ResolvedShell ResolveArgv(IReadOnlyList<string> shellArgv)
{
if (shellArgv is null)
{
throw new ArgumentNullException(nameof(shellArgv));
}
if (shellArgv.Count == 0)
{
throw new ArgumentException("shellArgv must contain at least the binary path.", nameof(shellArgv));
}
var binary = shellArgv[0];
var kind = ClassifyKind(binary);
var extra = shellArgv.Count > 1 ? new string[shellArgv.Count - 1] : Array.Empty<string>();
for (var i = 1; i < shellArgv.Count; i++)
{
extra[i - 1] = shellArgv[i];
}
return new ResolvedShell(binary, kind, ExtraArgv: extra);
}
private static ResolvedShell ClassifyExplicit(string path) =>
new(path, ClassifyKind(path));
private static ShellKind ClassifyKind(string path)
{
var name = Path.GetFileNameWithoutExtension(path).ToUpperInvariant();
return name switch
{
"PWSH" or "POWERSHELL" => ShellKind.PowerShell,
"CMD" => ShellKind.Cmd,
"BASH" => ShellKind.Bash,
// All other POSIX shells (sh, zsh, dash, ash, ksh, busybox, ...)
// are launched as plain sh so we don't pass bash-only flags like
// --noprofile / --norc, which zsh and dash reject.
_ => ShellKind.Sh,
};
}
private static bool TryFindOnPath(string name, out string fullPath)
{
var pathEnv = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrEmpty(pathEnv))
{
fullPath = string.Empty;
return false;
}
var exts = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? new[] { ".exe", ".cmd", ".bat", string.Empty }
: new[] { string.Empty };
foreach (var dir in pathEnv!.Split(Path.PathSeparator))
{
if (string.IsNullOrEmpty(dir))
{
continue;
}
foreach (var ext in exts)
{
var candidate = Path.Combine(dir, name + ext);
if (File.Exists(candidate))
{
fullPath = candidate;
return true;
}
}
}
fullPath = string.Empty;
return false;
}
private static string SystemRoot() =>
Environment.GetEnvironmentVariable("SystemRoot") ?? @"C:\Windows";
}
/// <summary>Identifies the dialect of the resolved shell.</summary>
internal enum ShellKind
{
/// <summary>POSIX bash; supports <c>--noprofile</c> / <c>--norc</c>.</summary>
Bash,
/// <summary>PowerShell (pwsh or Windows PowerShell).</summary>
PowerShell,
/// <summary>Windows cmd.exe.</summary>
Cmd,
/// <summary>Generic POSIX shell (sh, zsh, dash, ash, ksh, busybox) — bash-only flags are not passed.</summary>
Sh,
}
internal readonly record struct ResolvedShell(string Binary, ShellKind Kind, IReadOnlyList<string>? ExtraArgv = null)
{
public IReadOnlyList<string> StatelessArgvForCommand(string command)
{
var extra = this.ExtraArgv ?? Array.Empty<string>();
var suffix = this.Kind switch
{
ShellKind.PowerShell => new[]
{
"-NoProfile",
"-NoLogo",
"-NonInteractive",
"-Command",
command,
},
ShellKind.Cmd => new[] { "/d", "/c", command },
ShellKind.Sh => new[] { "-c", command },
_ => new[] { "--noprofile", "--norc", "-c", command },
};
if (extra.Count == 0)
{
return suffix;
}
var combined = new string[extra.Count + suffix.Length];
for (var i = 0; i < extra.Count; i++) { combined[i] = extra[i]; }
for (var i = 0; i < suffix.Length; i++) { combined[extra.Count + i] = suffix[i]; }
return combined;
}
/// <summary>
/// Argv for launching a long-lived shell that reads commands from stdin.
/// </summary>
public IReadOnlyList<string> PersistentArgv()
{
var extra = this.ExtraArgv ?? Array.Empty<string>();
var suffix = this.Kind switch
{
ShellKind.PowerShell => new[]
{
"-NoProfile",
"-NoLogo",
"-NonInteractive",
"-Command",
"-",
},
ShellKind.Cmd => throw new NotSupportedException(
"Persistent mode is not supported for cmd.exe — use pwsh, powershell, or a POSIX shell."),
ShellKind.Sh => Array.Empty<string>(),
_ => new[] { "--noprofile", "--norc" },
};
if (extra.Count == 0)
{
return suffix;
}
var combined = new string[extra.Count + suffix.Length];
for (var i = 0; i < extra.Count; i++) { combined[i] = extra[i]; }
for (var i = 0; i < suffix.Length; i++) { combined[extra.Count + i] = suffix[i]; }
return combined;
}
}
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// The outcome of a single shell command invocation.
/// </summary>
/// <param name="Stdout">Captured standard output, possibly truncated.</param>
/// <param name="Stderr">Captured standard error, possibly truncated.</param>
/// <param name="ExitCode">The exit status reported by the shell or subprocess. <c>-1</c> if the process never exited cleanly.</param>
/// <param name="Duration">How long the command took to execute end-to-end.</param>
/// <param name="Truncated"><see langword="true"/> when stdout or stderr was truncated.</param>
/// <param name="TimedOut"><see langword="true"/> when the command was killed because it exceeded the configured timeout.</param>
public sealed record ShellResult(
string Stdout,
string Stderr,
int ExitCode,
TimeSpan Duration,
bool Truncated = false,
bool TimedOut = false)
{
/// <summary>
/// Format the result as a single text block suitable for return to a language model.
/// </summary>
/// <returns>A multi-line string combining stdout, stderr, status flags, and the exit code.</returns>
public string FormatForModel()
{
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(this.Stdout))
{
_ = sb.Append(this.Stdout);
if (this.Truncated)
{
_ = sb.AppendLine().Append("[stdout truncated]");
}
_ = sb.AppendLine();
}
if (!string.IsNullOrEmpty(this.Stderr))
{
_ = sb.Append("stderr: ").Append(this.Stderr).AppendLine();
}
if (this.TimedOut)
{
_ = sb.AppendLine("[command timed out]");
}
_ = sb.Append("exit_code: ").Append(this.ExitCode);
return sb.ToString();
}
}
@@ -0,0 +1,962 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Tools.Shell;
/// <summary>
/// A long-lived shell subprocess that executes commands one at a time using a
/// <b>sentinel protocol</b> to mark command boundaries. State (current
/// directory, exported variables, function definitions, etc.) is preserved
/// across calls.
/// </summary>
/// <remarks>
/// <para>
/// <b>Single-owner contract.</b> A <see cref="ShellSession"/> is owned by exactly one
/// conversation / agent session — i.e., one user. The backing shell process carries
/// mutable state (cwd, exported variables, history, background jobs) that every
/// subsequent command can observe, and <c>_runLock</c> serializes every call onto the
/// single stdin/stdout pipe. There is no per-caller isolation. The enclosing executor
/// must not share a single session across users, tenants, or concurrent conversations;
/// it must create one session per agent session and dispose it when the session ends.
/// </para>
/// <para>
/// Cross-OS implementation notes:
/// </para>
/// <list type="bullet">
/// <item>
/// PowerShell hosted with <c>-Command -</c> waits for a complete parse before
/// executing. Multi-line <c>try { ... }</c> blocks therefore stall with stdin
/// open, so the user command is base64-encoded and invoked with
/// <c>Invoke-Expression</c> on a single line.
/// </item>
/// <item>
/// <c>Write-Output</c> may drop trailing newlines when stdout is redirected.
/// The sentinel is therefore emitted via <c>[Console]::WriteLine</c> +
/// <c>[Console]::Out.Flush()</c>.
/// </item>
/// <item>
/// <c>$LASTEXITCODE</c> only tracks external-process exits, so the rc is
/// derived from <c>$?</c> and caught exceptions as well.
/// </item>
/// <item>
/// stdout/stderr are drained by long-running reader tasks; per-call buffer
/// offsets are snapshotted before the command is written and scanned forward,
/// which avoids late stderr being attributed to the next command.
/// </item>
/// </list>
/// </remarks>
internal sealed class ShellSession : IAsyncDisposable
{
private const int ReadChunk = 64 * 1024;
private static readonly TimeSpan s_shutdownGrace = TimeSpan.FromSeconds(2);
// Brief quiescence to let late stderr drain after the sentinel is seen.
private static readonly TimeSpan s_stderrQuiescence = TimeSpan.FromMilliseconds(50);
// Time window to wait for the sentinel after we've sent SIGINT / Ctrl+C
// to the shell. If the sentinel still doesn't land we fall back to a
// hard close-and-respawn.
private static readonly TimeSpan s_interruptGrace = TimeSpan.FromMilliseconds(500);
private readonly ResolvedShell _shell;
private readonly string? _workingDirectory;
private readonly bool _confineWorkingDirectory;
private readonly IReadOnlyDictionary<string, string?>? _environment;
private readonly bool _cleanEnvironment;
private readonly int _maxOutputBytes;
// Serializes commands onto the single stdin/stdout pipe. This is an
// ordering primitive within one owning session; it is NOT a multi-tenant
// isolation mechanism. ShellSession is single-owner — see the type-level
// remarks. The lock just guarantees that concurrent calls from the one
// owner queue cleanly instead of interleaving on the pipe.
private readonly SemaphoreSlim _runLock = new(1, 1);
private readonly SemaphoreSlim _lifecycleLock = new(1, 1);
private readonly string _sentinelTag;
private Process? _proc;
private bool _isSessionLeader;
private Task? _stdoutReader;
private Task? _stderrReader;
private readonly List<byte> _stdoutBuf = new(capacity: 4096);
private readonly List<byte> _stderrBuf = new(capacity: 1024);
private readonly object _bufferGate = new();
private TaskCompletionSource<bool> _stdoutSignal = NewSignal();
private bool _stdoutClosed;
public ShellSession(
ResolvedShell shell,
string? workingDirectory,
bool confineWorkingDirectory,
IReadOnlyDictionary<string, string?>? environment,
bool cleanEnvironment,
int maxOutputBytes)
{
this._shell = shell;
this._workingDirectory = workingDirectory;
this._confineWorkingDirectory = confineWorkingDirectory;
this._environment = environment;
this._cleanEnvironment = cleanEnvironment;
this._maxOutputBytes = maxOutputBytes;
// Cryptographically-random tag prevents a rogue command from echoing
// a matching earlier sentinel.
var bytes = new byte[8];
#if NET6_0_OR_GREATER
System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);
#else
using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create())
{
rng.GetBytes(bytes);
}
#endif
#pragma warning disable CA1308 // sentinel tag is matched against shell-emitted lowercase hex; not for security or display
this._sentinelTag = Convert.ToHexString(bytes).ToLowerInvariant();
#pragma warning restore CA1308
}
public async ValueTask DisposeAsync()
{
await this.CloseAsync().ConfigureAwait(false);
this._runLock.Dispose();
this._lifecycleLock.Dispose();
}
private async Task EnsureStartedAsync()
{
await this._lifecycleLock.WaitAsync().ConfigureAwait(false);
try
{
#pragma warning disable RCS1146 // HasExited can throw on disposed proc; null check intentional
if (this._proc is not null && !this._proc.HasExited)
#pragma warning restore RCS1146
{
return;
}
var startInfo = new ProcessStartInfo
{
FileName = this._shell.Binary,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = this._workingDirectory ?? Directory.GetCurrentDirectory(),
};
foreach (var arg in this._shell.PersistentArgv())
{
startInfo.ArgumentList.Add(arg);
}
// On POSIX, wrap the shell in `setsid` so the spawned process
// becomes a session leader (PID == PGID). This is what makes
// `killpg(proc.Id, SIGINT)` in InterruptCurrentCommandAsync
// correctly target the shell + its in-flight command instead
// of inheriting the agent host's process group. If setsid is
// not available we fall back to a direct launch and the
// interrupt path becomes a best-effort no-op (the caller's
// hard close-and-respawn handles the timeout case).
this._isSessionLeader = false;
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
&& TryFindSetsid(out var setsidPath))
{
var originalArgs = new List<string>(startInfo.ArgumentList);
startInfo.FileName = setsidPath;
startInfo.ArgumentList.Clear();
startInfo.ArgumentList.Add(this._shell.Binary);
foreach (var arg in originalArgs)
{
startInfo.ArgumentList.Add(arg);
}
this._isSessionLeader = true;
}
if (this._cleanEnvironment)
{
// Strip everything inherited except the allowlist in
// EnvironmentSanitizer.PreservedVariables, so the shell can
// still locate itself and basic tools.
EnvironmentSanitizer.RemoveNonPreserved(startInfo.Environment);
}
if (this._environment is not null)
{
foreach (var kv in this._environment)
{
if (kv.Value is null)
{
_ = startInfo.Environment.Remove(kv.Key);
}
else
{
startInfo.Environment[kv.Key] = kv.Value;
}
}
}
this._stdoutBuf.Clear();
this._stderrBuf.Clear();
this._stdoutSignal = NewSignal();
this._stdoutClosed = false;
var proc = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
_ = proc.Start();
this._proc = proc;
this._stdoutReader = Task.Run(() => this.ReadLoopAsync(proc.StandardOutput.BaseStream, this._stdoutBuf, isStdout: true));
this._stderrReader = Task.Run(() => this.ReadLoopAsync(proc.StandardError.BaseStream, this._stderrBuf, isStdout: false));
// Best-effort: make PowerShell emit UTF-8 so the sentinel is byte-clean.
if (this._shell.Kind == ShellKind.PowerShell)
{
await this.WriteRawAsync(
"$OutputEncoding = [Console]::OutputEncoding = " +
"[System.Text.UTF8Encoding]::new($false);" +
"$ErrorActionPreference = 'Stop'\n").ConfigureAwait(false);
}
}
finally
{
_ = this._lifecycleLock.Release();
}
}
public async Task CloseAsync()
{
await this._lifecycleLock.WaitAsync().ConfigureAwait(false);
try
{
var proc = this._proc;
this._proc = null;
#pragma warning disable RCS1146
if (proc is null || proc.HasExited)
#pragma warning restore RCS1146
{
await this.CancelReadersAsync().ConfigureAwait(false);
proc?.Dispose();
return;
}
try
{
try
{
await proc.StandardInput.WriteLineAsync("exit").ConfigureAwait(false);
await proc.StandardInput.FlushAsync().ConfigureAwait(false);
proc.StandardInput.Close();
}
catch (IOException) { /* pipe may already be closed */ }
catch (ObjectDisposedException) { }
using var cts = new CancellationTokenSource(s_shutdownGrace);
try
{
await proc.WaitForExitAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
KillProcessTree(proc);
}
}
finally
{
await this.CancelReadersAsync().ConfigureAwait(false);
proc.Dispose();
}
}
finally
{
_ = this._lifecycleLock.Release();
}
}
private async Task CancelReadersAsync()
{
// Reader loops exit when their stream closes; just wait for them.
if (this._stdoutReader is not null)
{
try { await this._stdoutReader.ConfigureAwait(false); }
catch { /* best-effort */ }
}
if (this._stderrReader is not null)
{
try { await this._stderrReader.ConfigureAwait(false); }
catch { /* best-effort */ }
}
this._stdoutReader = null;
this._stderrReader = null;
}
/// <summary>Run a single command in the live session and return the result.</summary>
public async Task<ShellResult> RunAsync(string command, TimeSpan? timeout, CancellationToken cancellationToken)
{
await this.EnsureStartedAsync().ConfigureAwait(false);
await this._runLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await this.RunLockedAsync(command, timeout, cancellationToken).ConfigureAwait(false);
}
finally
{
_ = this._runLock.Release();
}
}
private async Task<ShellResult> RunLockedAsync(string command, TimeSpan? timeout, CancellationToken cancellationToken)
{
var proc = this._proc ?? throw new InvalidOperationException("Session not started.");
// Per-command random suffix on top of the session tag.
var suffix = new byte[4];
#if NET6_0_OR_GREATER
System.Security.Cryptography.RandomNumberGenerator.Fill(suffix);
#else
using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create())
{
rng.GetBytes(suffix);
}
#endif
#pragma warning disable CA1308
var sentinel = $"__AF_END_{this._sentinelTag}_{Convert.ToHexString(suffix).ToLowerInvariant()}__";
#pragma warning restore CA1308
var script = this.BuildScript(command, sentinel);
int stdoutOffset, stderrOffset;
lock (this._bufferGate)
{
stdoutOffset = this._stdoutBuf.Count;
stderrOffset = this._stderrBuf.Count;
// Reset stdout signal so the wait loop blocks on fresh data.
this._stdoutSignal = NewSignal();
}
var stopwatch = Stopwatch.StartNew();
try
{
await proc.StandardInput.WriteAsync(script.AsMemory(), cancellationToken).ConfigureAwait(false);
await proc.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
}
catch (IOException ex)
{
throw new IOException("Persistent shell session is no longer alive.", ex);
}
var needle = Encoding.UTF8.GetBytes(sentinel);
var hardCap = this._maxOutputBytes * 4;
var (sentinelIdx, exitCode, timedOut, overflow) = await this.WaitForSentinelAsync(
needle, stdoutOffset, hardCap, timeout, cancellationToken).ConfigureAwait(false);
if (timedOut)
{
// Graceful path: interrupt the current command (SIGINT / Ctrl+C)
// and give the shell a moment to print its own sentinel. If that
// works the session survives — `cd` and exported variables from
// earlier calls are preserved across the timeout.
await this.InterruptCurrentCommandAsync().ConfigureAwait(false);
using var graceCts = new CancellationTokenSource(s_interruptGrace);
try
{
using var graceLink = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, graceCts.Token);
var (postIdx, _, postTimedOut, postOverflow) = await this.WaitForSentinelAsync(
needle, stdoutOffset, hardCap, s_interruptGrace, graceLink.Token).ConfigureAwait(false);
if (!postTimedOut && !postOverflow && postIdx >= 0)
{
sentinelIdx = postIdx;
// Treat a successfully-interrupted command as a timeout
// for the result envelope but keep the session alive.
await Task.Delay(s_stderrQuiescence, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
byte[] stdoutRawI;
byte[] stderrRawI;
lock (this._bufferGate)
{
stdoutRawI = SnapshotRange(this._stdoutBuf, stdoutOffset, sentinelIdx - stdoutOffset);
stderrRawI = SnapshotRange(this._stderrBuf, stderrOffset, this._stderrBuf.Count - stderrOffset);
}
var stdoutI = Encoding.UTF8.GetString(stdoutRawI).TrimEnd('\r', '\n');
var stderrI = Encoding.UTF8.GetString(stderrRawI);
var (soutI, soTI) = TruncateHeadTail(stdoutI, this._maxOutputBytes);
var (serrI, seTI) = TruncateHeadTail(stderrI, this._maxOutputBytes);
return new ShellResult(
Stdout: soutI,
Stderr: serrI,
ExitCode: 124,
Duration: stopwatch.Elapsed,
Truncated: soTI || seTI,
TimedOut: true);
}
}
catch (OperationCanceledException) { /* fall through to hard close */ }
}
if (timedOut || overflow)
{
// Best-effort recovery: tear the session down. Next call respawns.
await this.CloseAsync().ConfigureAwait(false);
stopwatch.Stop();
byte[] stdoutBytes;
byte[] stderrBytes;
lock (this._bufferGate)
{
stdoutBytes = SnapshotRange(this._stdoutBuf, stdoutOffset, this._stdoutBuf.Count - stdoutOffset);
stderrBytes = SnapshotRange(this._stderrBuf, stderrOffset, this._stderrBuf.Count - stderrOffset);
}
var (so, soT) = TruncateHeadTail(Encoding.UTF8.GetString(stdoutBytes), this._maxOutputBytes);
var (se, seT) = TruncateHeadTail(Encoding.UTF8.GetString(stderrBytes), this._maxOutputBytes);
return new ShellResult(
Stdout: so,
Stderr: se,
ExitCode: timedOut ? 124 : -1,
Duration: stopwatch.Elapsed,
Truncated: soT || seT,
TimedOut: timedOut);
}
// Let stderr quiesce briefly — late writes from the completing command
// otherwise leak into the next run().
await Task.Delay(s_stderrQuiescence, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
byte[] stdoutRaw;
byte[] stderrRaw;
lock (this._bufferGate)
{
stdoutRaw = SnapshotRange(this._stdoutBuf, stdoutOffset, sentinelIdx - stdoutOffset);
stderrRaw = SnapshotRange(this._stderrBuf, stderrOffset, this._stderrBuf.Count - stderrOffset);
}
var stdout = Encoding.UTF8.GetString(stdoutRaw).TrimEnd('\r', '\n');
var stderr = Encoding.UTF8.GetString(stderrRaw);
var (sout, soutTrunc) = TruncateHeadTail(stdout, this._maxOutputBytes);
var (serr, serrTrunc) = TruncateHeadTail(stderr, this._maxOutputBytes);
return new ShellResult(
Stdout: sout,
Stderr: serr,
ExitCode: exitCode,
Duration: stopwatch.Elapsed,
Truncated: soutTrunc || serrTrunc,
TimedOut: false);
}
private async Task<(int sentinelIdx, int exitCode, bool timedOut, bool overflow)> WaitForSentinelAsync(
byte[] needle, int searchFrom, int hardCap, TimeSpan? timeout, CancellationToken cancellationToken)
{
using var timeoutCts = timeout is null
? new CancellationTokenSource()
: new CancellationTokenSource(timeout.Value);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, timeoutCts.Token);
while (true)
{
int idx;
int bufLen;
bool closed;
TaskCompletionSource<bool> signal;
lock (this._bufferGate)
{
bufLen = this._stdoutBuf.Count;
closed = this._stdoutClosed;
signal = this._stdoutSignal;
idx = IndexOf(this._stdoutBuf, needle, searchFrom);
}
if (idx >= 0)
{
var rc = await this.ReadExitCodeAsync(idx + needle.Length, linkedCts.Token).ConfigureAwait(false);
return (idx, rc, false, false);
}
if (bufLen - searchFrom > hardCap)
{
return (-1, -1, false, true);
}
if (closed)
{
return (-1, -1, false, true);
}
try
{
await signal.Task.WaitAsync(TimeSpan.FromMilliseconds(100), linkedCts.Token).ConfigureAwait(false);
}
catch (TimeoutException)
{
// Spin and re-check.
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
return (-1, -1, true, false);
}
}
}
private async Task<int> ReadExitCodeAsync(int afterIdx, CancellationToken cancellationToken)
{
// The trailer is "_<digits>\n". Wait briefly for the newline to land.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1);
while (DateTime.UtcNow < deadline)
{
int len;
byte[] tail;
TaskCompletionSource<bool> signal;
lock (this._bufferGate)
{
len = this._stdoutBuf.Count - afterIdx;
tail = len > 0 ? SnapshotRange(this._stdoutBuf, afterIdx, len) : Array.Empty<byte>();
signal = this._stdoutSignal = NewSignal();
}
var nl = Array.IndexOf(tail, (byte)'\n');
if (nl >= 0)
{
return ParseRc(tail, nl);
}
try
{
await signal.Task.WaitAsync(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException) { }
}
return -1;
}
private static int ParseRc(byte[] tail, int newlineIdx)
{
if (newlineIdx == 0 || tail[0] != (byte)'_')
{
return -1;
}
var digits = new StringBuilder();
for (var i = 1; i < newlineIdx; i++)
{
var b = tail[i];
if (b == '\r')
{
break;
}
if ((b >= '0' && b <= '9') || b == '-')
{
_ = digits.Append((char)b);
}
else
{
return -1;
}
}
return int.TryParse(digits.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var rc)
? rc
: -1;
}
private string BuildScript(string command, string sentinel)
{
// Idempotent re-anchor: in confined mode every command is prefixed
// with a `cd` back to the configured workdir so a `cd` inside one
// command doesn't leak to the next.
var effective = this.MaybeReanchor(command);
if (this._shell.Kind == ShellKind.PowerShell)
{
// Base64-encode the command so multi-line constructs don't stall
// the pwsh parser. Sentinel is emitted via [Console]::WriteLine
// so the pipeline formatter can't drop the newline.
var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(effective));
return
"& {" +
" $__af_rc = 0;" +
" try {" +
$" $__af_cmd = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded}'));" +
// Force the user command's success output through the same
// [Console]::Out pipe as the sentinel, *inside the try* so
// every byte of output is flushed before the finally fires.
// Without this, pwsh defers Out-Default formatting until the
// script block returns and the sentinel races ahead of the
// user's output in the byte stream.
" Invoke-Expression $__af_cmd 2>&1 | ForEach-Object {" +
" if ($_ -is [System.Management.Automation.ErrorRecord]) {" +
" [Console]::Error.WriteLine(($_ | Out-String).TrimEnd());" +
" } else {" +
" [Console]::WriteLine(($_ | Out-String).TrimEnd());" +
" }" +
" };" +
" [Console]::Out.Flush();" +
" if ($LASTEXITCODE -ne $null) { $__af_rc = $LASTEXITCODE }" +
" elseif (-not $?) { $__af_rc = 1 }" +
" } catch {" +
" [Console]::Error.WriteLine($_.ToString());" +
" $__af_rc = 1" +
" } finally {" +
$" [Console]::WriteLine('{sentinel}_' + $__af_rc);" +
" [Console]::Out.Flush()" +
" }" +
" }\n";
}
// POSIX shell. Run the user command in a brace group so we capture
// its exit status, then print the sentinel on a line of its own.
// ``set +e`` around the trailer prevents a prior ``set -e`` from
// skipping the sentinel print.
return "{ " + effective + "\n" +
"}; __af_rc=$?; set +e; " +
$"printf '\\n{sentinel}_%s\\n' \"$__af_rc\"\n";
}
private string MaybeReanchor(string command)
{
if (!this._confineWorkingDirectory || string.IsNullOrEmpty(this._workingDirectory))
{
return command;
}
return this._shell.Kind == ShellKind.PowerShell
? $"Set-Location -LiteralPath {QuotePowerShell(this._workingDirectory!)}\n{command}"
: $"cd -- {QuotePosix(this._workingDirectory!)}\n{command}";
}
/// <summary>
/// Wrap <paramref name="value"/> in a PowerShell single-quoted string literal,
/// escaping embedded single quotes by doubling. Single-quoted PowerShell
/// strings perform no expansion, so this is safe against <c>$(...)</c>,
/// <c>$var</c>, and backtick interpolation.
/// </summary>
internal static string QuotePowerShell(string value) =>
"'" + value.Replace("'", "''", StringComparison.Ordinal) + "'";
/// <summary>
/// Wrap <paramref name="value"/> in POSIX single quotes, terminating and
/// re-opening the literal around any embedded single quote
/// (<c>'\u0027\\\u0027'</c>). POSIX single-quoted strings perform no
/// expansion, so this is safe against <c>$VAR</c>, <c>$(...)</c>, and
/// backtick interpolation.
/// </summary>
internal static string QuotePosix(string value) =>
"'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'";
/// <summary>
/// Send SIGINT (POSIX) or Ctrl+Break (Windows) to the live shell so the
/// currently-running command is cancelled but the shell itself survives.
/// Used to honor a per-command timeout without losing session state.
/// </summary>
internal async Task InterruptCurrentCommandAsync()
{
var proc = this._proc;
#pragma warning disable RCS1146
if (proc is null || proc.HasExited)
#pragma warning restore RCS1146
{
return;
}
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// pwsh hosted in -NoInteractive mode doesn't have a console
// group attached to it, so GenerateConsoleCtrlEvent typically
// can't reach it. Best we can do without ripping the session
// is to write Ctrl+C to stdin, which the pwsh REPL picks up
// for the in-flight pipeline. If that doesn't work the caller
// falls back to a hard close-and-respawn.
try
{
await proc.StandardInput.WriteAsync("\u0003").ConfigureAwait(false);
await proc.StandardInput.FlushAsync().ConfigureAwait(false);
}
catch (IOException) { }
catch (ObjectDisposedException) { }
}
else
{
// Send SIGINT to the process group so the shell + any direct
// child receive it. p/invoke killpg via libc. We only do
// this when EnsureStartedAsync succeeded in wrapping the
// shell in `setsid` — otherwise `proc.Id` is NOT a process
// group id (the child inherited the agent's PGID) and
// calling killpg on it would signal the agent.
if (!this._isSessionLeader)
{
return;
}
_ = NativeMethods.killpg(proc.Id, NativeMethods.SIGINT);
}
}
catch (Exception ex) when (ex is InvalidOperationException || ex is System.ComponentModel.Win32Exception)
{
// Best-effort interrupt — fall through to caller's hard-close path.
}
await Task.CompletedTask.ConfigureAwait(false);
}
private static bool TryFindSetsid(out string fullPath)
{
// Check well-known locations first to avoid PATH-based lookups when possible.
foreach (var c in new[] { "/usr/bin/setsid", "/bin/setsid", "/usr/local/bin/setsid" })
{
if (File.Exists(c))
{
fullPath = c;
return true;
}
}
// Fall back to PATH.
var pathEnv = Environment.GetEnvironmentVariable("PATH");
if (!string.IsNullOrEmpty(pathEnv))
{
foreach (var dir in pathEnv!.Split(Path.PathSeparator))
{
if (string.IsNullOrEmpty(dir))
{
continue;
}
var candidate = Path.Combine(dir, "setsid");
if (File.Exists(candidate))
{
fullPath = candidate;
return true;
}
}
}
fullPath = string.Empty;
return false;
}
private static class NativeMethods
{
internal const int SIGINT = 2;
// killpg lives in libc on Linux/macOS. The previous annotation used
// DllImportSearchPath.System32 — that's a Windows-only loader hint and
// does nothing for libc.so on POSIX. SafeDirectories satisfies
// CA5392/CA5393 without falling back to the unsafe AssemblyDirectory
// probe path. The call site is also gated to non-Windows, so the
// import is never resolved on Windows.
[DllImport("libc", SetLastError = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
internal static extern int killpg(int pgrp, int sig);
}
private async Task WriteRawAsync(string text)
{
if (this._proc is null)
{
return;
}
await this._proc.StandardInput.WriteAsync(text).ConfigureAwait(false);
await this._proc.StandardInput.FlushAsync().ConfigureAwait(false);
}
private async Task ReadLoopAsync(Stream stream, List<byte> buf, bool isStdout)
{
var chunk = new byte[ReadChunk];
try
{
while (true)
{
int n;
try
{
n = await stream.ReadAsync(chunk.AsMemory(), CancellationToken.None).ConfigureAwait(false);
}
catch (IOException) { break; }
catch (ObjectDisposedException) { break; }
if (n == 0)
{
break;
}
lock (this._bufferGate)
{
// Bulk-copy the chunk into the backing list. ArraySegment<byte>
// implements ICollection<byte>, so AddRange takes the fast path
// and avoids per-byte resize/branching on the hot path.
buf.AddRange(new ArraySegment<byte>(chunk, 0, n));
if (isStdout)
{
// Swap the signal BEFORE completing the old one so any
// consumer that next reads `_stdoutSignal` sees a fresh
// (uncompleted) TCS. Without this, a consumer looping in
// WaitForSentinelAsync would re-read the same completed
// TCS, causing WaitAsync to return synchronously every
// iteration — a tight busy-spin until the sentinel
// arrives or the timeout fires.
var prev = this._stdoutSignal;
this._stdoutSignal = NewSignal();
_ = prev.TrySetResult(true);
}
}
}
}
finally
{
if (isStdout)
{
lock (this._bufferGate)
{
this._stdoutClosed = true;
_ = this._stdoutSignal.TrySetResult(true);
}
}
}
}
private static byte[] SnapshotRange(List<byte> buf, int start, int length)
{
if (length <= 0)
{
return Array.Empty<byte>();
}
var result = new byte[length];
for (var i = 0; i < length; i++)
{
result[i] = buf[start + i];
}
return result;
}
private static int IndexOf(List<byte> buf, byte[] needle, int from)
{
// Caller holds the buffer gate. Linear search; needle is ~30 bytes
// so this is fine for our buffer sizes (< few MB even in worst-case
// overflow).
var end = buf.Count - needle.Length;
for (var i = from; i <= end; i++)
{
var match = true;
for (var j = 0; j < needle.Length; j++)
{
if (buf[i + j] != needle[j])
{
match = false;
break;
}
}
if (match)
{
return i;
}
}
return -1;
}
/// <summary>
/// Truncate <paramref name="data"/> to at most <paramref name="cap"/> UTF-8 bytes
/// using a head/tail strategy. Splits between runes (never inside a multi-byte
/// UTF-8 sequence) so the result is always valid UTF-8 / .NET text.
/// </summary>
/// <param name="data">The text to truncate.</param>
/// <param name="cap">Maximum number of UTF-8 bytes to retain (excluding the marker line).</param>
/// <returns>The (possibly truncated) text and a flag indicating whether truncation occurred.</returns>
internal static (string text, bool truncated) TruncateHeadTail(string data, int cap)
{
if (cap <= 0 || string.IsNullOrEmpty(data))
{
return (data, false);
}
var totalBytes = Encoding.UTF8.GetByteCount(data);
if (totalBytes <= cap)
{
return (data, false);
}
var headCap = cap / 2;
var tailCap = cap - headCap;
var head = TakePrefixByBytes(data, headCap);
var tail = TakeSuffixByBytes(data, tailCap);
var droppedBytes = totalBytes - Encoding.UTF8.GetByteCount(head) - Encoding.UTF8.GetByteCount(tail);
if (droppedBytes < 0)
{
droppedBytes = 0;
}
return ($"{head}\n[... truncated {droppedBytes} bytes ...]\n{tail}", true);
}
private static string TakePrefixByBytes(string data, int maxBytes)
{
if (maxBytes <= 0)
{
return string.Empty;
}
// Iterate by rune so we never split a surrogate pair and never have to
// reason about Encoder state. Rune.Utf8SequenceLength is the byte width
// of the rune in UTF-8; for unpaired surrogates EnumerateRunes yields
// Rune.ReplacementChar (3 bytes), which matches what UTF-8 encoding
// would have produced anyway.
var byteCount = 0;
var charsTaken = 0;
foreach (var rune in data.EnumerateRunes())
{
var n = rune.Utf8SequenceLength;
if (byteCount + n > maxBytes)
{
break;
}
byteCount += n;
charsTaken += rune.Utf16SequenceLength;
}
return data.Substring(0, charsTaken);
}
private static string TakeSuffixByBytes(string data, int maxBytes)
{
if (maxBytes <= 0)
{
return string.Empty;
}
// Same approach as the prefix walker, but we need to skip an unknown
// prefix and keep the suffix. Walk the runes forward to learn the total
// UTF-8 byte count, then walk again skipping while the remaining tail
// would exceed `maxBytes`.
var totalBytes = 0;
foreach (var rune in data.EnumerateRunes())
{
totalBytes += rune.Utf8SequenceLength;
}
if (totalBytes <= maxBytes)
{
return data;
}
var bytesToSkip = totalBytes - maxBytes;
var skipped = 0;
var startCharIndex = 0;
foreach (var rune in data.EnumerateRunes())
{
var n = rune.Utf8SequenceLength;
if (skipped + n > bytesToSkip)
{
break;
}
skipped += n;
startCharIndex += rune.Utf16SequenceLength;
}
return data.Substring(startCharIndex);
}
private static void KillProcessTree(Process process)
{
try
{
#if NET5_0_OR_GREATER
process.Kill(entireProcessTree: true);
#else
process.Kill();
#endif
}
catch (InvalidOperationException) { }
catch (System.ComponentModel.Win32Exception) { }
}
private static TaskCompletionSource<bool> NewSignal()
=> new(TaskCreationOptions.RunContinuationsAsynchronously);
}
@@ -266,7 +266,33 @@ internal sealed class HandoffAgentExecutor :
sharedState.Conversation.AddMessages(incomingMessages);
}
newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages);
if (result.IsHandoffRequested)
{
int preHandoffMessageCount = result.Response.Messages.Count - 1;
newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages.Take(preHandoffMessageCount));
// The following message contains the Handoff FunctionCallResult which should be added to the conversation history with
// the caveat that we need to get it back next time _this_ agent is invoked because we need to feed the FunctionCallResult
// back to the agent. So ignore the bookmark update.
ChatMessage handoffCallResultMessage = result.Response.Messages[preHandoffMessageCount];
if (handoffCallResultMessage.Role != ChatRole.Tool)
{
throw new InvalidOperationException("The last message in a handoff response must be a Tool message containing the Handoff FunctionCallResult.");
}
if (handoffCallResultMessage.Contents.Count != 1 ||
handoffCallResultMessage.Contents[0] is not FunctionResultContent)
{
throw new InvalidOperationException("The Tool message in a handoff response must contain exactly one content item of type FunctionResultContent.");
}
_ = sharedState.Conversation.AddMessage(handoffCallResultMessage);
}
else
{
newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages);
}
return new ValueTask();
},
@@ -376,39 +402,28 @@ internal sealed class HandoffAgentExecutor :
List<AgentResponseUpdate> updates = [];
List<FunctionCallContent> candidateRequests = [];
await this.InvokeWithStateAsync(
async (state, ctx, ct) =>
this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
IAsyncEnumerable<AgentResponseUpdate> agentStream =
this._agent.RunStreamingAsync(messages, this._session, this._agentOptions, cancellationToken);
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter);
bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest)
{
this._session ??= await this._agent.CreateSessionAsync(ct).ConfigureAwait(false);
IAsyncEnumerable<AgentResponseUpdate> agentStream =
this._agent.RunStreamingAsync(messages,
this._session,
options: this._agentOptions,
cancellationToken: ct);
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name);
if (isHandoffRequest)
{
await AddUpdateAsync(update, ct).ConfigureAwait(false);
collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter);
bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest)
{
bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name);
if (isHandoffRequest)
{
candidateRequests.Add(candidateHandoffRequest);
}
return !isHandoffRequest;
}
candidateRequests.Add(candidateHandoffRequest);
}
return state;
},
context,
cancellationToken: cancellationToken).ConfigureAwait(false);
return !isHandoffRequest;
}
}
if (candidateRequests.Count > 1)
{
@@ -98,7 +98,10 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return base.ConfigureProtocol(protocolBuilder).ConfigureRoutes(ConfigureRoutes);
return base.ConfigureProtocol(protocolBuilder)
.SendsMessage<ChatMessage>()
.SendsMessage<ResetChatSignal>()
.ConfigureRoutes(ConfigureRoutes);
void ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddPortHandler<MagenticPlanReviewRequest, MagenticPlanReviewResponse>(
"RequestPlanReview",
@@ -3,13 +3,14 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
internal sealed class StreamingToolCallResultPairMatcher
{
private enum CallType
internal enum CallType
{
Function,
McpServerTool
@@ -17,7 +18,7 @@ internal sealed class StreamingToolCallResultPairMatcher
private record CallSummaryKey(CallType Type, string CallId);
private struct ToolCallSummary(CallType callType, string callId, string name)
internal struct ToolCallSummary(CallType callType, string callId, string name)
{
public CallType CallType => callType;
@@ -28,6 +29,12 @@ internal sealed class StreamingToolCallResultPairMatcher
private readonly Dictionary<CallSummaryKey, ToolCallSummary> _callSummaries = new();
public bool HasUnmatchedCalls => this._callSummaries.Count > 0;
public IEnumerable<ToolCallSummary> UnmatchedCalls => this.HasUnmatchedCalls
? this._callSummaries.Values.ToList()
: [];
private void Collect(CallType callType, string callId, string name, string callContentTypeName, string resultContentTypeName)
{
CallSummaryKey key = new(callType, callId);
@@ -329,40 +329,18 @@ public sealed partial class ChatClientAgent : AIAgent
this._logger.LogAgentChatClientInvokedStreamingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
bool hasUpdates;
// Ensure the inner enumerator is always disposed, even if the consumer breaks out early
// (e.g. ToolApprovalAgent does `yield break` after emitting an approval request). Without
// this, downstream decorators like PerServiceCallChatHistoryPersistingChatClient would be
// left suspended at `yield return`, never running their finally blocks, and any in-flight
// FunctionResultContent / FunctionCallContent state would not be persisted before the next
// turn, leaving the next request to the model with dangling tool calls.
try
{
// Ensure we start the streaming request
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = responseUpdatesEnumerator.Current;
if (update is not null)
{
update.AuthorName ??= this.Name;
responseUpdates.Add(update);
yield return new(update)
{
AgentId = this.Id,
ContinuationToken = WrapContinuationToken(update.ContinuationToken, GetInputMessages(inputMessages, continuationToken), responseUpdates)
};
}
bool hasUpdates;
try
{
// Re-ensure the run context has the resolved session before each MoveNextAsync.
// The base class RunStreamingAsync restores the original context (potentially with
// null session) after each yield, so we must re-establish it for the decorator.
EnsureRunContextHasSession(safeSession);
// Ensure we start the streaming request
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
@@ -370,20 +348,55 @@ public sealed partial class ChatClientAgent : AIAgent
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = responseUpdatesEnumerator.Current;
if (update is not null)
{
update.AuthorName ??= this.Name;
responseUpdates.Add(update);
yield return new(update)
{
AgentId = this.Id,
ContinuationToken = WrapContinuationToken(update.ContinuationToken, GetInputMessages(inputMessages, continuationToken), responseUpdates)
};
}
try
{
// Re-ensure the run context has the resolved session before each MoveNextAsync.
// The base class RunStreamingAsync restores the original context (potentially with
// null session) after each yield, so we must re-establish it for the decorator.
EnsureRunContextHasSession(safeSession);
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
throw;
}
}
var chatResponse = responseUpdates.ToChatResponse();
var forceEndOfRunPersistence = continuationToken is not null || chatOptions?.AllowBackgroundResponses is true;
// We can derive the type of supported session from whether we have a conversation id,
// so let's update it and set the conversation id for the service session case.
this.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence);
// Notify providers of all new messages unless persistence is handled per-service-call by the decorator.
// When resuming from a continuation token or using background responses, force notification
// to send the combined data (per-service-call persistence is unreliable for these scenarios).
await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken, forceNotify: forceEndOfRunPersistence).ConfigureAwait(false);
}
finally
{
await responseUpdatesEnumerator.DisposeAsync().ConfigureAwait(false);
}
var chatResponse = responseUpdates.ToChatResponse();
var forceEndOfRunPersistence = continuationToken is not null || chatOptions?.AllowBackgroundResponses is true;
// We can derive the type of supported session from whether we have a conversation id,
// so let's update it and set the conversation id for the service session case.
this.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence);
// Notify providers of all new messages unless persistence is handled per-service-call by the decorator.
// When resuming from a continuation token or using background responses, force notification
// to send the combined data (per-service-call persistence is unreliable for these scenarios).
await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken, forceNotify: forceEndOfRunPersistence).ConfigureAwait(false);
}
/// <inheritdoc/>
@@ -206,6 +206,31 @@ public sealed class MessageInjectingChatClient : DelegatingChatClient
}
}
/// <summary>
/// Gets a snapshot of the pending injected messages for the specified session.
/// </summary>
/// <remarks>
/// Returns a copy of the current pending messages that have not yet been consumed by the
/// injection loop. This can be used to display pending messages to the user. The returned
/// list is a point-in-time snapshot; messages may be consumed between calls.
/// </remarks>
/// <param name="session">The agent session to check.</param>
/// <returns>A read-only list of pending messages, or an empty list if none are pending.</returns>
public IReadOnlyList<ChatMessage> GetPendingMessages(AgentSession session)
{
Throw.IfNull(session);
if (!session.StateBag.TryGetValue<List<ChatMessage>>(PendingMessagesStateKey, out var queue) || queue is null)
{
return Array.Empty<ChatMessage>();
}
lock (queue)
{
return queue.Count == 0 ? Array.Empty<ChatMessage>() : queue.ToList();
}
}
/// <summary>
/// Gets or creates the pending injected messages queue from the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
@@ -152,7 +152,14 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
|| options?.AllowBackgroundResponses is true;
bool skipSimulation = isServiceManaged || isContinuationOrBackground;
var newMessages = messages as IList<ChatMessage> ?? messages.ToList();
// Snapshot the input messages into a private list. The caller (typically
// FunctionInvokingChatClient) reuses a single mutable buffer across iterations,
// and the streaming path can defer persistence until after the caller has already
// mutated that buffer for the next iteration (e.g. on the cooperative early-exit
// path NotifyProvidersOfEarlyExitInputAsync). Aliasing the caller's list would
// then cause us to persist the wrong messages — losing FunctionResultContent and
// corrupting history with dangling FunctionCallContent.
var newMessages = messages.ToList();
// When simulating, load history and prepend it. When the service manages
// history (real ConversationId) or this is a continuation/background run,
@@ -174,45 +181,83 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
throw;
}
bool hasUpdates;
bool loopExitedNormally = false;
bool serviceErrorOccurred = false;
try
{
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = enumerator.Current;
responseUpdates.Add(update.Clone());
// If the service returned a real ConversationId on any update, remember that.
// Otherwise stamp our sentinel so FICC treats this as service-managed —
// unless this is a continuation/background run where the agent handles everything.
if (!string.IsNullOrEmpty(update.ConversationId))
{
isServiceManaged = true;
}
else if (!skipSimulation)
{
update.ConversationId = LocalHistoryConversationId;
}
yield return update;
bool hasUpdates;
try
{
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
serviceErrorOccurred = true;
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = enumerator.Current;
responseUpdates.Add(update.Clone());
// If the service returned a real ConversationId on any update, remember that.
// Otherwise stamp our sentinel so FICC treats this as service-managed —
// unless this is a continuation/background run where the agent handles everything.
if (!string.IsNullOrEmpty(update.ConversationId))
{
isServiceManaged = true;
}
else if (!skipSimulation)
{
update.ConversationId = LocalHistoryConversationId;
}
yield return update;
try
{
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
serviceErrorOccurred = true;
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
throw;
}
}
loopExitedNormally = true;
}
finally
{
// If the iterator was disposed by the consumer before completing — e.g.
// ToolApprovalAgent does `yield break` after emitting an approval request — persist
// the input messages so that any in-flight FunctionResultContent paired with
// previously-persisted FunctionCallContent is not lost between turns. We only do
// this on the cooperative-pause path; service errors deliberately do NOT persist
// input messages (history of failed calls is the caller's responsibility, e.g.
// by retrying or starting from an earlier point).
if (!loopExitedNormally && !serviceErrorOccurred)
{
// Prefer the original cancellation token so cleanup remains responsive; fall
// back to None only if the caller's token has already been canceled (otherwise
// the notify call would observe the cancellation, throw, and mask the
// original early-exit reason).
var persistToken = cancellationToken.IsCancellationRequested ? CancellationToken.None : cancellationToken;
try
{
await NotifyProvidersOfEarlyExitInputAsync(agent, session, newMessages, options, persistToken).ConfigureAwait(false);
}
catch
{
// Best-effort persistence; swallow to avoid masking the original exit reason.
}
}
// Always dispose the underlying enumerator on every exit path (normal completion,
// exception, or early consumer disposal) to release the underlying HTTP/stream.
await enumerator.DisposeAsync().ConfigureAwait(false);
}
var chatResponse = responseUpdates.ToChatResponse();
@@ -236,6 +281,30 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
}
}
/// <summary>
/// Notifies <see cref="ChatHistoryProvider"/>s of the input messages only (no response
/// messages) on the cooperative early-exit path — e.g. when <c>ToolApprovalAgent</c>
/// does <c>yield break</c> after emitting an approval request. This ensures any
/// in-flight <see cref="FunctionResultContent"/> paired with previously-persisted
/// <see cref="FunctionCallContent"/> is not orphaned in the persisted chat history.
/// The notification is routed through the same success channel used at the end of a
/// normal run; the providers themselves decide how (or whether) to persist.
/// </summary>
private static async Task NotifyProvidersOfEarlyExitInputAsync(
ChatClientAgent agent,
ChatClientAgentSession session,
List<ChatMessage> newMessages,
ChatOptions? options,
CancellationToken cancellationToken)
{
if (newMessages.Count == 0)
{
return;
}
await agent.NotifyProvidersOfNewMessagesAsync(session, newMessages, [], options, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sets the sentinel <see cref="LocalHistoryConversationId"/> on the response and session
/// so that <see cref="FunctionInvokingChatClient"/> treats the conversation as service-managed.
@@ -21,6 +21,10 @@ internal static class TestSettings
public const string AzureAIModelDeploymentName = "AZURE_AI_MODEL_DEPLOYMENT_NAME";
public const string AzureAIProjectEndpoint = "AZURE_AI_PROJECT_ENDPOINT";
// Azure AI Search (Foundry.Hosting integration tests, RAG scenario)
public const string AzureSearchEndpoint = "AZURE_SEARCH_ENDPOINT";
public const string AzureSearchIndexName = "AZURE_SEARCH_INDEX_NAME";
// Foundry Hosted Agents (Foundry.Hosting integration tests)
public const string FoundryHostingItImage = "IT_HOSTED_AGENT_IMAGE";
@@ -18,6 +18,7 @@ namespace AnthropicChatCompletion.IntegrationTests;
/// Integration tests for Anthropic Skills functionality.
/// These tests are designed to be run locally with a valid Anthropic API key.
/// </summary>
[Trait("Category", "Integration")]
public sealed class AnthropicSkillsIntegrationTests
{
[Fact]
@@ -33,6 +33,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
@@ -1,8 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using Azure;
using Azure.AI.Projects;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -29,9 +32,9 @@ AIAgent agent = scenario switch
"happy-path" => CreateHappyPathAgent(projectClient, deployment),
"tool-calling" => CreateToolCallingAgent(projectClient, deployment),
"tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment),
"toolbox" => CreateToolboxAgent(projectClient, deployment),
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
"custom-storage" => CreateCustomStorageAgent(projectClient, deployment),
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
};
@@ -80,17 +83,6 @@ static AIAgent CreateToolCallingApprovalAgent(AIProjectClient client, string dep
AIFunctionFactory.Create(SendEmail)
]);
static AIAgent CreateToolboxAgent(AIProjectClient client, string deployment) =>
// TODO: wire Foundry toolbox host once API surface is finalized for hosted agents.
client.AsAIAgent(
model: deployment,
instructions: "You are a toolbox enabled assistant. Use GetEnvironmentName when asked.",
name: "toolbox-agent",
description: "Toolbox test agent (placeholder).",
tools: [
AIFunctionFactory.Create(GetEnvironmentName)
]);
static AIAgent CreateMcpToolboxAgent(AIProjectClient client, string deployment) =>
// TODO: wire MCP toolbox client to https://learn.microsoft.com/api/mcp.
client.AsAIAgent(
@@ -107,6 +99,62 @@ static AIAgent CreateCustomStorageAgent(AIProjectClient client, string deploymen
name: "custom-storage-agent",
description: "Custom storage test agent (placeholder).");
static AIAgent CreateAzureSearchRagAgent(AIProjectClient client, string deployment)
{
// The fixture (AzureSearchRagHostedAgentFixture) injects AZURE_SEARCH_ENDPOINT and
// AZURE_SEARCH_INDEX_NAME into the hosted agent definition. The index is provisioned
// out of band (see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md for the
// required schema and seed content); the container only needs read access. The
// agent's managed identity must hold 'Search Index Data Reader' on the search service
// scope.
var searchEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT")
?? throw new InvalidOperationException("AZURE_SEARCH_ENDPOINT is not set for IT_SCENARIO=azure-search-rag."));
var indexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME")
?? throw new InvalidOperationException("AZURE_SEARCH_INDEX_NAME is not set for IT_SCENARIO=azure-search-rag.");
var searchClient = new SearchClient(searchEndpoint, indexName, new DefaultAzureCredential());
var options = new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 6,
};
return client.AsAIAgent(new ChatClientAgentOptions
{
Name = "azure-search-rag-agent",
ChatOptions = new ChatOptions
{
ModelId = deployment,
Instructions = "You are a helpful support specialist for Contoso Outdoors. " +
"Answer questions using the provided context and cite the source document when available.",
},
AIContextProviders = [new TextSearchProvider(CreateAzureSearchAdapter(searchClient), options)]
});
}
static Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>>
CreateAzureSearchAdapter(SearchClient client, int top = 3) =>
async (query, cancellationToken) =>
{
var searchOptions = new SearchOptions { Size = top };
Response<SearchResults<SearchDocument>> response =
await client.SearchAsync<SearchDocument>(query, searchOptions, cancellationToken).ConfigureAwait(false);
var results = new List<TextSearchProvider.TextSearchResult>();
await foreach (SearchResult<SearchDocument> hit in response.Value.GetResultsAsync().WithCancellation(cancellationToken).ConfigureAwait(false))
{
results.Add(new TextSearchProvider.TextSearchResult
{
SourceName = hit.Document.TryGetValue("sourceName", out var name) ? name?.ToString() ?? string.Empty : string.Empty,
SourceLink = hit.Document.TryGetValue("sourceLink", out var link) ? link?.ToString() ?? string.Empty : string.Empty,
Text = hit.Document.TryGetValue("content", out var content) ? content?.ToString() ?? string.Empty : string.Empty,
RawRepresentation = hit
});
}
return results;
};
// session-files scenario: agent reads files from $HOME inside the per-session sandbox volume.
// Mirrors the dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files sample.
static AIAgent CreateSessionFilesAgent(AIProjectClient client, string deployment) =>
@@ -143,9 +191,6 @@ static string SendEmail(
[Description("Email subject")] string subject) =>
$"Email sent to {to} with subject '{subject}'.";
[Description("Returns the deployment environment name.")]
static string GetEnvironmentName() => "integration-test";
// session-files tools: resolve paths against $HOME (the per-session sandbox volume).
[Description("Get the absolute path of the session home directory ($HOME).")]
static string GetHomeDirectory() => SessionHome();
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Foundry.Hosting.IntegrationTests.Fixtures;
using Microsoft.Agents.AI;
namespace Foundry.Hosting.IntegrationTests;
/// <summary>
/// End to end RAG integration tests against a hosted agent backed by Azure AI Search.
/// The hosted agent runs the test container with <c>IT_SCENARIO=azure-search-rag</c>, which
/// wires <see cref="TextSearchProvider"/> over a real <c>SearchClient</c> against the
/// pre-seeded Contoso Outdoors index.
/// </summary>
/// <remarks>
/// Each test asks for a unique <c>*-CANARY-*</c> token that exists ONLY in the seeded
/// document. The model cannot fabricate these tokens from its training data, so a passing
/// assertion is proof the agent retrieved the seeded document via Azure AI Search rather
/// than answering from general knowledge.
/// </remarks>
[Trait("Category", "FoundryHostedAgents")]
public sealed class AzureSearchRagHostedAgentTests(AzureSearchRagHostedAgentFixture fixture)
: IClassFixture<AzureSearchRagHostedAgentFixture>
{
private readonly AzureSearchRagHostedAgentFixture _fixture = fixture;
[Fact]
public async Task RagAnswer_CitesSeededReturnPolicy_WhenAskedAboutReturnsAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act: ask about the canary SKU embedded in the seeded Return Policy doc. The
// canary token (TR-CANARY-7821) is unfakeable - it does not exist in any model
// training data, so its presence in the answer is proof the agent retrieved
// the seeded document via the Azure AI Search adapter.
var response = await agent.RunAsync(
"What item code do I get with my return? Cite the source.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("TR-CANARY-7821", response.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RagAnswer_CitesShippingGuide_WhenAskedAboutShippingAsync()
{
// Arrange
var agent = this._fixture.Agent;
// Act: canary promo code (SHIP-CANARY-4493) is unique to the seeded Shipping
// Guide doc. Its presence proves the answer was grounded in retrieved content.
var response = await agent.RunAsync(
"What promo code can I use for free overnight shipping? Cite the source.");
// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("SHIP-CANARY-4493", response.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RagAnswer_StaysGroundedWithoutContext_WhenAskedUnrelatedQuestionAsync()
{
// Arrange: ask something that is NOT covered by the three seeded Contoso documents.
var agent = this._fixture.Agent;
// Act
var response = await agent.RunAsync(
"What is the boiling point of liquid nitrogen in degrees Celsius? " +
"Just give the number with units, no other context.");
// Assert: response is non empty AND does NOT fabricate a Contoso source citation.
// The agent may either answer from its general knowledge or admit uncertainty; either
// is acceptable. The key assertion is that we do not see a fake Contoso link.
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.DoesNotContain("contoso.com", response.Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using AgentConformance.IntegrationTests.Support;
using Shared.IntegrationTests;
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=azure-search-rag</c> mode.
/// Wires the container up with an Azure AI Search backed <see cref="Microsoft.Agents.AI.TextSearchProvider"/>
/// adapter that retrieves Contoso Outdoors documents from a pre-provisioned search index before each
/// model invocation.
/// </summary>
/// <remarks>
/// Prerequisites managed out of band:
/// <list type="bullet">
/// <item><description>The <c>it-azure-search-rag</c> agent's managed identity must hold
/// <c>Search Index Data Reader</c> on the search service scope. Granted manually after
/// the first <c>scripts/it-bootstrap-agents.ps1</c> run; see the IT README.</description></item>
/// <item><description>The search index referenced by <c>AZURE_SEARCH_INDEX_NAME</c> must
/// already exist with the documented schema and Contoso Outdoors content. The search
/// service is shared with <c>python-sample-validation.yml</c>; no .NET-side provisioning
/// script ships with this repository.</description></item>
/// </list>
/// </remarks>
public sealed class AzureSearchRagHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "azure-search-rag";
/// <summary>
/// Inject the AZURE_SEARCH_* env vars onto the hosted agent definition so the test container
/// scenario branch can construct its <c>SearchClient</c>. These names are NOT in the platform
/// reserved <c>FOUNDRY_*</c> / <c>AGENT_*</c> namespace so they are safe to set.
/// </summary>
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
{
environment[TestSettings.AzureSearchEndpoint] = TestConfiguration.GetRequiredValue(TestSettings.AzureSearchEndpoint);
environment[TestSettings.AzureSearchIndexName] = TestConfiguration.GetRequiredValue(TestSettings.AzureSearchIndexName);
}
}
@@ -1,14 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Foundry.Hosting.IntegrationTests.Fixtures;
/// <summary>
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=toolbox</c> mode.
/// The container hosts a Foundry toolbox with at least one server registered tool. Tests verify
/// that the model can invoke those tools and that client side toolbox additions surface alongside
/// server side registrations when listed.
/// </summary>
public sealed class ToolboxHostedAgentFixture : HostedAgentFixture
{
protected override string ScenarioName => "toolbox";
}

Some files were not shown because too many files have changed in this diff Show More