Compare commits

..
249 changed files with 3680 additions and 20620 deletions
+12 -124
View File
@@ -38,8 +38,6 @@ jobs:
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
functionsChanged: ${{ steps.filter.outputs.functions }}
coreChanged: ${{ steps.filter.outputs.core }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
@@ -66,24 +64,6 @@ jobs:
- 'dotnet/Directory.Packages.props'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1'
- '.github/workflows/dotnet-build-and-test.yml'
functions:
- 'dotnet/src/Microsoft.Agents.AI.DurableTask/**'
- 'dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/**'
- 'dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/**'
- 'dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/**'
- '.github/actions/azure-functions-integration-setup/**'
- '.github/workflows/dotnet-build-and-test.yml'
core:
- 'dotnet/src/Microsoft.Agents.AI/**'
- 'dotnet/src/Microsoft.Agents.AI.Abstractions/**'
- 'dotnet/src/Microsoft.Agents.AI.OpenAI/**'
- 'dotnet/src/Microsoft.Agents.AI.Workflows/**'
- 'dotnet/src/Microsoft.Agents.AI.Workflows.Generators/**'
- 'dotnet/eng/scripts/New-FilteredSolution.ps1'
- 'dotnet/tests/Directory.Build.props'
- 'dotnet/Directory.Packages.props'
- 'dotnet/global.json'
- '.github/workflows/dotnet-build-and-test.yml'
# run only if 'dotnet' files were changed
- name: dotnet tests
if: steps.filter.outputs.dotnet == 'true'
@@ -231,11 +211,10 @@ jobs:
Verbose = $true
}
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
-TestProjectNameIncludeFilter "*UnitTests*" `
-TestProjectNameFilter "*UnitTests*" `
-OutputPath dotnet/filtered-unit.slnx
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
-TestProjectNameIncludeFilter "*IntegrationTests*" `
-TestProjectNameExcludeFilter "*DurableTask.IntegrationTests*","*AzureFunctions.IntegrationTests*" `
-TestProjectNameFilter "*IntegrationTests*" `
-OutputPath dotnet/filtered-integration.slnx
- name: Run Unit Tests
@@ -277,6 +256,14 @@ jobs:
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# This setup action is required for both Durable Task and Azure Functions integration tests.
# We only run it on Ubuntu since the Durable Task and Azure Functions features are not available
# on .NET Framework (net472) which is what we use the Windows runner for.
- name: Set up Durable Task and Azure Functions Integration Test Emulators
if: github.event_name != 'pull_request' && matrix.integration-tests && matrix.os == 'ubuntu-latest'
uses: ./.github/actions/azure-functions-integration-setup
id: azure-functions-setup
- name: Run Integration Tests
shell: pwsh
working-directory: dotnet
@@ -429,110 +416,11 @@ jobs:
AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }}
# IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step.
# DurableTask and AzureFunctions integration tests (ubuntu/net10.0 only).
# Split from main dotnet-test job for path-based filtering and parallelism.
dotnet-test-functions:
needs: [paths-filter]
if: >
github.event_name != 'pull_request' &&
(needs.paths-filter.outputs.functionsChanged == 'true' ||
needs.paths-filter.outputs.coreChanged == 'true' ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
environment: integration
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
.
.github
dotnet
python
declarative-agents
- name: Setup dotnet
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Build functions integration test projects
shell: bash
working-directory: dotnet
run: |
dotnet build ./tests/Microsoft.Agents.AI.DurableTask.IntegrationTests -c Release -f net10.0 --warnaserror
dotnet build ./tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests -c Release -f net10.0 --warnaserror
- name: Azure CLI Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Set up Durable Task and Azure Functions Integration Test Emulators
uses: ./.github/actions/azure-functions-integration-setup
id: azure-functions-setup
- name: Run Functions Integration Tests
shell: pwsh
working-directory: dotnet
run: |
# Run DurableTask integration tests
dotnet test `
--project ./tests/Microsoft.Agents.AI.DurableTask.IntegrationTests `
-f net10.0 `
-c Release `
--no-build -v Normal `
--report-xunit-trx `
--report-junit `
--results-directory ../IntegrationTestResults/ `
--ignore-exit-code 8 `
--filter-not-trait "Category=IntegrationDisabled" `
--parallel-algorithm aggressive `
--max-threads 2.0x
# Run AzureFunctions integration tests
dotnet test `
--project ./tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests `
-f net10.0 `
-c Release `
--no-build -v Normal `
--report-xunit-trx `
--report-junit `
--results-directory ../IntegrationTestResults/ `
--ignore-exit-code 8 `
--filter-not-trait "Category=IntegrationDisabled" `
--parallel-algorithm aggressive `
--max-threads 2.0x
env:
# OpenAI Models
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_CHAT_MODEL_NAME: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
OPENAI_REASONING_MODEL_NAME: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
# Azure OpenAI Models
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }}
# Azure AI Foundry
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
- name: Upload functions test results
if: always()
uses: actions/upload-artifact@v7
with:
name: dotnet-test-results-functions-net10.0-ubuntu-latest
path: IntegrationTestResults/**/*.junit
if-no-files-found: ignore
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
dotnet-build-and-test-check:
if: always()
runs-on: ubuntu-latest
needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it, dotnet-test-functions]
needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it]
steps:
- name: Get Date
shell: bash
@@ -579,7 +467,7 @@ jobs:
github.event_name != 'pull_request' &&
(contains(join(needs.*.result, ','), 'success') ||
contains(join(needs.*.result, ','), 'failure'))
needs: [dotnet-test, dotnet-test-functions]
needs: [dotnet-test]
runs-on: ubuntu-latest
defaults:
run:
+14 -15
View File
@@ -1,8 +1,12 @@
name: Issue Triage
on:
issues:
types: [opened, labeled]
workflow_dispatch:
inputs:
issue_number:
description: Issue number to triage
required: true
type: string
permissions:
contents: read
@@ -10,13 +14,7 @@ permissions:
id-token: write
concurrency:
group: >-
issue-triage-${{ github.repository }}-${{
((github.event.action == 'opened' && contains(github.event.issue.labels.*.name, 'bug'))
|| (github.event.action == 'labeled' && github.event.label.name == 'bug'))
&& github.event.issue.number
|| github.run_id
}}
group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }}
cancel-in-progress: true
env:
@@ -28,7 +26,6 @@ env:
jobs:
team_check:
runs-on: ubuntu-latest
if: ${{ (github.event.action == 'opened' && contains(github.event.issue.labels.*.name, 'bug')) || (github.event.action == 'labeled' && github.event.label.name == 'bug') }}
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
issue_number: ${{ steps.issue.outputs.issue_number }}
@@ -39,13 +36,18 @@ jobs:
shell: bash
env:
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
run: |
set -euo pipefail
issue_number="${ISSUE_NUMBER_EVENT}"
if [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
issue_number="${ISSUE_NUMBER_EVENT}"
else
issue_number="${ISSUE_NUMBER_INPUT}"
fi
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
echo "Could not determine issue number from event payload." >&2
echo "Could not determine issue number; for workflow_dispatch runs, the 'issue_number' input is required." >&2
exit 1
fi
@@ -164,9 +166,6 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
# Not seen by the agent prompt; used only to push a paper-trail
# branch back to maf-dashboard at run end.
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
+13 -95
View File
@@ -2,7 +2,7 @@ name: Merge Gatekeeper
on:
pull_request:
branches: ["main", "feature*"]
branches: [ "main", "feature*" ]
merge_group:
branches: ["main"]
@@ -13,105 +13,23 @@ concurrency:
jobs:
merge-gatekeeper:
runs-on: ubuntu-latest
# Restrict permissions of the GITHUB_TOKEN.
# Docs: https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs
permissions:
checks: read
statuses: read
steps:
- name: Wait for required checks
- name: Run Merge Gatekeeper
# NOTE: v1 is updated to reflect the latest v1.x.y. Please use any tag/branch that suits your needs:
# https://github.com/upsidr/merge-gatekeeper/tags
# https://github.com/upsidr/merge-gatekeeper/branches
uses: upsidr/merge-gatekeeper@v1
if: github.event_name == 'pull_request'
uses: actions/github-script@v8
env:
TIMEOUT_SECONDS: "3600"
INTERVAL_SECONDS: "30"
SELF_JOB_NAME: ${{ github.job }}
with:
token: ${{ secrets.GITHUB_TOKEN }}
timeout: 3600
interval: 30
# "Cleanup artifacts", "Agent", "Prepare", and "Upload results" are check runs
# created by an org-level GitHub App (MSDO), not by any workflow in this repo.
# They are outside our control and their transient failures should not block merges.
IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results"
with:
script: |
const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS);
const intervalSeconds = Number(process.env.INTERVAL_SECONDS);
const selfName = process.env.SELF_JOB_NAME;
const ignored = new Set(
process.env.IGNORED_NAMES.split(',').map((s) => s.trim()).filter(Boolean),
);
const sha = context.payload.pull_request.head.sha;
const { owner, repo } = context.repo;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Mirrors upsidr/merge-gatekeeper: merge combined-statuses and check-runs
// for the PR head SHA, with combined-statuses winning on name collision.
async function collectChecks() {
const merged = new Map();
const combined = await github.rest.repos.getCombinedStatusForRef({
owner, repo, ref: sha, per_page: 100,
});
for (const s of combined.data.statuses ?? []) {
if (!merged.has(s.context)) {
// Combined-status states: success | pending | error | failure
merged.set(s.context, { name: s.context, state: s.state });
}
}
const runs = await github.paginate(github.rest.checks.listForRef, {
owner, repo, ref: sha, per_page: 100,
});
for (const r of runs) {
if (merged.has(r.name)) continue;
let state;
if (r.status !== 'completed') {
state = 'pending';
} else if (r.conclusion === 'skipped') {
continue; // Skipped runs are dropped, matching the original action.
} else if (r.conclusion === 'success' || r.conclusion === 'neutral') {
state = 'success';
} else {
// cancelled | timed_out | action_required | stale | failure
state = 'error';
}
merged.set(r.name, { name: r.name, state });
}
return [...merged.values()];
}
function evaluate(entries) {
const failed = [];
const pending = [];
const succeeded = [];
for (const e of entries) {
if (e.name === selfName || ignored.has(e.name)) continue;
if (e.state === 'success') succeeded.push(e.name);
else if (e.state === 'error' || e.state === 'failure') failed.push(e.name);
else pending.push(e.name);
}
return { failed, pending, succeeded };
}
const deadline = Date.now() + timeoutSeconds * 1000;
for (;;) {
const entries = await collectChecks();
const { failed, pending, succeeded } = evaluate(entries);
core.info(
`succeeded=${succeeded.length} pending=${pending.length} failed=${failed.length}`,
);
if (failed.length) {
core.setFailed(`Failing checks: ${failed.join(', ')}`);
return;
}
if (pending.length === 0) {
core.info(`All required checks passed: ${succeeded.join(', ') || '(none)'}`);
return;
}
if (Date.now() > deadline) {
core.setFailed(`Timed out waiting for: ${pending.join(', ')}`);
return;
}
core.info(`Waiting on (${pending.length}): ${pending.slice(0, 10).join(', ')}${pending.length > 10 ? ', …' : ''}`);
await sleep(intervalSeconds * 1000);
}
ignored: CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results
-2
View File
@@ -246,5 +246,3 @@ dotnet/filtered-*.slnx
# Local tool state
.omc/
.omx/
**/issues/
+2 -2
View File
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<!-- Aspire.* -->
<PackageVersion Include="Anthropic" Version="12.20.0" />
<PackageVersion Include="Anthropic" Version="12.13.0" />
<PackageVersion Include="Anthropic.Foundry" Version="0.5.0" />
<PackageVersion Include="Aspire.Hosting" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
@@ -194,4 +194,4 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
</Project>
+1 -11
View File
@@ -1,4 +1,4 @@
<Solution>
<Solution>
<Configurations>
<BuildType Name="Debug" />
<BuildType Name="Publish" />
@@ -65,7 +65,6 @@
<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" />
@@ -124,8 +123,6 @@
<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" />
@@ -242,7 +239,6 @@
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
@@ -299,7 +295,6 @@
</Folder>
<Folder Name="/Samples/03-workflows/Evaluation/">
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Evaluation_WorkflowEval.csproj" />
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/Evaluation_WorkflowExpectedOutputs.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/">
</Folder>
@@ -584,7 +579,6 @@
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
<Project Path="src/Microsoft.Agents.AI.Harness/Microsoft.Agents.AI.Harness.csproj" />
<Project Path="src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
@@ -596,7 +590,6 @@
<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" />
@@ -619,7 +612,6 @@
<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" />
@@ -639,7 +631,6 @@
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Harness.UnitTests/Microsoft.Agents.AI.Harness.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj" />
@@ -649,7 +640,6 @@
<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" />
-1
View File
@@ -7,7 +7,6 @@
"src\\Microsoft.Agents.AI.AGUI\\Microsoft.Agents.AI.AGUI.csproj",
"src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.csproj",
"src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj",
"src\\Microsoft.Agents.AI.Harness\\Microsoft.Agents.AI.Harness.csproj",
"src\\Microsoft.Agents.AI.AzureAI.Persistent\\Microsoft.Agents.AI.AzureAI.Persistent.csproj",
"src\\Microsoft.Agents.AI.Foundry\\Microsoft.Agents.AI.Foundry.csproj",
"src\\Microsoft.Agents.AI.Foundry.Hosting\\Microsoft.Agents.AI.Foundry.Hosting.csproj",
+4 -32
View File
@@ -21,15 +21,10 @@
.PARAMETER Configuration
Optional MSBuild configuration used when querying TargetFrameworks. Defaults to Debug.
.PARAMETER TestProjectNameIncludeFilter
.PARAMETER TestProjectNameFilter
Optional wildcard pattern to filter test project names (e.g., *UnitTests*, *IntegrationTests*).
When specified, only test projects whose filename matches this pattern are kept.
.PARAMETER TestProjectNameExcludeFilter
Optional wildcard pattern(s) to exclude test projects by name (e.g., *DurableTask.IntegrationTests*).
When specified, test projects whose filename matches any of these patterns are removed.
Applied after TestProjectNameIncludeFilter. Can be a single string or an array of strings.
.PARAMETER ExcludeSamples
When specified, removes all projects under the samples/ directory from the solution.
@@ -43,15 +38,11 @@
.EXAMPLE
# Generate a solution with only unit test projects
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameIncludeFilter "*UnitTests*" -OutputPath filtered-unit.slnx
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameFilter "*UnitTests*" -OutputPath filtered-unit.slnx
.EXAMPLE
# Inline usage with dotnet test (PowerShell)
dotnet test --solution (./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net472) --no-build -f net472
.EXAMPLE
# Generate integration tests excluding DurableTask and AzureFunctions
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameIncludeFilter "*IntegrationTests*" -TestProjectNameExcludeFilter "*DurableTask.IntegrationTests*","*AzureFunctions.IntegrationTests*" -OutputPath filtered-other-integration.slnx
#>
[CmdletBinding()]
@@ -64,9 +55,7 @@ param(
[string]$Configuration = "Debug",
[string]$TestProjectNameIncludeFilter,
[string[]]$TestProjectNameExcludeFilter,
[string]$TestProjectNameFilter,
[switch]$ExcludeSamples,
@@ -111,30 +100,13 @@ foreach ($proj in $allProjects) {
$isTestProject = $projRelPath -like "*tests/*"
# Filter test projects by name pattern if specified
if ($isTestProject -and $TestProjectNameIncludeFilter -and ($projFileName -notlike $TestProjectNameIncludeFilter)) {
if ($isTestProject -and $TestProjectNameFilter -and ($projFileName -notlike $TestProjectNameFilter)) {
Write-Verbose "Removing (name filter): $projRelPath"
$removed += $projRelPath
$proj.ParentNode.RemoveChild($proj) | Out-Null
continue
}
# Exclude test projects matching any exclusion pattern
if ($isTestProject -and $TestProjectNameExcludeFilter) {
$excluded = $false
foreach ($pattern in $TestProjectNameExcludeFilter) {
if ($projFileName -like $pattern) {
$excluded = $true
break
}
}
if ($excluded) {
Write-Verbose "Removing (exclude filter): $projRelPath"
$removed += $projRelPath
$proj.ParentNode.RemoveChild($proj) | Out-Null
continue
}
}
if (-not (Test-Path $projFullPath)) {
Write-Verbose "Project not found, keeping in solution: $projRelPath"
$kept += $projRelPath
@@ -478,17 +478,6 @@ internal static class WorkflowSamples
ExpectedOutputDescription = ["The output should show a workflow invoking a function tool (e.g. a menu plugin) to answer a question about the soup of the day."],
},
new SampleDefinition
{
Name = "Workflow_Declarative_InvokeFoundryToolboxMcp",
ProjectPath = "samples/03-workflows/Declarative/InvokeFoundryToolboxMcp",
RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME", "FOUNDRY_TOOLBOX_NAME", "FOUNDRY_AGENT_TOOLSET_API_VERSION"],
Inputs = ["How do I use Azure OpenAI with my data?"],
InputDelayMs = 3000,
ExpectedOutputDescription = ["The output should show a workflow using Foundry Toolbox MCP tools to search Microsoft Learn documentation and web search to provide a summary of results."],
},
new SampleDefinition
{
Name = "Workflow_Declarative_InvokeMcpTool",
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.6.1</VersionPrefix>
<VersionPrefix>1.5.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260514</DateSuffix>
<DateSuffix>260507</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.6.1</GitTag>
<GitTag>1.5.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -1,22 +0,0 @@
<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>
@@ -1,130 +0,0 @@
// 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)"}");
}
}
@@ -20,18 +20,22 @@ using OpenAI.Responses;
#pragma warning disable OPENAI001 // Experimental API
#pragma warning disable AAIP001 // AgentToolboxes is experimental
// Name of the toolbox to create and connect to.
// Must match the `<name>` segment of FOUNDRY_TOOLBOX_ENDPOINT.
const string ToolboxName = "research_toolbox";
const string Query = "What tools do you have access to?";
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.
var toolboxEndpoint = await CreateSampleToolboxAsync(ToolboxName, endpoint, credential);
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")
@@ -47,11 +51,6 @@ await using McpClient mcpClient = await McpClient.CreateAsync(
{
Endpoint = new Uri(toolboxEndpoint),
Name = "foundry_toolbox",
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string>
{
["Foundry-Features"] = "Toolboxes=V1Preview",
},
},
httpClient));
@@ -75,7 +74,7 @@ Console.WriteLine($"Assistant: {await agent.RunAsync(Query)}");
// ---------------------------------------------------------------------------
// Helper: create (or replace) a sample toolbox so the sample runs end-to-end
// ---------------------------------------------------------------------------
static async Task<string> CreateSampleToolboxAsync(string name, string endpoint, TokenCredential credential)
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
@@ -104,13 +103,12 @@ static async Task<string> CreateSampleToolboxAsync(string name, string endpoint,
serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync(
var created = (await toolboxClient.CreateToolboxVersionAsync(
name: name,
tools: [mcpTool],
description: "Sample toolbox with an MCP tool — created by Agent_Step25 sample.")).Value;
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
return $"{endpoint}/toolboxes/{created.Name}/mcp?api-version=v{created.Version}";
}
// ---------------------------------------------------------------------------
@@ -19,11 +19,10 @@ 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 sample creates a toolbox named `research_toolbox` in your Foundry project on
startup, then connects to its MCP endpoint at
`{AZURE_AI_PROJECT_ENDPOINT}/toolboxes/research_toolbox/mcp?api-version=v{version}`.
The `<name>` segment of `FOUNDRY_TOOLBOX_ENDPOINT` must match the `ToolboxName` constant in `Program.cs`.
## Run the sample
@@ -1,90 +0,0 @@
// 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
};
}
@@ -1,14 +0,0 @@
<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>
@@ -1,151 +0,0 @@
// 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; } = "";
}
@@ -1,101 +0,0 @@
// 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++;
}
}
}
@@ -1,94 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveFramework;
namespace Harness.ConsoleReactiveComponents;
/// <summary>
/// Props for <see cref="TextPanel"/>.
/// </summary>
public record TextPanelProps : ConsoleReactiveProps
{
/// <summary>Gets the items to render in the panel. Each item is a pre-rendered
/// console string (may include ANSI escape sequences and newlines).</summary>
public IReadOnlyList<string> Items { get; init; } = [];
}
/// <summary>
/// A component that renders a list of pre-rendered string items vertically.
/// Designed for rendering dynamic items in a non-scroll region that may be
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveComponent.Height"/>
/// exceeds the number of output lines, leftover lines are erased.
/// </summary>
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
{
/// <summary>
/// Calculates the height (in lines) needed to render all items.
/// </summary>
/// <param name="items">The items to measure.</param>
/// <returns>The total number of lines all items will occupy.</returns>
public static int CalculateHeight(IReadOnlyList<string> items)
{
int total = 0;
for (int i = 0; i < items.Count; i++)
{
total += CountLines(items[i]);
}
return total;
}
/// <inheritdoc />
public override void RenderCore(TextPanelProps props, ConsoleReactiveState state)
{
int currentRow = 0;
for (int i = 0; i < props.Items.Count; i++)
{
string text = props.Items[i];
string[] lines = text.Split('\n');
int 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;
}
}
@@ -1,66 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveFramework;
namespace Harness.ConsoleReactiveComponents;
/// <summary>
/// Props for <see cref="TextScrollPanel"/>.
/// </summary>
public record TextScrollPanelProps : ConsoleReactiveProps
{
/// <summary>Gets the items to render in the scroll panel. Each item is a pre-rendered
/// console string (may include ANSI escape sequences and newlines).</summary>
public IReadOnlyList<string> Items { get; init; } = [];
}
/// <summary>
/// State for <see cref="TextScrollPanel"/>.
/// </summary>
/// <param name="RenderedCount">The number of items already rendered.</param>
public record TextScrollPanelState(int RenderedCount = 0) : ConsoleReactiveState;
/// <summary>
/// A component that renders pre-rendered string items within a scroll area.
/// All items are considered finalized — only new items since the last render are output.
/// Use <see cref="Reset"/> to force a full re-render.
/// </summary>
public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, TextScrollPanelState>
{
/// <summary>
/// Initializes a new instance of the <see cref="TextScrollPanel"/> class.
/// </summary>
public TextScrollPanel()
{
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++)
{
Console.Write(props.Items[i]);
}
// Update state to track what we've rendered
this.State = new TextScrollPanelState(props.Items.Count);
}
}
@@ -1,87 +0,0 @@
// 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);
}
}
}
@@ -1,110 +0,0 @@
// 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;
@@ -1,10 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
@@ -1,83 +0,0 @@
// 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);
}
}
}
@@ -1,57 +0,0 @@
// 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);
}
}
}
@@ -1,26 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles the <c>/exit</c> command to shut down the console application.
/// </summary>
public sealed class ExitCommandHandler : CommandHandler
{
/// <inheritdoc/>
public override string? GetHelpText() => "/exit (quit)";
/// <inheritdoc/>
public override ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
{
if (!input.Equals("/exit", StringComparison.OrdinalIgnoreCase))
{
return new ValueTask<bool>(false);
}
ux.RequestShutdown();
return new ValueTask<bool>(true);
}
}
@@ -5,25 +5,24 @@ using Microsoft.Agents.AI;
namespace Harness.Shared.Console.Commands;
/// <summary>
/// Base class for console command handlers (e.g., /todos, /mode). Command handlers
/// are checked in order before user input is sent to the agent. The first handler
/// that accepts the input prevents further handlers from being checked.
/// 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.
/// </summary>
public abstract class CommandHandler
public interface ICommandHandler
{
/// <summary>
/// Gets the help text for this command, displayed in the mode-and-help bar.
/// Gets the help text for this command, displayed in the console header.
/// Returns <see langword="null"/> if the command is not currently available.
/// </summary>
/// <returns>Help text like <c>"/todos (show todo list)"</c>, or <see langword="null"/>.</returns>
public abstract string? GetHelpText();
string? GetHelpText();
/// <summary>
/// Attempts to handle the given user input.
/// </summary>
/// <param name="input">The raw user input string.</param>
/// <param name="session">The current agent session.</param>
/// <param name="ux">The UX state driver for rendering output.</param>
/// <returns><see langword="true"/> if this handler handled the input; <see langword="false"/> otherwise.</returns>
public abstract ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux);
ValueTask<bool> TryHandleAsync(string input, AgentSession session);
}
@@ -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>
public sealed class ModeCommandHandler : CommandHandler
internal sealed class ModeCommandHandler : ICommandHandler
{
private readonly AgentModeProvider? _modeProvider;
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
@@ -24,28 +24,28 @@ public sealed class ModeCommandHandler : CommandHandler
}
/// <inheritdoc/>
public override string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
public string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
/// <inheritdoc/>
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
public ValueTask<bool> TryHandleAsync(string input, AgentSession session)
{
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
{
return false;
return ValueTask.FromResult(false);
}
if (this._modeProvider is null)
{
await ux.WriteInfoLineAsync("AgentModeProvider is not available.").ConfigureAwait(false);
return true;
System.Console.WriteLine("AgentModeProvider is not available.");
return ValueTask.FromResult(true);
}
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
{
string current = this._modeProvider.GetMode(session);
await ux.WriteInfoLineAsync($"Current mode: {current}").ConfigureAwait(false);
return true;
System.Console.WriteLine($"\n Current mode: {current}\n");
return ValueTask.FromResult(true);
}
string newMode = parts[1];
@@ -53,14 +53,17 @@ public sealed class ModeCommandHandler : CommandHandler
try
{
this._modeProvider.SetMode(session, newMode);
ux.CurrentMode = newMode;
await ux.WriteInfoLineAsync($"Switched to {newMode} mode.", ModeColors.Get(newMode, this._modeColors)).ConfigureAwait(false);
System.Console.ForegroundColor = ConsoleWriter.GetModeColor(newMode, this._modeColors);
System.Console.WriteLine($"\n Switched to {newMode} mode.\n");
System.Console.ResetColor();
}
catch (ArgumentException ex)
{
await ux.WriteInfoLineAsync(ex.Message, ConsoleColor.Red).ConfigureAwait(false);
System.Console.ForegroundColor = ConsoleColor.Red;
System.Console.WriteLine($"\n {ex}\n");
System.Console.ResetColor();
}
return true;
return ValueTask.FromResult(true);
}
}
@@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles the <c>/todos</c> command to display the current todo list.
/// </summary>
public sealed class TodoCommandHandler : CommandHandler
internal sealed class TodoCommandHandler : ICommandHandler
{
private readonly TodoProvider? _todoProvider;
@@ -21,10 +21,10 @@ public sealed class TodoCommandHandler : CommandHandler
}
/// <inheritdoc/>
public override string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
public string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
/// <inheritdoc/>
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
public async ValueTask<bool> TryHandleAsync(string input, AgentSession session)
{
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
{
@@ -33,28 +33,34 @@ public sealed class TodoCommandHandler : CommandHandler
if (this._todoProvider is null)
{
await ux.WriteInfoLineAsync("TodoProvider is not available.").ConfigureAwait(false);
System.Console.WriteLine("TodoProvider is not available.");
return true;
}
var todos = await this._todoProvider.GetAllTodosAsync(session).ConfigureAwait(false);
if (todos.Count == 0)
{
await ux.WriteInfoLineAsync("No todos yet.").ConfigureAwait(false);
System.Console.WriteLine("\n No todos yet.\n");
return true;
}
await ux.WriteInfoLineAsync("── Todo List ──").ConfigureAwait(false);
System.Console.WriteLine();
System.Console.WriteLine(" ── Todo List ──");
foreach (var item in todos)
{
string status = item.IsComplete ? "✓" : "○";
ConsoleColor color = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White;
string description = string.IsNullOrWhiteSpace(item.Description)
? string.Empty
: $" — {item.Description}";
await ux.WriteInfoLineAsync($"[{status}] #{item.Id} {item.Title}{description}", color).ConfigureAwait(false);
System.Console.ForegroundColor = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White;
System.Console.Write($" [{status}] #{item.Id} {item.Title}");
if (!string.IsNullOrWhiteSpace(item.Description))
{
System.Console.Write($" — {item.Description}");
}
System.Console.WriteLine();
}
System.Console.ResetColor();
System.Console.WriteLine();
return true;
}
}
@@ -1,71 +0,0 @@
// 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);
}
}
@@ -1,120 +0,0 @@
// 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));
}
}
}
@@ -0,0 +1,278 @@
// 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,
};
}
@@ -1,59 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Represents an action returned by an observer at the end of an agent turn.
/// Subtypes describe either a question to ask the user (<see cref="FollowUpQuestion"/>)
/// or a message to add directly to the next agent input (<see cref="FollowUpMessage"/>).
/// </summary>
public abstract record FollowUpAction;
/// <summary>
/// Represents a question that should be presented to the user. The
/// <see cref="Continuation"/> delegate is invoked with the user's answer and the
/// UX state driver, and returns an optional <see cref="ChatMessage"/> to add to the
/// next agent invocation.
/// </summary>
/// <param name="Prompt">The question text shown to the user.</param>
/// <param name="Continuation">
/// Invoked with the user's answer and the UX state driver. The driver lets the
/// continuation write output (e.g., an action label like "Approved") in addition
/// to producing an optional <see cref="ChatMessage"/> for the next agent invocation.
/// </param>
public abstract record FollowUpQuestion(
string Prompt,
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation) : FollowUpAction;
/// <summary>
/// A free-form text question. The user may type any response.
/// </summary>
/// <param name="Prompt">The question text shown to the user.</param>
/// <param name="Continuation">Continuation that builds the response message.</param>
public sealed record TextFollowUpQuestion(
string Prompt,
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
: FollowUpQuestion(Prompt, Continuation);
/// <summary>
/// A choice question. The user picks from <paramref name="Choices"/>, optionally with
/// the ability to enter custom text when <paramref name="AllowCustomText"/> is true.
/// </summary>
/// <param name="Prompt">The question text shown to the user.</param>
/// <param name="Choices">The list of pre-defined choices.</param>
/// <param name="AllowCustomText">If true, the user may type a custom response in addition to the listed choices.</param>
/// <param name="Continuation">Continuation that builds the response message.</param>
public sealed record ChoiceFollowUpQuestion(
string Prompt,
IReadOnlyList<string> Choices,
bool AllowCustomText,
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
: FollowUpQuestion(Prompt, Continuation);
/// <summary>
/// A message to add directly to the next agent invocation without prompting the user.
/// </summary>
/// <param name="Message">The chat message to add.</param>
public sealed record FollowUpMessage(ChatMessage Message) : FollowUpAction;
@@ -1,279 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.Shared.Console.Commands;
using Harness.Shared.Console.Observers;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Orchestrates agent invocations driven by user-input events from the UI.
/// The component invokes the runner's input handlers (<see cref="OnUserInputAsync"/>,
/// <see cref="OnStreamingInputAsync"/>, <see cref="StartAgentTurnAsync"/>) directly;
/// the runner mutates UI state through the supplied <see cref="IUXStateDriver"/>.
/// All per-turn follow-up state (pending questions and accumulated responses) lives
/// in the component's state record — the runner reads/writes it exclusively through
/// the driver and holds no per-turn fields itself.
/// </summary>
public sealed class HarnessAgentRunner : IDisposable
{
private readonly AIAgent _agent;
private readonly AgentSession _session;
private readonly AgentModeProvider? _modeProvider;
private readonly MessageInjectingChatClient? _messageInjector;
private readonly IReadOnlyList<CommandHandler> _commandHandlers;
private readonly IReadOnlyList<ConsoleObserver> _observers;
private readonly IUXStateDriver _ux;
private readonly SemaphoreSlim _inputGate = new(1, 1);
/// <summary>
/// Initializes a new instance of the <see cref="HarnessAgentRunner"/> class.
/// </summary>
public HarnessAgentRunner(
AIAgent agent,
AgentSession session,
AgentModeProvider? modeProvider,
MessageInjectingChatClient? messageInjector,
IReadOnlyList<CommandHandler> commandHandlers,
IReadOnlyList<ConsoleObserver> observers,
IUXStateDriver ux)
{
this._agent = agent;
this._session = session;
this._modeProvider = modeProvider;
this._messageInjector = messageInjector;
this._commandHandlers = commandHandlers;
this._observers = observers;
this._ux = ux;
this.HelpText = string.Join(
", ",
commandHandlers
.Select(h => h.GetHelpText())
.Where(t => t is not null)!);
}
/// <summary>
/// Gets the help text describing all available commands (joined by ", "), suitable
/// for display in the mode-and-help bar. Computed from the supplied
/// <c>commandHandlers</c>.
/// </summary>
public string HelpText { get; }
/// <inheritdoc/>
public void Dispose() => this._inputGate.Dispose();
/// <summary>
/// Handles a top-level user input submission (TextInput mode, no pending question).
/// Dispatches to command handlers, or starts an agent turn.
/// </summary>
internal async Task OnUserInputAsync(string text)
{
await this._inputGate.WaitAsync().ConfigureAwait(false);
try
{
this._ux.WriteUserInputEcho(text);
foreach (var handler in this._commandHandlers)
{
if (await handler.TryHandleAsync(text, this._session, this._ux).ConfigureAwait(false))
{
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
return;
}
}
await this.RunAgentLoopAsync([new ChatMessage(ChatRole.User, text)]).ConfigureAwait(false);
}
finally
{
this._inputGate.Release();
}
}
/// <summary>
/// Handles a user input submission while an agent turn is streaming. The text is
/// enqueued via the <see cref="MessageInjectingChatClient"/> so it can be picked up
/// by the agent on its next opportunity.
/// </summary>
internal Task OnStreamingInputAsync(string text)
{
if (this._messageInjector is null)
{
return Task.CompletedTask;
}
this._messageInjector.EnqueueMessages(this._session, [new ChatMessage(ChatRole.User, text)]);
this._ux.SetQueuedMessages(this._messageInjector.GetPendingMessages(this._session));
return Task.CompletedTask;
}
/// <summary>
/// Resumes (or completes) a turn after the user has answered all pending follow-up
/// questions. The component invokes this with the messages drained from
/// <see cref="IUXStateDriver.TakeFollowUpResponses"/>; an empty list simply ends
/// the streaming display state without invoking the agent.
/// </summary>
internal async Task StartAgentTurnAsync(IList<ChatMessage> messages)
{
await this._inputGate.WaitAsync().ConfigureAwait(false);
try
{
if (messages.Count == 0)
{
this.CompleteTurn();
return;
}
await this.RunAgentLoopAsync(messages).ConfigureAwait(false);
}
finally
{
this._inputGate.Release();
}
}
private async Task RunAgentLoopAsync(IList<ChatMessage> messages)
{
IList<ChatMessage>? nextMessages = messages;
IReadOnlyList<ChatMessage> lastPendingMessages = this._messageInjector?.GetPendingMessages(this._session) ?? [];
while (nextMessages is not null)
{
var runOptions = new AgentRunOptions();
foreach (var observer in this._observers)
{
observer.ConfigureRunOptions(runOptions, this._agent, this._session);
}
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
this._ux.BeginStreaming();
this._ux.BeginStreamingOutput();
try
{
await foreach (var update in this._agent.RunStreamingAsync(nextMessages, this._session, runOptions))
{
if (this._modeProvider is not null)
{
string currentMode = this._modeProvider.GetMode(this._session);
if (currentMode != this._ux.CurrentMode)
{
this._ux.CurrentMode = currentMode;
}
}
foreach (var content in update.Contents)
{
foreach (var observer in this._observers)
{
await observer.OnContentAsync(this._ux, content, this._agent, this._session).ConfigureAwait(false);
}
}
if (!string.IsNullOrEmpty(update.Text))
{
foreach (var observer in this._observers)
{
await observer.OnTextAsync(this._ux, update.Text, this._agent, this._session).ConfigureAwait(false);
}
}
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
}
}
catch (Exception ex)
{
await this._ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red).ConfigureAwait(false);
}
// Final sync after streaming.
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
this._ux.StopSpinner();
await this._ux.EndStreamingOutputAsync().ConfigureAwait(false);
// Collect FollowUpActions from each observer.
var directMessages = new List<ChatMessage>();
var questions = new List<FollowUpQuestion>();
foreach (var observer in this._observers)
{
var actions = await observer.OnStreamCompleteAsync(this._ux, this._agent, this._session).ConfigureAwait(false);
if (actions is null)
{
continue;
}
foreach (var action in actions)
{
switch (action)
{
case FollowUpMessage msg:
directMessages.Add(msg.Message);
break;
case FollowUpQuestion q:
questions.Add(q);
break;
}
}
}
bool hasFollowUpActions = directMessages.Count > 0 || questions.Count > 0;
await this._ux.WriteNoTextWarningAsync(hasFollowUpActions).ConfigureAwait(false);
// Add any direct messages to the accumulator regardless of whether questions follow —
// they're sent on the next agent invocation, either by us (if no questions) or by
// the component (after the user finishes answering, via StartAgentTurnAsync).
foreach (var msg in directMessages)
{
this._ux.AddFollowUpResponse(msg);
}
if (questions.Count > 0)
{
// Pause: hand control back to the UX to collect answers.
this._ux.QueueFollowUpQuestions(questions);
return;
}
// No questions to ask — drain anything we just accumulated and loop with it.
IReadOnlyList<ChatMessage> drained = this._ux.TakeFollowUpResponses();
nextMessages = drained.Count > 0 ? [.. drained] : null;
}
this.CompleteTurn();
}
private void CompleteTurn()
{
this._ux.EndStreaming();
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
}
/// <summary>
/// Synchronizes the queued items display with the message injector's pending messages.
/// Messages that have been consumed (drained by the service) are echoed to the output
/// area as regular user-input entries.
/// </summary>
private void SyncQueuedMessageDisplay(ref IReadOnlyList<ChatMessage> lastPendingMessages)
{
if (this._messageInjector is null)
{
return;
}
var pending = this._messageInjector.GetPendingMessages(this._session);
int consumedCount = lastPendingMessages.Count - pending.Count;
for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++)
{
string text = lastPendingMessages[i].Text ?? string.Empty;
this._ux.WriteUserInputEcho(text);
}
lastPendingMessages = pending;
this._ux.SetQueuedMessages(pending);
}
}
@@ -1,571 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Harness.ConsoleReactiveFramework;
using Harness.Shared.Console.Components;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// The main application component for the Harness console. Manages the scroll region
/// and bottom panel (text input, list selection, or streaming indicator). Owns the
/// <see cref="HarnessConsoleUXStateDriver"/> and routes user input events to the
/// registered <see cref="HarnessAgentRunner"/>.
/// </summary>
public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps, HarnessAppComponentState>, IDisposable
{
private readonly TopBottomRule _rule = new();
private readonly ListSelection _listSelection = new();
private readonly TextInput _textInput = new();
private readonly TextScrollPanel _textScrollPanel = new();
private readonly TextPanel _textPanel = new();
private readonly TextPanel _queuedPanel = new();
private readonly AgentStatus _agentStatus = new();
private readonly AgentModeAndHelp _modeAndHelp = new();
private readonly HarnessConsoleUXStateDriver _uxDriver;
private readonly TaskCompletionSource<bool> _shutdownTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly SemaphoreSlim _followUpGate = new(1, 1);
private int _scrollRegionBottom;
private bool _resizedSinceLastRender = true;
private bool _deactivated;
/// <summary>
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
/// </summary>
/// <param name="placeholder">Placeholder text shown when the input is empty.</param>
/// <param name="initialMode">The current agent mode, used to colour the rule and prompt.</param>
/// <param name="inputEnabled">Whether the bottom-panel input accepts keystrokes during streaming.</param>
/// <param name="runnerFactory">Factory invoked with the component's <see cref="IUXStateDriver"/>
/// to construct the <see cref="HarnessAgentRunner"/> that owns the agent loop.</param>
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
public HarnessAppComponent(
string placeholder,
string? initialMode,
bool inputEnabled,
Func<IUXStateDriver, HarnessAgentRunner> runnerFactory,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
this.Props = new ConsoleReactiveProps();
this.State = new HarnessAppComponentState
{
Mode = BottomPanelMode.TextInput,
Prompt = "> ",
Placeholder = placeholder,
ModeColor = ModeColors.Get(initialMode, modeColors),
ModeText = initialMode,
InputEnabled = inputEnabled,
ConsoleWidth = System.Console.WindowWidth,
ConsoleHeight = System.Console.WindowHeight,
};
this._uxDriver = new HarnessConsoleUXStateDriver(
getState: () => this.State!,
setState: s => this.SetState(s),
requestShutdown: () => this._shutdownTcs.TrySetResult(true),
modeColors: modeColors);
this.Runner = runnerFactory(this._uxDriver);
// Seed help text now that the runner (which knows the registered command handlers)
// is available. Direct assignment — no Render is triggered until the caller invokes Render().
this.State = this.State with { HelpText = this.Runner.HelpText };
KeyEventListener.Instance.KeyPressed += this.OnKeyPressed;
ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized;
}
/// <summary>
/// Gets the agent runner that owns the agent loop. Constructed by the factory
/// passed to the component's constructor.
/// </summary>
public HarnessAgentRunner Runner { get; }
/// <summary>
/// Completes when a command handler requests application shutdown (e.g. the user types <c>/exit</c>).
/// Awaited by <see cref="HarnessConsole.RunAgentAsync"/>.
/// </summary>
public Task ShutdownTask => this._shutdownTcs.Task;
/// <summary>
/// Deactivates the component, resetting the scroll region and unsubscribing from events.
/// This method is idempotent and safe to call multiple times.
/// </summary>
public void Deactivate()
{
if (this._deactivated)
{
return;
}
this._deactivated = true;
this._agentStatus.Dispose();
KeyEventListener.Instance.KeyPressed -= this.OnKeyPressed;
ConsoleResizeListener.Instance.ConsoleResized -= this.OnConsoleResized;
}
/// <inheritdoc/>
public void Dispose()
{
this.Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases managed resources.
/// </summary>
/// <param name="disposing"><c>true</c> to release managed resources.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
this.Deactivate();
this._followUpGate.Dispose();
this.Runner.Dispose();
}
}
private void OnKeyPressed(object? sender, KeyPressEventArgs e)
{
BottomPanelMode mode = this.State!.Mode;
if (mode == BottomPanelMode.TextInput)
{
this.HandleTextInputKey(e);
}
else if (mode == BottomPanelMode.ListSelection)
{
this.HandleListSelectionKey(e);
}
else if (mode == BottomPanelMode.Streaming && this.State.InputEnabled)
{
this.HandleStreamingInputKey(e);
}
}
private void HandleTextInputKey(KeyPressEventArgs e)
{
if (e.KeyInfo.Key == ConsoleKey.Enter)
{
string text = this.State!.InputText;
if (string.IsNullOrWhiteSpace(text))
{
return;
}
this.SetState(this.State with { InputText = "" });
this.DispatchTextInputSubmission(text);
}
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
{
if (this.State!.InputText.Length > 0)
{
this.SetState(this.State with { InputText = this.State.InputText[..^1] });
}
}
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
{
this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar });
}
}
private void HandleListSelectionKey(KeyPressEventArgs e)
{
int maxIndex = this.State!.ListSelectionOptions.Count - 1;
if (this.State.ListSelectionCustomTextPlaceholder != null)
{
maxIndex = this.State.ListSelectionOptions.Count;
}
bool isOnCustomTextOption = this.State.ListSelectionCustomTextPlaceholder != null
&& this.State.ListSelectionIndex == this.State.ListSelectionOptions.Count;
if (e.KeyInfo.Key == ConsoleKey.UpArrow)
{
this.SetState(this.State with { ListSelectionIndex = Math.Max(0, this.State.ListSelectionIndex - 1) });
}
else if (e.KeyInfo.Key == ConsoleKey.DownArrow)
{
this.SetState(this.State with { ListSelectionIndex = Math.Min(maxIndex, this.State.ListSelectionIndex + 1) });
}
else if (e.KeyInfo.Key == ConsoleKey.Enter)
{
string result = isOnCustomTextOption
? this.State.ListSelectionCustomInputText
: this.State.ListSelectionOptions[this.State.ListSelectionIndex];
this.SetState(this.State with { ListSelectionCustomInputText = "", ListSelectionIndex = 0 });
this.DispatchListSelectionSubmission(result);
}
else if (isOnCustomTextOption)
{
if (e.KeyInfo.Key == ConsoleKey.Backspace)
{
if (this.State.ListSelectionCustomInputText.Length > 0)
{
this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText[..^1] });
}
}
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
{
this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText + e.KeyInfo.KeyChar });
}
}
}
private void HandleStreamingInputKey(KeyPressEventArgs e)
{
if (e.KeyInfo.Key == ConsoleKey.Enter)
{
string text = this.State!.InputText;
if (string.IsNullOrWhiteSpace(text))
{
return;
}
this.SetState(this.State with { InputText = "" });
_ = this.Runner.OnStreamingInputAsync(text);
}
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
{
if (this.State!.InputText.Length > 0)
{
this.SetState(this.State with { InputText = this.State.InputText[..^1] });
}
}
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
{
this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar });
}
}
private void DispatchTextInputSubmission(string text)
{
if (this.State!.PendingQuestions.Count > 0)
{
_ = this.HandleFollowUpAnswerAsync(text);
}
else
{
_ = this.Runner.OnUserInputAsync(text);
}
}
private void DispatchListSelectionSubmission(string text)
{
// List selection is only used to answer FollowUpQuestions.
_ = this.HandleFollowUpAnswerAsync(text);
}
/// <summary>
/// Handles a user answer to the head of the pending follow-up question queue:
/// awaits the question's continuation (which is responsible for echoing both the
/// question and answer to the scroll area as it sees fit), appends any returned
/// chat message to the response accumulator, advances the queue, and — when the
/// queue empties — drains the accumulator and resumes the runner.
/// </summary>
private async Task HandleFollowUpAnswerAsync(string text)
{
IReadOnlyList<ChatMessage>? messagesToSend = null;
await this._followUpGate.WaitAsync().ConfigureAwait(false);
try
{
HarnessConsoleUXStateDriver ux = this._uxDriver;
IReadOnlyList<FollowUpQuestion> queue = this.State!.PendingQuestions;
if (queue.Count == 0)
{
return;
}
FollowUpQuestion head = queue[0];
ChatMessage? response;
try
{
response = await head.Continuation(text, ux).ConfigureAwait(false);
}
catch (Exception ex)
{
await ux.WriteInfoLineAsync($"❌ Follow-up handler error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false);
response = null;
}
if (response is not null)
{
ux.AddFollowUpResponse(response);
}
ux.AdvanceFollowUpQuestion();
if (this.State!.PendingQuestions.Count == 0)
{
messagesToSend = ux.TakeFollowUpResponses();
}
}
finally
{
this._followUpGate.Release();
}
// Resume the agent outside the gate — StartAgentTurnAsync runs the full agent
// loop which may queue new follow-up questions (re-entering this method).
if (messagesToSend is not null)
{
try
{
await this.Runner.StartAgentTurnAsync([.. messagesToSend]).ConfigureAwait(false);
}
catch (Exception ex)
{
await this._uxDriver.WriteInfoLineAsync($"❌ Agent error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false);
}
}
}
private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e)
{
this._resizedSinceLastRender = true;
this.SetState(this.State! with
{
ConsoleWidth = e.NewWidth,
ConsoleHeight = e.NewHeight,
});
}
/// <inheritdoc />
public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentState state)
{
if (this._deactivated)
{
return;
}
// Determine the text panel height for the last scroll item
IReadOnlyList<string> lastItems = state.ScrollAreaContentItems.Count > 0
? [state.ScrollAreaContentItems[^1]]
: [];
int textPanelHeight = TextPanel.CalculateHeight(lastItems);
if (textPanelHeight > 0)
{
textPanelHeight++; // Extra line for spacing between text panel and rule
}
// Calculate queued items panel height
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
// Build the bottom panel child based on mode
ConsoleReactiveComponent bottomChild;
int bottomChildHeight;
if (state.Mode == BottomPanelMode.ListSelection)
{
var listProps = new ListSelectionProps
{
Title = state.ListSelectionTitle,
Items = state.ListSelectionOptions,
SelectedIndex = state.ListSelectionIndex,
HighlightColor = state.ListHighlightColor,
CustomTextPlaceholder = state.ListSelectionCustomTextPlaceholder,
CustomText = state.ListSelectionCustomInputText,
};
bottomChildHeight = ListSelection.CalculateHeight(listProps);
this._listSelection.Height = bottomChildHeight;
this._listSelection.Props = listProps;
bottomChild = this._listSelection;
}
else if (state.Mode == BottomPanelMode.Streaming)
{
TextInputProps textInputProps;
if (state.InputEnabled)
{
textInputProps = new TextInputProps
{
Prompt = state.Prompt,
Text = state.InputText,
Placeholder = state.Placeholder,
};
}
else
{
textInputProps = new TextInputProps
{
Prompt = state.Prompt,
Text = "",
Placeholder = state.StreamingPrompt,
};
}
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
this._textInput.Width = state.ConsoleWidth;
this._textInput.Height = bottomChildHeight;
this._textInput.Props = textInputProps;
bottomChild = this._textInput;
}
else
{
var textInputProps = new TextInputProps
{
Prompt = state.Prompt,
Text = state.InputText,
Placeholder = state.Placeholder,
};
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
this._textInput.Width = state.ConsoleWidth;
this._textInput.Height = bottomChildHeight;
this._textInput.Props = textInputProps;
bottomChild = this._textInput;
}
var ruleProps = new TopBottomRuleProps
{
Width = state.ConsoleWidth,
Color = state.ModeColor,
Children = [bottomChild],
};
var agentStatusProps = new AgentStatusProps
{
ShowSpinner = state.ShowSpinner,
UsageText = state.UsageText,
};
var modeAndHelpProps = new AgentModeAndHelpProps
{
Mode = state.ModeText,
ModeColor = state.ModeColor,
HelpText = state.HelpText,
};
// Hide agent status and mode/help during follow-up questions (ListSelection mode)
// as they clutter the UI and aren't relevant.
bool showStatusAndHelp = state.Mode != BottomPanelMode.ListSelection;
int agentStatusHeight = showStatusAndHelp ? AgentStatus.CalculateHeight(agentStatusProps) : 0;
int modeAndHelpHeight = showStatusAndHelp ? AgentModeAndHelp.CalculateHeight(modeAndHelpProps) : 0;
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
int nonScrollHeight = ruleHeight + textPanelHeight + agentStatusHeight + queuedPanelHeight + modeAndHelpHeight + 1; // +1 for bottom padding
int scrollBottom = Math.Max(1, state.ConsoleHeight - nonScrollHeight);
// If scroll region changed or a clear is needed, reset everything
if (this._resizedSinceLastRender || (this._scrollRegionBottom != 0 && scrollBottom != this._scrollRegionBottom))
{
// Reset scroll region to full screen before erasing so the erase covers all rows —
// some terminals only erase within the active DECSTBM region.
System.Console.Write(AnsiEscapes.ResetScrollRegion);
System.Console.Write(AnsiEscapes.EraseEntireScreen);
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
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<string> scrollItems = state.ScrollAreaContentItems.Count > 1
? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.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 = state.QueuedItems,
};
this._queuedPanel.Render();
// Render the agent status line between queued items and rule
int agentStatusY = queuedPanelY + queuedPanelHeight;
if (showStatusAndHelp)
{
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
if (showStatusAndHelp)
{
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(state);
}
private void PositionCursor(HarnessAppComponentState state)
{
if (state.Mode == BottomPanelMode.TextInput
|| (state.Mode == BottomPanelMode.Streaming && state.InputEnabled))
{
int promptLength = state.Prompt.Length;
int textWidth = state.ConsoleWidth - promptLength;
int textLength = state.InputText.Length;
int textInputY = this._rule.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 (state.Mode == BottomPanelMode.ListSelection
&& state.ListSelectionCustomTextPlaceholder != null
&& state.ListSelectionIndex == state.ListSelectionOptions.Count)
{
int titleLines = state.ListSelectionTitle?.Split('\n').Length ?? 0;
int customOptionY = this._rule.Y + 1 + titleLines + state.ListSelectionOptions.Count;
int cursorCol = 2 + state.ListSelectionCustomInputText.Length + 1;
System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
}
}
}
@@ -1,125 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveFramework;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Determines which component is shown in the bottom panel.
/// </summary>
public enum BottomPanelMode
{
/// <summary>Show the text input component for user input.</summary>
TextInput,
/// <summary>Show the list selection component for interactive prompts.</summary>
ListSelection,
/// <summary>Show a disabled input indicator during agent streaming.</summary>
Streaming,
}
/// <summary>
/// Internal state for <see cref="HarnessAppComponent"/>. All UI fields that may
/// change after construction live here; they are mutated exclusively via
/// <see cref="ConsoleReactiveComponent{TProps,TState}.SetState"/> by the
/// owning <see cref="HarnessConsoleUXStateDriver"/>.
/// </summary>
public record HarnessAppComponentState : ConsoleReactiveState
{
// --- Console dimensions ---
/// <summary>Gets the current console width in columns.</summary>
public int ConsoleWidth { get; init; }
/// <summary>Gets the current console height in rows.</summary>
public int ConsoleHeight { get; init; }
// --- Bottom panel mode ---
/// <summary>Gets the bottom panel mode.</summary>
public BottomPanelMode Mode { get; init; } = BottomPanelMode.TextInput;
/// <summary>
/// Gets the queue of follow-up questions waiting for user answers. The head
/// (<c>[0]</c>) is the question currently being displayed; subsequent items
/// are dispatched in order as each is answered. While this queue is non-empty,
/// the next user submission is treated as the answer to the head question
/// instead of going to the agent runner's normal input handler.
/// </summary>
public IReadOnlyList<FollowUpQuestion> PendingQuestions { get; init; } = [];
/// <summary>
/// Gets the accumulated follow-up response messages collected during the
/// current agent turn — both direct <see cref="FollowUpMessage"/>s emitted
/// by observers and continuation results from answered questions. Consumed
/// by the runner via <see cref="IUXStateDriver.TakeFollowUpResponses"/>
/// before the next agent invocation.
/// </summary>
public IReadOnlyList<ChatMessage> AccumulatedFollowUpResponses { get; init; } = [];
// --- Text input (active in TextInput / Streaming modes) ---
/// <summary>Gets the prompt string for text input mode.</summary>
public string Prompt { get; init; } = "> ";
/// <summary>Gets the placeholder text shown when the input is empty.</summary>
public string Placeholder { get; init; } = "";
/// <summary>Gets the current input text being typed.</summary>
public string InputText { get; init; } = "";
/// <summary>Gets a value indicating whether input is enabled during streaming.</summary>
public bool InputEnabled { get; init; }
/// <summary>Gets the prompt to show during streaming when input is disabled.</summary>
public string StreamingPrompt { get; init; } = "(agent is running...)";
// --- List selection (active in ListSelection mode) ---
/// <summary>Gets the title text displayed above the list selection (for interactive prompts).</summary>
public string? ListSelectionTitle { get; init; }
/// <summary>Gets the list selection options.</summary>
public IReadOnlyList<string> ListSelectionOptions { get; init; } = [];
/// <summary>Gets the highlighted option index in list selection mode.</summary>
public int ListSelectionIndex { get; init; }
/// <summary>Gets the placeholder text for the custom text input option in the list.</summary>
public string? ListSelectionCustomTextPlaceholder { get; init; }
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
public string ListSelectionCustomInputText { get; init; } = "";
/// <summary>Gets the highlight color for the active list item.</summary>
public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan;
// --- Scroll / output area ---
/// <summary>Gets the items rendered in the scroll-area. Each item is a pre-rendered
/// console string (may include ANSI escape sequences and newlines).</summary>
public IReadOnlyList<string> ScrollAreaContentItems { get; init; } = [];
/// <summary>Gets the queued input items to display above the rule. Each item is a
/// pre-rendered console string (may include ANSI escape sequences and newlines).</summary>
public IReadOnlyList<string> QueuedItems { get; init; } = [];
// --- Agent mode + status display ---
/// <summary>Gets the foreground color for the rule borders and mode label.</summary>
public ConsoleColor? ModeColor { get; init; }
/// <summary>Gets the current mode name displayed below the bottom rule (e.g. "plan").</summary>
public string? ModeText { get; init; }
/// <summary>Gets the help text displayed below the bottom rule (available commands).</summary>
public string? HelpText { get; init; }
/// <summary>Gets a value indicating whether the agent status spinner is visible.</summary>
public bool ShowSpinner { get; init; }
/// <summary>Gets the formatted token usage text to display in the status bar.</summary>
public string? UsageText { get; init; }
}
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Harness.Shared.Console.Commands;
using Harness.Shared.Console.Observers;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
@@ -13,58 +15,200 @@ public static class HarnessConsole
{
/// <summary>
/// Runs an interactive console session with the specified agent.
/// Constructs the reactive UI component and the <see cref="HarnessAgentRunner"/>,
/// wires them together, and awaits the component's <see cref="HarnessAppComponent.ShutdownTask"/>
/// (which completes when the user types <c>/exit</c>).
/// Supports streaming output, tool call display, spinner animation,
/// optional planning UX with structured output, and the <c>/todos</c> command.
/// </summary>
/// <param name="agent">The agent to interact with.</param>
/// <param name="userPrompt">A short prompt to the user, displayed as a placeholder in the input area.</param>
/// <param name="title">The title displayed in the console header.</param>
/// <param name="userPrompt">A short prompt to the user, displayed below the title.</param>
/// <param name="options">Optional configuration options for the console session.</param>
public static async Task RunAgentAsync(AIAgent agent, string userPrompt, HarnessConsoleOptions? options = null)
public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, HarnessConsoleOptions? options = null)
{
options ??= new();
// Null means use defaults; an explicit (possibly empty) list means use exactly what was provided.
var observers = options.Observers
?? HarnessConsoleOptions.BuildDefaultObservers();
var commandHandlers = options.CommandHandlers
?? HarnessConsoleOptions.BuildDefaultCommandHandlers(agent, options.ModeColors);
if (options.EnablePlanningUx
&& (string.IsNullOrWhiteSpace(options.PlanningModeName) || string.IsNullOrWhiteSpace(options.ExecutionModeName)))
{
throw new ArgumentException(
"When EnablePlanningUx is true, both PlanningModeName and ExecutionModeName must be configured.",
nameof(options));
}
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>
{
new TodoCommandHandler(todoProvider),
new ModeCommandHandler(modeProvider, options.ModeColors),
};
var commands = commandHandlers
.Select(h => h.GetHelpText())
.Where(t => t is not null)
.Append("exit (quit)");
System.Console.WriteLine($"Commands: {string.Join(", ", commands)}");
System.Console.WriteLine();
AgentSession session = await agent.CreateSessionAsync();
using var writer = new ConsoleWriter(options.ModeColors);
writer.CurrentMode = modeProvider?.GetMode(session);
using var component = new HarnessAppComponent(
placeholder: userPrompt,
initialMode: modeProvider?.GetMode(session),
inputEnabled: messageInjector is not null,
runnerFactory: ux => new HarnessAgentRunner(
agent: agent,
session: session,
modeProvider: modeProvider,
messageInjector: messageInjector,
commandHandlers: commandHandlers,
observers: observers,
ux: ux),
modeColors: options.ModeColors);
string prompt = BuildUserPrompt(modeProvider, session);
string? userInput = await writer.ReadLineAsync(prompt);
// Trigger the initial render of the component now that state is seeded.
component.Render();
try
// Main loop to run a command or agent and get the next user command/input.
while (!string.IsNullOrWhiteSpace(userInput) && !userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
await component.ShutdownTask.ConfigureAwait(false);
}
finally
{
component.Deactivate();
// 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))
{
handled = true;
break;
}
}
if (!handled)
{
await RunAgentTurnAsync(agent, session, modeProvider, options, writer, userInput);
}
writer.CurrentMode = modeProvider?.GetMode(session);
prompt = BuildUserPrompt(modeProvider, session);
userInput = await writer.ReadLineAsync(prompt);
}
System.Console.ResetColor();
System.Console.Write(AnsiEscapes.ResetScrollRegion);
System.Console.Write(AnsiEscapes.EraseEntireScreen);
System.Console.Write(AnsiEscapes.MoveCursor(1, 1));
System.Console.WriteLine("Goodbye!");
}
/// <summary>
/// Runs one or more agent invocations for a single user turn, using the current
/// observers. Re-invokes automatically for tool approvals and mode-driven follow-ups
/// (e.g., planning clarification loops).
/// </summary>
private static async Task RunAgentTurnAsync(
AIAgent agent,
AgentSession session,
AgentModeProvider? modeProvider,
HarnessConsoleOptions options,
ConsoleWriter writer,
string userInput)
{
IList<ChatMessage>? nextMessages = [new ChatMessage(ChatRole.User, userInput)];
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();
try
{
await foreach (var update in agent.RunStreamingAsync(nextMessages, session, runOptions))
{
// Update mode color if the mode changed during streaming.
if (modeProvider is not null)
{
string currentMode = modeProvider.GetMode(session);
if (currentMode != writer.CurrentMode)
{
writer.CurrentMode = currentMode;
}
}
foreach (var content in update.Contents)
{
foreach (var observer in observers)
{
await observer.OnContentAsync(writer, content);
}
}
if (!string.IsNullOrEmpty(update.Text))
{
foreach (var observer in observers)
{
await observer.OnTextAsync(writer, update.Text);
}
}
}
}
catch (Exception ex)
{
await writer.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red);
}
// Collect messages from all observers.
var combinedMessages = new List<ChatMessage>();
bool hasObserverMessages = false;
foreach (var observer in observers)
{
var messages = await observer.OnStreamCompleteAsync(writer, agent, session, options);
if (messages is { Count: > 0 })
{
combinedMessages.AddRange(messages);
hasObserverMessages = true;
}
}
await writer.WriteStreamFooterAsync(hasFollowUpMessages: hasObserverMessages);
nextMessages = combinedMessages.Count > 0 ? combinedMessages : null;
}
}
private static List<ConsoleObserver> CreateObservers(HarnessConsoleOptions options, AgentModeProvider? modeProvider, AgentSession session)
{
var observers = new List<ConsoleObserver>
{
new ToolCallDisplayObserver(),
new ToolApprovalObserver(),
new ErrorDisplayObserver(),
new ReasoningDisplayObserver(),
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))
{
observers.Add(new PlanningOutputObserver(modeProvider));
}
else
{
observers.Add(new TextOutputObserver());
}
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: ";
}
}
@@ -1,11 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.ObjectModel;
using Harness.Shared.Console.Commands;
using Harness.Shared.Console.Observers;
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
namespace Harness.Shared.Console;
/// <summary>
@@ -14,120 +8,45 @@ namespace Harness.Shared.Console;
public class HarnessConsoleOptions
{
/// <summary>
/// Gets or sets the list of console observers that participate in the agent response
/// streaming lifecycle. Use the factory methods on this class to create common observer sets.
/// When <see langword="null"/> (the default), a default set of observers is used.
/// Set to an empty list to disable all observers.
/// Gets or sets the optional maximum context window size in tokens.
/// When set, token usage is displayed as a percentage of the budget.
/// </summary>
public IReadOnlyList<ConsoleObserver>? Observers { get; set; }
public int? MaxContextWindowTokens { get; set; }
/// <summary>
/// Gets or sets the list of command handlers to check before sending user input to the agent.
/// Use <see cref="BuildDefaultCommandHandlers"/> to create the default set.
/// When <see langword="null"/> (the default), a default set of handlers is used.
/// Set to an empty list to disable all command handlers.
/// Gets or sets the optional maximum output tokens.
/// Used with <see cref="MaxContextWindowTokens"/> to show input/output budget breakdown.
/// </summary>
public IReadOnlyList<CommandHandler>? CommandHandlers { get; set; }
public int? MaxOutputTokens { get; set; }
/// <summary>
/// The default mode-to-color mapping used when no custom <see cref="ModeColors"/> are provided.
/// Gets or sets a value indicating whether the planning UX is enabled.
/// When <see langword="true"/> and the agent is in the mode specified by <see cref="PlanningModeName"/>,
/// the console uses structured output to present clarification questions and approval requests
/// instead of streaming free-form text.
/// </summary>
public static readonly IReadOnlyDictionary<string, ConsoleColor> DefaultModeColors = new ReadOnlyDictionary<string, ConsoleColor>(
new Dictionary<string, ConsoleColor>(StringComparer.OrdinalIgnoreCase)
{
["plan"] = ConsoleColor.Cyan,
["execute"] = ConsoleColor.Green,
});
/// <value>Defaults to <see langword="false"/>.</value>
public bool EnablePlanningUx { get; set; }
/// <summary>
/// Gets or sets the name of the agent mode that activates the planning UX.
/// Must be set when <see cref="EnablePlanningUx"/> is <see langword="true"/>.
/// </summary>
public string? PlanningModeName { get; set; }
/// <summary>
/// Gets or sets the name of the agent mode to switch to when the user approves a plan.
/// Must be set when <see cref="EnablePlanningUx"/> is <see langword="true"/>.
/// </summary>
public string? ExecutionModeName { get; set; }
/// <summary>
/// Gets or sets a mapping of agent mode names to console colors.
/// When a mode is not found in this dictionary, the default color (<see cref="ConsoleColor.Gray"/>) is used.
/// </summary>
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(DefaultModeColors, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Creates the default set of observers without planning support.
/// Includes tool call display, tool approval, error display, reasoning display,
/// usage display, and text output.
/// </summary>
/// <param name="maxContextWindowTokens">Optional maximum context window size in tokens for usage display.</param>
/// <param name="maxOutputTokens">Optional maximum output tokens for usage display.</param>
/// <param name="toolFormatters">Optional tool call formatters. When <see langword="null"/>,
/// each observer uses the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/>.</param>
/// <returns>A list of observers for a standard (non-planning) console session.</returns>
public static List<ConsoleObserver> BuildDefaultObservers(
int? maxContextWindowTokens = null,
int? maxOutputTokens = null,
IReadOnlyList<ToolCallFormatter>? toolFormatters = null)
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(StringComparer.OrdinalIgnoreCase)
{
return
[
new ToolCallDisplayObserver(toolFormatters),
new ToolApprovalObserver(toolFormatters),
new ErrorDisplayObserver(),
new ReasoningDisplayObserver(),
new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens),
new TextOutputObserver(),
];
}
/// <summary>
/// Creates the default set of observers with planning support.
/// Includes a <see cref="PlanningOutputObserver"/> instead of <see cref="TextOutputObserver"/>.
/// </summary>
/// <param name="agent">The agent, used to resolve <see cref="AgentModeProvider"/>.</param>
/// <param name="planModeName">The mode name that represents the planning mode.</param>
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
/// <param name="modeColors">Optional mode-to-color mapping for display.
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
/// <param name="maxContextWindowTokens">Optional maximum context window size in tokens for usage display.</param>
/// <param name="maxOutputTokens">Optional maximum output tokens for usage display.</param>
/// <param name="toolFormatters">Optional tool call formatters. When <see langword="null"/>,
/// each observer uses the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/>.</param>
/// <returns>A list of observers for a planning-enabled console session.</returns>
public static List<ConsoleObserver> BuildObserversWithPlanning(
AIAgent agent,
string planModeName,
string executionModeName,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null,
int? maxContextWindowTokens = null,
int? maxOutputTokens = null,
IReadOnlyList<ToolCallFormatter>? toolFormatters = null)
{
var modeProvider = agent.GetService<AgentModeProvider>()
?? throw new InvalidOperationException("Planning requires an AgentModeProvider service on the agent.");
return
[
new ToolCallDisplayObserver(toolFormatters),
new ToolApprovalObserver(toolFormatters),
new ErrorDisplayObserver(),
new ReasoningDisplayObserver(),
new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens),
new PlanningOutputObserver(modeProvider, planModeName, executionModeName, modeColors ?? DefaultModeColors),
];
}
/// <summary>
/// Creates the default set of command handlers.
/// Includes exit, todo, and mode command handlers.
/// </summary>
/// <param name="agent">The agent, used to resolve <see cref="TodoProvider"/> and <see cref="AgentModeProvider"/>.</param>
/// <param name="modeColors">Optional mode-to-color mapping for the mode command display.
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
/// <returns>A list of command handlers for a standard console session.</returns>
public static List<CommandHandler> BuildDefaultCommandHandlers(
AIAgent agent,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
var todoProvider = agent.GetService<TodoProvider>();
var modeProvider = agent.GetService<AgentModeProvider>();
return
[
new ExitCommandHandler(),
new TodoCommandHandler(todoProvider),
new ModeCommandHandler(modeProvider, modeColors ?? DefaultModeColors),
];
}
["plan"] = ConsoleColor.Cyan,
["execute"] = ConsoleColor.Green,
};
}
@@ -1,408 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Default <see cref="IUXStateDriver"/> implementation. Owned by
/// <see cref="HarnessAppComponent"/>; mutates the component's state via a
/// <c>SetState</c>-style callback. Each public operation updates state and lets
/// the component's render-skip optimization handle the actual draw.
/// </summary>
internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
{
private readonly Func<HarnessAppComponentState> _getState;
private readonly Action<HarnessAppComponentState> _setState;
private readonly Action _requestShutdown;
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
private readonly List<string> _outputItems = [];
private readonly object _stateLock = new();
private OutputEntryType? _lastEntryType;
private bool _hasReceivedAnyText;
private OutputEntry? _currentStreamingEntry;
private int _currentStreamingEntryIndex = -1;
private string? _currentMode;
/// <summary>
/// Initializes a new instance of the <see cref="HarnessConsoleUXStateDriver"/> class.
/// </summary>
/// <param name="getState">Returns the component's current state.</param>
/// <param name="setState">Replaces the component's state and triggers a re-render.</param>
/// <param name="requestShutdown">Callback invoked when a command handler requests application shutdown.</param>
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
public HarnessConsoleUXStateDriver(
Func<HarnessAppComponentState> getState,
Action<HarnessAppComponentState> setState,
Action requestShutdown,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
this._getState = getState;
this._setState = setState;
this._requestShutdown = requestShutdown;
this._modeColors = modeColors;
this._currentMode = getState().ModeText;
}
/// <inheritdoc/>
public string? CurrentMode
{
get => this._currentMode;
set
{
this.UpdateState(s =>
{
this._currentMode = value;
return s with
{
ModeColor = ModeColors.Get(value, this._modeColors),
ModeText = value,
};
});
}
}
/// <inheritdoc/>
public void BeginStreaming() =>
this.UpdateState(s => s with
{
Mode = BottomPanelMode.Streaming,
ShowSpinner = true,
});
/// <inheritdoc/>
public void StopSpinner() =>
this.UpdateState(s => s with { ShowSpinner = false });
/// <inheritdoc/>
public void EndStreaming() =>
this.UpdateState(s => s with
{
Mode = BottomPanelMode.TextInput,
ShowSpinner = false,
});
/// <inheritdoc/>
public void BeginStreamingOutput()
{
lock (this._stateLock)
{
this._hasReceivedAnyText = false;
this._currentStreamingEntry = null;
this._currentStreamingEntryIndex = -1;
}
}
/// <inheritdoc/>
public void SetUsageText(string usageText) =>
this.UpdateState(s => s with { UsageText = usageText });
/// <inheritdoc/>
public void SetQueuedMessages(IReadOnlyList<ChatMessage> pending)
{
var newQueued = new List<string>(pending.Count);
foreach (var msg in pending)
{
string text = msg.Text ?? string.Empty;
newQueued.Add(RenderEntry($" 💬 {text}\n", ConsoleColor.DarkGray));
}
this.UpdateState(s => s with { QueuedItems = newQueued });
}
/// <inheritdoc/>
public void QueueFollowUpQuestions(IReadOnlyList<FollowUpQuestion> questions)
{
if (questions.Count == 0)
{
return;
}
this.UpdateState(s =>
{
bool wasEmpty = s.PendingQuestions.Count == 0;
var combined = new List<FollowUpQuestion>(s.PendingQuestions.Count + questions.Count);
combined.AddRange(s.PendingQuestions);
combined.AddRange(questions);
HarnessAppComponentState next = s with { PendingQuestions = combined };
if (wasEmpty)
{
next = this.ConfigureForHeadQuestion(next, combined[0]);
}
return next;
});
}
/// <inheritdoc/>
public void AddFollowUpResponse(ChatMessage response)
{
this.UpdateState(s =>
{
var combined = new List<ChatMessage>(s.AccumulatedFollowUpResponses.Count + 1);
combined.AddRange(s.AccumulatedFollowUpResponses);
combined.Add(response);
return s with { AccumulatedFollowUpResponses = combined };
});
}
/// <inheritdoc/>
public void AdvanceFollowUpQuestion()
{
this.UpdateState(s =>
{
if (s.PendingQuestions.Count == 0)
{
return s;
}
var remaining = s.PendingQuestions.Skip(1).ToList();
HarnessAppComponentState next = s with { PendingQuestions = remaining };
if (remaining.Count > 0)
{
return this.ConfigureForHeadQuestion(next, remaining[0]);
}
return next with
{
Mode = BottomPanelMode.TextInput,
ListSelectionOptions = [],
ListSelectionTitle = null,
ListSelectionCustomTextPlaceholder = null,
ListSelectionIndex = 0,
ListSelectionCustomInputText = "",
};
});
}
/// <inheritdoc/>
public IReadOnlyList<ChatMessage> TakeFollowUpResponses()
{
return this.UpdateState(s =>
{
IReadOnlyList<ChatMessage> responses = s.AccumulatedFollowUpResponses;
if (responses.Count == 0)
{
return (s, responses);
}
return (s with { AccumulatedFollowUpResponses = [] }, responses);
});
}
/// <summary>
/// Configures the bottom-panel display fields on the supplied state for the
/// given head question. For text questions, also writes the prompt as an
/// info line above the input row as a side effect.
/// </summary>
private HarnessAppComponentState ConfigureForHeadQuestion(HarnessAppComponentState state, FollowUpQuestion question)
{
if (question is ChoiceFollowUpQuestion choice)
{
return state with
{
Mode = BottomPanelMode.ListSelection,
ListSelectionOptions = choice.Choices.ToList(),
ListSelectionTitle = choice.Prompt,
ListSelectionCustomTextPlaceholder = choice.AllowCustomText ? "✏️ Type a custom response..." : null,
ListSelectionIndex = 0,
ListSelectionCustomInputText = "",
};
}
// Text question — prompt is rendered as an info line above the input row.
// We append entries and capture the scroll snapshot inline so the caller's
// single _setState picks up both the new output and the UI mode change.
ConsoleColor ruleColor = ModeColors.Get(this._currentMode, this._modeColors);
List<string> scrollSnapshot = this.AppendOutputEntriesAndSnapshot(
new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor),
new OutputEntry(OutputEntryType.InfoLine, $" {question.Prompt}", ruleColor));
return state with
{
Mode = BottomPanelMode.TextInput,
ListSelectionOptions = [],
ListSelectionTitle = null,
ListSelectionCustomTextPlaceholder = null,
ListSelectionIndex = 0,
ListSelectionCustomInputText = "",
ScrollAreaContentItems = scrollSnapshot,
};
}
/// <inheritdoc/>
public void WriteUserInputEcho(string text)
{
this.UpdateState(s =>
{
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
OutputEntryType.UserInput,
$"\nYou: {text}\n\n",
ConsoleColor.Green));
return s with { ScrollAreaContentItems = snapshot };
});
}
/// <inheritdoc/>
public Task WriteInfoAsync(string text, ConsoleColor? color = null) =>
this.WriteInfoCoreAsync(text, color, newLine: false);
/// <inheritdoc/>
public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) =>
this.WriteInfoCoreAsync(text, color, newLine: true);
private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
{
this.UpdateState(s =>
{
// Add a blank line separator when transitioning from streaming text or user input.
string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter
? "\n "
: " ";
string fullText = newLine ? prefix + text + "\n\n" : prefix + text;
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
OutputEntryType.InfoLine,
fullText,
color ?? ModeColors.Get(this._currentMode, this._modeColors)));
return s with { ScrollAreaContentItems = snapshot };
});
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task WriteTextAsync(string text, ConsoleColor? color = null)
{
this.UpdateState(s =>
{
this._lastEntryType = OutputEntryType.StreamingText;
this._hasReceivedAnyText = true;
ConsoleColor effectiveColor = color ?? ModeColors.Get(this._currentMode, this._modeColors);
if (this._currentStreamingEntry is not null
&& this._currentStreamingEntryIndex == this._outputItems.Count - 1)
{
// The streaming entry is still the last item — safe to replace in place.
this._currentStreamingEntry = this._currentStreamingEntry with
{
Text = this._currentStreamingEntry.Text + text,
};
this._outputItems[^1] = RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color);
}
else
{
// Either the first text delta or other entries (tool calls, info lines)
// were appended after the previous streaming entry — start a fresh one.
const string Prefix = "\n";
this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor);
this._outputItems.Add(RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color));
this._currentStreamingEntryIndex = this._outputItems.Count - 1;
}
return s with { ScrollAreaContentItems = new List<string>(this._outputItems) };
});
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task EndStreamingOutputAsync()
{
this.UpdateState(s =>
{
if (this._hasReceivedAnyText)
{
this._outputItems.Add(RenderEntry("\n", null));
this._currentStreamingEntry = null;
this._lastEntryType = OutputEntryType.StreamFooter;
return s with { ScrollAreaContentItems = new List<string>(this._outputItems) };
}
return s;
});
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task WriteNoTextWarningAsync(bool hasFollowUpActions)
{
if (!this._hasReceivedAnyText && !hasFollowUpActions)
{
this.UpdateState(s =>
{
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
OutputEntryType.StreamFooter,
" (no text response from agent)\n",
ConsoleColor.DarkYellow));
return s with { ScrollAreaContentItems = snapshot };
});
}
return Task.CompletedTask;
}
/// <summary>
/// Wraps the supplied text with ANSI foreground color escape sequences (or returns
/// the text unchanged when no color is specified). Output is appended to
/// <see cref="_outputItems"/> and consumed verbatim by <see cref="TextScrollPanel"/>
/// and <see cref="TextPanel"/>.
/// </summary>
private static string RenderEntry(string text, ConsoleColor? color) =>
color.HasValue
? $"{AnsiEscapes.SetForegroundColor(color.Value)}{text}{AnsiEscapes.ResetAttributes}"
: text;
private void UpdateState(Func<HarnessAppComponentState, HarnessAppComponentState> update)
{
lock (this._stateLock)
{
this._setState(update(this._getState()));
}
}
private T UpdateState<T>(Func<HarnessAppComponentState, (HarnessAppComponentState State, T Result)> update)
{
lock (this._stateLock)
{
var (newState, result) = update(this._getState());
this._setState(newState);
return result;
}
}
/// <summary>
/// Appends one or more output entries to the output list, updates
/// <see cref="_lastEntryType"/> to the last entry's type, and returns a
/// snapshot of <see cref="_outputItems"/>. Must be called inside a locked
/// context (e.g. within an <see cref="UpdateState"/> callback).
/// </summary>
private List<string> AppendOutputEntriesAndSnapshot(params OutputEntry[] entries)
{
this.AppendOutputEntriesCore(entries);
return new List<string>(this._outputItems);
}
private void AppendOutputEntriesCore(OutputEntry[] entries)
{
foreach (OutputEntry entry in entries)
{
this._outputItems.Add(RenderEntry(entry.Text, entry.Color));
}
if (entries.Length > 0)
{
this._lastEntryType = entries[^1].Type;
}
}
/// <inheritdoc/>
public void RequestShutdown() => this._requestShutdown();
}
@@ -7,10 +7,12 @@
<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>
@@ -1,120 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Abstraction over the harness UI state. All callers (observers, command handlers,
/// the agent runner) interact with the UI exclusively through this interface, which
/// internally translates each operation into a <c>SetState</c> call on the underlying
/// reactive component.
/// </summary>
/// <remarks>
/// This interface is intentionally narrow: it does not expose blocking input methods.
/// The agent runner orchestrates input flow via <see cref="FollowUpQuestion"/>
/// objects returned from observers.
/// </remarks>
public interface IUXStateDriver
{
/// <summary>
/// Gets or sets the current agent mode (e.g. "plan", "execute"). Setting also
/// refreshes the rule colour and bottom-panel prompt to match the new mode.
/// </summary>
string? CurrentMode { get; set; }
/// <summary>
/// Echoes a submitted user input as a regular user-input entry in the output area.
/// </summary>
void WriteUserInputEcho(string text);
/// <summary>
/// Writes informational output as an output entry, without a trailing newline.
/// </summary>
Task WriteInfoAsync(string text, ConsoleColor? color = null);
/// <summary>
/// Writes informational output as an output entry, followed by a newline.
/// </summary>
Task WriteInfoLineAsync(string text, ConsoleColor? color = null);
/// <summary>
/// Writes streaming text output from the agent. Successive calls accumulate into a
/// single streaming entry that is re-rendered by the text panel.
/// </summary>
Task WriteTextAsync(string text, ConsoleColor? color = null);
/// <summary>
/// Writes a blank-line separator to visually close the streaming output section.
/// </summary>
Task EndStreamingOutputAsync();
/// <summary>
/// Shows a "(no text response from agent)" warning if no text was received
/// and no observer produced follow-up actions.
/// </summary>
Task WriteNoTextWarningAsync(bool hasFollowUpActions);
/// <summary>
/// Switches the bottom panel to streaming mode and starts the spinner.
/// </summary>
void BeginStreaming();
/// <summary>
/// Stops the spinner without leaving streaming mode.
/// </summary>
void StopSpinner();
/// <summary>
/// Switches the bottom panel back to text-input mode and stops the spinner.
/// </summary>
void EndStreaming();
/// <summary>
/// Resets per-turn streaming bookkeeping in preparation for a new agent turn.
/// </summary>
void BeginStreamingOutput();
/// <summary>
/// Sets the formatted usage text shown on the agent status bar.
/// </summary>
void SetUsageText(string usageText);
/// <summary>
/// Replaces the queued-message display with one entry per pending message.
/// </summary>
void SetQueuedMessages(IReadOnlyList<ChatMessage> pending);
/// <summary>
/// Appends the supplied questions to the pending follow-up question queue in
/// component state. If the queue was empty, the bottom-panel display is
/// reconfigured to present the new head question.
/// </summary>
void QueueFollowUpQuestions(IReadOnlyList<FollowUpQuestion> questions);
/// <summary>
/// Appends a message to the accumulated follow-up response list in component state.
/// Called by the runner for direct <see cref="FollowUpMessage"/> outputs and by
/// the component when a question's continuation produces a response.
/// </summary>
void AddFollowUpResponse(ChatMessage response);
/// <summary>
/// Pops the head of the pending follow-up question queue. Reconfigures the
/// bottom-panel display for the new head, or restores the default text-input
/// mode if the queue is now empty.
/// </summary>
void AdvanceFollowUpQuestion();
/// <summary>
/// Returns the current accumulated follow-up responses and clears them in state.
/// Called by the runner immediately before invoking the next agent turn.
/// </summary>
IReadOnlyList<ChatMessage> TakeFollowUpResponses();
/// <summary>
/// Signals that the application should shut down. Completes the shutdown task
/// on the owning component.
/// </summary>
void RequestShutdown();
}
@@ -1,31 +0,0 @@
// 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;
}
}
@@ -18,41 +18,36 @@ public abstract class ConsoleObserver
/// Override to set options such as <see cref="AgentRunOptions.ResponseFormat"/>.
/// </summary>
/// <param name="options">The run options to configure.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
public virtual void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
public virtual void ConfigureRunOptions(AgentRunOptions options)
{
}
/// <summary>
/// Called for each <see cref="AIContent"/> item in the response stream.
/// </summary>
/// <param name="ux">The UX state driver, used for rendering output.</param>
/// <param name="writer">The console writer for rendering output.</param>
/// <param name="content">The content item from the stream.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
public virtual Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) => Task.CompletedTask;
public virtual Task OnContentAsync(ConsoleWriter writer, AIContent content) => Task.CompletedTask;
/// <summary>
/// Called for each text update in the response stream.
/// </summary>
/// <param name="ux">The UX state driver, used for rendering output.</param>
/// <param name="writer">The console writer for rendering output.</param>
/// <param name="text">The text from the update.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
public virtual Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session) => Task.CompletedTask;
public virtual Task OnTextAsync(ConsoleWriter writer, string text) => Task.CompletedTask;
/// <summary>
/// Called after the response stream completes. Returns a heterogeneous list of
/// follow-up actions (questions to ask the user, and/or messages to add directly to
/// the next agent invocation), or <see langword="null"/> if no follow-up is needed.
/// 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="ux">The UX state driver, used for rendering output.</param>
/// <param name="writer">The console writer for rendering output.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
/// <returns>Follow-up actions to process after the stream completes, or <see langword="null"/>.</returns>
public virtual Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
IUXStateDriver ux,
/// <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,
AIAgent agent,
AgentSession session) => Task.FromResult<IList<FollowUpAction>?>(null);
AgentSession session,
HarnessConsoleOptions options) => Task.FromResult<IList<ChatMessage>?>(null);
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
@@ -8,10 +7,10 @@ namespace Harness.Shared.Console.Observers;
/// <summary>
/// Displays error content (❌) from the response stream.
/// </summary>
public sealed class ErrorDisplayObserver : ConsoleObserver
internal sealed class ErrorDisplayObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
{
if (content is ErrorContent errorContent)
{
@@ -26,7 +25,7 @@ public sealed class ErrorDisplayObserver : ConsoleObserver
errorText += $" details: {errorContent.Details}";
}
await ux.WriteInfoLineAsync(errorText, ConsoleColor.Red);
await writer.WriteInfoLineAsync(errorText, ConsoleColor.Red);
}
}
}
@@ -2,77 +2,51 @@
using System.Text;
using System.Text.Json;
using Harness.ConsoleReactiveComponents;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
/// <summary>
/// Planning observer that is mode-aware: in planning mode it configures structured
/// JSON output, collects streamed text, and deserializes it as a <see cref="PlanningResponse"/>;
/// in execution mode it passes text straight through to <see cref="IUXStateDriver.WriteTextAsync"/>
/// for live streaming display.
/// Planning observer that configures structured output, collects streamed text,
/// and deserializes it as a <see cref="PlanningResponse"/>. Renders clarification
/// questions and approval prompts, and manages mode switching when the user approves a plan.
/// </summary>
public sealed class PlanningOutputObserver : ConsoleObserver
internal sealed class PlanningOutputObserver : ConsoleObserver
{
private readonly StringBuilder _textCollector = new();
private readonly AgentModeProvider _modeProvider;
private readonly string _planModeName;
private readonly string _executionModeName;
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
/// <summary>
/// Initializes a new instance of the <see cref="PlanningOutputObserver"/> class.
/// </summary>
/// <param name="modeProvider">The mode provider for switching modes on approval.</param>
/// <param name="planModeName">The mode name that represents the planning mode.</param>
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
/// <param name="modeColors">Optional mode-to-color mapping for display.</param>
public PlanningOutputObserver(AgentModeProvider modeProvider, string planModeName, string executionModeName, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
public PlanningOutputObserver(AgentModeProvider modeProvider)
{
this._modeProvider = modeProvider;
this._planModeName = planModeName;
this._executionModeName = executionModeName;
this._modeColors = modeColors;
}
/// <inheritdoc/>
public override void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
public override void ConfigureRunOptions(AgentRunOptions options)
{
if (this.IsPlanningMode(this._modeProvider.GetMode(session)))
{
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
}
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
}
/// <inheritdoc/>
public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session)
public override Task OnTextAsync(ConsoleWriter writer, string text)
{
if (this.IsPlanningMode(ux.CurrentMode))
{
// Planning mode: collect text silently for JSON parsing after the stream.
this._textCollector.Append(text);
return Task.CompletedTask;
}
// Execution mode: stream text directly to the console.
return ux.WriteTextAsync(text);
// Collect text silently instead of displaying it.
this._textCollector.Append(text);
return Task.CompletedTask;
}
/// <inheritdoc/>
public override async Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
IUXStateDriver ux,
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
ConsoleWriter writer,
AIAgent agent,
AgentSession session)
AgentSession session,
HarnessConsoleOptions options)
{
if (!this.IsPlanningMode(ux.CurrentMode))
{
// Execution mode: text was already streamed live; nothing to parse.
this._textCollector.Clear();
return null;
}
// Read collected text from our stream observation.
string collectedText = this._textCollector.ToString();
this._textCollector.Clear();
@@ -90,20 +64,21 @@ public sealed class PlanningOutputObserver : ConsoleObserver
}
catch (JsonException ex)
{
await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
await writer.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
await writer.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
return null;
}
if (planningResponse is null)
{
await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
await writer.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
return null;
}
// Render based on response type.
if (planningResponse.Type == PlanningResponseType.Clarification)
{
return BuildClarificationActions(planningResponse);
return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(writer, planningResponse));
}
if (planningResponse.Type == PlanningResponseType.Approval)
@@ -111,91 +86,92 @@ public sealed class PlanningOutputObserver : ConsoleObserver
var question = planningResponse.Questions.FirstOrDefault();
if (question is null)
{
await ux.WriteInfoLineAsync("(approval response had no content)", ConsoleColor.DarkYellow);
await writer.WriteInfoLineAsync("(approval response had no content)", ConsoleColor.DarkYellow);
return null;
}
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
string response = await this.RenderApprovalAndCollectResponseAsync(writer, 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));
}
return AsUserMessages(response);
}
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
await writer.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
return null;
}
private static List<FollowUpAction> BuildClarificationActions(PlanningResponse response)
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)
{
var actions = new List<FollowUpAction>(response.Questions.Count);
var answers = new List<string>();
foreach (var question in response.Questions)
{
string prompt = question.Message;
async Task<ChatMessage?> Continuation(string answer, IUXStateDriver ux)
{
if (string.IsNullOrWhiteSpace(answer))
{
string noAnswer = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray)}(no answer){AnsiEscapes.ResetAttributes}";
await ux.WriteInfoLineAsync(noAnswer, ConsoleColor.Gray).ConfigureAwait(false);
return null;
}
string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{answer}{AnsiEscapes.ResetAttributes}";
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
return new ChatMessage(ChatRole.User, $"Q: {prompt}\nA: {answer}");
}
await writer.WriteInfoLineAsync(string.Empty);
await writer.WriteInfoLineAsync(question.Message);
string? answer;
if (question.Choices is { Count: > 0 })
{
actions.Add(new ChoiceFollowUpQuestion(
Prompt: prompt,
Choices: question.Choices,
AllowCustomText: true,
Continuation: Continuation));
answer = await writer.ReadSelectionAsync(
"Choose an option:",
question.Choices);
}
else
{
actions.Add(new TextFollowUpQuestion(
Prompt: prompt,
Continuation: Continuation));
answer = (await writer.ReadLineAsync("Response: "))?.Trim();
}
if (!string.IsNullOrWhiteSpace(answer))
{
answers.Add($"Q: {question.Message}\nA: {answer}");
}
}
return actions;
return answers.Count > 0 ? string.Join("\n\n", answers) : null;
}
private ChoiceFollowUpQuestion BuildApprovalAction(PlanningQuestion question, AgentSession session)
private async Task<string> RenderApprovalAndCollectResponseAsync(ConsoleWriter writer, PlanningQuestion question, HarnessConsoleOptions options)
{
const string ApproveOption = "Approve and switch to execute mode";
var choices = new List<string> { ApproveOption };
await writer.WriteInfoLineAsync(question.Message);
return new ChoiceFollowUpQuestion(
Prompt: question.Message,
Choices: choices,
AllowCustomText: true,
Continuation: async (selection, ux) =>
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);
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))
{
string formatted = $"🔹 {question.Message}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{selection}{AnsiEscapes.ResetAttributes}";
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
// Treat empty feedback as no changes — re-prompt the agent with the plan.
return "No changes suggested. Please re-present the plan for approval.";
}
if (selection == ApproveOption)
{
this._modeProvider.SetMode(session, this._executionModeName);
await ux.WriteInfoLineAsync(
$"✅ Switched to {this._executionModeName} mode.",
ModeColors.Get(this._executionModeName, this._modeColors)).ConfigureAwait(false);
return new ChatMessage(ChatRole.User, "Approved");
}
return feedback;
}
// Custom freeform input — treat as suggested changes.
return new ChatMessage(ChatRole.User, selection);
});
// Custom freeform input — treat as suggested changes.
return selection;
}
/// <summary>
/// Returns <see langword="true"/> when the current mode matches the configured plan mode name.
/// A <see langword="null"/> mode (no mode provider) is also treated as planning mode.
/// </summary>
private bool IsPlanningMode(string? currentMode) =>
currentMode is null || string.Equals(currentMode, this._planModeName, StringComparison.OrdinalIgnoreCase);
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
@@ -8,14 +7,14 @@ namespace Harness.Shared.Console.Observers;
/// <summary>
/// Displays reasoning content in dark magenta from the response stream.
/// </summary>
public sealed class ReasoningDisplayObserver : ConsoleObserver
internal sealed class ReasoningDisplayObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
{
if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text))
{
await ux.WriteTextAsync(reasoning.Text, ConsoleColor.DarkMagenta);
await writer.WriteTextAsync(reasoning.Text, ConsoleColor.DarkMagenta);
}
}
}
@@ -1,18 +1,16 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
namespace Harness.Shared.Console.Observers;
/// <summary>
/// Streams agent text output directly to the console.
/// Used in normal (non-planning) mode.
/// </summary>
public sealed class TextOutputObserver : ConsoleObserver
internal sealed class TextOutputObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session)
public override async Task OnTextAsync(ConsoleWriter writer, string text)
{
await ux.WriteTextAsync(text);
await writer.WriteTextAsync(text);
}
}
@@ -1,7 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
@@ -9,103 +7,86 @@ namespace Harness.Shared.Console.Observers;
/// <summary>
/// Collects <see cref="ToolApprovalRequestContent"/> items during the response stream,
/// displays approval-needed notifications inline, and after the stream completes returns
/// one <see cref="ChoiceFollowUpQuestion"/> per pending approval request. Each question's
/// continuation produces a separate <see cref="ChatMessage"/> carrying the approval
/// response content.
/// displays approval-needed notifications inline, and prompts the user for approval
/// decisions after the stream completes.
/// </summary>
public sealed class ToolApprovalObserver : ConsoleObserver
internal sealed class ToolApprovalObserver : ConsoleObserver
{
private readonly List<ToolApprovalRequestContent> _approvalRequests = [];
private readonly IReadOnlyList<ToolCallFormatter> _formatters;
/// <summary>
/// Initializes a new instance of the <see cref="ToolApprovalObserver"/> class.
/// </summary>
/// <param name="formatters">Optional list of tool formatters. When <see langword="null"/>,
/// the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/> are used.</param>
public ToolApprovalObserver(IReadOnlyList<ToolCallFormatter>? formatters = null)
{
this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters();
}
/// <inheritdoc/>
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
{
if (content is ToolApprovalRequestContent approvalRequest)
{
this._approvalRequests.Add(approvalRequest);
string toolName = approvalRequest.ToolCall is FunctionCallContent fc
? ToolCallFormatter.Format(this._formatters, fc)
? ToolCallFormatter.Format(fc)
: approvalRequest.ToolCall?.ToString() ?? "unknown";
await ux.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
await writer.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
}
}
/// <inheritdoc/>
public override Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
IUXStateDriver ux,
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
ConsoleWriter writer,
AIAgent agent,
AgentSession session)
AgentSession session,
HarnessConsoleOptions options)
{
if (this._approvalRequests.Count == 0)
{
return Task.FromResult<IList<FollowUpAction>?>(null);
}
var actions = new List<FollowUpAction>(this._approvalRequests.Count);
foreach (var request in this._approvalRequests)
{
actions.Add(this.BuildApprovalQuestion(request));
return null;
}
var messages = await PromptForApprovalsAsync(writer, this._approvalRequests);
this._approvalRequests.Clear();
return Task.FromResult<IList<FollowUpAction>?>(actions);
return messages;
}
private ChoiceFollowUpQuestion BuildApprovalQuestion(ToolApprovalRequestContent request)
private static async Task<List<ChatMessage>?> PromptForApprovalsAsync(ConsoleWriter writer, List<ToolApprovalRequestContent> approvalRequests)
{
string toolName = request.ToolCall is FunctionCallContent fc
? ToolCallFormatter.Format(this._formatters, fc)
: request.ToolCall?.ToString() ?? "unknown";
var choices = new List<string>
if (approvalRequests.Count == 0)
{
"Approve this call",
"Always approve this tool (any arguments)",
"Always approve this tool with these arguments",
"Deny",
};
return null;
}
string prompt = $"🔐 Tool approval: {toolName}";
var responses = new List<AIContent>();
foreach (var request in approvalRequests)
{
string toolName = request.ToolCall is FunctionCallContent fc
? ToolCallFormatter.Format(fc)
: request.ToolCall?.ToString() ?? "unknown";
return new ChoiceFollowUpQuestion(
Prompt: prompt,
Choices: choices,
AllowCustomText: false,
Continuation: async (selection, ux) =>
var choices = new List<string>
{
AIContent response = selection switch
{
"Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
"Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
"Deny" => request.CreateResponse(approved: false, reason: "User denied"),
_ => request.CreateResponse(approved: true, reason: "User approved"),
};
"Approve this call",
"Always approve this tool (any arguments)",
"Always approve this tool with these arguments",
"Deny",
};
string action = selection switch
{
"Always approve this tool (any arguments)" => "✅ Always approved (any args)",
"Always approve this tool with these arguments" => "✅ Always approved (these args)",
"Deny" => "❌ Denied",
_ => "✅ Approved",
};
string selection = await writer.ReadSelectionAsync($"🔐 Tool approval: {toolName}", choices);
AIContent response = selection switch
{
"Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
"Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
"Deny" => request.CreateResponse(approved: false, reason: "User denied"),
_ => request.CreateResponse(approved: true, reason: "User approved"),
};
ConsoleColor answerColor = selection == "Deny" ? ConsoleColor.Red : ConsoleColor.Green;
string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(answerColor)}{action}{AnsiEscapes.ResetAttributes}";
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
string action = selection switch
{
"Always approve this tool (any arguments)" => "✅ Always approved (any args)",
"Always approve this tool with these arguments" => "✅ Always approved (these args)",
"Deny" => "❌ Denied",
_ => "✅ Approved",
};
await writer.WriteInfoLineAsync($" {action}", ConsoleColor.DarkGray);
return new ChatMessage(ChatRole.User, [response]);
});
responses.Add(response);
}
return [new ChatMessage(ChatRole.User, responses)];
}
}
@@ -1,7 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
@@ -10,30 +8,18 @@ namespace Harness.Shared.Console.Observers;
/// Displays tool call notifications (🔧) for <see cref="FunctionCallContent"/>
/// and <see cref="ToolCallContent"/> items in the response stream.
/// </summary>
public sealed class ToolCallDisplayObserver : ConsoleObserver
internal sealed class ToolCallDisplayObserver : ConsoleObserver
{
private readonly IReadOnlyList<ToolCallFormatter> _formatters;
/// <summary>
/// Initializes a new instance of the <see cref="ToolCallDisplayObserver"/> class.
/// </summary>
/// <param name="formatters">Optional list of tool formatters. When <see langword="null"/>,
/// the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/> are used.</param>
public ToolCallDisplayObserver(IReadOnlyList<ToolCallFormatter>? formatters = null)
{
this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters();
}
/// <inheritdoc/>
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
{
if (content is FunctionCallContent functionCall)
{
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(this._formatters, functionCall)}...", ConsoleColor.DarkYellow);
await writer.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow);
}
else if (content is ToolCallContent toolCall)
{
await ux.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
await writer.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
}
}
}
@@ -0,0 +1,288 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
/// <summary>
/// Formats <see cref="FunctionCallContent"/> instances into human-readable strings
/// for console display.
/// </summary>
public static class ToolCallFormatter
{
/// <summary>
/// Returns a formatted string for the given tool call, with human-readable
/// details for known tools (todos, mode, sub-agents, web tools).
/// </summary>
/// <param name="call">The function call content to format.</param>
/// <returns>A formatted string describing the tool call.</returns>
public static string Format(FunctionCallContent call)
{
string? detail = call.Name switch
{
// Todo tools
"TodoList_Add" => FormatAddTodos(call),
"TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
"TodoList_GetRemaining" => null,
"TodoList_GetAll" => null,
// Mode tools
"AgentMode_Set" => FormatStringArg(call, "mode"),
"AgentMode_Get" => null,
// Sub-agent tools
"SubAgents_StartTask" => FormatStartSubTask(call),
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
"SubAgents_GetAllTasks" => null,
"SubAgents_ContinueTask" => FormatContinueTask(call),
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
// File memory tools
"FileMemory_SaveFile" => FormatSaveFile(call),
"FileMemory_ReadFile" => FormatStringArg(call, "fileName"),
"FileMemory_DeleteFile" => FormatStringArg(call, "fileName"),
"FileMemory_ListFiles" => null,
"FileMemory_SearchFiles" => FormatSearchFiles(call),
// External tools
"web_search" => FormatStringArg(call, "query"),
"DownloadUri" => FormatStringArg(call, "uri"),
_ => FormatFallback(call),
};
return detail is not null ? $"{call.Name} {detail}" : call.Name;
}
private static string? FormatAddTodos(FunctionCallContent call)
{
if (call.Arguments?.TryGetValue("todos", out object? todosObj) != true || todosObj is null)
{
return null;
}
var titles = new List<string>();
if (todosObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in jsonArray.EnumerateArray())
{
string? title = item.TryGetProperty("title", out JsonElement titleElement)
? titleElement.GetString()
: null;
if (!string.IsNullOrEmpty(title))
{
titles.Add(title);
}
}
}
if (titles.Count == 0)
{
return null;
}
var sb = new StringBuilder();
sb.Append($"({titles.Count} item{(titles.Count == 1 ? "" : "s")})");
foreach (string title in titles)
{
sb.Append($"\n • {title}");
}
return sb.ToString();
}
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
{
List<int>? ids = GetIntList(call, paramName);
if (ids is null || ids.Count == 0)
{
return null;
}
return $"({verb} #{string.Join(", #", ids)})";
}
private static string? FormatSingleId(FunctionCallContent call, string paramName)
{
int? id = GetInt(call, paramName);
return id.HasValue ? $"(task #{id.Value})" : null;
}
private static string? FormatStartSubTask(FunctionCallContent call)
{
string? agentName = GetString(call, "agentName");
string? description = GetString(call, "description");
if (agentName is null && description is null)
{
return null;
}
var sb = new StringBuilder("(");
if (agentName is not null)
{
sb.Append($"agent: {agentName}");
}
if (description is not null)
{
if (agentName is not null)
{
sb.Append(", ");
}
sb.Append($"\"{Truncate(description, 60)}\"");
}
sb.Append(')');
return sb.ToString();
}
private static string? FormatContinueTask(FunctionCallContent call)
{
int? taskId = GetInt(call, "taskId");
string? text = GetString(call, "text");
if (!taskId.HasValue)
{
return null;
}
return text is not null
? $"(task #{taskId.Value}, \"{Truncate(text, 50)}\")"
: $"(task #{taskId.Value})";
}
private static string? FormatSaveFile(FunctionCallContent call)
{
string? fileName = GetString(call, "fileName");
string? description = GetString(call, "description");
if (fileName is null)
{
return null;
}
return string.IsNullOrEmpty(description)
? $"({fileName})"
: $"({fileName}, with description)";
}
private static string? FormatSearchFiles(FunctionCallContent call)
{
string? pattern = GetString(call, "regexPattern");
string? filePattern = GetString(call, "filePattern");
if (pattern is null)
{
return null;
}
return string.IsNullOrEmpty(filePattern)
? $"(/{pattern}/)"
: $"(/{pattern}/ in {filePattern})";
}
private static string? FormatStringArg(FunctionCallContent call, string paramName)
{
string? value = GetString(call, paramName);
return value is not null ? $"({value})" : null;
}
private static string? FormatFallback(FunctionCallContent call)
{
if (call.Arguments is null || call.Arguments.Count == 0)
{
return null;
}
var parts = new List<string>();
foreach (var kvp in call.Arguments)
{
string? stringValue = kvp.Value switch
{
JsonElement je => je.ValueKind switch
{
JsonValueKind.String => je.GetString(),
JsonValueKind.Number => je.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null,
},
not null => kvp.Value.ToString(),
_ => null,
};
if (stringValue is not null)
{
parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}");
}
}
return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null;
}
private static string? GetString(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
return value switch
{
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
string s => s,
_ => value.ToString(),
};
}
private static int? GetInt(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
return value switch
{
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
int i => i,
_ => int.TryParse(value.ToString(), out int parsed) ? parsed : null,
};
}
private static List<int>? GetIntList(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
var result = new List<int>();
if (value is JsonElement je && je.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in je.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number)
{
result.Add(item.GetInt32());
}
}
}
return result.Count > 0 ? result : null;
}
private static string Truncate(string text, int maxLength)
{
return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…");
}
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
@@ -8,7 +7,7 @@ namespace Harness.Shared.Console.Observers;
/// <summary>
/// Displays token usage statistics (📊) from the response stream.
/// </summary>
public sealed class UsageDisplayObserver : ConsoleObserver
internal sealed class UsageDisplayObserver : ConsoleObserver
{
private readonly int? _maxContextWindowTokens;
private readonly int? _maxOutputTokens;
@@ -25,21 +24,19 @@ public sealed class UsageDisplayObserver : ConsoleObserver
}
/// <inheritdoc/>
public override Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
{
if (content is UsageContent usage)
{
if (usage.Details is not null)
{
ux.SetUsageText(this.FormatUsageBreakdown(usage.Details));
await writer.WriteInfoLineAsync(this.FormatUsageBreakdown(usage.Details), ConsoleColor.DarkGray);
}
else
{
ux.SetUsageText("📊 Tokens —");
await writer.WriteInfoLineAsync("📊 Tokens —", ConsoleColor.DarkGray);
}
}
return Task.CompletedTask;
}
private string FormatUsageBreakdown(UsageDetails details)
@@ -1,34 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Harness.Shared.Console;
/// <summary>
/// Represents the type of an output entry in the console conversation.
/// </summary>
internal enum OutputEntryType
{
/// <summary>User input echo (e.g. "You: hello").</summary>
UserInput,
/// <summary>In-progress streaming text from the agent (accumulated chunk by chunk).</summary>
StreamingText,
/// <summary>Informational line (tool calls, errors, usage, approval requests, etc.).</summary>
InfoLine,
/// <summary>Stream footer (e.g. "(no text response from agent)").</summary>
StreamFooter,
/// <summary>Pending injected message notification.</summary>
PendingMessage,
}
/// <summary>
/// Represents a single output entry in the console conversation history.
/// Used internally by <see cref="HarnessConsoleUXStateDriver"/> to track
/// the in-progress streaming entry and last-entry type for spacing decisions.
/// </summary>
/// <param name="Type">The type of output entry.</param>
/// <param name="Text">The text content of the entry.</param>
/// <param name="Color">Optional foreground color for rendering.</param>
internal sealed record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null);
@@ -0,0 +1,77 @@
// 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");
}
}
}
@@ -1,51 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Catch-all formatter that handles any tool not matched by a more specific formatter.
/// Displays a generic summary of the tool's arguments. This formatter should always be
/// placed last in the formatter list.
/// </summary>
public sealed class FallbackToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => true;
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call)
{
if (call.Arguments is null || call.Arguments.Count == 0)
{
return null;
}
var parts = new List<string>();
foreach (var kvp in call.Arguments)
{
string? stringValue = kvp.Value switch
{
JsonElement je => je.ValueKind switch
{
JsonValueKind.String => je.GetString(),
JsonValueKind.Number => je.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null,
},
not null => kvp.Value.ToString(),
_ => null,
};
if (stringValue is not null)
{
parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}");
}
}
return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null;
}
}
@@ -1,61 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>FileMemory_*</c> tool calls, showing file names and search patterns
/// with tree-view corners for save operations.
/// </summary>
public sealed class FileMemoryToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("FileMemory_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"FileMemory_SaveFile" => FormatSaveFile(call),
"FileMemory_ReadFile" => FormatStringArg(call, "fileName"),
"FileMemory_DeleteFile" => FormatStringArg(call, "fileName"),
"FileMemory_SearchFiles" => FormatSearchFiles(call),
_ => null,
};
private static string? FormatSaveFile(FunctionCallContent call)
{
string? fileName = GetStringArgumentValue(call, "fileName");
string? description = GetStringArgumentValue(call, "description");
if (fileName is null)
{
return null;
}
return string.IsNullOrEmpty(description)
? $"\n └─ {fileName}"
: $"\n └─ {fileName} (with description)";
}
private static string? FormatSearchFiles(FunctionCallContent call)
{
string? pattern = GetStringArgumentValue(call, "regexPattern");
string? filePattern = GetStringArgumentValue(call, "filePattern");
if (pattern is null)
{
return null;
}
return string.IsNullOrEmpty(filePattern)
? $"(/{pattern}/)"
: $"(/{pattern}/ in {filePattern})";
}
private static string? FormatStringArg(FunctionCallContent call, string paramName)
{
string? value = GetStringArgumentValue(call, paramName);
return value is not null ? $"({value})" : null;
}
}
@@ -1,27 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>AgentMode_*</c> tool calls, showing the target mode for Set operations.
/// </summary>
public sealed class ModeToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("AgentMode_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"AgentMode_Set" => FormatStringArg(call, "mode"),
_ => null,
};
private static string? FormatStringArg(FunctionCallContent call, string paramName)
{
string? value = GetStringArgumentValue(call, paramName);
return value is not null ? $"({value})" : null;
}
}
@@ -1,101 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>SubAgents_*</c> tool calls with human-readable details
/// for task start, continue, wait, and result retrieval operations.
/// </summary>
public sealed class SubAgentToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("SubAgents_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"SubAgents_StartTask" => FormatStartSubTask(call),
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
"SubAgents_ContinueTask" => FormatContinueTask(call),
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
_ => null,
};
private static string? FormatStartSubTask(FunctionCallContent call)
{
string? agentName = GetStringArgumentValue(call, "agentName");
string? description = GetStringArgumentValue(call, "description");
if (agentName is null && description is null)
{
return null;
}
var sb = new StringBuilder();
if (agentName is not null && description is not null)
{
sb.Append($"\n ├─ Agent: {agentName}");
sb.Append($"\n └─ \"{Truncate(description, 80)}\"");
}
else if (agentName is not null)
{
sb.Append($"\n └─ Agent: {agentName}");
}
else
{
sb.Append($"\n └─ \"{Truncate(description!, 80)}\"");
}
return sb.ToString();
}
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
{
List<int>? ids = GetIntListArgumentValue(call, paramName);
if (ids is null || ids.Count == 0)
{
return null;
}
var sb = new StringBuilder();
for (int i = 0; i < ids.Count; i++)
{
string connector = i < ids.Count - 1 ? "├─" : "└─";
sb.Append($"\n {connector} {verb} #{ids[i]}");
}
return sb.ToString();
}
private static string? FormatSingleId(FunctionCallContent call, string paramName)
{
int? id = GetIntArgumentValue(call, paramName);
return id.HasValue ? $"(task #{id.Value})" : null;
}
private static string? FormatContinueTask(FunctionCallContent call)
{
int? taskId = GetIntArgumentValue(call, "taskId");
string? text = GetStringArgumentValue(call, "text");
if (!taskId.HasValue)
{
return null;
}
if (text is not null)
{
var sb = new StringBuilder();
sb.Append($"\n ├─ Task #{taskId.Value}");
sb.Append($"\n └─ \"{Truncate(text, 80)}\"");
return sb.ToString();
}
return $"\n └─ Task #{taskId.Value}";
}
}
@@ -1,84 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>TodoList_*</c> tool calls with tree-view output for added items
/// and structured output for complete/remove operations.
/// </summary>
public sealed class TodoToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("TodoList_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"TodoList_Add" => FormatAddTodos(call),
"TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
_ => null,
};
private static string? FormatAddTodos(FunctionCallContent call)
{
if (call.Arguments?.TryGetValue("todos", out object? todosObj) != true || todosObj is null)
{
return null;
}
var titles = new List<string>();
if (todosObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in jsonArray.EnumerateArray())
{
string? title = item.TryGetProperty("title", out JsonElement titleElement)
? titleElement.GetString()
: null;
if (!string.IsNullOrEmpty(title))
{
titles.Add(title);
}
}
}
if (titles.Count == 0)
{
return null;
}
var sb = new StringBuilder();
sb.Append($"({titles.Count} item{(titles.Count == 1 ? "" : "s")})");
for (int i = 0; i < titles.Count; i++)
{
string connector = i < titles.Count - 1 ? "├─" : "└─";
sb.Append($"\n {connector} {titles[i]}");
}
return sb.ToString();
}
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
{
List<int>? ids = GetIntListArgumentValue(call, paramName);
if (ids is null || ids.Count == 0)
{
return null;
}
var sb = new StringBuilder();
for (int i = 0; i < ids.Count; i++)
{
string connector = i < ids.Count - 1 ? "├─" : "└─";
sb.Append($"\n {connector} {verb} #{ids[i]}");
}
return sb.ToString();
}
}
@@ -1,135 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Base class for tool call formatters that produce human-readable display strings
/// for <see cref="FunctionCallContent"/> items shown in the console.
/// </summary>
public abstract class ToolCallFormatter
{
/// <summary>
/// Returns <see langword="true"/> if this formatter can handle the given function call.
/// </summary>
/// <param name="call">The function call content to check.</param>
/// <returns><see langword="true"/> if this formatter should be used; otherwise <see langword="false"/>.</returns>
public abstract bool CanFormat(FunctionCallContent call);
/// <summary>
/// Returns the detail portion of the formatted output for the given tool call,
/// or <see langword="null"/> if only the tool name should be displayed.
/// </summary>
/// <param name="call">The function call content to format.</param>
/// <returns>A detail string to append after the tool name, or <see langword="null"/>.</returns>
public abstract string? FormatDetail(FunctionCallContent call);
/// <summary>
/// Formats a tool call using the first matching formatter from the provided list.
/// Returns <c>"{toolName} {detail}"</c> when a formatter produces detail,
/// or just <c>"{toolName}"</c> otherwise.
/// </summary>
internal static string Format(IReadOnlyList<ToolCallFormatter> formatters, FunctionCallContent call)
{
foreach (var formatter in formatters)
{
if (formatter.CanFormat(call))
{
string? detail = formatter.FormatDetail(call);
return detail is not null ? $"{call.Name} {detail}" : call.Name;
}
}
return call.Name;
}
/// <summary>
/// Creates the default list of tool call formatters. The <see cref="FallbackToolFormatter"/>
/// is always last. Users can call this method and combine the result with their own formatters.
/// </summary>
/// <returns>A list of all built-in tool call formatters.</returns>
public static List<ToolCallFormatter> BuildDefaultToolFormatters()
{
return
[
new TodoToolFormatter(),
new ModeToolFormatter(),
new SubAgentToolFormatter(),
new FileMemoryToolFormatter(),
new WebSearchToolFormatter(),
new FallbackToolFormatter(),
];
}
/// <summary>
/// Extracts a string argument value from a function call.
/// </summary>
protected static string? GetStringArgumentValue(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
return value switch
{
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
string s => s,
_ => value.ToString(),
};
}
/// <summary>
/// Extracts an integer argument value from a function call.
/// </summary>
protected static int? GetIntArgumentValue(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
return value switch
{
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
int i => i,
_ => int.TryParse(value.ToString(), out int parsed) ? parsed : null,
};
}
/// <summary>
/// Extracts a list of integer argument values from a function call.
/// </summary>
protected static List<int>? GetIntListArgumentValue(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
var result = new List<int>();
if (value is JsonElement je && je.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in je.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number)
{
result.Add(item.GetInt32());
}
}
}
return result.Count > 0 ? result : null;
}
/// <summary>
/// Truncates a string to the specified maximum length, appending an ellipsis if truncated.
/// </summary>
protected static string Truncate(string text, int maxLength)
{
return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…");
}
}
@@ -1,22 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>web_search</c> tool calls, showing the search query.
/// </summary>
public sealed class WebSearchToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) =>
call.Name is "web_search";
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call)
{
string? value = GetStringArgumentValue(call, "query");
return value is not null ? $"({value})" : null;
}
}
@@ -1,23 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Extensions.AI;
namespace SampleApp;
/// <summary>
/// Formats <c>DownloadUri</c> tool calls, showing the target URI.
/// </summary>
public sealed class DownloadUriToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) =>
call.Name is "DownloadUri";
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call)
{
string? value = GetStringArgumentValue(call, "uri");
return value is not null ? $"({value})" : null;
}
}
@@ -13,7 +13,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a HarnessAgent with the Harness AIContextProviders
// This sample demonstrates how to use a ChatClientAgent with the Harness AIContextProviders
// (TodoProvider and AgentModeProvider) for interactive research tasks with web search
// capabilities powered by Azure AI Foundry.
// The agent plans research tasks, creates a todo list, gets user approval,
@@ -8,8 +8,7 @@
//
// Special commands:
// /todos — Display the current todo list without invoking the agent.
// /mode — Get or set the current agent mode.
// /exit — End the session.
// exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
@@ -17,8 +16,8 @@
using System.ClientModel.Primitives;
using Azure.Identity;
using Harness.Shared.Console;
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
@@ -30,7 +29,7 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYME
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
// Create a HarnessAgent with the Harness providers (TodoProvider and AgentModeProvider)
// Create a ChatClientAgent with the Harness providers (TodoProvider and AgentModeProvider)
// and research-focused instructions including the mandatory planning workflow.
var instructions =
"""
@@ -111,9 +110,13 @@ var instructions =
- Check for relevant previously downloaded data / findings before starting new research.
""";
// Create the agent using AsHarnessAgent, which pre-configures function invocation,
// per-service-call chat history persistence, and in-loop compaction.
// Then wrap with UseToolApproval to allow auto-approving tools once confirmed.
// Create a compaction strategy based on the model's context window.
// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens.
// Defaults: tool result eviction at 50% of input budget, truncation at 80%.
var compactionStrategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens);
AIAgent agent =
// Create an OpenAIClient that communicates with the Foundry responses service.
new OpenAIClient(
@@ -127,32 +130,48 @@ AIAgent agent =
RetryPolicy = new ClientRetryPolicy(3) // Enable retries to improve resiliency.
})
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves.
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "ResearchAgent",
Description = "A research assistant that plans and executes research tasks.",
AIContextProviders =
[
new TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session.
new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session.
new FileMemoryProvider( // Add an AIContextProvider that can store memories in files under a session specific working folder.
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
],
ChatOptions = new ChatOptions
.AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves.
// Build a ChatClient Pipeline
.AsBuilder()
.UseFunctionInvocation() // We are building our own stack from scratch so we need to include Function Invocation ourselves.
.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.
// Build our agent on top of the ChatClient Pipeline
.BuildAIAgent(
new ChatClientAgentOptions
{
Instructions = instructions,
Tools =
Name = "ResearchAgent",
Description = "A research assistant that plans and executes research tasks.",
UseProvidedChatClientAsIs = true, // Since we built our own stack from scratch we need to tell the agent not to also add defaults like Function Invocation.
RequirePerServiceCallChatHistoryPersistence = true, // Since we are added the per service call persistence ChatClient, we need to tell the agent to not also store chat history at the end of the run.
ChatHistoryProvider = new InMemoryChatHistoryProvider( // Store chat history in memory in the session object. Will persist if the session is persisted.
new InMemoryChatHistoryProviderOptions
{
ChatReducer = compactionStrategy.AsChatReducer(), // Run compaction on the InMemory chat history when it gets too large.
}),
AIContextProviders =
[
ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service.
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
new WebBrowsingToolOptions { AllowPublicNetworks = true }),
new TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session.
new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session.
new FileMemoryProvider( // Add an AIContextProvider that can store memories in files under a session specific working folder.
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
],
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
Reasoning = new() { Effort = ReasoningEffort.Medium },
},
})
ChatOptions = new ChatOptions
{
Instructions = instructions,
Tools =
[
ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service.
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
new WebBrowsingToolOptions { AllowPublicNetworks = true }),
],
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
Reasoning = new() { Effort = ReasoningEffort.Medium },
},
})
.AsBuilder()
.UseToolApproval() // Add the ability to auto approve tools once a user has said they don't want to be asked again. Approval rules are tied to the session.
.Build();
@@ -160,15 +179,13 @@ AIAgent agent =
// Run the interactive console session using the shared HarnessConsole helper.
await HarnessConsole.RunAgentAsync(
agent,
title: "Research Assistant",
userPrompt: "Enter a research topic to get started.",
new HarnessConsoleOptions
{
Observers = HarnessConsoleOptions.BuildObserversWithPlanning(
agent,
planModeName: "plan",
executionModeName: "execute",
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens,
toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()]),
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
MaxContextWindowTokens = MaxContextWindowTokens,
MaxOutputTokens = MaxOutputTokens,
EnablePlanningUx = true,
PlanningModeName = "plan",
ExecutionModeName = "execute"
});
@@ -1,11 +1,10 @@
# What this sample demonstrates
This sample demonstrates how to use a `HarnessAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Azure AI Foundry. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and context-window compaction.
This sample demonstrates how to use a `ChatClientAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Azure AI Foundry.
Key features showcased:
- **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction
- **ToolApproval** — the agent is wrapped with `UseToolApproval()` to allow auto-approving tools once confirmed
- **ChatClientAgent** — configured directly with Harness providers for planning and task management
- **Web Search** — the agent can search the web for current information via `ResponseTool.CreateWebSearchTool()`
- **TodoProvider** — the agent creates and manages a todo list to track research questions
- **AgentModeProvider** — the agent switches between "plan" mode (breaking down the topic) and "execute" mode (answering each research question)
@@ -13,7 +13,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
@@ -6,7 +6,7 @@
// equipped with Foundry's hosted web search tool.
//
// Special commands:
// /exit — End the session.
// exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
@@ -22,9 +22,6 @@ using OpenAI.Responses;
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
// --- Sub-agent: Web Search Agent ---
// This agent can search the web and is used by the parent agent to look up stock prices.
AIAgent webSearchAgent =
@@ -37,19 +34,20 @@ AIAgent webSearchAgent =
})
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "WebSearchAgent",
Description = "An agent that can search the web to find information.",
ChatOptions = new ChatOptions
.AsAIAgent(
new ChatClientAgentOptions
{
Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.",
Tools =
[
ResponseTool.CreateWebSearchTool().AsAITool(),
],
},
});
Name = "WebSearchAgent",
Description = "An agent that can search the web to find information.",
ChatOptions = new ChatOptions
{
Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.",
Tools =
[
ResponseTool.CreateWebSearchTool().AsAITool(),
],
},
});
// --- Parent agent: Stock Price Researcher ---
// This agent orchestrates the sub-agent to look up stock prices in parallel.
@@ -85,22 +83,24 @@ AIAgent parentAgent =
})
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "StockPriceResearcher",
Description = "An agent that researches stock prices using sub-agents.",
AIContextProviders =
[
new SubAgentsProvider([webSearchAgent]),
],
ChatOptions = new ChatOptions
.AsAIAgent(
new ChatClientAgentOptions
{
Instructions = parentInstructions,
MaxOutputTokens = 16_000,
},
});
Name = "StockPriceResearcher",
Description = "An agent that researches stock prices using sub-agents.",
AIContextProviders =
[
new SubAgentsProvider([webSearchAgent]),
],
ChatOptions = new ChatOptions
{
Instructions = parentInstructions,
MaxOutputTokens = 16_000,
},
});
// Run the interactive console session.
await HarnessConsole.RunAgentAsync(
parentAgent,
title: "Stock Price Researcher (SubAgents Demo)",
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
@@ -1,6 +1,6 @@
# Harness Step 02 — SubAgents (Stock Price Research)
This sample demonstrates how to use the **SubAgentsProvider** to delegate work from a parent agent to sub-agents. Both agents use `HarnessAgent` for pre-configured function invocation, per-service-call persistence, and context-window compaction.
This sample demonstrates how to use the **SubAgentsProvider** to delegate work from a parent agent to sub-agents.
## What It Does
@@ -13,7 +13,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a HarnessAgent with the FileAccessProvider
// This sample demonstrates how to use a ChatClientAgent with the FileAccessProvider
// to give an agent access to a folder of CSV data files. The agent can read, analyze,
// and extract information from the data, then write results back as new files.
//
@@ -8,7 +8,7 @@
// Ask the agent to analyze the data, produce summaries, or create new output files.
//
// Special commands:
// /exit — End the session.
// exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
@@ -17,6 +17,7 @@ using System.ClientModel.Primitives;
using Azure.Identity;
using Harness.Shared.Console;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
@@ -56,7 +57,11 @@ var instructions =
- Always explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process.
""";
// Create the chat client from the OpenAI provider.
// Create a compaction strategy based on the model's context window.
var compactionStrategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens);
AIAgent agent =
new OpenAIClient(
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
@@ -67,22 +72,39 @@ AIAgent agent =
})
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "DataAnalyst",
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
AIContextProviders =
[
new FileAccessProvider(fileStore),
],
ChatOptions = new ChatOptions
.AsBuilder()
.UseFunctionInvocation()
.UsePerServiceCallChatHistoryPersistence()
.UseAIContextProviders(new CompactionProvider(compactionStrategy))
.BuildAIAgent(
new ChatClientAgentOptions
{
Instructions = instructions,
MaxOutputTokens = MaxOutputTokens,
},
});
Name = "DataAnalyst",
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
UseProvidedChatClientAsIs = true,
RequirePerServiceCallChatHistoryPersistence = true,
ChatHistoryProvider = new InMemoryChatHistoryProvider(
new InMemoryChatHistoryProviderOptions
{
ChatReducer = compactionStrategy.AsChatReducer(),
}),
AIContextProviders =
[
new FileAccessProvider(fileStore),
],
ChatOptions = new ChatOptions
{
Instructions = instructions,
MaxOutputTokens = MaxOutputTokens,
},
})
.AsBuilder()
.Build();
// Run the interactive console session.
await HarnessConsole.RunAgentAsync(
agent,
title: "Data Processing Assistant",
userPrompt: "Ask me to analyze the data files, produce summaries, or create output files.");
@@ -1,10 +1,9 @@
# What this sample demonstrates
This sample demonstrates how to use a `HarnessAgent` with the `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and in-loop compaction — so the sample only needs to supply the chat client, token limits, and application-specific options.
This sample demonstrates how to use a `ChatClientAgent` with the `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results.
Key features showcased:
- **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction
- **FileAccessProvider** — gives the agent tools to read, write, list, search, and delete files in a shared data folder
- **CSV data processing** — the agent reads sales transaction data and performs analysis on demand
- **Output file creation** — the agent can write summaries, filtered data, or reports back to the data folder
@@ -1,42 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="OpenAI" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Mcp\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="InvokeFoundryToolboxMcp.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,87 +0,0 @@
#
# This workflow demonstrates invoking MCP tools through a Foundry toolbox MCP proxy.
#
# The toolbox is provisioned with TWO different tool types:
# 1. A Foundry built-in web_search tool
# 2. A Microsoft Learn MCP server (microsoft_docs)
# Both are surfaced through the same MCP-compatible toolbox endpoint.
#
# The workflow:
# 1. Accepts a documentation/web search query as input
# 2. Lists the tools exposed by the Foundry toolbox using reserved toolName: tools/list
# 3. Invokes the microsoft_docs_search MCP tool
# 4. Invokes the built-in web_search tool against the same toolbox endpoint
# 5. Uses an agent to summarize and combine both result sets
#
# Example input:
# How do I use Azure OpenAI with my data?
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_foundry_toolbox_mcp
actions:
# Set the search query from user input.
- kind: SetVariable
id: set_search_query
variable: Local.SearchQuery
value: =System.LastMessage.Text
# List tools exposed by the Foundry toolbox MCP proxy.
- kind: InvokeMcpTool
id: list_toolbox_tools
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
serverLabel: foundry_toolbox
toolName: tools/list
conversationId: =System.ConversationId
headers:
Foundry-Features: Toolboxes=V1Preview
output:
autoSend: true
result: Local.ToolboxTools
# Invoke a specific tool exposed through the toolbox and add the result to the conversation.
- kind: InvokeMcpTool
id: search_docs_with_toolbox
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
serverLabel: foundry_toolbox
toolName: =Env.FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL & "___microsoft_docs_search"
conversationId: =System.ConversationId
headers:
Foundry-Features: Toolboxes=V1Preview
arguments:
query: =Local.SearchQuery
output:
autoSend: true
result: Local.SearchResult
# Invoke the web_search built-in tool through the same toolbox proxy. The toolbox surfaces
# built-in Foundry tools (like web_search) alongside MCP tools through one MCP-compatible
# endpoint. Note that web_search expects argument 'search_query' (not 'query').
- kind: InvokeMcpTool
id: search_web_with_toolbox
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
serverLabel: foundry_toolbox
toolName: =Env.FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME
conversationId: =System.ConversationId
headers:
Foundry-Features: Toolboxes=V1Preview
arguments:
search_query: =Local.SearchQuery
output:
autoSend: true
result: Local.WebSearchResult
# Use the agent to summarize what happened and answer from the toolbox result.
- kind: InvokeAzureAgent
id: summarize_toolbox_result
agent:
name: FoundryToolboxMcpAgent
conversationId: =System.ConversationId
input:
messages: =UserMessage("Combine the Microsoft Learn docs results and the Foundry web search results in the conversation to answer the query " & Local.SearchQuery)
output:
autoSend: true
messages: Local.Summary
@@ -1,218 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates using InvokeMcpTool to call MCP tools through a Foundry toolbox.
// It creates a sample toolbox that exposes Microsoft Learn MCP tools, lists the toolbox tools
// through the reserved tools/list operation, then calls microsoft_docs_search from the workflow.
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
using Microsoft.Extensions.Configuration;
using OpenAI.Responses;
using Shared.Foundry;
using Shared.Workflows;
#pragma warning disable OPENAI001 // Experimental API
#pragma warning disable AAIP001 // AgentToolboxes is experimental
namespace Demo.Workflows.Declarative.InvokeFoundryToolboxMcp;
/// <summary>
/// Demonstrates a workflow that uses InvokeMcpTool to call MCP tools exposed through a Foundry toolbox.
/// </summary>
/// <remarks>
/// This sample provisions a toolbox with Microsoft Learn MCP tools, uses the reserved
/// <c>tools/list</c> tool name to list the toolbox tools, calls one specific toolbox tool,
/// and has a Foundry agent summarize the results.
/// </remarks>
internal sealed class Program
{
private const string ToolboxNameSetting = "FOUNDRY_TOOLBOX_NAME";
private const string ToolboxApiVersionSetting = "FOUNDRY_AGENT_TOOLSET_API_VERSION";
private const string ToolboxMcpServerUrlSetting = "FOUNDRY_TOOLBOX_MCP_SERVER_URL";
private const string DocsServerLabelSetting = "FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL";
private const string WebSearchToolNameSetting = "FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME";
private const string DefaultToolboxName = "declarative_foundry_toolbox_mcp";
private const string DefaultToolboxApiVersion = "v1";
private const string DefaultDocsServerLabel = "microsoft_docs";
private const string DefaultWebSearchToolName = "web_search";
public static async Task Main(string[] args)
{
// Initialize configuration
IConfiguration configuration = Application.InitializeConfig();
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
string toolboxName = configuration[ToolboxNameSetting] ?? DefaultToolboxName;
string toolboxApiVersion = configuration[ToolboxApiVersionSetting] ?? DefaultToolboxApiVersion;
string docsServerLabel = configuration[DocsServerLabelSetting] ?? DefaultDocsServerLabel;
string webSearchToolName = configuration[WebSearchToolNameSetting] ?? DefaultWebSearchToolName;
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
DefaultAzureCredential credential = new();
// Ensure sample toolbox and agent exist in Foundry
string toolboxEndpoint = await CreateSampleToolboxAsync(toolboxName, docsServerLabel, foundryEndpoint, credential);
string toolboxMcpServerUrl = BuildToolboxMcpServerUrl(toolboxEndpoint, toolboxName, toolboxApiVersion);
IConfiguration workflowConfiguration = new ConfigurationBuilder()
.AddConfiguration(configuration)
.AddInMemoryCollection(new Dictionary<string, string?>
{
[ToolboxMcpServerUrlSetting] = toolboxMcpServerUrl,
[DocsServerLabelSetting] = docsServerLabel,
[WebSearchToolNameSetting] = webSearchToolName,
})
.Build();
await CreateAgentAsync(foundryEndpoint, configuration, credential);
// Get input from command line or console
string workflowInput = Application.GetInput(args);
// Create the MCP tool handler for invoking the Foundry toolbox MCP proxy.
ConcurrentBag<HttpClient> createdHttpClients = [];
DefaultMcpToolHandler mcpToolHandler = new(
httpClientProvider: async (serverUrl, _) =>
{
await Task.CompletedTask.ConfigureAwait(false);
if (!string.Equals(serverUrl, toolboxMcpServerUrl, StringComparison.OrdinalIgnoreCase))
{
return null;
}
FoundryToolboxBearerTokenHandler handler = new(credential)
{
InnerHandler = new HttpClientHandler()
};
HttpClient httpClient = new(handler);
createdHttpClients.Add(httpClient);
return httpClient;
});
try
{
// Create the workflow factory with MCP tool provider
WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint)
{
Configuration = workflowConfiguration,
McpToolHandler = mcpToolHandler
};
// Execute the workflow
WorkflowRunner runner = new() { UseJsonCheckpoints = true };
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
}
finally
{
// Clean up connections and dispose created HttpClients
await mcpToolHandler.DisposeAsync();
foreach (HttpClient httpClient in createdHttpClients)
{
httpClient.Dispose();
}
}
}
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration, TokenCredential credential)
{
AIProjectClient aiProjectClient = new(foundryEndpoint, credential);
await aiProjectClient.CreateAgentAsync(
agentName: "FoundryToolboxMcpAgent",
agentDefinition: DefineToolboxAgent(configuration),
agentDescription: "Summarizes Foundry toolbox MCP tool results");
}
private static DeclarativeAgentDefinition DefineToolboxAgent(IConfiguration configuration)
{
return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel))
{
Instructions =
"""
You are a helpful assistant that explains results produced by tools exposed through a Foundry toolbox.
The conversation history contains output from BOTH a Microsoft Learn documentation search (MCP) and a Foundry web search.
Synthesize an answer that draws on both sources, calls out where they agree or differ, and notes which toolbox tool produced each fact when it is relevant.
Be concise.
"""
};
}
private static async Task<string> CreateSampleToolboxAsync(string name, string serverLabel, Uri foundryEndpoint, TokenCredential credential)
{
AgentAdministrationClientOptions options = new();
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
AgentAdministrationClient adminClient = new(foundryEndpoint, credential, options);
AgentToolboxes toolboxClient = adminClient.GetAgentToolboxes();
try
{
await toolboxClient.DeleteToolboxAsync(name);
Console.WriteLine($"Deleted existing toolbox '{name}'");
}
catch (ClientResultException ex) when (ex.Status == 404)
{
// Toolbox does not exist.
}
ProjectsAgentTool webTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateWebSearchTool());
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
serverLabel: serverLabel,
serverUri: new Uri("https://learn.microsoft.com/api/mcp"),
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync(
name: name,
tools: [webTool, mcpTool],
description: "Sample toolbox combining Foundry web search with the Microsoft Learn MCP tools for the declarative InvokeFoundryToolboxMcp sample.")).Value;
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
return $"{foundryEndpoint.ToString().TrimEnd('/')}/toolboxes";
}
private static string BuildToolboxMcpServerUrl(string toolboxEndpoint, string toolboxName, string apiVersion) =>
$"{toolboxEndpoint.TrimEnd('/')}/{toolboxName}/mcp?api-version={Uri.EscapeDataString(apiVersion)}";
private sealed class FoundryToolboxBearerTokenHandler(TokenCredential credential) : DelegatingHandler
{
private static readonly TokenRequestContext s_tokenContext =
new(["https://ai.azure.com/.default"]);
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(s_tokenContext, cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken);
}
}
private sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
{
private const string FeatureHeader = "Foundry-Features";
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Add(FeatureHeader, feature);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Add(FeatureHeader, feature);
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
}
@@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
</Project>
@@ -1,76 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates evaluating a multi-agent workflow against a
// golden answer using Foundry's reference-based Similarity evaluator.
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using FoundryEvals = Microsoft.Agents.AI.Foundry.FoundryEvals;
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-4o-mini";
// 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.
AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Build a two-agent workflow: a researcher writes a draft answer, then an
// editor polishes it into the final response that we compare to ground truth.
// EmitAgentResponseEvents is enabled so the workflow surfaces an AgentResponseEvent
// for each agent — this is what EvaluateAsync uses to find the overall final answer.
var hostOptions = new AIAgentHostOptions { EmitAgentResponseEvents = true };
AIAgent researcher = projectClient.AsAIAgent(
model: deploymentName,
instructions: "You research questions and produce a short factual draft answer.",
name: "researcher");
AIAgent editor = projectClient.AsAIAgent(
model: deploymentName,
instructions: "You take a draft answer and produce the final concise response.",
name: "editor");
ExecutorBinding researcherExecutor = researcher.BindAsExecutor(hostOptions);
ExecutorBinding editorExecutor = editor.BindAsExecutor(hostOptions);
Workflow workflow = new WorkflowBuilder(researcherExecutor)
.AddEdge(researcherExecutor, editorExecutor)
.Build();
// Run the workflow against the user question.
const string Query = "What is the capital of France?";
const string GroundTruth = "Paris";
await using Run run = await InProcessExecution.RunAsync(
workflow,
new ChatMessage(ChatRole.User, Query));
// Evaluate the overall workflow output against a golden answer using the
// reference-based Similarity evaluator. The 'expectedOutput' value is stamped
// onto the overall EvalItem.ExpectedOutput and is surfaced to Foundry as
// `ground_truth` in the underlying JSONL payload.
//
// Per-agent breakdown is disabled here: ground truth applies to the workflow's
// final answer, not to each sub-agent's intermediate output. Without
// includePerAgent: false, the evaluator would be invoked for per-agent items
// (which have no ExpectedOutput) and Similarity would fail validation.
FoundryEvals similarity = new(projectClient, deploymentName, FoundryEvals.Similarity);
AgentEvaluationResults results = await run.EvaluateAsync(
similarity,
includePerAgent: false,
expectedOutput: GroundTruth);
Console.WriteLine($"Query: {Query}");
Console.WriteLine($"Expected: {GroundTruth}");
Console.WriteLine($"Provider: {results.ProviderName}");
Console.WriteLine($"Passed: {results.Passed}/{results.Total}");
if (results.ReportUrl is not null)
{
Console.WriteLine($"Report: {results.ReportUrl}");
}
@@ -1,37 +0,0 @@
# Evaluation - Workflow Expected Outputs
This sample demonstrates evaluating a multi-agent workflow's final answer
against a golden expected output using Foundry's reference-based **Similarity**
evaluator.
## What this sample demonstrates
- Building a small researcher → editor workflow
- Running the workflow and obtaining a `Run`
- Calling `run.EvaluateAsync(evaluator, expectedOutput: ...)` to attach a
ground-truth answer to the overall workflow item
- Using `FoundryEvals.Similarity`, which requires a `ground_truth` value
per item
The `expectedOutput` value is stamped onto the overall `EvalItem.ExpectedOutput`
and is surfaced to Foundry as `ground_truth` in the JSONL payload sent to the
Evals API.
## Prerequisites
- .NET 10 SDK or later
- 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-4o-mini"
```
## Run the sample
```powershell
cd dotnet/samples/03-workflows/Evaluation
dotnet run --project .\Evaluation_WorkflowExpectedOutputs
```
@@ -163,22 +163,10 @@ app.MapA2AHttpJson(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves");
app.MapDevUI();
app.MapOpenAIResponses();
app.MapOpenAIResponses(pirateAgentBuilder);
app.MapOpenAIResponses(knightsKnavesAgentBuilder);
app.MapOpenAIResponses(chemistryAgent);
app.MapOpenAIResponses(mathsAgent);
app.MapOpenAIResponses(literatureAgent);
app.MapOpenAIResponses(scienceSequentialWorkflow);
app.MapOpenAIResponses(scienceConcurrentWorkflow);
app.MapOpenAIConversations();
app.MapOpenAIChatCompletions(pirateAgentBuilder);
app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder);
app.MapOpenAIChatCompletions(chemistryAgent);
app.MapOpenAIChatCompletions(mathsAgent);
app.MapOpenAIChatCompletions(literatureAgent);
app.MapOpenAIChatCompletions(scienceSequentialWorkflow);
app.MapOpenAIChatCompletions(scienceConcurrentWorkflow);
// Map the agents HTTP endpoints
app.MapAgentDiscovery("/agents");
@@ -24,7 +24,7 @@ internal sealed class OpenAIChatCompletionsAgentClient(HttpClient httpClient) :
{
OpenAIClientOptions options = new()
{
Endpoint = new Uri(httpClient.BaseAddress!, $"/{Uri.EscapeDataString(agentName)}/v1/"),
Endpoint = new Uri(httpClient.BaseAddress!, $"/{agentName}/v1/"),
Transport = new HttpClientPipelineTransport(httpClient)
};
@@ -23,7 +23,7 @@ internal sealed class OpenAIResponsesAgentClient(HttpClient httpClient) : AgentC
{
OpenAIClientOptions options = new()
{
Endpoint = new Uri(httpClient.BaseAddress!, $"/{Uri.EscapeDataString(agentName)}/v1/"),
Endpoint = new Uri(httpClient.BaseAddress!, "/v1/"),
Transport = new HttpClientPipelineTransport(httpClient)
};
+14 -29
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)
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
_ = Throw.IfNull(messages);
A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false);
this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name);
if (GetContinuationToken(inputMessages, options) is { } token)
if (GetContinuationToken(messages, 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, agentTask.Status.State);
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
return this.ConvertToAgentResponse(agentTask);
}
SendMessageRequest sendParams = new()
{
Message = CreateA2AMessage(typedSession, inputMessages),
Message = CreateA2AMessage(typedSession, messages),
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, agentTask.Status.State);
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
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)
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
_ = Throw.IfNull(messages);
A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false);
@@ -153,7 +153,7 @@ public sealed class A2AAgent : AIAgent
ConfiguredCancelableAsyncEnumerable<StreamResponse> streamEvents;
if (GetContinuationToken(inputMessages, options) is { } token)
if (GetContinuationToken(messages, 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, inputMessages),
Message = CreateA2AMessage(typedSession, messages),
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
};
@@ -172,7 +172,6 @@ public sealed class A2AAgent : AIAgent
string? contextId = null;
string? taskId = null;
TaskState? taskState = null;
await foreach (var streamResponse in streamEvents)
{
@@ -188,7 +187,6 @@ 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;
@@ -196,7 +194,6 @@ 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;
@@ -212,7 +209,7 @@ public sealed class A2AAgent : AIAgent
}
}
UpdateSession(typedSession, contextId, taskId, taskState);
UpdateSession(typedSession, contextId, taskId);
}
/// <inheritdoc/>
@@ -320,7 +317,7 @@ public sealed class A2AAgent : AIAgent
}
}
private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null, TaskState? taskState = null)
private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null)
{
if (session is null)
{
@@ -338,10 +335,9 @@ 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, IReadOnlyCollection<ChatMessage> messages)
private static Message CreateA2AMessage(A2AAgentSession typedSession, IEnumerable<ChatMessage> messages)
{
var a2aMessage = messages.ToA2AMessage();
@@ -349,19 +345,9 @@ 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;
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;
}
// 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];
return a2aMessage;
}
@@ -458,7 +444,6 @@ public sealed class A2AAgent : AIAgent
Role = ChatRole.Assistant,
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
Contents = statusUpdateEvent.Status.GetUserInputRequests(),
};
}
@@ -5,8 +5,6 @@ using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using TaskState = A2A.TaskState;
namespace Microsoft.Agents.AI.A2A;
/// <summary>
@@ -20,11 +18,10 @@ public sealed class A2AAgentSession : AgentSession
}
[JsonConstructor]
internal A2AAgentSession(string? contextId, string? taskId, TaskState? taskState, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
internal A2AAgentSession(string? contextId, string? taskId, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
{
this.ContextId = contextId;
this.TaskId = taskId;
this.TaskState = taskState;
}
/// <summary>
@@ -39,12 +36,6 @@ 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)
{
@@ -66,5 +57,5 @@ public sealed class A2AAgentSession : AgentSession
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay =>
$"ContextId = {this.ContextId}, TaskId = {this.TaskId}, TaskState = {this.TaskState}, StateBag Count = {this.StateBag.Count}";
$"ContextId = {this.ContextId}, TaskId = {this.TaskId}, 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,7 +21,8 @@ internal static class A2AAIContentExtensions
foreach (var content in contents)
{
if (content.ToPart() is { } part)
var part = content.ToPart();
if (part is not null)
{
(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,14 +25,6 @@ internal static class A2AAgentTaskExtensions
}
}
if (agentTask.Status?.GetUserInputRequests() is { } userInputRequests)
{
(messages ??= []).Add(new(ChatRole.Assistant, userInputRequests)
{
RawRepresentation = agentTask.Status,
});
}
return messages;
}
@@ -50,11 +42,6 @@ internal static class A2AAgentTaskExtensions
}
}
if (agentTask.Status?.GetUserInputRequests() is { } userInputRequests)
{
(aiContents ??= []).AddRange(userInputRequests);
}
return aiContents;
}
}
@@ -1,35 +0,0 @@
// 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;
}
}
@@ -51,7 +51,9 @@ internal sealed class DevUIAuthFilter : IEndpointFilter
if (!isLoopback && !this._options.AllowRemoteAccess)
{
DevUILog.RejectedNonLoopbackRequest(this._logger, remoteIp);
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",
@@ -100,7 +100,10 @@ public static class DevUIExtensions
if (options.AllowRemoteAccess && !tokenConfigured && options.ConfigureEndpoints is null)
{
DevUILog.InsecurelyExposed(logger, DevUIOptions.AuthTokenEnvironmentVariable);
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);
}
}
}
@@ -1,20 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net;
namespace Microsoft.Agents.AI.DevUI;
internal static partial class DevUILog
{
[LoggerMessage(
EventId = 1,
Level = LogLevel.Warning,
Message = "Rejected non-loopback DevUI request from {RemoteIp}. Set DevUIOptions.AllowRemoteAccess to permit remote callers.")]
public static partial void RejectedNonLoopbackRequest(ILogger logger, IPAddress? remoteIp);
[LoggerMessage(
EventId = 2,
Level = LogLevel.Warning,
Message = "DevUI is configured with AllowRemoteAccess=true and no authentication. Set DevUIOptions.AuthToken, the {EnvVar} environment variable, or attach an authorization policy via ConfigureEndpoints.")]
public static partial void InsecurelyExposed(ILogger logger, string envVar);
}
@@ -130,7 +130,6 @@ internal static class FoundryEvalConverter
QueryMessages = ConvertMessages(queryMessages),
ResponseMessages = ConvertMessages(responseMessages),
Context = item.Context,
GroundTruth = item.ExpectedOutput,
ToolDefinitions = item.Tools is { Count: > 0 }
? item.Tools
.OfType<AIFunction>()
@@ -186,11 +185,6 @@ internal static class FoundryEvalConverter
dataMapping["context"] = "{{item.context}}";
}
if (GroundTruthEvaluators.Contains(qualified))
{
dataMapping["ground_truth"] = "{{item.ground_truth}}";
}
if (ToolEvaluators.Contains(qualified))
{
dataMapping["tool_definitions"] = "{{item.tool_definitions}}";
@@ -212,7 +206,7 @@ internal static class FoundryEvalConverter
/// <summary>
/// Builds the <c>item_schema</c> for custom JSONL eval definitions.
/// </summary>
internal static WireItemSchema BuildItemSchema(bool hasContext = false, bool hasTools = false, bool hasGroundTruth = false)
internal static WireItemSchema BuildItemSchema(bool hasContext = false, bool hasTools = false)
{
var properties = new Dictionary<string, WireSchemaProperty>
{
@@ -227,11 +221,6 @@ internal static class FoundryEvalConverter
properties["context"] = new WireSchemaProperty { Type = "string" };
}
if (hasGroundTruth)
{
properties["ground_truth"] = new WireSchemaProperty { Type = "string" };
}
if (hasTools)
{
properties["tool_definitions"] = new WireSchemaProperty { Type = "array" };
@@ -244,31 +233,6 @@ internal static class FoundryEvalConverter
};
}
/// <summary>
/// Returns the subset of <paramref name="evaluators"/> that require a ground-truth
/// (reference) value but cannot be evaluated because no item provided one.
/// </summary>
internal static List<string> FindMissingGroundTruthEvaluators(
IEnumerable<string> evaluators,
bool hasGroundTruth)
{
if (hasGroundTruth)
{
return [];
}
var missing = new List<string>();
foreach (var name in evaluators)
{
if (GroundTruthEvaluators.Contains(ResolveEvaluator(name)))
{
missing.Add(name);
}
}
return missing;
}
/// <summary>
/// Resolves a short evaluator name to its fully-qualified <c>builtin.*</c> form.
/// </summary>
@@ -313,12 +277,6 @@ internal static class FoundryEvalConverter
"builtin.tool_call_success",
};
// Evaluators that require a ground_truth (reference) value per item.
internal static readonly HashSet<string> GroundTruthEvaluators = new(StringComparer.OrdinalIgnoreCase)
{
"builtin.similarity",
};
// Short name → fully-qualified name mapping.
internal static readonly Dictionary<string, string> BuiltinEvaluators = new(StringComparer.OrdinalIgnoreCase)
{
@@ -103,9 +103,6 @@ internal sealed class WireEvalItemPayload
[JsonPropertyName("context")]
public string? Context { get; init; }
[JsonPropertyName("ground_truth")]
public string? GroundTruth { get; init; }
[JsonPropertyName("tool_definitions")]
public List<WireToolDefinition>? ToolDefinitions { get; init; }
}
@@ -145,8 +145,6 @@ public sealed class FoundryEvals : IAgentEvaluator
bool hasContext = payloads.Any(p => p.Context is not null);
bool hasTools = payloads.Any(p => p.ToolDefinitions is { Count: > 0 });
bool hasGroundTruth = payloads.Any(p => p.GroundTruth is not null);
bool allHaveGroundTruth = payloads.Count > 0 && payloads.All(p => p.GroundTruth is not null);
// Filter out tool evaluators if no items have tools; auto-add ToolCallAccuracy if tools present
var evaluators = FilterToolEvaluators(this._evaluatorNames, hasTools);
@@ -155,27 +153,13 @@ public sealed class FoundryEvals : IAgentEvaluator
evaluators = [.. evaluators, ToolCallAccuracy];
}
// Fail fast if a ground-truth evaluator (e.g. similarity) is requested but not
// every item carries an ExpectedOutput. Reference-based evaluators score each
// item against its own ground truth, so even one missing value will surface as
// a provider-side validation error. Catch it here with a clearer message.
var missingGroundTruth = FoundryEvalConverter.FindMissingGroundTruthEvaluators(evaluators, allHaveGroundTruth);
if (missingGroundTruth.Count > 0)
{
throw new InvalidOperationException(
"The following evaluator(s) require a ground-truth/expected output on every item but " +
$"at least one item is missing an {nameof(EvalItem.ExpectedOutput)}: {string.Join(", ", missingGroundTruth)}. " +
"Provide an expected output per item (for example via the 'expectedOutput' parameter on EvaluateAsync), " +
"or set 'includePerAgent: false' so the evaluator only runs on the overall item.");
}
// 2. Create the evaluation definition
var createEvalPayload = new WireCreateEvalRequest
{
Name = evalName,
DataSourceConfig = new WireCustomDataSourceConfig
{
ItemSchema = FoundryEvalConverter.BuildItemSchema(hasContext, hasTools, hasGroundTruth),
ItemSchema = FoundryEvalConverter.BuildItemSchema(hasContext, hasTools),
},
TestingCriteria = FoundryEvalConverter.BuildTestingCriteria(
evaluators, this._model, includeDataMapping: true),
@@ -838,15 +822,15 @@ public sealed class FoundryEvals : IAgentEvaluator
var result = new EvalItemResult(itemId, status, scores);
// Extract error info from sample
if (outputItem.TryGetProperty("sample", out var sample) && sample.ValueKind == JsonValueKind.Object)
if (outputItem.TryGetProperty("sample", out var sample))
{
if (sample.TryGetProperty("error", out var errObj) && errObj.ValueKind == JsonValueKind.Object)
if (sample.TryGetProperty("error", out var errObj))
{
result.ErrorCode = errObj.TryGetProperty("code", out var code) ? code.GetString() : null;
result.ErrorMessage = errObj.TryGetProperty("message", out var msg) ? msg.GetString() : null;
}
if (sample.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object && usage.TryGetProperty("total_tokens", out var tt) && tt.ValueKind == JsonValueKind.Number)
if (sample.TryGetProperty("usage", out var usage) && usage.TryGetProperty("total_tokens", out var tt) && tt.ValueKind == JsonValueKind.Number)
{
var tokenUsage = new Dictionary<string, int>();
if (usage.TryGetProperty("prompt_tokens", out var pt) && pt.ValueKind == JsonValueKind.Number)
@@ -902,7 +886,7 @@ public sealed class FoundryEvals : IAgentEvaluator
}
// Extract response_id from datasource_item
if (outputItem.TryGetProperty("datasource_item", out var dsItem) && dsItem.ValueKind == JsonValueKind.Object)
if (outputItem.TryGetProperty("datasource_item", out var dsItem))
{
if (dsItem.TryGetProperty("resp_id", out var respId))
{
@@ -1,42 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Provides extension methods for creating a <see cref="HarnessAgent"/> from an <see cref="IChatClient"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static class ChatClientHarnessExtensions
{
/// <summary>
/// Creates a new <see cref="HarnessAgent"/> that wraps this <see cref="IChatClient"/> with a pre-configured
/// pipeline including function invocation, per-service-call chat history persistence, and in-loop compaction.
/// </summary>
/// <param name="chatClient">
/// The <see cref="IChatClient"/> that provides access to the underlying AI model.
/// </param>
/// <param name="maxContextWindowTokens">
/// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
/// Used to configure the compaction strategy.
/// </param>
/// <param name="maxOutputTokens">
/// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
/// Used to configure the compaction strategy.
/// </param>
/// <param name="options">
/// Optional configuration options for the agent, including instructions override, tools,
/// additional context providers, and chat history provider.
/// When <see langword="null"/>, the agent uses built-in default settings.
/// </param>
/// <returns>A new <see cref="HarnessAgent"/> instance.</returns>
public static HarnessAgent AsHarnessAgent(
this IChatClient chatClient,
int maxContextWindowTokens,
int maxOutputTokens,
HarnessAgentOptions? options = null) =>
new(chatClient, maxContextWindowTokens, maxOutputTokens, options);
}
@@ -1,137 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A pre-configured <see cref="DelegatingAIAgent"/> that wraps a <see cref="ChatClientAgent"/> with
/// function invocation, per-service-call chat history persistence, and in-loop compaction.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="HarnessAgent"/> assembles the following pipeline from a caller-supplied <see cref="IChatClient"/>:
/// <list type="number">
/// <item><description><see cref="FunctionInvokingChatClient"/> — automatic function/tool invocation.</description></item>
/// <item><description><see cref="MessageInjectingChatClient"/> — allows external code to inject messages into the conversation mid-stream.</description></item>
/// <item><description><see cref="PerServiceCallChatHistoryPersistingChatClient"/> — persists chat history after every individual service call within a function-invocation loop.</description></item>
/// <item><description><see cref="AIContextProviderChatClient"/> with a <see cref="CompactionProvider"/> — applies context-window compaction before each call so long function-invocation loops do not overflow the context window.</description></item>
/// </list>
/// </para>
/// <para>
/// The underlying <see cref="ChatClientAgent"/> is configured with
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> and
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> set to <see langword="true"/>
/// to match the manually-assembled pipeline.
/// </para>
/// <para>
/// When no <see cref="HarnessAgentOptions.ChatHistoryProvider"/> is supplied, the agent defaults to an
/// <see cref="InMemoryChatHistoryProvider"/> whose chat reducer applies the same compaction strategy,
/// keeping in-memory history from growing unboundedly across sessions.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class HarnessAgent : DelegatingAIAgent
{
/// <summary>
/// The built-in default system instructions used when <see cref="ChatOptions.Instructions"/> is not set.
/// </summary>
public const string DefaultInstructions =
"""
You are a helpful AI assistant that uses tools to complete tasks.
## General guidelines
- Think through the task before acting. Break complex work into clear steps.
- Use the tools available to you to gather information, perform actions, and verify results.
- Explain your reasoning between tool calls so the user can follow your progress.
- If a tool call fails or returns unexpected results, adapt your approach rather than repeating the same call.
- When you have completed the task, present a clear and concise summary of what you did and what you found.
""";
/// <summary>
/// Initializes a new instance of the <see cref="HarnessAgent"/> class.
/// </summary>
/// <param name="chatClient">
/// The <see cref="IChatClient"/> that provides access to the underlying AI model.
/// The agent wraps this client in a function-invocation, per-service-call persistence,
/// and compaction pipeline automatically.
/// </param>
/// <param name="maxContextWindowTokens">
/// The maximum number of tokens the model's context window supports (e.g., 1,050,000 for gpt-5.4).
/// Used to configure the compaction strategy.
/// </param>
/// <param name="maxOutputTokens">
/// The maximum number of output tokens the model can generate per response (e.g., 128,000 for gpt-5.4).
/// Used to configure the compaction strategy and to limit the model's output.
/// </param>
/// <param name="options">
/// Optional configuration options for the agent, including instructions override, tools,
/// additional context providers, and chat history provider.
/// When <see langword="null"/>, the agent uses built-in default settings.
/// </param>
/// <exception cref="System.ArgumentNullException">
/// <paramref name="chatClient"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="System.ArgumentOutOfRangeException">
/// <paramref name="maxContextWindowTokens"/> is not positive, or
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>.
/// </exception>
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null)
: base(BuildInnerAgent(
Throw.IfNull(chatClient),
maxContextWindowTokens,
maxOutputTokens,
options))
{
}
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
{
var compactionStrategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: maxContextWindowTokens,
maxOutputTokens: maxOutputTokens);
ChatHistoryProvider chatHistoryProvider = options?.ChatHistoryProvider
?? new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
{
ChatReducer = compactionStrategy.AsChatReducer(),
});
string instructions = options?.ChatOptions?.Instructions ?? DefaultInstructions;
ChatOptions chatOptions = BuildChatOptions(options?.ChatOptions, instructions, maxOutputTokens);
var compactionProvider = new CompactionProvider(compactionStrategy);
return chatClient
.AsBuilder()
.UseFunctionInvocation()
.UseMessageInjection()
.UsePerServiceCallChatHistoryPersistence()
.UseAIContextProviders(compactionProvider)
.BuildAIAgent(new ChatClientAgentOptions
{
Id = options?.Id,
Name = options?.Name,
Description = options?.Description,
ChatOptions = chatOptions,
ChatHistoryProvider = chatHistoryProvider,
AIContextProviders = options?.AIContextProviders,
UseProvidedChatClientAsIs = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
}
private static ChatOptions BuildChatOptions(ChatOptions? source, string instructions, int maxOutputTokens)
{
ChatOptions result = source?.Clone() ?? new ChatOptions();
result.Instructions = instructions;
result.MaxOutputTokens ??= maxOutputTokens;
return result;
}
}
@@ -1,64 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents configuration options for a <see cref="HarnessAgent"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class HarnessAgentOptions
{
/// <summary>
/// Gets or sets the agent id.
/// </summary>
public string? Id { get; set; }
/// <summary>
/// Gets or sets the agent name.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the agent description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets additional chat options such as tools for the agent to use.
/// </summary>
/// <remarks>
/// <para>
/// Use <see cref="ChatOptions.Tools"/> to supply additional tools the agent can invoke.
/// </para>
/// <para>
/// Use <see cref="ChatOptions.Instructions"/> to override the <see cref="HarnessAgent"/>'s built-in
/// default instructions. When <see cref="ChatOptions.Instructions"/> is <see langword="null"/> or not set,
/// the default instructions are used.
/// </para>
/// </remarks>
public ChatOptions? ChatOptions { get; set; }
/// <summary>
/// Gets or sets the <see cref="ChatHistoryProvider"/> to use for storing chat history.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, the agent defaults to an <see cref="InMemoryChatHistoryProvider"/>
/// configured with a compaction-based chat reducer derived from the <c>maxContextWindowTokens</c>
/// and <c>maxOutputTokens</c> constructor parameters of <see cref="HarnessAgent"/>.
/// </remarks>
public ChatHistoryProvider? ChatHistoryProvider { get; set; }
/// <summary>
/// Gets or sets additional <see cref="AIContextProvider"/> instances to include in the agent pipeline.
/// </summary>
/// <remarks>
/// These providers are passed to the underlying <see cref="ChatClientAgent"/> via
/// <see cref="ChatClientAgentOptions.AIContextProviders"/>.
/// </remarks>
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
}
@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsReleaseCandidate>false</IsReleaseCandidate>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Harness</Title>
<Description>Provides the HarnessAgent, a pre-configured AI agent that can be used for long running tasks.</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Harness.UnitTests" />
</ItemGroup>
</Project>
@@ -1,34 +0,0 @@
// 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);
}
@@ -1,22 +0,0 @@
// 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";
}
@@ -1,636 +0,0 @@
// 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) { }
}
@@ -1,78 +0,0 @@
// 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";
}
@@ -1,61 +0,0 @@
// 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;
}
}
}
@@ -1,120 +0,0 @@
// 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);
}
}

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