mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b0522d62d | ||
|
|
8636c70ddf | ||
|
|
61f636ffb8 | ||
|
|
afcb6b1a00 | ||
|
|
8ccaf7fb82 | ||
|
|
3f522a8246 | ||
|
|
66a09a76af | ||
|
|
1b6f7d80fd | ||
|
|
3bbc81554b | ||
|
|
3ebbdb01b4 | ||
|
|
aad20c2b33 | ||
|
|
dff23a9413 | ||
|
|
eff36b504e | ||
|
|
7cea5e162a | ||
|
|
ddc0fcf81f | ||
|
|
a60e541c9a | ||
|
|
da308f5f1e | ||
|
|
9b772f3413 | ||
|
|
c885ca3d7a | ||
|
|
0d09d40f0f | ||
|
|
d81a8753d7 | ||
|
|
19b2367366 | ||
|
|
ad95f2f2fa | ||
|
|
97eaef029e | ||
|
|
47fa59f8e9 | ||
|
|
68357b0250 | ||
|
|
410268b624 | ||
|
|
67f3db6280 | ||
|
|
2ef20cd0aa | ||
|
|
27671974c2 | ||
|
|
7432105ebe | ||
|
|
3256550c55 | ||
|
|
190ca75b6a | ||
|
|
8058fb1c5b | ||
|
|
189e64bfdd | ||
|
|
3047ad3066 | ||
|
|
0e12640c70 | ||
|
|
ae666a4887 | ||
|
|
eb40535436 | ||
|
|
2d83a9b10d | ||
|
|
198761d3ba | ||
|
|
4e65fabafc | ||
|
|
d40670748d | ||
|
|
fbccad091b | ||
|
|
741259476f | ||
|
|
09a3d0d307 | ||
|
|
ab09246dc4 | ||
|
|
7d23582e2b | ||
|
|
574631671d | ||
|
|
981726cc15 | ||
|
|
9b9604ce18 | ||
|
|
bd0d6070f1 | ||
|
|
37a043a797 | ||
|
|
f16cb9a118 | ||
|
|
9a301b8d4b | ||
|
|
15a11a426a | ||
|
|
cfd3dfe40b | ||
|
|
3b6a4574eb | ||
|
|
4409b00b86 | ||
|
|
818ae65b77 | ||
|
|
d8619b93ad | ||
|
|
ae57616b32 | ||
|
|
41d6c61f81 | ||
|
|
dfc3079d68 | ||
|
|
939d4d0153 | ||
|
|
fe09f13adb | ||
|
|
4ad96b64e7 | ||
|
|
e3875f2c91 | ||
|
|
9199c84d42 | ||
|
|
0bbedc4fa2 | ||
|
|
18d7a46a54 | ||
|
|
9d8c3f8cb7 | ||
|
|
9faf52de4f | ||
|
|
d2ce0e9087 | ||
|
|
0557b5782b | ||
|
|
eb709d8fc9 | ||
|
|
226c004b53 | ||
|
|
3aae3cb9de | ||
|
|
0340b7596b | ||
|
|
76772ffc19 | ||
|
|
27324a8013 | ||
|
|
57fb32efc8 | ||
|
|
3c1e2c40b8 | ||
|
|
d3518ad19d | ||
|
|
c06af9a1b3 | ||
|
|
1d94518f37 | ||
|
|
a478d1b53c | ||
|
|
ce70ca1a9f | ||
|
|
2a9b68d1bd | ||
|
|
1489d6620e | ||
|
|
8bb4692678 | ||
|
|
44381c051b | ||
|
|
213491da66 | ||
|
|
a95493a909 | ||
|
|
cdd80c61ac | ||
|
|
e56e6dad4d | ||
|
|
51ad460d5f | ||
|
|
65455751a4 | ||
|
|
b12109b7e4 | ||
|
|
be8d2619e4 | ||
|
|
705473c276 | ||
|
|
f25e81701d | ||
|
|
f3f71f0fe8 | ||
|
|
ddfbdf5c7a | ||
|
|
806075ae61 | ||
|
|
d2e694dfe1 | ||
|
|
6f86debb81 | ||
|
|
9f3f7fd03b | ||
|
|
e9a6d43237 | ||
|
|
384e26abd7 | ||
|
|
162985f2a3 | ||
|
|
e7dc3b91f1 | ||
|
|
d7ca9c8f16 | ||
|
|
57c901a245 | ||
|
|
36b9b41e3b | ||
|
|
550209fe6e | ||
|
|
27f926609f | ||
|
|
7476049d7e | ||
|
|
5a087885a2 | ||
|
|
4b5a8478de | ||
|
|
330d3d7165 | ||
|
|
f3db60fa65 | ||
|
|
4a2da953ca | ||
|
|
e558d36ff6 |
@@ -32,7 +32,13 @@ runs:
|
|||||||
if grep -q "name = \"$pkg\"" "$f"; then
|
if grep -q "name = \"$pkg\"" "$f"; then
|
||||||
pkg_dir=$(dirname "$f" | sed 's|python/||')
|
pkg_dir=$(dirname "$f" | sed 's|python/||')
|
||||||
echo "Excluding workspace package: $pkg ($pkg_dir)"
|
echo "Excluding workspace package: $pkg ($pkg_dir)"
|
||||||
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
|
if awk '/^\[tool\.uv\.workspace\]/{f=1;next} /^\[/{f=0} f && /^exclude = \[/{found=1} END{exit !found}' python/pyproject.toml; then
|
||||||
|
if ! awk '/^\[tool\.uv\.workspace\]/{f=1;next} /^\[/{f=0} f && /^exclude = \[/ && index($0, "\"'"$pkg_dir"'\"")' python/pyproject.toml | grep -q .; then
|
||||||
|
sed -i.bak '/\[tool\.uv\.workspace\]/,/^\[/ { /^exclude = \[/ s|\]|, "'"$pkg_dir"'"]| }' python/pyproject.toml
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
|
||||||
|
fi
|
||||||
sed -i.bak '/'"$pkg"' = { workspace = true }/d' python/pyproject.toml
|
sed -i.bak '/'"$pkg"' = { workspace = true }/d' python/pyproject.toml
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -40,4 +46,4 @@ runs:
|
|||||||
- name: Install the project
|
- name: Install the project
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd python && uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit
|
cd python && uv sync --all-packages --all-extras --dev --prerelease=if-necessary-or-explicit
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
|
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
|
||||||
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
|
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
|
||||||
|
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
|
||||||
|
functionsChanged: ${{ steps.filter.outputs.functions }}
|
||||||
|
coreChanged: ${{ steps.filter.outputs.core }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: dorny/paths-filter@v3
|
- uses: dorny/paths-filter@v3
|
||||||
@@ -47,6 +50,40 @@ jobs:
|
|||||||
- 'dotnet/**'
|
- 'dotnet/**'
|
||||||
cosmosdb:
|
cosmosdb:
|
||||||
- 'dotnet/src/Microsoft.Agents.AI.CosmosNoSql/**'
|
- 'dotnet/src/Microsoft.Agents.AI.CosmosNoSql/**'
|
||||||
|
# The Foundry hosted-agent IT is costly (builds a container, pushes to ACR,
|
||||||
|
# provisions live agents). Only run it when the project under test, its
|
||||||
|
# dependency chain, the test container, the test fixture, or their tooling
|
||||||
|
# changed. Keep this list in sync with $hashedDirs in scripts/it-build-image.ps1.
|
||||||
|
foundryHosting:
|
||||||
|
- 'dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/**'
|
||||||
|
- 'dotnet/src/Microsoft.Agents.AI.Foundry/**'
|
||||||
|
- 'dotnet/src/Microsoft.Agents.AI/**'
|
||||||
|
- 'dotnet/src/Microsoft.Agents.AI.Abstractions/**'
|
||||||
|
- 'dotnet/src/Microsoft.Agents.AI.Workflows/**'
|
||||||
|
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/**'
|
||||||
|
- 'dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/**'
|
||||||
|
- 'dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/**'
|
||||||
|
- '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
|
# run only if 'dotnet' files were changed
|
||||||
- name: dotnet tests
|
- name: dotnet tests
|
||||||
if: steps.filter.outputs.dotnet == 'true'
|
if: steps.filter.outputs.dotnet == 'true'
|
||||||
@@ -194,10 +231,11 @@ jobs:
|
|||||||
Verbose = $true
|
Verbose = $true
|
||||||
}
|
}
|
||||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
|
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
|
||||||
-TestProjectNameFilter "*UnitTests*" `
|
-TestProjectNameIncludeFilter "*UnitTests*" `
|
||||||
-OutputPath dotnet/filtered-unit.slnx
|
-OutputPath dotnet/filtered-unit.slnx
|
||||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
|
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
|
||||||
-TestProjectNameFilter "*IntegrationTests*" `
|
-TestProjectNameIncludeFilter "*IntegrationTests*" `
|
||||||
|
-TestProjectNameExcludeFilter "*DurableTask.IntegrationTests*","*AzureFunctions.IntegrationTests*" `
|
||||||
-OutputPath dotnet/filtered-integration.slnx
|
-OutputPath dotnet/filtered-integration.slnx
|
||||||
|
|
||||||
- name: Run Unit Tests
|
- name: Run Unit Tests
|
||||||
@@ -239,14 +277,6 @@ jobs:
|
|||||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_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
|
- name: Run Integration Tests
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
working-directory: dotnet
|
working-directory: dotnet
|
||||||
@@ -257,8 +287,11 @@ jobs:
|
|||||||
-c ${{ matrix.configuration }} `
|
-c ${{ matrix.configuration }} `
|
||||||
--no-build -v Normal `
|
--no-build -v Normal `
|
||||||
--report-xunit-trx `
|
--report-xunit-trx `
|
||||||
|
--report-junit `
|
||||||
|
--results-directory ../IntegrationTestResults/ `
|
||||||
--ignore-exit-code 8 `
|
--ignore-exit-code 8 `
|
||||||
--filter-not-trait "Category=IntegrationDisabled" `
|
--filter-not-trait "Category=IntegrationDisabled" `
|
||||||
|
--filter-not-trait "Category=FoundryHostedAgents" `
|
||||||
--parallel-algorithm aggressive `
|
--parallel-algorithm aggressive `
|
||||||
--max-threads 2.0x
|
--max-threads 2.0x
|
||||||
env:
|
env:
|
||||||
@@ -277,6 +310,10 @@ jobs:
|
|||||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
|
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
|
||||||
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
|
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
|
||||||
|
# Anthropic Models
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
ANTHROPIC_CHAT_MODEL_NAME: ${{ vars.ANTHROPIC_CHAT_MODEL_NAME }}
|
||||||
|
ANTHROPIC_REASONING_MODEL_NAME: ${{ vars.ANTHROPIC_REASONING_MODEL_NAME }}
|
||||||
|
|
||||||
# Generate test reports and check coverage
|
# Generate test reports and check coverage
|
||||||
- name: Generate test reports
|
- name: Generate test reports
|
||||||
@@ -299,11 +336,203 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: ./dotnet/eng/scripts/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
|
run: ./dotnet/eng/scripts/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
|
||||||
|
|
||||||
|
- name: Upload integration test results
|
||||||
|
if: always() && github.event_name != 'pull_request' && matrix.integration-tests
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: dotnet-test-results-${{ matrix.targetFramework }}-${{ matrix.os }}
|
||||||
|
path: IntegrationTestResults/**/*.junit
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
# The Foundry hosted-agent IT is costly (it builds a container, pushes to ACR, and provisions
|
||||||
|
# live agents on a separate Foundry project). Running it in its own job keeps the overall
|
||||||
|
# workflow time roughly flat: it executes in parallel to dotnet-build and dotnet-test and is
|
||||||
|
# gated on paths-filter.outputs.foundryHostingChanges so unrelated edits skip the work.
|
||||||
|
dotnet-foundry-hosted-it:
|
||||||
|
needs: paths-filter
|
||||||
|
if: github.event_name != 'pull_request' && needs.paths-filter.outputs.foundryHostingChanges == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: integration
|
||||||
|
env:
|
||||||
|
configuration: Release
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
sparse-checkout: |
|
||||||
|
.
|
||||||
|
.github
|
||||||
|
dotnet
|
||||||
|
python
|
||||||
|
|
||||||
|
- name: Setup dotnet
|
||||||
|
uses: actions/setup-dotnet@v5.2.0
|
||||||
|
with:
|
||||||
|
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||||
|
|
||||||
|
# Build the test csproj directly instead of a filtered slnx + -f override.
|
||||||
|
# The test project pins TargetFrameworks=net10.0 and its ProjectReference closure
|
||||||
|
# gives MSBuild a single-rooted graph, so each multi-targeted dependency is invoked
|
||||||
|
# exactly once for net10.0. This avoids the MSB3026/MSB3491/MSB4018/MSB3883 file-lock
|
||||||
|
# collisions caused by parallel inner-builds racing on shared bin/obj output paths
|
||||||
|
# under the previous slnx + global TFM override approach.
|
||||||
|
- name: Build Foundry hosted IT (and its deps)
|
||||||
|
shell: bash
|
||||||
|
run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --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 }}
|
||||||
|
|
||||||
|
# We rebuild and push the test container image on every IT run so framework code changes
|
||||||
|
# are picked up; the image tag is content-hashed across the test container source AND its
|
||||||
|
# framework project references, so identical content is a no-op push.
|
||||||
|
#
|
||||||
|
# The script always passes --no-dependencies to dotnet publish so publish never re-touches
|
||||||
|
# the framework lib DLLs the prior "Build Foundry hosted IT (and its deps)" step produced.
|
||||||
|
# This structurally eliminates the MSB3026 collision that VBCSCompiler from the prebuild
|
||||||
|
# would otherwise cause by holding file handles to those DLLs. Do not remove the prebuild
|
||||||
|
# step: the subsequent `dotnet test --no-build` step and the publish's ProjectReference
|
||||||
|
# resolution both depend on the prebuilt outputs being present.
|
||||||
|
- name: Build and push Foundry Hosted Agents test container
|
||||||
|
id: build-foundry-hosted-image
|
||||||
|
shell: pwsh
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
$registry = "${{ vars.IT_HOSTED_AGENT_REGISTRY }}"
|
||||||
|
if ([string]::IsNullOrWhiteSpace($registry)) {
|
||||||
|
throw "IT_HOSTED_AGENT_REGISTRY not set in the integration environment."
|
||||||
|
}
|
||||||
|
& "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry | Tee-Object -FilePath $env:GITHUB_ENV -Append
|
||||||
|
|
||||||
|
- name: Run Foundry Hosted Agents Integration Tests
|
||||||
|
shell: pwsh
|
||||||
|
working-directory: dotnet
|
||||||
|
run: |
|
||||||
|
dotnet test --project tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj `
|
||||||
|
-c $env:configuration `
|
||||||
|
--no-build -v Normal `
|
||||||
|
--report-xunit-trx `
|
||||||
|
--ignore-exit-code 8 `
|
||||||
|
--filter-trait "Category=FoundryHostedAgents"
|
||||||
|
env:
|
||||||
|
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.IT_HOSTED_AGENT_PROJECT_ENDPOINT }}
|
||||||
|
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME }}
|
||||||
|
# Azure AI Search (for the azure-search-rag scenario). Reuses the integration
|
||||||
|
# environment secrets shared with python-sample-validation.yml. The index is
|
||||||
|
# provisioned out of band; see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
|
||||||
|
# for the required schema and seed content.
|
||||||
|
AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }}
|
||||||
|
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
|
# 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:
|
dotnet-build-and-test-check:
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [dotnet-build, dotnet-test]
|
needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it, dotnet-test-functions]
|
||||||
steps:
|
steps:
|
||||||
- name: Get Date
|
- name: Get Date
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -341,3 +570,64 @@ jobs:
|
|||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
script: core.setFailed('Integration Tests Cancelled!')
|
script: core.setFailed('Integration Tests Cancelled!')
|
||||||
|
|
||||||
|
# Integration test trend report (aggregates JUnit XML results from dotnet test jobs)
|
||||||
|
dotnet-integration-test-report:
|
||||||
|
name: Integration Test Report
|
||||||
|
if: >
|
||||||
|
always() &&
|
||||||
|
github.event_name != 'pull_request' &&
|
||||||
|
(contains(join(needs.*.result, ','), 'success') ||
|
||||||
|
contains(join(needs.*.result, ','), 'failure'))
|
||||||
|
needs: [dotnet-test, dotnet-test-functions]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: python
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
sparse-checkout: |
|
||||||
|
.github/actions/python-setup
|
||||||
|
python
|
||||||
|
- name: Set up python and install the project
|
||||||
|
uses: ./.github/actions/python-setup
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
os: ${{ runner.os }}
|
||||||
|
- name: Download all test results from current run
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: dotnet-test-results-*
|
||||||
|
path: dotnet-test-results/
|
||||||
|
- name: Restore report history cache
|
||||||
|
uses: actions/cache/restore@v4
|
||||||
|
with:
|
||||||
|
path: python/dotnet-integration-report-history.json
|
||||||
|
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||||
|
restore-keys: |
|
||||||
|
dotnet-integration-report-history-
|
||||||
|
- name: Generate trend report
|
||||||
|
run: >
|
||||||
|
uv run python scripts/integration_test_report/aggregate.py
|
||||||
|
../dotnet-test-results/
|
||||||
|
dotnet-integration-report-history.json
|
||||||
|
dotnet-integration-test-report.md
|
||||||
|
- name: Post to Job Summary
|
||||||
|
if: always()
|
||||||
|
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||||
|
- name: Save report history cache
|
||||||
|
if: always()
|
||||||
|
uses: actions/cache/save@v4
|
||||||
|
with:
|
||||||
|
path: python/dotnet-integration-report-history.json
|
||||||
|
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||||
|
- name: Upload trend report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: dotnet-integration-test-report
|
||||||
|
path: |
|
||||||
|
python/dotnet-integration-test-report.md
|
||||||
|
python/dotnet-integration-report-history.json
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
name: Issue Triage
|
name: Issue Triage
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
issues:
|
||||||
inputs:
|
types: [opened, labeled]
|
||||||
issue_number:
|
|
||||||
description: Issue number to triage
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -14,7 +10,13 @@ permissions:
|
|||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }}
|
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
|
||||||
|
}}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -26,6 +28,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
team_check:
|
team_check:
|
||||||
runs-on: ubuntu-latest
|
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:
|
outputs:
|
||||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||||
issue_number: ${{ steps.issue.outputs.issue_number }}
|
issue_number: ${{ steps.issue.outputs.issue_number }}
|
||||||
@@ -36,18 +39,13 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
|
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
|
||||||
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
|
issue_number="${ISSUE_NUMBER_EVENT}"
|
||||||
issue_number="${ISSUE_NUMBER_EVENT}"
|
|
||||||
else
|
|
||||||
issue_number="${ISSUE_NUMBER_INPUT}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
|
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
echo "Could not determine issue number; for workflow_dispatch runs, the 'issue_number' input is required." >&2
|
echo "Could not determine issue number from event payload." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -151,21 +149,22 @@ jobs:
|
|||||||
--apply-labels
|
--apply-labels
|
||||||
|
|
||||||
- name: Stop after spam gate
|
- name: Stop after spam gate
|
||||||
if: ${{ steps.spam.outputs.decision != 'allow' }}
|
if: ${{ steps.spam.outputs.allow_triage != 'true' }}
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
SPAM_DECISION: ${{ steps.spam.outputs.decision }}
|
|
||||||
run: |
|
run: |
|
||||||
echo "Stopping: spam gate decided: ${SPAM_DECISION}"
|
echo "Stopping: issue triage preflight did not allow automation."
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Reproduce reported issue
|
- name: Reproduce reported issue
|
||||||
if: ${{ steps.spam.outputs.decision == 'allow' }}
|
if: ${{ steps.spam.outputs.allow_triage == 'true' }}
|
||||||
id: repro
|
id: repro
|
||||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_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 }}
|
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||||
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
|
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Merge Gatekeeper
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main", "feature*" ]
|
branches: ["main", "feature*"]
|
||||||
merge_group:
|
merge_group:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
|
|
||||||
@@ -13,23 +13,105 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
merge-gatekeeper:
|
merge-gatekeeper:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Restrict permissions of the GITHUB_TOKEN.
|
|
||||||
# Docs: https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs
|
|
||||||
permissions:
|
permissions:
|
||||||
checks: read
|
checks: read
|
||||||
statuses: read
|
statuses: read
|
||||||
steps:
|
steps:
|
||||||
- name: Run Merge Gatekeeper
|
- name: Wait for required checks
|
||||||
# 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'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
uses: actions/github-script@v8
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
env:
|
||||||
timeout: 3600
|
TIMEOUT_SECONDS: "3600"
|
||||||
interval: 30
|
INTERVAL_SECONDS: "30"
|
||||||
|
SELF_JOB_NAME: ${{ github.job }}
|
||||||
# "Cleanup artifacts", "Agent", "Prepare", and "Upload results" are check runs
|
# "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.
|
# 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.
|
# They are outside our control and their transient failures should not block merges.
|
||||||
ignored: CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -246,3 +246,5 @@ dotnet/filtered-*.slnx
|
|||||||
# Local tool state
|
# Local tool state
|
||||||
.omc/
|
.omc/
|
||||||
.omx/
|
.omx/
|
||||||
|
|
||||||
|
**/issues/
|
||||||
|
|||||||
@@ -6,8 +6,12 @@
|
|||||||
[](https://learn.microsoft.com/en-us/agent-framework/)
|
[](https://learn.microsoft.com/en-us/agent-framework/)
|
||||||
[](https://pypi.org/project/agent-framework/)
|
[](https://pypi.org/project/agent-framework/)
|
||||||
[](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
|
[](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
|
||||||
|
[](https://github.com/microsoft/agent-framework/stargazers)
|
||||||
|
|
||||||
Welcome to Microsoft's comprehensive multi-language framework for building, orchestrating, and deploying AI agents with support for both .NET and Python implementations. This framework provides everything from simple chat agents to complex multi-agent workflows with graph-based orchestration.
|
|
||||||
|
Microsoft Agent Framework (MAF) is an open, multi-language framework for building **production-grade AI agents and multi-agent workflows** in **.NET and Python**.
|
||||||
|
|
||||||
|
Microsoft Agent Framework is built for teams taking agents from prototype to production. It provides a consistent foundation for building, orchestrating, and operating agent systems across Python and .NET, while keeping architecture choices open as requirements evolve, and supports a broad ecosystem including Microsoft Foundry, Azure OpenAI, OpenAI, and the GitHub Copilot SDK, with samples and hosting patterns for both local development and cloud deployment.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://www.youtube.com/watch?v=AAgdMhftj8w" title="Watch the full Agent Framework introduction (30 min)">
|
<a href="https://www.youtube.com/watch?v=AAgdMhftj8w" title="Watch the full Agent Framework introduction (30 min)">
|
||||||
@@ -21,10 +25,54 @@ Welcome to Microsoft's comprehensive multi-language framework for building, orch
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 📋 Getting Started
|
## Is this the right framework for you?
|
||||||
|
|
||||||
### 📦 Installation
|
MAF is a strong fit if you:
|
||||||
|
- are building agents and workflows you expect to run in production,
|
||||||
|
- need orchestration beyond a single prompt or stateless chat loop,
|
||||||
|
- want graph-based patterns such as sequential, concurrent, handoff, and group collaboration,
|
||||||
|
- care about durability, restartability, observability, governance, or human-in-the-loop control,
|
||||||
|
- need provider flexibility so your architecture can evolve without major rewrites.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
Explore new MAF capabilities and real implementation patterns on the [official blog](https://devblogs.microsoft.com/agent-framework/).
|
||||||
|
|
||||||
|
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
|
||||||
|
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
|
||||||
|
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
|
||||||
|
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
|
||||||
|
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
|
||||||
|
- [Python middleware](./python/samples/02-agents/middleware/) | [.NET middleware](./dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/)
|
||||||
|
- **Orchestration Patterns & Workflows**: Build multi-agent systems with graph-based workflows supporting sequential, concurrent, handoff, and group collaboration patterns; includes checkpointing, streaming, human-in-the-loop, and time-travel
|
||||||
|
- [Python workflows](./python/samples/03-workflows/) | [.NET workflows](./dotnet/samples/03-workflows/)
|
||||||
|
- **Foundry Hosted Agents (new)**: Deploy and host your agents to Foundry-hosted infrastructure with just 2 additional lines of code
|
||||||
|
- [Python samples](./python/samples/04-hosting/foundry-hosted-agents/) | [.NET samples](./dotnet/samples/04-hosting/FoundryHostedAgents/)
|
||||||
|
- **Observability**: Built-in OpenTelemetry integration for distributed tracing, monitoring, and debugging
|
||||||
|
- [Python observability](./python/samples/02-agents/observability/) | [.NET telemetry](./dotnet/samples/02-agents/AgentOpenTelemetry/)
|
||||||
|
- **Declarative Agents**: Define agents using YAML for faster setup and versioning
|
||||||
|
- [Declarative agent samples](./declarative-agents/)
|
||||||
|
- **Agent Skills**: Build domain-specific knowledge bases from multiple sources—files, inline code, class libraries—for agents to discover and use
|
||||||
|
- [Skills design](./docs/decisions/0021-agent-skills-design.md)
|
||||||
|
- **AF Labs**: Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives
|
||||||
|
- [Labs directory](./python/packages/lab/)
|
||||||
|
- **DevUI**: Interactive developer UI for agent development, testing, and debugging workflows
|
||||||
|
- [See the DevUI in action](https://www.youtube.com/watch?v=mOAaGY4WPvc)
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Getting Started](#getting-started)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Learning Resources](#learning-resources)
|
||||||
|
- [Quickstart](#quickstart)
|
||||||
|
- [Basic Agent - Python](#basic-agent---python)
|
||||||
|
- [Basic Agent - .NET](#basic-agent---net)
|
||||||
|
- [More Examples & Samples](#more-examples--samples)
|
||||||
|
- [Community & Feedback](#community--feedback)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
- [Contributor Resources](#contributor-resources)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
### Installation
|
||||||
Python
|
Python
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -37,9 +85,13 @@ pip install agent-framework
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet add package Microsoft.Agents.AI
|
dotnet add package Microsoft.Agents.AI
|
||||||
|
# For Foundry integration (used in the .NET quickstart below):
|
||||||
|
dotnet add package Microsoft.Agents.AI.Foundry
|
||||||
|
dotnet add package Azure.AI.Projects
|
||||||
|
dotnet add package Azure.Identity
|
||||||
```
|
```
|
||||||
|
|
||||||
### 📚 Documentation
|
### Learning Resources
|
||||||
|
|
||||||
- **[Overview](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)** - High level overview of the framework
|
- **[Overview](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)** - High level overview of the framework
|
||||||
- **[Quick Start](https://learn.microsoft.com/agent-framework/tutorials/quick-start)** - Get started with a simple agent
|
- **[Quick Start](https://learn.microsoft.com/agent-framework/tutorials/quick-start)** - Get started with a simple agent
|
||||||
@@ -48,44 +100,9 @@ dotnet add package Microsoft.Agents.AI
|
|||||||
- **[Migration from Semantic Kernel](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel)** - Guide to migrate from Semantic Kernel
|
- **[Migration from Semantic Kernel](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel)** - Guide to migrate from Semantic Kernel
|
||||||
- **[Migration from AutoGen](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-autogen)** - Guide to migrate from AutoGen
|
- **[Migration from AutoGen](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-autogen)** - Guide to migrate from AutoGen
|
||||||
|
|
||||||
Still have questions? Join our [weekly office hours](./COMMUNITY.md#public-community-office-hours) or ask questions in our [Discord channel](https://discord.gg/b5zjErwbQM) to get help from the team and other users.
|
### Quickstart
|
||||||
|
|
||||||
### ✨ **Highlights**
|
#### Basic Agent - Python
|
||||||
|
|
||||||
- **Graph-based Workflows**: Connect agents and deterministic functions using data flows with streaming, checkpointing, human-in-the-loop, and time-travel capabilities
|
|
||||||
- [Python workflows](./python/samples/03-workflows/) | [.NET workflows](./dotnet/samples/03-workflows/)
|
|
||||||
- **AF Labs**: Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives
|
|
||||||
- [Labs directory](./python/packages/lab/)
|
|
||||||
- **DevUI**: Interactive developer UI for agent development, testing, and debugging workflows
|
|
||||||
- [DevUI package](./python/packages/devui/)
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.youtube.com/watch?v=mOAaGY4WPvc">
|
|
||||||
<img src="https://img.youtube.com/vi/mOAaGY4WPvc/hqdefault.jpg" alt="See the DevUI in action" width="480">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.youtube.com/watch?v=mOAaGY4WPvc">
|
|
||||||
See the DevUI in action (1 min)
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
|
|
||||||
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
|
|
||||||
- **Observability**: Built-in OpenTelemetry integration for distributed tracing, monitoring, and debugging
|
|
||||||
- [Python observability](./python/samples/02-agents/observability/) | [.NET telemetry](./dotnet/samples/02-agents/AgentOpenTelemetry/)
|
|
||||||
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
|
|
||||||
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
|
|
||||||
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
|
|
||||||
- [Python middleware](./python/samples/02-agents/middleware/) | [.NET middleware](./dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/)
|
|
||||||
|
|
||||||
### 💬 **We want your feedback!**
|
|
||||||
|
|
||||||
- For bugs, please file a [GitHub issue](https://github.com/microsoft/agent-framework/issues).
|
|
||||||
|
|
||||||
## Quickstart
|
|
||||||
|
|
||||||
### Basic Agent - Python
|
|
||||||
|
|
||||||
Create a simple Azure Responses Agent that writes a haiku about the Microsoft Agent Framework
|
Create a simple Azure Responses Agent that writes a haiku about the Microsoft Agent Framework
|
||||||
|
|
||||||
@@ -109,7 +126,7 @@ async def main():
|
|||||||
# project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
# project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||||
# model=os.environ["FOUNDRY_MODEL_DEPLOYMENT_NAME"],
|
# model=os.environ["FOUNDRY_MODEL_DEPLOYMENT_NAME"],
|
||||||
),
|
),
|
||||||
name="HaikuBot",
|
name="HaikuAgent",
|
||||||
instructions="You are an upbeat assistant that writes beautifully.",
|
instructions="You are an upbeat assistant that writes beautifully.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,40 +136,24 @@ if __name__ == "__main__":
|
|||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
```
|
```
|
||||||
|
|
||||||
### Basic Agent - .NET
|
#### Basic Agent - .NET
|
||||||
Create a simple Agent, using Microsoft Foundry with token-based auth, that writes a haiku about the Microsoft Agent Framework
|
Create a simple Agent, using Microsoft Foundry that writes a haiku about the Microsoft Agent Framework
|
||||||
|
|
||||||
```c#
|
```c#
|
||||||
// dotnet add package Microsoft.Agents.AI.Foundry
|
// This sample shows how to create and run a basic agent with AIProjectClient.AsAIAgent(...).
|
||||||
// Use `az login` to authenticate with Azure CLI
|
|
||||||
using Azure.AI.Projects;
|
|
||||||
using Azure.Identity;
|
|
||||||
using System;
|
|
||||||
using Azure.AI.Projects;
|
using Azure.AI.Projects;
|
||||||
using Azure.Identity;
|
using Azure.Identity;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
|
||||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||||
|
|
||||||
var agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
AIAgent agent =
|
||||||
.AsAIAgent(model: deploymentName, name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
|
new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||||
|
.AsAIAgent(model: deploymentName, instructions: "You are an upbeat assistant that writes beautifully.", name: "HaikuAgent");
|
||||||
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
|
|
||||||
```
|
|
||||||
|
|
||||||
Create a simple Agent, using OpenAI Responses, that writes a haiku about the Microsoft Agent Framework
|
|
||||||
|
|
||||||
```c#
|
|
||||||
// dotnet add package Microsoft.Agents.AI.OpenAI
|
|
||||||
using System;
|
|
||||||
using OpenAI;
|
|
||||||
using OpenAI.Responses;
|
|
||||||
|
|
||||||
// Replace the <apikey> with your OpenAI API key.
|
|
||||||
var agent = new OpenAIClient("<apikey>")
|
|
||||||
.GetResponsesClient()
|
|
||||||
.AsAIAgent(model: "gpt-5.4-mini", name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
|
|
||||||
|
|
||||||
|
// Once you have the agent, you can invoke it like any other AIAgent.
|
||||||
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
|
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -175,6 +176,12 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
|
|||||||
- [Hosting](./dotnet/samples/04-hosting): A2A, Durable Agents, Durable Workflows
|
- [Hosting](./dotnet/samples/04-hosting): A2A, Durable Agents, Durable Workflows
|
||||||
- [End-to-End](./dotnet/samples/05-end-to-end): full applications and demos
|
- [End-to-End](./dotnet/samples/05-end-to-end): full applications and demos
|
||||||
|
|
||||||
|
## Community & Feedback
|
||||||
|
|
||||||
|
- **Found a bug?** File a [GitHub issue](https://github.com/microsoft/agent-framework/issues) to help us improve.
|
||||||
|
- **Enjoying MAF?** [](https://github.com/microsoft/agent-framework) to show your support and help others discover the project.
|
||||||
|
- **Have questions?** Join our [Discord](https://discord.gg/b5zjErwbQM) or visit [weekly office hours](./COMMUNITY.md#public-community-office-hours).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
@@ -187,16 +194,7 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
|
|||||||
> **Tip:** `DefaultAzureCredential` is convenient for development but in production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
> **Tip:** `DefaultAzureCredential` is convenient for development but in production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
For environment variable configuration specific to each sample, refer to the README in the sample directory ([Python samples](./python/samples/) | [.NET samples](./dotnet/samples/)).
|
||||||
The samples typically read configuration from environment variables. Common required variables:
|
|
||||||
|
|
||||||
| Variable | Used by | Purpose |
|
|
||||||
|----------|---------|---------|
|
|
||||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI samples | Your Azure OpenAI resource URL |
|
|
||||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI samples | Model deployment name (e.g. `gpt-4o-mini`) |
|
|
||||||
| `AZURE_AI_PROJECT_ENDPOINT` | Microsoft Foundry samples | Your Microsoft Foundry project endpoint |
|
|
||||||
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Microsoft Foundry samples | Model deployment name |
|
|
||||||
| `OPENAI_API_KEY` | OpenAI (non-Azure) samples | Your OpenAI platform API key |
|
|
||||||
|
|
||||||
## Contributor Resources
|
## Contributor Resources
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
contact: rogerbarreto
|
||||||
|
date: 2026-05-07
|
||||||
|
deciders: rogerbarreto
|
||||||
|
consulted: []
|
||||||
|
informed: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Hosted session identity context for Foundry Hosting
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
Server-hosted Foundry agents need a way to scope per-user state (most notably `FoundryMemoryProvider` memories) by the end user that initiated the request. The Foundry platform already injects `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers on every Responses request, but the agent-framework hosting layer did not surface those values to `AIContextProvider` instances. The provider's `stateInitializer` only received an `AgentSession?` with no identity attached, so per-user scoping was impossible without out-of-band plumbing.
|
||||||
|
|
||||||
|
## Decision Drivers
|
||||||
|
|
||||||
|
- Memory and any future user-private context must be partitioned per end user without per-sample boilerplate.
|
||||||
|
- The identity must be **read-only** from the perspective of `AIContextProvider`s, so a buggy or hostile provider cannot escalate or leak across users.
|
||||||
|
- The persisted session must validate against the live request on every resume to defend against session-id leak and in-process tampering.
|
||||||
|
- The change must work for every existing hosted-agent type (`ChatClientAgent`, `FoundryAgent`, future ones) without per-type refactoring of cast-heavy code paths in `Microsoft.Agents.AI`.
|
||||||
|
- Local Docker debugging must remain possible when the platform headers are absent.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
1. **`HostedSessionContext` stored in `AgentSessionStateBag`, exposed via a public read accessor and an `internal` setter.** Hosting writes once on session creation and validates on every resume.
|
||||||
|
2. **Specialised `HostedAgentSession : AgentSession` wrapper** that carries `UserId`/`ChatId` properties, with `GetService<ChatClientAgentSession>()` as the unwrap escape hatch.
|
||||||
|
3. **New property on `AgentSession` base class** (`HostedSessionContext? HostedContext { get; internal set; }`).
|
||||||
|
4. **AsyncLocal middleware** that reads the headers and stuffs them into a per-request `AsyncLocal<HostedSessionContext>` consumed by the provider.
|
||||||
|
|
||||||
|
For the source of identity:
|
||||||
|
- A. The platform-injected `IsolationContext` exposed by `ResponseContext.Isolation` (typed `UserIsolationKey`/`ChatIsolationKey`).
|
||||||
|
- B. The OpenAI Responses spec's top-level `request.User` field.
|
||||||
|
- C. A custom HTTP header `x-client-user`.
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
**Option 1** was chosen for the storage shape, sourced from **Option A** (`ResponseContext.Isolation`).
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- **Wrapper rejected (Option 2).** `ChatClientAgentSession` is `sealed` and `ChatClientAgent` rejects any other session type via direct `is not ChatClientAgentSession` checks at multiple call sites. Wrapping would force non-trivial refactors across `Microsoft.Agents.AI` and a corresponding repeat for every other agent type.
|
||||||
|
- **Base-class property rejected (Option 3).** Leaks "hosted" semantics into the universal `AgentSession` abstraction used by Durable, A2A, and CopilotStudio agents that have no notion of a hosted user.
|
||||||
|
- **AsyncLocal rejected (Option 4).** Surfaces the concept only locally, requires every consumer to re-implement the bridge, and cannot be enforced as read-only.
|
||||||
|
- **`request.User` rejected (Option B).** Set by the caller, not the platform. Forging it client-side trivially defeats per-user partitioning.
|
||||||
|
- **`x-client-user` rejected (Option C).** Non-standard, requires custom HTTP plumbing, and duplicates the platform-provided isolation contract.
|
||||||
|
|
||||||
|
Implementation summary in `Microsoft.Agents.AI.Foundry.Hosting`:
|
||||||
|
|
||||||
|
| Type | Visibility | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `HostedSessionContext` | public sealed | Captures `UserId` and `ChatId` (both required, non-whitespace). |
|
||||||
|
| `HostedSessionContextExtensions.GetHostedContext` | public | Read accessor for `AIContextProvider`s. |
|
||||||
|
| `HostedSessionContextExtensions.SetHostedContext` | internal | Writer reserved for the hosting assembly. Backed by `AgentSessionStateBag` under a well-known key for serialisation. |
|
||||||
|
| `HostedSessionIsolationKeyProvider` (abstract) | public | DI-resolvable factory. Async signature: `ValueTask<HostedSessionContext?> GetKeysAsync(ResponseContext, CreateResponse, CancellationToken)`. |
|
||||||
|
| `PlatformHostedSessionIsolationKeyProvider` | internal sealed | Default implementation. Maps `context.Isolation.UserIsolationKey` and `context.Isolation.ChatIsolationKey`. Returns `null` when either is absent. |
|
||||||
|
|
||||||
|
Behaviour added to `AgentFrameworkResponseHandler.CreateAsync`:
|
||||||
|
|
||||||
|
1. Resolve `HostedSessionIsolationKeyProvider` from DI; fall back to `PlatformHostedSessionIsolationKeyProvider`.
|
||||||
|
2. Call `GetKeysAsync(context, request, cancellationToken)`. A `null` result throws `InvalidOperationException` (becomes 500). A null/whitespace `UserId` or `ChatId` is rejected by `HostedSessionContext`'s constructor.
|
||||||
|
3. Branch on the **session's existing context**, not on whether a `conversation_id` was supplied:
|
||||||
|
- **No session (`session is null`):** nothing to stamp; skip.
|
||||||
|
- **Session present but un-stamped (`GetHostedContext() is null`):** treat as fresh. This covers both newly-created sessions and pre-existing sessions whose `conversation_id` was provisioned externally (e.g. via `conversations.CreateProjectConversationAsync()`) before the first hosted-agent request. Stamp the resolved identity now.
|
||||||
|
- **Session present with stamped context:** strict resume. The persisted `UserId` and `ChatId` must equal the resolved values exactly. Mismatch throws `ResponsesApiException` with status 403 and body `Hosted session identity context mismatch`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Positive:
|
||||||
|
|
||||||
|
- Per-user memory partitioning works out of the box for any agent that consumes a `Microsoft.Agents.AI.Foundry.FoundryMemoryProvider` configured to read `session.GetHostedContext().UserId`.
|
||||||
|
- Cross-user session-id leak and in-process tampering of the persisted identity both surface as a 403 with a deliberately uninformative body.
|
||||||
|
- The identity is opaque to the framework, matching the platform's semantics. The framework never inspects user identity; the `IsolationContext` keys are pre-partitioned per agent.
|
||||||
|
|
||||||
|
Negative:
|
||||||
|
|
||||||
|
- Every existing hosted sample fails locally without a `HostedSessionIsolationKeyProvider` registered, because the platform headers are absent outside the platform. Mitigated by shipping `Hosted_Shared_Contributor_Setup` with `DevTemporaryLocalSessionIsolationKeyProvider` and `AddDevTemporaryLocalContributorSetup`, and migrating all 9 existing responses samples.
|
||||||
|
- An attacker who can plant an un-stamped session under a victim's `conversation_id` *before* the victim's first hosted-agent request would be stamped with the attacker's identity on that first request. This is not a regression vs. behaviour without this contract, and is mitigated in practice because the `conversation_id` namespace is allocated by the platform per project. Once a session is stamped, the strict equality check fully defends the resume path.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Per-request `User` field on `CreateResponse` is intentionally not consumed; only the platform `IsolationContext` headers carry trustworthy identity.
|
||||||
|
- Generic (non-Foundry) hosting layers can re-define an equivalent type if needed; nothing in this ADR is moved into `Microsoft.Agents.AI.Hosting` because `Microsoft.Agents.AI.Foundry.Hosting` does not depend on it.
|
||||||
|
- HMAC tamper signatures over the persisted context are not implemented; comparison against `ResponseContext.Isolation` on every request is sufficient because the platform sets those headers at the trust boundary.
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Aspire.* -->
|
<!-- Aspire.* -->
|
||||||
<PackageVersion Include="Anthropic" Version="12.13.0" />
|
<PackageVersion Include="Anthropic" Version="12.20.0" />
|
||||||
<PackageVersion Include="Anthropic.Foundry" Version="0.5.0" />
|
<PackageVersion Include="Anthropic.Foundry" Version="0.5.0" />
|
||||||
<PackageVersion Include="Aspire.Hosting" Version="$(AspireAppHostSdkVersion)" />
|
<PackageVersion Include="Aspire.Hosting" Version="$(AspireAppHostSdkVersion)" />
|
||||||
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
|
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
|
||||||
@@ -25,10 +25,11 @@
|
|||||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
|
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
|
||||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
|
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
|
||||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
|
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
|
||||||
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
|
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
|
||||||
|
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.2" />
|
||||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
|
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
|
||||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||||
<PackageVersion Include="Azure.Core" Version="1.53.0" />
|
<PackageVersion Include="Azure.Core" Version="1.55.0" />
|
||||||
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
|
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
|
||||||
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
|
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
|
||||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
|
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
|
||||||
@@ -43,7 +44,7 @@
|
|||||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
|
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
|
||||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
|
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
|
||||||
<PackageVersion Include="System.ClientModel" Version="1.10.0" />
|
<PackageVersion Include="System.ClientModel" Version="1.11.0" />
|
||||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
||||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||||
@@ -71,12 +72,12 @@
|
|||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
|
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
|
||||||
<!-- Microsoft.Extensions.* -->
|
<!-- Microsoft.Extensions.* -->
|
||||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.5.1" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.5.0" />
|
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.5.1" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.4.0" />
|
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.4.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.4.0" />
|
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.4.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.3.0-preview.1.26109.11" />
|
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.3.0-preview.1.26109.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.0" />
|
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
|
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.1" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.1" />
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
|
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
|
||||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
|
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
|
||||||
<!-- Agent SDKs -->
|
<!-- Agent SDKs -->
|
||||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="0.1.29" />
|
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0-beta.2" />
|
||||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
|
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
|
||||||
<!-- M365 Agents SDK -->
|
<!-- M365 Agents SDK -->
|
||||||
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
|
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
|
||||||
@@ -109,6 +110,9 @@
|
|||||||
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
|
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
|
||||||
<!-- MCP -->
|
<!-- MCP -->
|
||||||
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
|
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
|
||||||
|
<!-- Hyperlight -->
|
||||||
|
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
|
||||||
|
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
|
||||||
<!-- Inference SDKs -->
|
<!-- Inference SDKs -->
|
||||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||||
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||||
@@ -191,4 +195,4 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -33,3 +33,4 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
|
|||||||
- [Design Documents](../docs/design)
|
- [Design Documents](../docs/design)
|
||||||
- [Architectural Decision Records](../docs/decisions)
|
- [Architectural Decision Records](../docs/decisions)
|
||||||
- [MSFT Learn Docs](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)
|
- [MSFT Learn Docs](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
|
<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_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_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj" />
|
||||||
|
<Project Path="samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment/Agent_Step21_ShellWithEnvironment.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||||
@@ -121,8 +122,11 @@
|
|||||||
<File Path="samples/02-agents/Harness/README.md" />
|
<File Path="samples/02-agents/Harness/README.md" />
|
||||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
|
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
|
||||||
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
|
<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_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj" />
|
||||||
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
|
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
|
||||||
|
<Project Path="samples/02-agents/Harness/Harness_Step04_CodeExecution/Harness_Step04_CodeExecution.csproj" />
|
||||||
|
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||||
|
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
|
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
|
||||||
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
|
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
|
||||||
@@ -167,7 +171,7 @@
|
|||||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
|
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
|
||||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
|
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
|
||||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
|
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
|
||||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
|
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Agent_Step25_FoundryToolboxMcp.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Samples/02-agents/Evaluation/">
|
<Folder Name="/Samples/02-agents/Evaluation/">
|
||||||
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
|
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
|
||||||
@@ -175,6 +179,12 @@
|
|||||||
<Project Path="samples/02-agents/Evaluation/Evaluation_Multimodal/Evaluation_Multimodal.csproj" />
|
<Project Path="samples/02-agents/Evaluation/Evaluation_Multimodal/Evaluation_Multimodal.csproj" />
|
||||||
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
|
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/Samples/02-agents/AgentWithCodeAct/">
|
||||||
|
<File Path="samples/02-agents/AgentWithCodeAct/README.md" />
|
||||||
|
<Project Path="samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj" />
|
||||||
|
<Project Path="samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj" />
|
||||||
|
<Project Path="samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj" />
|
||||||
|
</Folder>
|
||||||
<Folder Name="/Samples/02-agents/AgentWithMemory/">
|
<Folder Name="/Samples/02-agents/AgentWithMemory/">
|
||||||
<File Path="samples/02-agents/AgentWithMemory/README.md" />
|
<File Path="samples/02-agents/AgentWithMemory/README.md" />
|
||||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
|
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
|
||||||
@@ -233,6 +243,7 @@
|
|||||||
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
|
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
|
||||||
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.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/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/InvokeHttpRequest/InvokeHttpRequest.csproj" />
|
||||||
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
|
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
|
||||||
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
|
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
|
||||||
@@ -289,6 +300,7 @@
|
|||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Samples/03-workflows/Evaluation/">
|
<Folder Name="/Samples/03-workflows/Evaluation/">
|
||||||
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Evaluation_WorkflowEval.csproj" />
|
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Evaluation_WorkflowEval.csproj" />
|
||||||
|
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/Evaluation_WorkflowExpectedOutputs.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Samples/04-hosting/">
|
<Folder Name="/Samples/04-hosting/">
|
||||||
</Folder>
|
</Folder>
|
||||||
@@ -307,15 +319,30 @@
|
|||||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/">
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/">
|
||||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj" />
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/">
|
||||||
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj" />
|
||||||
|
</Folder>
|
||||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/">
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/">
|
||||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj" />
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
|
||||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/">
|
||||||
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/HostedMemoryAgent.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/">
|
||||||
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/">
|
||||||
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/Hosted_Shared_Contributor_Setup.csproj" />
|
||||||
|
</Folder>
|
||||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
||||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/">
|
||||||
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj" />
|
||||||
|
</Folder>
|
||||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/">
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/">
|
||||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj" />
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
@@ -323,6 +350,7 @@
|
|||||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj" />
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/">
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/">
|
||||||
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj" />
|
||||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj" />
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/">
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/">
|
||||||
@@ -357,7 +385,7 @@
|
|||||||
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
|
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
|
||||||
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
|
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
|
||||||
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
|
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Samples/05-end-to-end/">
|
<Folder Name="/Samples/05-end-to-end/">
|
||||||
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
|
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
|
||||||
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
|
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
|
||||||
@@ -535,6 +563,16 @@
|
|||||||
<Folder Name="/Solution Items/src/Shared/StructuredOutput/">
|
<Folder Name="/Solution Items/src/Shared/StructuredOutput/">
|
||||||
<File Path="src/Shared/StructuredOutput/StructuredOutputSchemaUtilities.cs" />
|
<File Path="src/Shared/StructuredOutput/StructuredOutputSchemaUtilities.cs" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/Solution Items/src/Shared/Workflows/" />
|
||||||
|
<Folder Name="/Solution Items/src/Shared/Workflows/Execution/">
|
||||||
|
<File Path="src/Shared/Workflows/Execution/README.md" />
|
||||||
|
<File Path="src/Shared/Workflows/Execution/WorkflowFactory.cs" />
|
||||||
|
<File Path="src/Shared/Workflows/Execution/WorkflowRunner.cs" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/Solution Items/src/Shared/Workflows/Settings/">
|
||||||
|
<File Path="src/Shared/Workflows/Settings/Application.cs" />
|
||||||
|
<File Path="src/Shared/Workflows/Settings/README.md" />
|
||||||
|
</Folder>
|
||||||
<Folder Name="/Solution Items/tests/">
|
<Folder Name="/Solution Items/tests/">
|
||||||
<File Path="tests/.editorconfig" />
|
<File Path="tests/.editorconfig" />
|
||||||
<File Path="tests/Directory.Build.props" />
|
<File Path="tests/Directory.Build.props" />
|
||||||
@@ -553,6 +591,7 @@
|
|||||||
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
|
<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.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.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.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.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||||
@@ -560,9 +599,11 @@
|
|||||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||||
|
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||||
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
<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.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.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.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.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
|
||||||
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
||||||
@@ -576,12 +617,16 @@
|
|||||||
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
|
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
|
||||||
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
|
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
|
||||||
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
|
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
|
||||||
|
<Project Path="tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj" />
|
||||||
|
<Project Path="tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj" />
|
||||||
<Project Path="tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj" />
|
<Project Path="tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj" />
|
||||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj" />
|
<Project Path="tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj" />
|
||||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
|
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
|
||||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
|
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
|
||||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj" />
|
<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.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/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
|
||||||
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
|
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
|
||||||
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj" />
|
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj" />
|
||||||
@@ -601,14 +646,17 @@
|
|||||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
|
<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.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.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.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.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" />
|
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj" />
|
||||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
||||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
||||||
|
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj" />
|
||||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
|
<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.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.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.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.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" />
|
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"src\\Microsoft.Agents.AI.AGUI\\Microsoft.Agents.AI.AGUI.csproj",
|
"src\\Microsoft.Agents.AI.AGUI\\Microsoft.Agents.AI.AGUI.csproj",
|
||||||
"src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.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.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.AzureAI.Persistent\\Microsoft.Agents.AI.AzureAI.Persistent.csproj",
|
||||||
"src\\Microsoft.Agents.AI.Foundry\\Microsoft.Agents.AI.Foundry.csproj",
|
"src\\Microsoft.Agents.AI.Foundry\\Microsoft.Agents.AI.Foundry.csproj",
|
||||||
"src\\Microsoft.Agents.AI.Foundry.Hosting\\Microsoft.Agents.AI.Foundry.Hosting.csproj",
|
"src\\Microsoft.Agents.AI.Foundry.Hosting\\Microsoft.Agents.AI.Foundry.Hosting.csproj",
|
||||||
@@ -30,7 +31,8 @@
|
|||||||
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
|
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
|
||||||
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
|
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
|
||||||
"src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj",
|
"src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj",
|
||||||
"src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj"
|
"src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj",
|
||||||
|
"src\\Microsoft.Agents.AI.Hyperlight\\Microsoft.Agents.AI.Hyperlight.csproj"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,15 @@
|
|||||||
.PARAMETER Configuration
|
.PARAMETER Configuration
|
||||||
Optional MSBuild configuration used when querying TargetFrameworks. Defaults to Debug.
|
Optional MSBuild configuration used when querying TargetFrameworks. Defaults to Debug.
|
||||||
|
|
||||||
.PARAMETER TestProjectNameFilter
|
.PARAMETER TestProjectNameIncludeFilter
|
||||||
Optional wildcard pattern to filter test project names (e.g., *UnitTests*, *IntegrationTests*).
|
Optional wildcard pattern to filter test project names (e.g., *UnitTests*, *IntegrationTests*).
|
||||||
When specified, only test projects whose filename matches this pattern are kept.
|
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
|
.PARAMETER ExcludeSamples
|
||||||
When specified, removes all projects under the samples/ directory from the solution.
|
When specified, removes all projects under the samples/ directory from the solution.
|
||||||
|
|
||||||
@@ -38,11 +43,15 @@
|
|||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
# Generate a solution with only unit test projects
|
# Generate a solution with only unit test projects
|
||||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameFilter "*UnitTests*" -OutputPath filtered-unit.slnx
|
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameIncludeFilter "*UnitTests*" -OutputPath filtered-unit.slnx
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
# Inline usage with dotnet test (PowerShell)
|
# 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
|
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()]
|
[CmdletBinding()]
|
||||||
@@ -55,7 +64,9 @@ param(
|
|||||||
|
|
||||||
[string]$Configuration = "Debug",
|
[string]$Configuration = "Debug",
|
||||||
|
|
||||||
[string]$TestProjectNameFilter,
|
[string]$TestProjectNameIncludeFilter,
|
||||||
|
|
||||||
|
[string[]]$TestProjectNameExcludeFilter,
|
||||||
|
|
||||||
[switch]$ExcludeSamples,
|
[switch]$ExcludeSamples,
|
||||||
|
|
||||||
@@ -100,13 +111,30 @@ foreach ($proj in $allProjects) {
|
|||||||
$isTestProject = $projRelPath -like "*tests/*"
|
$isTestProject = $projRelPath -like "*tests/*"
|
||||||
|
|
||||||
# Filter test projects by name pattern if specified
|
# Filter test projects by name pattern if specified
|
||||||
if ($isTestProject -and $TestProjectNameFilter -and ($projFileName -notlike $TestProjectNameFilter)) {
|
if ($isTestProject -and $TestProjectNameIncludeFilter -and ($projFileName -notlike $TestProjectNameIncludeFilter)) {
|
||||||
Write-Verbose "Removing (name filter): $projRelPath"
|
Write-Verbose "Removing (name filter): $projRelPath"
|
||||||
$removed += $projRelPath
|
$removed += $projRelPath
|
||||||
$proj.ParentNode.RemoveChild($proj) | Out-Null
|
$proj.ParentNode.RemoveChild($proj) | Out-Null
|
||||||
continue
|
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)) {
|
if (-not (Test-Path $projFullPath)) {
|
||||||
Write-Verbose "Project not found, keeping in solution: $projRelPath"
|
Write-Verbose "Project not found, keeping in solution: $projRelPath"
|
||||||
$kept += $projRelPath
|
$kept += $projRelPath
|
||||||
|
|||||||
@@ -478,6 +478,17 @@ internal static class WorkflowSamples
|
|||||||
ExpectedOutputDescription = ["The output should show a workflow invoking a function tool (e.g. a menu plugin) to answer a question about the soup of the day."],
|
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
|
new SampleDefinition
|
||||||
{
|
{
|
||||||
Name = "Workflow_Declarative_InvokeMcpTool",
|
Name = "Workflow_Declarative_InvokeMcpTool",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<!-- Central version prefix - applies to all nuget packages. -->
|
<!-- Central version prefix - applies to all nuget packages. -->
|
||||||
<VersionPrefix>1.3.0</VersionPrefix>
|
<VersionPrefix>1.6.1</VersionPrefix>
|
||||||
<RCNumber>1</RCNumber>
|
<RCNumber>1</RCNumber>
|
||||||
<DateSuffix>260423</DateSuffix>
|
<DateSuffix>260514</DateSuffix>
|
||||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
<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)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||||
<GitTag>1.3.0</GitTag>
|
<GitTag>1.6.1</GitTag>
|
||||||
|
|
||||||
<Configurations>Debug;Release;Publish</Configurations>
|
<Configurations>Debug;Release;Publish</Configurations>
|
||||||
<IsPackable>true</IsPackable>
|
<IsPackable>true</IsPackable>
|
||||||
|
|||||||
@@ -50,12 +50,12 @@ Console.WriteLine(await agent.RunAsync("My name is Ruaidhrí", session));
|
|||||||
Console.WriteLine(await agent.RunAsync("I am 20 years old", session));
|
Console.WriteLine(await agent.RunAsync("I am 20 years old", session));
|
||||||
|
|
||||||
// We can serialize the session. The serialized state will include the state of the memory component.
|
// We can serialize the session. The serialized state will include the state of the memory component.
|
||||||
JsonElement sesionElement = await agent.SerializeSessionAsync(session);
|
JsonElement sessionElement = await agent.SerializeSessionAsync(session);
|
||||||
|
|
||||||
Console.WriteLine("\n>> Use deserialized session with previously created memories\n");
|
Console.WriteLine("\n>> Use deserialized session with previously created memories\n");
|
||||||
|
|
||||||
// Later we can deserialize the session and continue the conversation with the previous memory component state.
|
// Later we can deserialize the session and continue the conversation with the previous memory component state.
|
||||||
var deserializedSession = await agent.DeserializeSessionAsync(sesionElement);
|
var deserializedSession = await agent.DeserializeSessionAsync(sessionElement);
|
||||||
Console.WriteLine(await agent.RunAsync("What is my name and age?", deserializedSession));
|
Console.WriteLine(await agent.RunAsync("What is my name and age?", deserializedSession));
|
||||||
|
|
||||||
Console.WriteLine("\n>> Read memories using memory component\n");
|
Console.WriteLine("\n>> Read memories using memory component\n");
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ static Task<PermissionRequestResult> PromptPermission(PermissionRequest request,
|
|||||||
Console.Write("Approve? (y/n): ");
|
Console.Write("Approve? (y/n): ");
|
||||||
|
|
||||||
string? input = Console.ReadLine()?.Trim().ToUpperInvariant();
|
string? input = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||||
string kind = input is "Y" or "YES" ? "approved" : "denied-interactively-by-user";
|
PermissionRequestResultKind kind = input is "Y" or "YES"
|
||||||
|
? PermissionRequestResultKind.Approved
|
||||||
|
: PermissionRequestResultKind.Rejected;
|
||||||
|
|
||||||
return Task.FromResult(new PermissionRequestResult { Kind = kind });
|
return Task.FromResult(new PermissionRequestResult { Kind = kind });
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.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.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// This sample shows how to use HyperlightCodeActProvider as a sandboxed Python
|
||||||
|
// code interpreter: the model can write and execute arbitrary Python code to
|
||||||
|
// answer quantitative questions without calling any additional tools.
|
||||||
|
|
||||||
|
using Azure.AI.OpenAI;
|
||||||
|
using Azure.Identity;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Agents.AI.Hyperlight;
|
||||||
|
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 guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
|
||||||
|
|
||||||
|
using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(guestPath));
|
||||||
|
|
||||||
|
AIAgent agent = new AzureOpenAIClient(
|
||||||
|
new Uri(endpoint),
|
||||||
|
new DefaultAzureCredential())
|
||||||
|
.GetChatClient(deploymentName)
|
||||||
|
.AsAIAgent(new ChatClientAgentOptions()
|
||||||
|
{
|
||||||
|
ChatOptions = new() { Instructions = "You are a helpful assistant. When the user asks something quantitative, write Python and call `execute_code` instead of guessing." },
|
||||||
|
AIContextProviders = [codeAct],
|
||||||
|
});
|
||||||
|
|
||||||
|
Console.WriteLine(await agent.RunAsync("What is the 20th Fibonacci number?"));
|
||||||
|
Console.WriteLine(await agent.RunAsync("Compute the mean and standard deviation of [1, 4, 9, 16, 25, 36]."));
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# AgentWithCodeAct_Step01_Interpreter
|
||||||
|
|
||||||
|
A minimal CodeAct sample. The agent uses `HyperlightCodeActProvider` as a
|
||||||
|
sandboxed Python interpreter: when the user asks something quantitative, the
|
||||||
|
model writes Python and invokes the `execute_code` tool rather than answering
|
||||||
|
from memory.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|--------------------------------|-------------------------------------------------------------------------------------------|
|
||||||
|
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
|
||||||
|
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
|
||||||
|
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
|
||||||
|
|
||||||
|
Authentication uses `DefaultAzureCredential`.
|
||||||
|
|
||||||
|
## Getting the guest module
|
||||||
|
|
||||||
|
The Python guest module is built from the
|
||||||
|
[hyperlight-dev/hyperlight-sandbox](https://github.com/hyperlight-dev/hyperlight-sandbox)
|
||||||
|
repository — see its README for the exact `cargo`/`just` invocations and
|
||||||
|
the location of the resulting `.wasm` / `.aot` file. Set
|
||||||
|
`HYPERLIGHT_PYTHON_GUEST_PATH` to the absolute path of that artifact
|
||||||
|
before running the sample.
|
||||||
|
|
||||||
|
Hyperlight requires a hardware virtualization back end on the host:
|
||||||
|
KVM on Linux or WHP (Windows Hypervisor Platform) on Windows.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cd AgentWithCodeAct_Step01_Interpreter
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.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.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// This sample shows how to use HyperlightCodeActProvider with provider-owned
|
||||||
|
// tools (exposed inside the sandbox via `call_tool(...)`). The model can
|
||||||
|
// orchestrate those tools in a single Python block, reducing round-trips. A
|
||||||
|
// sensitive tool (`send_email`) is additionally wrapped in
|
||||||
|
// ApprovalRequiredAIFunction so any code that reaches it requires user approval
|
||||||
|
// for the entire execute_code invocation.
|
||||||
|
|
||||||
|
using Azure.AI.OpenAI;
|
||||||
|
using Azure.Identity;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Agents.AI.Hyperlight;
|
||||||
|
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 guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
|
||||||
|
|
||||||
|
AIFunction fetchDocs = AIFunctionFactory.Create(
|
||||||
|
(string topic) => $"Docs for {topic}: (...)",
|
||||||
|
name: "fetch_docs",
|
||||||
|
description: "Fetch documentation for a given topic.");
|
||||||
|
|
||||||
|
AIFunction queryData = AIFunctionFactory.Create(
|
||||||
|
(string query) => $"Rows for `{query}`: []",
|
||||||
|
name: "query_data",
|
||||||
|
description: "Run a read-only SQL-like query against the sample store.");
|
||||||
|
|
||||||
|
AIFunction sendEmail = new ApprovalRequiredAIFunction(
|
||||||
|
AIFunctionFactory.Create(
|
||||||
|
(string to, string subject) => $"Sent '{subject}' to {to}.",
|
||||||
|
name: "send_email",
|
||||||
|
description: "Send an email on behalf of the user."));
|
||||||
|
|
||||||
|
var options = HyperlightCodeActProviderOptions.CreateForWasm(guestPath);
|
||||||
|
options.Tools = [fetchDocs, queryData, sendEmail];
|
||||||
|
|
||||||
|
using var codeAct = new HyperlightCodeActProvider(options);
|
||||||
|
|
||||||
|
AIAgent agent = new AzureOpenAIClient(
|
||||||
|
new Uri(endpoint),
|
||||||
|
new DefaultAzureCredential())
|
||||||
|
.GetChatClient(deploymentName)
|
||||||
|
.AsAIAgent(new ChatClientAgentOptions()
|
||||||
|
{
|
||||||
|
ChatOptions = new() { Instructions = "You are a helpful assistant. Prefer orchestrating your work in a single `execute_code` block using `call_tool(...)` over issuing many direct tool calls." },
|
||||||
|
AIContextProviders = [codeAct],
|
||||||
|
});
|
||||||
|
|
||||||
|
Console.WriteLine(await agent.RunAsync("Look up docs on 'retries' and query the 'orders' table, then summarize."));
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# AgentWithCodeAct_Step02_ToolEnabled
|
||||||
|
|
||||||
|
Demonstrates adding provider-owned tools to `HyperlightCodeActProvider`. Those
|
||||||
|
tools are **only** available to code running inside the sandbox via
|
||||||
|
`call_tool("<name>", ...)` — they are never exposed to the model as direct
|
||||||
|
tools. This lets the model orchestrate multiple tool calls in a single Python
|
||||||
|
block.
|
||||||
|
|
||||||
|
One tool (`send_email`) is wrapped in `ApprovalRequiredAIFunction`, which causes
|
||||||
|
the entire `execute_code` invocation to require user approval when that tool
|
||||||
|
is configured.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|--------------------------------|-------------------------------------------------------------------------------------------|
|
||||||
|
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
|
||||||
|
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
|
||||||
|
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cd AgentWithCodeAct_Step02_ToolEnabled
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Planned follow-up
|
||||||
|
|
||||||
|
A more realistic "upload a file (e.g. an Excel workbook), have the agent
|
||||||
|
analyze it with code" sample is planned as a separate step that will use
|
||||||
|
`HostInputDirectory` together with a guest tool capable of reading the
|
||||||
|
uploaded file. It will be added in a follow-up PR once the corresponding
|
||||||
|
guest module support is in place.
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.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.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// This sample shows how to wire up CodeAct manually using
|
||||||
|
// HyperlightExecuteCodeFunction rather than the AIContextProvider. Use this
|
||||||
|
// when you want a fixed tool surface for the agent's lifetime and don't need
|
||||||
|
// the per-run snapshot/registry semantics of HyperlightCodeActProvider.
|
||||||
|
|
||||||
|
using Azure.AI.OpenAI;
|
||||||
|
using Azure.Identity;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Agents.AI.Hyperlight;
|
||||||
|
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 guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
|
||||||
|
|
||||||
|
AIFunction calculate = AIFunctionFactory.Create(
|
||||||
|
(double a, double b) => a * b,
|
||||||
|
name: "multiply",
|
||||||
|
description: "Multiply two numbers.");
|
||||||
|
|
||||||
|
var options = HyperlightCodeActProviderOptions.CreateForWasm(guestPath);
|
||||||
|
options.Tools = [calculate];
|
||||||
|
|
||||||
|
using var executeCode = new HyperlightExecuteCodeFunction(options);
|
||||||
|
|
||||||
|
var instructions =
|
||||||
|
"You are a helpful assistant. When math is involved, solve it by writing Python "
|
||||||
|
+ "and calling `execute_code` instead of computing values yourself.\n\n"
|
||||||
|
+ executeCode.BuildInstructions(toolsVisibleToModel: false);
|
||||||
|
|
||||||
|
AIAgent agent = new AzureOpenAIClient(
|
||||||
|
new Uri(endpoint),
|
||||||
|
new DefaultAzureCredential())
|
||||||
|
.GetChatClient(deploymentName)
|
||||||
|
.AsAIAgent(instructions: instructions, tools: [executeCode]);
|
||||||
|
|
||||||
|
Console.WriteLine(await agent.RunAsync("What is 12.3 * 4.5? Use the multiply tool from within `execute_code`."));
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# AgentWithCodeAct_Step03_ManualWiring
|
||||||
|
|
||||||
|
Shows how to wire CodeAct manually using `HyperlightExecuteCodeFunction` as a
|
||||||
|
direct agent tool instead of via an `AIContextProvider`. This is useful when
|
||||||
|
the sandbox's tool surface and capabilities are fixed for the agent's
|
||||||
|
lifetime, avoiding per-run snapshot/restore of the provider registry.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|--------------------------------|-------------------------------------------------------------------------------------------|
|
||||||
|
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
|
||||||
|
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
|
||||||
|
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cd AgentWithCodeAct_Step03_ManualWiring
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Agent Framework CodeAct (Hyperlight) Samples
|
||||||
|
|
||||||
|
These samples show how to enable an agent to write and execute code in a
|
||||||
|
Hyperlight-backed sandbox via the CodeAct pattern. Guest code can be pure
|
||||||
|
Python (interpreter mode) or orchestrate host-provided tools through
|
||||||
|
`call_tool(...)` — all inside a secure sandbox with opt-in filesystem and
|
||||||
|
network access.
|
||||||
|
|
||||||
|
|Sample|Description|
|
||||||
|
|---|---|
|
||||||
|
|[Code interpreter](./AgentWithCodeAct_Step01_Interpreter/)|Uses `HyperlightCodeActProvider` as a sandboxed Python interpreter with no host tools.|
|
||||||
|
|[Tool-enabled CodeAct](./AgentWithCodeAct_Step02_ToolEnabled/)|Registers provider-owned tools that guest code can orchestrate via `call_tool(...)`, with an approval-required tool for sensitive actions.|
|
||||||
|
|[Manual wiring](./AgentWithCodeAct_Step03_ManualWiring/)|Uses `HyperlightExecuteCodeFunction` directly as an agent tool when the sandbox configuration is fixed.|
|
||||||
|
|
||||||
|
All samples require a Hyperlight Python guest module. Set
|
||||||
|
`HYPERLIGHT_PYTHON_GUEST_PATH` to its absolute path before running.
|
||||||
+75
-2
@@ -8,6 +8,11 @@
|
|||||||
// even if the process is interrupted mid-loop, but may also result in chat history that is not
|
// even if the process is interrupted mid-loop, but may also result in chat history that is not
|
||||||
// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
|
// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
|
||||||
//
|
//
|
||||||
|
// Additionally, this sample demonstrates the MessageInjectingChatClient feature, which allows tool
|
||||||
|
// code to inject new user messages during the function execution loop. When a tool or anything else enqueues
|
||||||
|
// a message via MessageInjectingChatClient.EnqueueMessages during the tool execution loop, the PerServiceCallChatHistoryPersistingChatClient
|
||||||
|
// detects the pending message before the next service call and includes the injected message in the request.
|
||||||
|
//
|
||||||
// To use end-of-run persistence instead (atomic run semantics), remove the
|
// To use end-of-run persistence instead (atomic run semantics), remove the
|
||||||
// RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run
|
// RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run
|
||||||
// persistence is the default behavior.
|
// persistence is the default behavior.
|
||||||
@@ -54,6 +59,37 @@ static string GetTime([Description("The city name.")] string city) =>
|
|||||||
_ => $"{city}: time data not available."
|
_ => $"{city}: time data not available."
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// This tool demonstrates message injection during the function execution loop.
|
||||||
|
// When called, it checks travel advisories for a city. If an advisory is active, it uses
|
||||||
|
// the ambient run context to resolve MessageInjectingChatClient and injects a follow-up user message
|
||||||
|
// asking for alternative destinations. The model will process this injected message on the next
|
||||||
|
// service call — even though the parent FunctionInvokingChatClient loop would otherwise stop.
|
||||||
|
[Description("Check current travel advisories for a city.")]
|
||||||
|
static string CheckTravelAdvisory([Description("The city name.")] string city)
|
||||||
|
{
|
||||||
|
// Simulated travel advisory data.
|
||||||
|
var advisory = city.ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"LONDON" => "Travel advisory: Severe fog warnings in London. Flights may be delayed or cancelled.",
|
||||||
|
"SEATTLE" => "Travel advisory: Heavy rainfall expected. Flooding possible in low-lying areas.",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (advisory is null)
|
||||||
|
{
|
||||||
|
return $"{city}: No active travel advisories.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// When an advisory is found, inject a follow-up question so the model automatically
|
||||||
|
// suggests alternatives without the user needing to ask.
|
||||||
|
var runContext = AIAgent.CurrentRunContext!;
|
||||||
|
runContext.Agent.GetService<MessageInjectingChatClient>()?.EnqueueMessages(
|
||||||
|
runContext.Session!,
|
||||||
|
[new ChatMessage(ChatRole.User, $"Given the travel advisory for {city}, what alternative cities would you recommend instead?")]);
|
||||||
|
|
||||||
|
return advisory;
|
||||||
|
}
|
||||||
|
|
||||||
// Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence.
|
// Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence.
|
||||||
// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
|
// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
|
||||||
// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
|
// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
|
||||||
@@ -65,10 +101,11 @@ AIAgent agent = chatClient.AsAIAgent(
|
|||||||
{
|
{
|
||||||
Name = "WeatherAssistant",
|
Name = "WeatherAssistant",
|
||||||
RequirePerServiceCallChatHistoryPersistence = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
|
EnableMessageInjection = true,
|
||||||
ChatOptions = new()
|
ChatOptions = new()
|
||||||
{
|
{
|
||||||
Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
|
Instructions = "You are a helpful travel assistant. When asked about cities, call the appropriate tools for each city.",
|
||||||
Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime)]
|
Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime), AIFunctionFactory.Create(CheckTravelAdvisory)]
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,6 +146,18 @@ async Task RunNonStreamingAsync()
|
|||||||
response = await agent.RunAsync(FollowUp2, session);
|
response = await agent.RunAsync(FollowUp2, session);
|
||||||
PrintAgentResponse(response.Text);
|
PrintAgentResponse(response.Text);
|
||||||
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
|
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
|
||||||
|
|
||||||
|
// Fourth turn — demonstrates message injection during the function loop.
|
||||||
|
// The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up
|
||||||
|
// user message asking for alternative cities. After the tool completes, the internal loop
|
||||||
|
// in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message
|
||||||
|
// and calls the service again, so the model answers the follow-up automatically.
|
||||||
|
const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories.";
|
||||||
|
PrintUserMessage(TravelPrompt);
|
||||||
|
|
||||||
|
response = await agent.RunAsync(TravelPrompt, session);
|
||||||
|
PrintAgentResponse(response.Text);
|
||||||
|
PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task RunStreamingAsync()
|
async Task RunStreamingAsync()
|
||||||
@@ -181,6 +230,30 @@ async Task RunStreamingAsync()
|
|||||||
|
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
|
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
|
||||||
|
|
||||||
|
// Fourth turn — demonstrates message injection during the function loop (streaming).
|
||||||
|
// The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up
|
||||||
|
// user message asking for alternative cities. After the tool completes, the internal loop
|
||||||
|
// in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message
|
||||||
|
// and calls the service again, so the model answers the follow-up automatically.
|
||||||
|
const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories.";
|
||||||
|
PrintUserMessage(TravelPrompt);
|
||||||
|
|
||||||
|
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||||
|
Console.Write("\n[Agent] ");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
await foreach (var update in agent.RunStreamingAsync(TravelPrompt, session))
|
||||||
|
{
|
||||||
|
Console.Write(update);
|
||||||
|
|
||||||
|
// During streaming we should be able to see updates to the chat history
|
||||||
|
// before the full run completes, as each service call is made and persisted.
|
||||||
|
PrintChatHistory(session, "During travel advisory run", ref lastChatHistorySize, ref lastConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void PrintUserMessage(string message)
|
void PrintUserMessage(string message)
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.AI.OpenAI" />
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// Shell tool with environment-aware system prompt
|
||||||
|
//
|
||||||
|
// WARNING: This sample uses LocalShellExecutor, which executes real commands
|
||||||
|
// against the shell on this machine. Approval gating is disabled here so
|
||||||
|
// the demo runs unattended; in any real application keep approval on
|
||||||
|
// (the default), or use DockerShellExecutor for container isolation. The
|
||||||
|
// commands the model emits below are read-only or scoped (echo, cd into
|
||||||
|
// a temp folder, set a process-local env var) but a different model or
|
||||||
|
// prompt could choose to do something destructive. Run this only in an
|
||||||
|
// environment where you are comfortable with the agent typing into your
|
||||||
|
// terminal.
|
||||||
|
//
|
||||||
|
// Demonstrates LocalShellExecutor in both modes paired with
|
||||||
|
// ShellEnvironmentProvider, an AIContextProvider that probes the live
|
||||||
|
// shell (OS, family, version, CWD, common CLIs) and injects authoritative
|
||||||
|
// system-prompt instructions so the agent emits commands in the right
|
||||||
|
// idiom (PowerShell vs POSIX).
|
||||||
|
//
|
||||||
|
// Two runs:
|
||||||
|
// 1) Stateless mode: each tool call runs in a fresh shell. Useful when
|
||||||
|
// commands are independent (read-only scripts, version checks, file
|
||||||
|
// listings) and you want strong isolation between calls. Side
|
||||||
|
// effects in one call (cd, exported variables) do NOT carry to the
|
||||||
|
// next.
|
||||||
|
// 2) Persistent mode: a single long-lived shell is reused across calls,
|
||||||
|
// so working directory and exported environment variables are
|
||||||
|
// preserved. Useful for multi-step workflows that build state
|
||||||
|
// (cd into a folder and run a sequence of commands there; set a
|
||||||
|
// token in one step and read it in the next).
|
||||||
|
|
||||||
|
using Azure.AI.OpenAI;
|
||||||
|
using Azure.Identity;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Agents.AI.Tools.Shell;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using OpenAI.Chat;
|
||||||
|
|
||||||
|
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||||
|
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||||
|
|
||||||
|
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||||
|
.GetChatClient(deploymentName);
|
||||||
|
|
||||||
|
const string Instructions = """
|
||||||
|
You are an agent with a single tool: run_shell. Use it to satisfy the
|
||||||
|
user's request. Do not describe what you would do — actually run the
|
||||||
|
commands. Reply with the final answer derived from real output.
|
||||||
|
""";
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// 1. Stateless mode — each call gets a fresh shell.
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
Console.WriteLine("### Stateless mode\n");
|
||||||
|
await using (var statelessShell = new LocalShellExecutor(new() { Mode = ShellMode.Stateless, AcknowledgeUnsafe = true }))
|
||||||
|
{
|
||||||
|
var envProvider = new ShellEnvironmentProvider(statelessShell);
|
||||||
|
var statelessAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||||
|
{
|
||||||
|
ChatOptions = new()
|
||||||
|
{
|
||||||
|
Instructions = Instructions,
|
||||||
|
Tools = [statelessShell.AsAIFunction(requireApproval: false)],
|
||||||
|
},
|
||||||
|
AIContextProviders = [envProvider],
|
||||||
|
});
|
||||||
|
|
||||||
|
var statelessSession = await statelessAgent.CreateSessionAsync();
|
||||||
|
Console.WriteLine(await statelessAgent.RunAsync("Print the current working directory.", statelessSession));
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
// Show that side effects do NOT carry between stateless calls: ask the
|
||||||
|
// agent to cd into the system temp directory in one call, then ask
|
||||||
|
// for the CWD in a second call. Stateless mode means the cd is gone.
|
||||||
|
Console.WriteLine(await statelessAgent.RunAsync("Change directory into the system temp folder, then print the current working directory.", statelessSession));
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine(await statelessAgent.RunAsync("In a NEW shell call, print the current working directory again. Tell me whether it matches the temp folder from the previous call.", statelessSession));
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
PrintSnapshot(envProvider.CurrentSnapshot!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// 2. Persistent mode — one shell, reused across calls. State carries.
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
Console.WriteLine("\n### Persistent mode\n");
|
||||||
|
await using (var persistentShell = new LocalShellExecutor(new() { Mode = ShellMode.Persistent, AcknowledgeUnsafe = true }))
|
||||||
|
{
|
||||||
|
var envProvider = new ShellEnvironmentProvider(persistentShell);
|
||||||
|
var persistentAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||||
|
{
|
||||||
|
ChatOptions = new()
|
||||||
|
{
|
||||||
|
Instructions = Instructions,
|
||||||
|
Tools = [persistentShell.AsAIFunction(requireApproval: false)],
|
||||||
|
},
|
||||||
|
AIContextProviders = [envProvider],
|
||||||
|
});
|
||||||
|
|
||||||
|
var persistentSession = await persistentAgent.CreateSessionAsync();
|
||||||
|
|
||||||
|
// State carries across calls in persistent mode: cd into temp, then
|
||||||
|
// verify the next call sees the new CWD.
|
||||||
|
Console.WriteLine(await persistentAgent.RunAsync("Change directory into the system temp folder, then print the current working directory.", persistentSession));
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine(await persistentAgent.RunAsync("In a NEW shell call, print the current working directory again. Tell me whether it still matches the temp folder.", persistentSession));
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
// Same idea with an exported variable: set in one call, read in the next.
|
||||||
|
Console.WriteLine(await persistentAgent.RunAsync("Set the environment variable DEMO_TOKEN to the value 'hello-world'.", persistentSession));
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine(await persistentAgent.RunAsync("Print the current value of DEMO_TOKEN. Tell me exactly what value the shell reports.", persistentSession));
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
PrintSnapshot(envProvider.CurrentSnapshot!);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void PrintSnapshot(ShellEnvironmentSnapshot snap)
|
||||||
|
{
|
||||||
|
Console.WriteLine("--- Captured environment snapshot ---");
|
||||||
|
Console.WriteLine($" Family: {snap.Family}");
|
||||||
|
Console.WriteLine($" OS: {snap.OSDescription}");
|
||||||
|
Console.WriteLine($" Shell: {snap.ShellVersion ?? "(unknown)"}");
|
||||||
|
Console.WriteLine($" CWD: {snap.WorkingDirectory}");
|
||||||
|
foreach (var (tool, version) in snap.ToolVersions)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" {tool,-8} {version ?? "(not installed)"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.AI.Projects" />
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
<PackageReference Include="ModelContextProtocol" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// Foundry Toolbox via MCP (Streamable HTTP).
|
||||||
|
//
|
||||||
|
// Point an `McpClient` at a Foundry Toolbox's MCP endpoint. The agent
|
||||||
|
// discovers the toolbox's tools at runtime and invokes them locally.
|
||||||
|
|
||||||
|
using System.ClientModel;
|
||||||
|
using System.ClientModel.Primitives;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using Azure.AI.Projects;
|
||||||
|
using Azure.AI.Projects.Agents;
|
||||||
|
using Azure.Core;
|
||||||
|
using Azure.Identity;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using OpenAI.Responses;
|
||||||
|
|
||||||
|
#pragma warning disable OPENAI001 // Experimental API
|
||||||
|
#pragma warning disable AAIP001 // AgentToolboxes is experimental
|
||||||
|
|
||||||
|
// Name of the toolbox to create and connect to.
|
||||||
|
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";
|
||||||
|
|
||||||
|
TokenCredential credential = new DefaultAzureCredential();
|
||||||
|
|
||||||
|
// Comment out if the toolbox already exists in your Foundry project.
|
||||||
|
var toolboxEndpoint = await CreateSampleToolboxAsync(ToolboxName, endpoint, credential);
|
||||||
|
|
||||||
|
// Inject a fresh Azure AI bearer token on every MCP request.
|
||||||
|
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
|
||||||
|
{
|
||||||
|
InnerHandler = new HttpClientHandler(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Console.WriteLine($"Connecting to toolbox MCP endpoint: {toolboxEndpoint}");
|
||||||
|
|
||||||
|
await using McpClient mcpClient = await McpClient.CreateAsync(
|
||||||
|
new HttpClientTransport(
|
||||||
|
new HttpClientTransportOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri(toolboxEndpoint),
|
||||||
|
Name = "foundry_toolbox",
|
||||||
|
TransportMode = HttpTransportMode.StreamableHttp,
|
||||||
|
AdditionalHeaders = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Foundry-Features"] = "Toolboxes=V1Preview",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
httpClient));
|
||||||
|
|
||||||
|
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync();
|
||||||
|
Console.WriteLine($"Toolbox MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}");
|
||||||
|
|
||||||
|
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||||
|
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||||
|
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||||
|
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
|
||||||
|
|
||||||
|
AIAgent agent = aiProjectClient.AsAIAgent(
|
||||||
|
model: deploymentName,
|
||||||
|
instructions: "You are a helpful assistant. Use the available toolbox tools to answer the user.",
|
||||||
|
name: "ToolboxMcpAgent",
|
||||||
|
tools: [.. mcpTools.Cast<AITool>()]);
|
||||||
|
|
||||||
|
Console.WriteLine($"\nUser: {Query}\n");
|
||||||
|
Console.WriteLine($"Assistant: {await agent.RunAsync(Query)}");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
// Toolboxes are normally configured in the Foundry portal or a deployment
|
||||||
|
// script, not the application itself. This helper exists so the sample can
|
||||||
|
// be run end-to-end without first setting a toolbox up by hand.
|
||||||
|
|
||||||
|
// The Foundry-Features header is currently required for toolbox CRUD operations.
|
||||||
|
var options = new AgentAdministrationClientOptions();
|
||||||
|
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
|
||||||
|
var adminClient = new AgentAdministrationClient(new Uri(endpoint), credential, options);
|
||||||
|
var toolboxClient = adminClient.GetAgentToolboxes();
|
||||||
|
|
||||||
|
// Delete existing toolbox if present (ignore 404).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await toolboxClient.DeleteToolboxAsync(name);
|
||||||
|
Console.WriteLine($"Deleted existing toolbox '{name}'");
|
||||||
|
}
|
||||||
|
catch (ClientResultException ex) when (ex.Status == 404)
|
||||||
|
{
|
||||||
|
// Toolbox does not exist — nothing to delete.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a fresh version with a single MCP tool.
|
||||||
|
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
|
||||||
|
serverLabel: "api-specs",
|
||||||
|
serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
|
||||||
|
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
|
||||||
|
|
||||||
|
ToolboxVersion 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}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pipeline policy: adds the Foundry-Features header for toolbox CRUD calls
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
internal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DelegatingHandler: attaches a fresh Azure AI bearer token to every request
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
|
||||||
|
{
|
||||||
|
private readonly TokenRequestContext _tokenContext = new([scope]);
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||||
|
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Foundry Toolbox via MCP
|
||||||
|
|
||||||
|
This sample shows how to use a Foundry Toolbox by pointing an `McpClient` at the toolbox's MCP endpoint. The agent discovers the toolbox's tools at runtime and invokes them locally over MCP.
|
||||||
|
|
||||||
|
## What this sample demonstrates
|
||||||
|
|
||||||
|
- Connecting to a Foundry toolbox's MCP endpoint via Streamable HTTP transport
|
||||||
|
- Injecting a fresh Azure AI bearer token (`https://ai.azure.com/.default`) on every MCP request
|
||||||
|
- Passing the discovered MCP tools to `AIProjectClient.AsAIAgent(...)`
|
||||||
|
- Optional helper to create (or replace) a sample toolbox in the project so the sample is runnable end-to-end
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A Microsoft Foundry project with a toolbox configured (or let the sample create one for you)
|
||||||
|
- Azure CLI installed and authenticated (`az login`)
|
||||||
|
|
||||||
|
Set the following environment variables:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
|
||||||
|
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini"
|
||||||
|
```
|
||||||
|
|
||||||
|
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}`.
|
||||||
|
|
||||||
|
## Run the sample
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
-148
@@ -1,148 +0,0 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
|
||||||
|
|
||||||
// This sample shows how to load a Foundry toolbox and pass its tools as server-side
|
|
||||||
// tools when creating an agent. The Foundry platform handles tool execution — the agent
|
|
||||||
// process does not invoke tools locally.
|
|
||||||
|
|
||||||
using System.ClientModel;
|
|
||||||
using System.ClientModel.Primitives;
|
|
||||||
using Azure.AI.Projects;
|
|
||||||
using Azure.AI.Projects.Agents;
|
|
||||||
using Azure.Identity;
|
|
||||||
using Microsoft.Agents.AI;
|
|
||||||
using OpenAI.Responses;
|
|
||||||
|
|
||||||
#pragma warning disable OPENAI001 // Experimental API
|
|
||||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental
|
|
||||||
#pragma warning disable CS8321 // Local functions may be commented-out alternatives
|
|
||||||
|
|
||||||
// Replace with your own Foundry toolbox name.
|
|
||||||
const string ToolboxName = "research_toolbox";
|
|
||||||
// Used only by CombineToolboxes — swap in a second toolbox you own.
|
|
||||||
const string SecondToolboxName = "analysis_toolbox";
|
|
||||||
// Replace with any question that exercises the tools configured in your toolbox.
|
|
||||||
const string Query = "Introduce yourself and briefly describe the tools you can use to help me.";
|
|
||||||
|
|
||||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
|
||||||
?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint.");
|
|
||||||
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
|
||||||
|
|
||||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
|
||||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
|
||||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
|
||||||
var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
|
|
||||||
|
|
||||||
await Main(projectClient, model, endpoint);
|
|
||||||
// await CombineToolboxes(projectClient, model, endpoint);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main: single toolbox
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static async Task Main(AIProjectClient projectClient, string model, string endpoint)
|
|
||||||
{
|
|
||||||
Console.WriteLine("=== Foundry Toolbox Server-Side Tools Example ===");
|
|
||||||
|
|
||||||
// Comment out if the toolbox already exists in your Foundry project.
|
|
||||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
|
||||||
|
|
||||||
// Omit the version to resolve the toolbox's current default version at runtime.
|
|
||||||
var tools = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
|
||||||
|
|
||||||
AIAgent agent = projectClient
|
|
||||||
.AsAIAgent(
|
|
||||||
model: model,
|
|
||||||
instructions: "You are a research assistant. Use the available tools to answer questions.",
|
|
||||||
tools: tools.ToList());
|
|
||||||
|
|
||||||
Console.WriteLine($"User: {Query}");
|
|
||||||
Console.WriteLine($"Result: {await agent.RunAsync(Query)}\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Alternative: combine tools from multiple toolboxes
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static async Task CombineToolboxes(AIProjectClient projectClient, string model, string endpoint)
|
|
||||||
{
|
|
||||||
Console.WriteLine("=== Combine Toolboxes Example ===");
|
|
||||||
|
|
||||||
// Comment out if the toolboxes already exist in your Foundry project.
|
|
||||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
|
||||||
await CreateSampleToolboxAsync(SecondToolboxName, endpoint);
|
|
||||||
|
|
||||||
var toolboxA = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
|
||||||
var toolboxB = await projectClient.GetToolboxToolsAsync(SecondToolboxName);
|
|
||||||
|
|
||||||
var allTools = toolboxA.Concat(toolboxB).ToList();
|
|
||||||
|
|
||||||
AIAgent agent = projectClient
|
|
||||||
.AsAIAgent(
|
|
||||||
model: model,
|
|
||||||
instructions: "You are a research assistant. Use all available tools to answer questions.",
|
|
||||||
tools: allTools);
|
|
||||||
|
|
||||||
Console.WriteLine($"User: {Query}");
|
|
||||||
Console.WriteLine($"Combined-toolbox result: {await agent.RunAsync(Query)}\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper: create (or replace) a sample toolbox so the sample works out-of-the-box
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static async Task CreateSampleToolboxAsync(string name, string endpoint)
|
|
||||||
{
|
|
||||||
// Toolboxes are normally configured in the Foundry portal or a deployment
|
|
||||||
// script, not the application itself. This helper exists so the sample can
|
|
||||||
// be run end-to-end without first setting a toolbox up by hand.
|
|
||||||
|
|
||||||
// The Foundry-Features header is currently required for toolbox CRUD operations.
|
|
||||||
var options = new AgentAdministrationClientOptions();
|
|
||||||
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
|
|
||||||
var adminClient = new AgentAdministrationClient(
|
|
||||||
new Uri(endpoint),
|
|
||||||
new DefaultAzureCredential(),
|
|
||||||
options);
|
|
||||||
var toolboxClient = adminClient.GetAgentToolboxes();
|
|
||||||
|
|
||||||
// Delete existing toolbox if present (ignore 404).
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await toolboxClient.DeleteToolboxAsync(name);
|
|
||||||
Console.WriteLine($"Deleted existing toolbox '{name}'");
|
|
||||||
}
|
|
||||||
catch (ClientResultException ex) when (ex.Status == 404)
|
|
||||||
{
|
|
||||||
// Toolbox does not exist — nothing to delete.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a fresh version with a single MCP tool.
|
|
||||||
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
|
|
||||||
serverLabel: "api-specs",
|
|
||||||
serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
|
|
||||||
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
|
|
||||||
|
|
||||||
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))");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Pipeline policy that adds the Foundry-Features header for toolbox CRUD
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
internal 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-46
@@ -1,46 +0,0 @@
|
|||||||
# Agent_Step25_ToolboxServerSideTools
|
|
||||||
|
|
||||||
This sample demonstrates loading a named Foundry toolbox and passing its tools as
|
|
||||||
**server-side tools** when creating an agent via `AsAIAgent()`.
|
|
||||||
|
|
||||||
When tools from a toolbox are passed this way, they are sent as tool definitions in
|
|
||||||
the Responses API request. The Foundry platform handles tool execution — the agent
|
|
||||||
process does not invoke tools locally.
|
|
||||||
|
|
||||||
This is the dotnet equivalent of the Python sample:
|
|
||||||
`python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- A Microsoft Foundry project
|
|
||||||
- `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint
|
|
||||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable set (defaults to `gpt-5.4-mini`)
|
|
||||||
|
|
||||||
The sample recreates the toolbox on each run, replacing any existing toolbox with
|
|
||||||
the same name. Comment out the `CreateSampleToolboxAsync` call if you want to keep
|
|
||||||
an existing toolbox unchanged.
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
1. `projectClient.GetToolboxVersionAsync(name)` fetches the toolbox definition from the
|
|
||||||
Foundry project API (resolving the default version if none is specified)
|
|
||||||
2. `ToolboxVersion.ToAITools()` converts each tool definition to an `AITool` instance
|
|
||||||
3. The tools are passed to `AsAIAgent(tools: ...)` which includes them in the Responses
|
|
||||||
API request as server-side tool definitions
|
|
||||||
|
|
||||||
For a one-liner, use `projectClient.GetToolboxToolsAsync(name)` to fetch and convert in one call.
|
|
||||||
|
|
||||||
## Sample flows
|
|
||||||
|
|
||||||
| Flow | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `Main` (default) | Loads a single toolbox and runs an agent with its tools |
|
|
||||||
| `CombineToolboxes` | Loads two toolboxes and merges their tools into one agent |
|
|
||||||
|
|
||||||
Uncomment the desired flow in the top-level statements to try each one.
|
|
||||||
|
|
||||||
## Running the sample
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet run
|
|
||||||
```
|
|
||||||
@@ -73,6 +73,7 @@ Some samples require extra tool-specific environment variables. See each sample
|
|||||||
| [Memory search](./Agent_Step22_MemorySearch/) | Memory search tool |
|
| [Memory search](./Agent_Step22_MemorySearch/) | Memory search tool |
|
||||||
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
|
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
|
||||||
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
|
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
|
||||||
|
| [Foundry toolbox via MCP](./Agent_Step25_FoundryToolboxMcp/) | Use a Foundry Toolbox from a non-hosted agent via its MCP endpoint |
|
||||||
|
|
||||||
## Running the samples
|
## Running the samples
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Harness.ConsoleReactiveComponents;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides descriptive helpers for common ANSI/VT100 escape sequences used
|
||||||
|
/// in the split-console layout (DECSTBM scroll regions, cursor movement, line erasure).
|
||||||
|
/// </summary>
|
||||||
|
public static class AnsiEscapes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the scrollable region to rows 1 through <paramref name="bottom"/> (DECSTBM).
|
||||||
|
/// Content outside this region will not scroll.
|
||||||
|
/// </summary>
|
||||||
|
public static string SetScrollRegion(int bottom) => $"\x1b[1;{bottom}r";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resets the scroll region to the full terminal height (DECSTBM reset).
|
||||||
|
/// </summary>
|
||||||
|
public static string ResetScrollRegion => "\x1b[r";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves the cursor to the specified 1-based <paramref name="row"/> and <paramref name="column"/> (CUP).
|
||||||
|
/// </summary>
|
||||||
|
public static string MoveCursor(int row, int column) => $"\x1b[{row};{column}H";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Erases the current line from the cursor position to the end of the line (EL 0).
|
||||||
|
/// </summary>
|
||||||
|
public static string EraseToEndOfLine => "\x1b[0K";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Erases the entire current line (EL 2).
|
||||||
|
/// </summary>
|
||||||
|
public static string EraseEntireLine => "\x1b[2K";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Erases the entire screen.
|
||||||
|
/// </summary>
|
||||||
|
public static string EraseEntireScreen => "\x1b[2J";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Erases the scrollback buffer (ESC[3J). Use alongside <see cref="EraseEntireScreen"/>
|
||||||
|
/// to fully clear both the visible screen and the scroll history.
|
||||||
|
/// </summary>
|
||||||
|
public static string EraseScrollbackBuffer => "\x1b[3J";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the current cursor position (DECSC / SCP).
|
||||||
|
/// Note: most terminals have a single save slot — nested saves are not supported.
|
||||||
|
/// </summary>
|
||||||
|
public static string SaveCursor => "\x1b[s";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restores the previously saved cursor position (DECRC / RCP).
|
||||||
|
/// </summary>
|
||||||
|
public static string RestoreCursor => "\x1b[u";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves the cursor to the specified 1-based <paramref name="row"/> at column 1, then erases the entire line.
|
||||||
|
/// Convenience combination of <see cref="MoveCursor"/> and <see cref="EraseEntireLine"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static string MoveAndEraseLine(int row) => $"\x1b[{row};1H\x1b[2K";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the foreground text color using a <see cref="ConsoleColor"/> value.
|
||||||
|
/// </summary>
|
||||||
|
public static string SetForegroundColor(ConsoleColor color) => $"\x1b[{ConsoleColorToAnsi(color)}m";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resets all text attributes (color, bold, etc.) to their defaults.
|
||||||
|
/// </summary>
|
||||||
|
public static string ResetAttributes => "\x1b[0m";
|
||||||
|
|
||||||
|
private static int ConsoleColorToAnsi(ConsoleColor color) => color switch
|
||||||
|
{
|
||||||
|
ConsoleColor.Black => 30,
|
||||||
|
ConsoleColor.DarkRed => 31,
|
||||||
|
ConsoleColor.DarkGreen => 32,
|
||||||
|
ConsoleColor.DarkYellow => 33,
|
||||||
|
ConsoleColor.DarkBlue => 34,
|
||||||
|
ConsoleColor.DarkMagenta => 35,
|
||||||
|
ConsoleColor.DarkCyan => 36,
|
||||||
|
ConsoleColor.Gray => 37,
|
||||||
|
ConsoleColor.DarkGray => 90,
|
||||||
|
ConsoleColor.Red => 91,
|
||||||
|
ConsoleColor.Green => 92,
|
||||||
|
ConsoleColor.Yellow => 93,
|
||||||
|
ConsoleColor.Blue => 94,
|
||||||
|
ConsoleColor.Magenta => 95,
|
||||||
|
ConsoleColor.Cyan => 96,
|
||||||
|
ConsoleColor.White => 97,
|
||||||
|
_ => 37
|
||||||
|
};
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Harness.ConsoleReactiveFramework;
|
||||||
|
|
||||||
|
namespace Harness.ConsoleReactiveComponents;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A component that renders a selectable list of items with a cursor indicator.
|
||||||
|
/// The selected item is indicated with a ">" prefix and rendered in the highlight color.
|
||||||
|
/// Optionally includes a title above the list and a custom text input option at the bottom.
|
||||||
|
/// </summary>
|
||||||
|
public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, ConsoleReactiveState>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the height (in rows) required to render the list,
|
||||||
|
/// including the optional title and custom text input row.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="props">The list selection props.</param>
|
||||||
|
/// <returns>The number of rows needed.</returns>
|
||||||
|
public static int CalculateHeight(ListSelectionProps props)
|
||||||
|
{
|
||||||
|
int height = props.Items.Count;
|
||||||
|
if (props.CustomTextPlaceholder != null)
|
||||||
|
{
|
||||||
|
height++;
|
||||||
|
}
|
||||||
|
|
||||||
|
height += GetTitleLineCount(props.Title);
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void RenderCore(ListSelectionProps props, ConsoleReactiveState state)
|
||||||
|
{
|
||||||
|
int row = 0;
|
||||||
|
|
||||||
|
// Render the title lines (if any)
|
||||||
|
if (props.Title is not null)
|
||||||
|
{
|
||||||
|
foreach (string line in props.Title.Split('\n'))
|
||||||
|
{
|
||||||
|
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||||
|
Console.Write(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(props.Y + row, props.X));
|
||||||
|
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||||
|
|
||||||
|
bool isSelected = i == props.SelectedIndex;
|
||||||
|
bool isCustomTextOption = props.CustomTextPlaceholder != null && i == props.Items.Count;
|
||||||
|
|
||||||
|
// Cursor indicator
|
||||||
|
Console.Write(isSelected ? "> " : " ");
|
||||||
|
|
||||||
|
if (isCustomTextOption)
|
||||||
|
{
|
||||||
|
this.RenderCustomTextOption(props, isSelected);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (isSelected)
|
||||||
|
{
|
||||||
|
Console.Write(AnsiEscapes.SetForegroundColor(props.HighlightColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Write(props.Items[i]);
|
||||||
|
|
||||||
|
if (isSelected)
|
||||||
|
{
|
||||||
|
Console.Write(AnsiEscapes.ResetAttributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of lines the title occupies, or 0 if no title is set.
|
||||||
|
/// </summary>
|
||||||
|
private static int GetTitleLineCount(string? title) =>
|
||||||
|
title is null ? 0 : title.Split('\n').Length;
|
||||||
|
|
||||||
|
private void RenderCustomTextOption(ListSelectionProps props, bool isSelected)
|
||||||
|
{
|
||||||
|
if (props.CustomText.Length > 0)
|
||||||
|
{
|
||||||
|
// User has typed text — render in highlight color if selected
|
||||||
|
if (isSelected)
|
||||||
|
{
|
||||||
|
Console.Write(AnsiEscapes.SetForegroundColor(props.HighlightColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Write(props.CustomText);
|
||||||
|
|
||||||
|
if (isSelected)
|
||||||
|
{
|
||||||
|
Console.Write(AnsiEscapes.ResetAttributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(props.CustomTextPlaceholder))
|
||||||
|
{
|
||||||
|
// No text — show placeholder in dark grey (or highlight color if selected)
|
||||||
|
if (isSelected)
|
||||||
|
{
|
||||||
|
Console.Write(AnsiEscapes.SetForegroundColor(props.HighlightColor));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Write(" ");
|
||||||
|
Console.Write(props.CustomTextPlaceholder);
|
||||||
|
Console.Write(AnsiEscapes.ResetAttributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Props for <see cref="ListSelection"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record ListSelectionProps : ConsoleReactiveProps
|
||||||
|
{
|
||||||
|
/// <summary>Gets the title text displayed above the list items. May contain newlines for multi-line titles.</summary>
|
||||||
|
public string? Title { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the items to display in the list.</summary>
|
||||||
|
public IReadOnlyList<string> Items { get; init; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>Gets the zero-based index of the currently selected item.</summary>
|
||||||
|
public int SelectedIndex { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the highlight color for the active item. Defaults to <see cref="ConsoleColor.Cyan"/>.</summary>
|
||||||
|
public ConsoleColor HighlightColor { get; init; } = ConsoleColor.Cyan;
|
||||||
|
|
||||||
|
/// <summary>Gets the placeholder text for the custom text input option. If <c>null</c>, no custom option is shown.</summary>
|
||||||
|
public string? CustomTextPlaceholder { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the text being typed into the custom text input option.</summary>
|
||||||
|
public string CustomText { get; init; } = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Harness.ConsoleReactiveFramework;
|
||||||
|
|
||||||
|
namespace Harness.ConsoleReactiveComponents;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Props for <see cref="TextInput"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record TextInputProps : ConsoleReactiveProps
|
||||||
|
{
|
||||||
|
/// <summary>Gets the prompt string displayed on the left (e.g. "> " or "user > ").</summary>
|
||||||
|
public string Prompt { get; init; } = "> ";
|
||||||
|
|
||||||
|
/// <summary>Gets the text content to render to the right of the prompt.</summary>
|
||||||
|
public string Text { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Gets the placeholder text shown in dark grey when <see cref="Text"/> is empty.</summary>
|
||||||
|
public string Placeholder { get; init; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A component that renders a prompt with text input. Supports multi-line text
|
||||||
|
/// where continuation lines are indented to align with the text start position
|
||||||
|
/// (i.e. the column after the prompt).
|
||||||
|
/// </summary>
|
||||||
|
public class TextInput : ConsoleReactiveComponent<TextInputProps, ConsoleReactiveState>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the height (in rows) required to render the prompt and text
|
||||||
|
/// given the available width.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="props">The text input props.</param>
|
||||||
|
/// <param name="availableWidth">The total available width in columns.</param>
|
||||||
|
/// <returns>The number of rows needed.</returns>
|
||||||
|
public static int CalculateHeight(TextInputProps props, int availableWidth)
|
||||||
|
{
|
||||||
|
int promptLength = props.Prompt.Length;
|
||||||
|
int textWidth = availableWidth - promptLength;
|
||||||
|
|
||||||
|
if (textWidth <= 0 || props.Text.Length == 0)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int lines = 1;
|
||||||
|
int remaining = props.Text.Length - textWidth;
|
||||||
|
while (remaining > 0)
|
||||||
|
{
|
||||||
|
lines++;
|
||||||
|
remaining -= textWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void RenderCore(TextInputProps props, ConsoleReactiveState state)
|
||||||
|
{
|
||||||
|
int promptLength = props.Prompt.Length;
|
||||||
|
int textWidth = props.Width - promptLength;
|
||||||
|
string indent = new(' ', promptLength);
|
||||||
|
|
||||||
|
// First line: prompt + start of text
|
||||||
|
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
|
||||||
|
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||||
|
Console.Write(props.Prompt);
|
||||||
|
|
||||||
|
if (textWidth <= 0 || props.Text.Length == 0)
|
||||||
|
{
|
||||||
|
// Show placeholder if text is empty
|
||||||
|
if (props.Text.Length == 0 && props.Placeholder.Length > 0 && textWidth > 0)
|
||||||
|
{
|
||||||
|
Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||||
|
Console.Write(" ");
|
||||||
|
Console.Write(props.Placeholder[..Math.Min(props.Placeholder.Length, textWidth - 1)]);
|
||||||
|
Console.Write(AnsiEscapes.ResetAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int offset = 0;
|
||||||
|
int firstChunk = Math.Min(textWidth, props.Text.Length);
|
||||||
|
Console.Write(props.Text[offset..firstChunk]);
|
||||||
|
offset = firstChunk;
|
||||||
|
|
||||||
|
// Continuation lines: indented to align with text start
|
||||||
|
int row = 1;
|
||||||
|
while (offset < props.Text.Length)
|
||||||
|
{
|
||||||
|
int chunk = Math.Min(textWidth, props.Text.Length - offset);
|
||||||
|
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
|
||||||
|
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||||
|
Console.Write(indent);
|
||||||
|
Console.Write(props.Text[offset..(offset + chunk)]);
|
||||||
|
offset += chunk;
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Harness.ConsoleReactiveFramework;
|
||||||
|
|
||||||
|
namespace Harness.ConsoleReactiveComponents;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Props for <see cref="TextPanel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record TextPanelProps : ConsoleReactiveProps
|
||||||
|
{
|
||||||
|
/// <summary>Gets the items to render in the panel. Each item is a pre-rendered
|
||||||
|
/// console string (may include ANSI escape sequences and newlines).</summary>
|
||||||
|
public IReadOnlyList<string> Items { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A component that renders a list of pre-rendered string items vertically.
|
||||||
|
/// Designed for rendering dynamic items in a non-scroll region that may be
|
||||||
|
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveProps.Height"/>
|
||||||
|
/// exceeds the number of output lines, leftover lines are erased.
|
||||||
|
/// </summary>
|
||||||
|
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the height (in lines) needed to render all items.
|
||||||
|
/// </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(props.Y + currentRow));
|
||||||
|
Console.Write(lines[j]);
|
||||||
|
currentRow++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the component height exceeds the output, erase leftover lines
|
||||||
|
if (props.Height > currentRow)
|
||||||
|
{
|
||||||
|
for (int i = currentRow; i < props.Height; i++)
|
||||||
|
{
|
||||||
|
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountLines(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = 1;
|
||||||
|
for (int i = 0; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
if (text[i] == '\n')
|
||||||
|
{
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If text ends with a newline, don't count the trailing empty line
|
||||||
|
if (text[text.Length - 1] == '\n')
|
||||||
|
{
|
||||||
|
count--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// 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(props.Y + props.Height - 1, props.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Harness.ConsoleReactiveFramework;
|
||||||
|
|
||||||
|
namespace Harness.ConsoleReactiveComponents;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Props for <see cref="TopBottomRule"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record TopBottomRuleProps : ConsoleReactiveProps
|
||||||
|
{
|
||||||
|
/// <summary>Gets the foreground color of the horizontal rules. If <c>null</c>, the default terminal color is used.</summary>
|
||||||
|
public ConsoleColor? Color { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A component that renders a top and bottom horizontal rule (─) with children
|
||||||
|
/// stacked vertically between them.
|
||||||
|
/// </summary>
|
||||||
|
public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, ConsoleReactiveState>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the total height including the top rule, children, and bottom rule.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="props">The component props containing children.</param>
|
||||||
|
/// <returns>2 (for the rules) plus the sum of all children heights.</returns>
|
||||||
|
public static int CalculateHeight(TopBottomRuleProps props)
|
||||||
|
{
|
||||||
|
int childrenHeight = 0;
|
||||||
|
foreach (var child in props.Children)
|
||||||
|
{
|
||||||
|
childrenHeight += child.BaseProps?.Height ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top rule + children + bottom rule
|
||||||
|
return 2 + childrenHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void RenderCore(TopBottomRuleProps props, ConsoleReactiveState state)
|
||||||
|
{
|
||||||
|
int ruleWidth = props.Width;
|
||||||
|
string rule = new('─', ruleWidth);
|
||||||
|
|
||||||
|
if (props.Color.HasValue)
|
||||||
|
{
|
||||||
|
Console.Write(AnsiEscapes.SetForegroundColor(props.Color.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top rule
|
||||||
|
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
|
||||||
|
Console.Write(rule);
|
||||||
|
|
||||||
|
// Render children stacked below the top rule
|
||||||
|
int currentY = props.Y + 1;
|
||||||
|
|
||||||
|
if (props.Color.HasValue)
|
||||||
|
{
|
||||||
|
Console.Write(AnsiEscapes.ResetAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in props.Children)
|
||||||
|
{
|
||||||
|
child.BaseProps = child.BaseProps! with { X = props.X, Y = currentY };
|
||||||
|
child.Render();
|
||||||
|
currentY += child.BaseProps.Height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.Color.HasValue)
|
||||||
|
{
|
||||||
|
Console.Write(AnsiEscapes.SetForegroundColor(props.Color.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom rule
|
||||||
|
Console.Write(AnsiEscapes.MoveCursor(currentY, props.X));
|
||||||
|
Console.Write(rule);
|
||||||
|
|
||||||
|
if (props.Color.HasValue)
|
||||||
|
{
|
||||||
|
Console.Write(AnsiEscapes.ResetAttributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Harness.ConsoleReactiveFramework;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstract base class for all console UI components. Provides access to layout
|
||||||
|
/// through <see cref="BaseProps"/> and a <see cref="Render"/> method for drawing to the console.
|
||||||
|
/// Derive from <see cref="ConsoleReactiveComponent{TProps, TState}"/> instead of this class directly.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class ConsoleReactiveComponent
|
||||||
|
{
|
||||||
|
internal ConsoleReactiveComponent()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
|
||||||
|
/// Used by parent components to set layout (X, Y, Width, Height) on children without
|
||||||
|
/// knowing the concrete props type.
|
||||||
|
/// </summary>
|
||||||
|
public abstract ConsoleReactiveProps? BaseProps { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Renders the component to the console at its current position.</summary>
|
||||||
|
public abstract void Render();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidates the component's cached render state, causing the next <see cref="Render"/> call
|
||||||
|
/// to proceed even if props and state have not changed. Use after a screen erase to force repaint.
|
||||||
|
/// </summary>
|
||||||
|
public abstract void Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generic base class for console UI components with typed props and state.
|
||||||
|
/// Props represent externally supplied configuration; state represents internal mutable data.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TProps">The type of the component's props (external configuration).</typeparam>
|
||||||
|
/// <typeparam name="TState">The type of the component's internal state.</typeparam>
|
||||||
|
public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactiveComponent
|
||||||
|
where TProps : ConsoleReactiveProps
|
||||||
|
where TState : ConsoleReactiveState
|
||||||
|
{
|
||||||
|
private 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; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override ConsoleReactiveProps? BaseProps
|
||||||
|
{
|
||||||
|
get => this.Props;
|
||||||
|
set => this.Props = (TProps?)value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the component's internal state.</summary>
|
||||||
|
protected TState? State { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the component's state and triggers a re-render.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="newState">The new state value.</param>
|
||||||
|
public void SetState(TState newState)
|
||||||
|
{
|
||||||
|
this.State = newState;
|
||||||
|
this.Render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the component using the current props and state.
|
||||||
|
/// Uses a lock to prevent concurrent renders from multiple sources.
|
||||||
|
/// Skips rendering if neither props nor state have changed since the last render.
|
||||||
|
/// </summary>
|
||||||
|
public override void Render()
|
||||||
|
{
|
||||||
|
lock (this._renderLock)
|
||||||
|
{
|
||||||
|
if (this.Props is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EqualityComparer<TProps>.Default.Equals(this.Props, this._lastRenderedProps)
|
||||||
|
&& EqualityComparer<TState>.Default.Equals(this.State, this._lastRenderedState))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.RenderCore(this.Props, this.State!);
|
||||||
|
|
||||||
|
this._lastRenderedProps = this.Props;
|
||||||
|
this._lastRenderedState = this.State;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override void Invalidate()
|
||||||
|
{
|
||||||
|
lock (this._renderLock)
|
||||||
|
{
|
||||||
|
this._lastRenderedProps = default;
|
||||||
|
this._lastRenderedState = default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by <see cref="Render"/> to perform the actual rendering. Override this in derived classes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="props">The current props.</param>
|
||||||
|
/// <param name="state">The current state.</param>
|
||||||
|
public abstract void RenderCore(TProps props, TState state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base record for component props. Provides layout properties (position and size)
|
||||||
|
/// and an optional <see cref="Children"/> collection for composing child components.
|
||||||
|
/// </summary>
|
||||||
|
public record ConsoleReactiveProps
|
||||||
|
{
|
||||||
|
/// <summary>Gets the 1-based column position of the component.</summary>
|
||||||
|
public int X { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the 1-based row position of the component.</summary>
|
||||||
|
public int Y { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the width of the component in columns.</summary>
|
||||||
|
public int Width { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the height of the component in rows.</summary>
|
||||||
|
public int Height { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the child components to render within this component.</summary>
|
||||||
|
public IReadOnlyList<ConsoleReactiveComponent> Children { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base record for component state.
|
||||||
|
/// </summary>
|
||||||
|
public record ConsoleReactiveState;
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Harness.ConsoleReactiveFramework;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event args for console resize events, containing the old and new dimensions.
|
||||||
|
/// </summary>
|
||||||
|
public class ConsoleResizeEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>Gets the previous console width.</summary>
|
||||||
|
public int OldWidth { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the previous console height.</summary>
|
||||||
|
public int OldHeight { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the new console width.</summary>
|
||||||
|
public int NewWidth { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the new console height.</summary>
|
||||||
|
public int NewHeight { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ConsoleResizeEventArgs"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="oldWidth">The previous width.</param>
|
||||||
|
/// <param name="oldHeight">The previous height.</param>
|
||||||
|
/// <param name="newWidth">The new width.</param>
|
||||||
|
/// <param name="newHeight">The new height.</param>
|
||||||
|
public ConsoleResizeEventArgs(int oldWidth, int oldHeight, int newWidth, int newHeight)
|
||||||
|
{
|
||||||
|
this.OldWidth = oldWidth;
|
||||||
|
this.OldHeight = oldHeight;
|
||||||
|
this.NewWidth = newWidth;
|
||||||
|
this.NewHeight = newHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Singleton that polls console dimensions every 16ms and raises the
|
||||||
|
/// <see cref="ConsoleResized"/> event when the window size changes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConsoleResizeListener
|
||||||
|
{
|
||||||
|
#pragma warning disable IDE0052 // Remove unread private members
|
||||||
|
private readonly Task _task;
|
||||||
|
#pragma warning restore IDE0052 // Remove unread private members
|
||||||
|
|
||||||
|
private int _lastWidth;
|
||||||
|
private int _lastHeight;
|
||||||
|
|
||||||
|
private ConsoleResizeListener()
|
||||||
|
{
|
||||||
|
this._lastWidth = Console.WindowWidth;
|
||||||
|
this._lastHeight = Console.WindowHeight;
|
||||||
|
this._task = this.ListenForResizeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the singleton instance of <see cref="ConsoleResizeListener"/>.</summary>
|
||||||
|
public static ConsoleResizeListener Instance { get; } = new ConsoleResizeListener();
|
||||||
|
|
||||||
|
/// <summary>Raised when the console window is resized.</summary>
|
||||||
|
public event EventHandler<ConsoleResizeEventArgs>? ConsoleResized;
|
||||||
|
|
||||||
|
private async Task ListenForResizeAsync()
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int currentWidth = Console.WindowWidth;
|
||||||
|
int currentHeight = Console.WindowHeight;
|
||||||
|
|
||||||
|
if (currentWidth != this._lastWidth || currentHeight != this._lastHeight)
|
||||||
|
{
|
||||||
|
int oldWidth = this._lastWidth;
|
||||||
|
int oldHeight = this._lastHeight;
|
||||||
|
this._lastWidth = currentWidth;
|
||||||
|
this._lastHeight = currentHeight;
|
||||||
|
this.ConsoleResized?.Invoke(this, new ConsoleResizeEventArgs(oldWidth, oldHeight, currentWidth, currentHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Harness.ConsoleReactiveFramework;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event args for key press events, wrapping a <see cref="ConsoleKeyInfo"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class KeyPressEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>Gets the key information for the pressed key.</summary>
|
||||||
|
public ConsoleKeyInfo KeyInfo { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="KeyPressEventArgs"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="keyInfo">The key information.</param>
|
||||||
|
public KeyPressEventArgs(ConsoleKeyInfo keyInfo)
|
||||||
|
{
|
||||||
|
this.KeyInfo = keyInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Singleton that polls for console key presses every 16ms and raises the
|
||||||
|
/// <see cref="KeyPressed"/> event when a key is detected.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class KeyEventListener
|
||||||
|
{
|
||||||
|
#pragma warning disable IDE0052 // Remove unread private members
|
||||||
|
private readonly Task _task;
|
||||||
|
#pragma warning restore IDE0052 // Remove unread private members
|
||||||
|
|
||||||
|
private KeyEventListener()
|
||||||
|
{
|
||||||
|
this._task = this.ListenForKeyPressesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the singleton instance of <see cref="KeyEventListener"/>.</summary>
|
||||||
|
public static KeyEventListener Instance { get; } = new KeyEventListener();
|
||||||
|
|
||||||
|
/// <summary>Raised when a key is pressed in the console.</summary>
|
||||||
|
public event EventHandler<KeyPressEventArgs>? KeyPressed;
|
||||||
|
|
||||||
|
private async Task ListenForKeyPressesAsync()
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
while (Console.KeyAvailable)
|
||||||
|
{
|
||||||
|
var keyInfo = Console.ReadKey(intercept: true);
|
||||||
|
this.KeyPressed?.Invoke(this, new KeyPressEventArgs(keyInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-7
@@ -5,24 +5,25 @@ using Microsoft.Agents.AI;
|
|||||||
namespace Harness.Shared.Console.Commands;
|
namespace Harness.Shared.Console.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles a console command (e.g., /todos, /mode). Command handlers are checked
|
/// Base class for console command handlers (e.g., /todos, /mode). Command handlers
|
||||||
/// in order before user input is sent to the agent. The first handler that
|
/// are checked in order before user input is sent to the agent. The first handler
|
||||||
/// accepts the input prevents further handlers from being checked.
|
/// that accepts the input prevents further handlers from being checked.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ICommandHandler
|
public abstract class CommandHandler
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the help text for this command, displayed in the console header.
|
/// Gets the help text for this command, displayed in the mode-and-help bar.
|
||||||
/// Returns <see langword="null"/> if the command is not currently available.
|
/// Returns <see langword="null"/> if the command is not currently available.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>Help text like <c>"/todos (show todo list)"</c>, or <see langword="null"/>.</returns>
|
/// <returns>Help text like <c>"/todos (show todo list)"</c>, or <see langword="null"/>.</returns>
|
||||||
string? GetHelpText();
|
public abstract string? GetHelpText();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to handle the given user input.
|
/// Attempts to handle the given user input.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="input">The raw user input string.</param>
|
/// <param name="input">The raw user input string.</param>
|
||||||
/// <param name="session">The current agent session.</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>
|
/// <returns><see langword="true"/> if this handler handled the input; <see langword="false"/> otherwise.</returns>
|
||||||
bool TryHandle(string input, AgentSession session);
|
public abstract ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux);
|
||||||
}
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles the <c>/exit</c> command to shut down the console application.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExitCommandHandler : CommandHandler
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override string? GetHelpText() => "/exit (quit)";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||||
|
{
|
||||||
|
if (!input.Equals("/exit", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new ValueTask<bool>(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ux.RequestShutdown();
|
||||||
|
return new ValueTask<bool>(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-11
@@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles the <c>/mode</c> command to display or switch the current agent mode.
|
/// Handles the <c>/mode</c> command to display or switch the current agent mode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class ModeCommandHandler : ICommandHandler
|
public sealed class ModeCommandHandler : CommandHandler
|
||||||
{
|
{
|
||||||
private readonly AgentModeProvider? _modeProvider;
|
private readonly AgentModeProvider? _modeProvider;
|
||||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||||
@@ -24,10 +24,10 @@ internal sealed class ModeCommandHandler : ICommandHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
|
public override string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public bool TryHandle(string input, AgentSession session)
|
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||||
{
|
{
|
||||||
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
|
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -36,7 +36,7 @@ internal sealed class ModeCommandHandler : ICommandHandler
|
|||||||
|
|
||||||
if (this._modeProvider is null)
|
if (this._modeProvider is null)
|
||||||
{
|
{
|
||||||
System.Console.WriteLine("AgentModeProvider is not available.");
|
await ux.WriteInfoLineAsync("AgentModeProvider is not available.").ConfigureAwait(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ internal sealed class ModeCommandHandler : ICommandHandler
|
|||||||
if (parts.Length < 2)
|
if (parts.Length < 2)
|
||||||
{
|
{
|
||||||
string current = this._modeProvider.GetMode(session);
|
string current = this._modeProvider.GetMode(session);
|
||||||
System.Console.WriteLine($"\n Current mode: {current}\n");
|
await ux.WriteInfoLineAsync($"Current mode: {current}").ConfigureAwait(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,15 +53,12 @@ internal sealed class ModeCommandHandler : ICommandHandler
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
this._modeProvider.SetMode(session, newMode);
|
this._modeProvider.SetMode(session, newMode);
|
||||||
System.Console.ForegroundColor = ConsoleWriter.GetModeColor(newMode, this._modeColors);
|
ux.CurrentMode = newMode;
|
||||||
System.Console.WriteLine($"\n Switched to {newMode} mode.\n");
|
await ux.WriteInfoLineAsync($"Switched to {newMode} mode.", ModeColors.Get(newMode, this._modeColors)).ConfigureAwait(false);
|
||||||
System.Console.ResetColor();
|
|
||||||
}
|
}
|
||||||
catch (ArgumentException ex)
|
catch (ArgumentException ex)
|
||||||
{
|
{
|
||||||
System.Console.ForegroundColor = ConsoleColor.Red;
|
await ux.WriteInfoLineAsync(ex.Message, ConsoleColor.Red).ConfigureAwait(false);
|
||||||
System.Console.WriteLine($"\n {ex}\n");
|
|
||||||
System.Console.ResetColor();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <c>/session-export <filename></c> and <c>/session-import <filename></c>
|
||||||
|
/// commands for serializing the current session to a file and restoring a session from a file.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SessionCommandHandler : CommandHandler
|
||||||
|
{
|
||||||
|
private readonly AIAgent _agent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SessionCommandHandler"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agent">The agent used for session serialization and deserialization.</param>
|
||||||
|
public SessionCommandHandler(AIAgent agent)
|
||||||
|
{
|
||||||
|
this._agent = agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override string? GetHelpText() => "/session-export <file> | /session-import <file>";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||||
|
{
|
||||||
|
string command = input.Split(' ', 2)[0];
|
||||||
|
|
||||||
|
if (command.Equals("/session-export", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await this.HandleExportAsync(input, session, ux).ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.Equals("/session-import", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await this.HandleImportAsync(input, ux).ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleExportAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||||
|
{
|
||||||
|
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length < 2)
|
||||||
|
{
|
||||||
|
await ux.WriteInfoLineAsync("Usage: /session-export <filename>").ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string filename = parts[1];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JsonElement serialized = await this._agent.SerializeSessionAsync(session).ConfigureAwait(false);
|
||||||
|
string json = JsonSerializer.Serialize(serialized);
|
||||||
|
await File.WriteAllTextAsync(filename, json).ConfigureAwait(false);
|
||||||
|
await ux.WriteInfoLineAsync($"Session exported to {filename}").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await ux.WriteInfoLineAsync($"Failed to export session to {filename}: {ex.Message}").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleImportAsync(string input, IUXStateDriver ux)
|
||||||
|
{
|
||||||
|
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length < 2)
|
||||||
|
{
|
||||||
|
await ux.WriteInfoLineAsync("Usage: /session-import <filename>").ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string filename = parts[1];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string json = await File.ReadAllTextAsync(filename).ConfigureAwait(false);
|
||||||
|
JsonElement element = JsonSerializer.Deserialize<JsonElement>(json);
|
||||||
|
AgentSession newSession = await this._agent.DeserializeSessionAsync(element).ConfigureAwait(false);
|
||||||
|
await ux.ReplaceSessionAsync(newSession).ConfigureAwait(false);
|
||||||
|
await ux.WriteInfoLineAsync($"Session imported from {filename}").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
await ux.WriteInfoLineAsync($"File not found: {filename}").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await ux.WriteInfoLineAsync($"Failed to import session from {filename}: {ex.Message}").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-18
@@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles the <c>/todos</c> command to display the current todo list.
|
/// Handles the <c>/todos</c> command to display the current todo list.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class TodoCommandHandler : ICommandHandler
|
public sealed class TodoCommandHandler : CommandHandler
|
||||||
{
|
{
|
||||||
private readonly TodoProvider? _todoProvider;
|
private readonly TodoProvider? _todoProvider;
|
||||||
|
|
||||||
@@ -21,10 +21,10 @@ internal sealed class TodoCommandHandler : ICommandHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
|
public override string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public bool TryHandle(string input, AgentSession session)
|
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||||
{
|
{
|
||||||
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
|
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -33,34 +33,28 @@ internal sealed class TodoCommandHandler : ICommandHandler
|
|||||||
|
|
||||||
if (this._todoProvider is null)
|
if (this._todoProvider is null)
|
||||||
{
|
{
|
||||||
System.Console.WriteLine("TodoProvider is not available.");
|
await ux.WriteInfoLineAsync("TodoProvider is not available.").ConfigureAwait(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var todos = this._todoProvider.GetAllTodos(session);
|
var todos = await this._todoProvider.GetAllTodosAsync(session).ConfigureAwait(false);
|
||||||
if (todos.Count == 0)
|
if (todos.Count == 0)
|
||||||
{
|
{
|
||||||
System.Console.WriteLine("\n No todos yet.\n");
|
await ux.WriteInfoLineAsync("No todos yet.").ConfigureAwait(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
System.Console.WriteLine();
|
await ux.WriteInfoLineAsync("── Todo List ──").ConfigureAwait(false);
|
||||||
System.Console.WriteLine(" ── Todo List ──");
|
|
||||||
foreach (var item in todos)
|
foreach (var item in todos)
|
||||||
{
|
{
|
||||||
string status = item.IsComplete ? "✓" : "○";
|
string status = item.IsComplete ? "✓" : "○";
|
||||||
System.Console.ForegroundColor = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White;
|
ConsoleColor color = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White;
|
||||||
System.Console.Write($" [{status}] #{item.Id} {item.Title}");
|
string description = string.IsNullOrWhiteSpace(item.Description)
|
||||||
if (!string.IsNullOrWhiteSpace(item.Description))
|
? string.Empty
|
||||||
{
|
: $" — {item.Description}";
|
||||||
System.Console.Write($" — {item.Description}");
|
await ux.WriteInfoLineAsync($"[{status}] #{item.Id} {item.Title}{description}", color).ConfigureAwait(false);
|
||||||
}
|
|
||||||
|
|
||||||
System.Console.WriteLine();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
System.Console.ResetColor();
|
|
||||||
System.Console.WriteLine();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Harness.ConsoleReactiveComponents;
|
||||||
|
using Harness.ConsoleReactiveFramework;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console.Components;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Props for <see cref="AgentModeAndHelp"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record AgentModeAndHelpProps : ConsoleReactiveProps
|
||||||
|
{
|
||||||
|
/// <summary>Gets or sets the current mode name (e.g. "plan", "execute"), or <see langword="null"/> if no mode is active.</summary>
|
||||||
|
public string? Mode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the foreground color for the mode label.</summary>
|
||||||
|
public ConsoleColor? ModeColor { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the help text to display (e.g. available commands and exit info).</summary>
|
||||||
|
public string? HelpText { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A component that renders a single fixed line below the bottom rule showing
|
||||||
|
/// the current agent mode (in the mode colour) and available commands (in dark grey).
|
||||||
|
/// </summary>
|
||||||
|
public class AgentModeAndHelp : ConsoleReactiveComponent<AgentModeAndHelpProps, ConsoleReactiveState>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the height of the component.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="props">The component props.</param>
|
||||||
|
/// <returns>1 if there is content to display; otherwise 0.</returns>
|
||||||
|
public static int CalculateHeight(AgentModeAndHelpProps props) =>
|
||||||
|
(props.Mode is not null || !string.IsNullOrEmpty(props.HelpText)) ? 1 : 0;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void RenderCore(AgentModeAndHelpProps props, ConsoleReactiveState state)
|
||||||
|
{
|
||||||
|
if (props.Mode is null && string.IsNullOrEmpty(props.HelpText))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Console.Write(AnsiEscapes.SaveCursor);
|
||||||
|
System.Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y));
|
||||||
|
|
||||||
|
bool hasMode = props.Mode is not null;
|
||||||
|
|
||||||
|
if (hasMode)
|
||||||
|
{
|
||||||
|
if (props.ModeColor.HasValue)
|
||||||
|
{
|
||||||
|
System.Console.Write(AnsiEscapes.SetForegroundColor(props.ModeColor.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Console.Write($" [{props.Mode}]");
|
||||||
|
System.Console.Write(AnsiEscapes.ResetAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(props.HelpText))
|
||||||
|
{
|
||||||
|
string prefix = hasMode ? " " : " ";
|
||||||
|
System.Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||||
|
System.Console.Write($"{prefix}{props.HelpText}");
|
||||||
|
System.Console.Write(AnsiEscapes.ResetAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Console.Write(AnsiEscapes.RestoreCursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Harness.ConsoleReactiveComponents;
|
||||||
|
using Harness.ConsoleReactiveFramework;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console.Components;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Props for <see cref="AgentStatus"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record AgentStatusProps : ConsoleReactiveProps
|
||||||
|
{
|
||||||
|
/// <summary>Gets or sets a value indicating whether the spinner is visible.</summary>
|
||||||
|
public bool ShowSpinner { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the formatted token usage text to display.</summary>
|
||||||
|
public string? UsageText { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// State for <see cref="AgentStatus"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="SpinnerIndex">The current spinner animation frame index.</param>
|
||||||
|
public record AgentStatusState(int SpinnerIndex = 0) : ConsoleReactiveState;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A component that renders a single-line agent status bar with an animated spinner
|
||||||
|
/// and token usage statistics. Positioned above the rule in the non-scrolling area.
|
||||||
|
/// </summary>
|
||||||
|
public class AgentStatus : ConsoleReactiveComponent<AgentStatusProps, AgentStatusState>, IDisposable
|
||||||
|
{
|
||||||
|
private static readonly string[] s_spinnerFrames =
|
||||||
|
[
|
||||||
|
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly Timer _timer;
|
||||||
|
private AgentStatusProps? _previousProps;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AgentStatus"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public AgentStatus()
|
||||||
|
{
|
||||||
|
this.State = new AgentStatusState();
|
||||||
|
this._timer = new Timer(this.OnTimerTick, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the height of the agent status component.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="props">The component props.</param>
|
||||||
|
/// <returns>1 if the spinner or usage text is visible; otherwise 0.</returns>
|
||||||
|
public static int CalculateHeight(AgentStatusProps props)
|
||||||
|
{
|
||||||
|
return (props.ShowSpinner || !string.IsNullOrEmpty(props.UsageText)) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disposes the internal spinner timer.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
this.Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases managed resources.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="disposing"><c>true</c> to release managed resources.</param>
|
||||||
|
protected virtual void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
this._timer.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void RenderCore(AgentStatusProps props, AgentStatusState state)
|
||||||
|
{
|
||||||
|
if (!props.ShowSpinner && string.IsNullOrEmpty(props.UsageText))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Console.Write(AnsiEscapes.SaveCursor);
|
||||||
|
System.Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
|
||||||
|
if (props != this._previousProps)
|
||||||
|
{
|
||||||
|
System.Console.Write(AnsiEscapes.EraseToEndOfLine);
|
||||||
|
this._previousProps = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.ShowSpinner)
|
||||||
|
{
|
||||||
|
string frame = s_spinnerFrames[state.SpinnerIndex];
|
||||||
|
System.Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.Cyan));
|
||||||
|
System.Console.Write($" {frame} ");
|
||||||
|
System.Console.Write(AnsiEscapes.ResetAttributes);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
System.Console.Write(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(props.UsageText))
|
||||||
|
{
|
||||||
|
System.Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||||
|
System.Console.Write(props.UsageText);
|
||||||
|
System.Console.Write(AnsiEscapes.ResetAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Console.Write(AnsiEscapes.RestoreCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTimerTick(object? timerState)
|
||||||
|
{
|
||||||
|
if (this.Props is { ShowSpinner: true })
|
||||||
|
{
|
||||||
|
int nextIndex = ((this.State?.SpinnerIndex ?? 0) + 1) % s_spinnerFrames.Length;
|
||||||
|
this.SetState(new AgentStatusState(nextIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
|
||||||
|
|
||||||
using Spectre.Console;
|
|
||||||
|
|
||||||
namespace Harness.Shared.Console;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Centralizes all console output and spinner management for the harness console.
|
|
||||||
/// Observers write through this class so the spinner is automatically paused before output.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ConsoleWriter : IDisposable
|
|
||||||
{
|
|
||||||
private readonly Spinner _spinner = new();
|
|
||||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
|
||||||
|
|
||||||
private bool _lastWasText;
|
|
||||||
private bool _hasReceivedAnyText;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="ConsoleWriter"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
|
||||||
public ConsoleWriter(IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
|
||||||
{
|
|
||||||
this._modeColors = modeColors;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the current agent mode (e.g., "plan", "execute").
|
|
||||||
/// Used to determine the console color for mode-prefixed output.
|
|
||||||
/// </summary>
|
|
||||||
public string? CurrentMode { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes the agent response header (e.g., "[plan] Agent: ") and starts the spinner.
|
|
||||||
/// </summary>
|
|
||||||
public void WriteResponseHeader()
|
|
||||||
{
|
|
||||||
if (this.CurrentMode is not null)
|
|
||||||
{
|
|
||||||
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
|
|
||||||
System.Console.Write($"\n[{this.CurrentMode}] Agent: ");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
System.Console.Write("\nAgent: ");
|
|
||||||
}
|
|
||||||
|
|
||||||
this._lastWasText = true;
|
|
||||||
this._hasReceivedAnyText = false;
|
|
||||||
this._spinner.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes informational output with automatic prefix spacing, without a trailing newline.
|
|
||||||
/// Use when continuation content will be appended on the same line.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">The informational text to write (without leading newline/indent — added automatically).</param>
|
|
||||||
/// <param name="color">Optional console color for the text.</param>
|
|
||||||
public async Task WriteInfoAsync(string text, ConsoleColor? color = null)
|
|
||||||
{
|
|
||||||
await this.WriteInfoCoreAsync(text, color, newLine: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes informational output with automatic prefix spacing, followed by a newline.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">The informational text to write (without leading newline/indent — added automatically).</param>
|
|
||||||
/// <param name="color">Optional console color for the text.</param>
|
|
||||||
public async Task WriteInfoLineAsync(string text, ConsoleColor? color = null)
|
|
||||||
{
|
|
||||||
await this.WriteInfoCoreAsync(text, color, newLine: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
|
|
||||||
{
|
|
||||||
await this._spinner.StopAsync();
|
|
||||||
|
|
||||||
string prefix = this._lastWasText ? "\n\n " : " ";
|
|
||||||
this._lastWasText = false;
|
|
||||||
|
|
||||||
System.Console.ForegroundColor = color ?? GetModeColor(this.CurrentMode, this._modeColors);
|
|
||||||
|
|
||||||
if (newLine)
|
|
||||||
{
|
|
||||||
System.Console.WriteLine(prefix + text);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
System.Console.Write(prefix + text);
|
|
||||||
}
|
|
||||||
|
|
||||||
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
|
|
||||||
|
|
||||||
this._spinner.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes text output from the agent, managing line break state.
|
|
||||||
/// Ensures a newline is written before the first text output.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">The text to write.</param>
|
|
||||||
/// <param name="color">Optional console color override for this text.</param>
|
|
||||||
public async Task WriteTextAsync(string text, ConsoleColor? color = null)
|
|
||||||
{
|
|
||||||
await this._spinner.StopAsync();
|
|
||||||
|
|
||||||
if (!this._lastWasText)
|
|
||||||
{
|
|
||||||
System.Console.Write("\n");
|
|
||||||
this._lastWasText = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._hasReceivedAnyText = true;
|
|
||||||
|
|
||||||
if (color.HasValue)
|
|
||||||
{
|
|
||||||
System.Console.ForegroundColor = color.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
System.Console.Write(text);
|
|
||||||
|
|
||||||
if (color.HasValue)
|
|
||||||
{
|
|
||||||
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._spinner.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads a line of input from the console, pausing the spinner while waiting for input.
|
|
||||||
/// Optionally displays a prompt before reading. The prompt is rendered between
|
|
||||||
/// two horizontal rules for visual clarity.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="prompt">Optional prompt text to display before reading input.</param>
|
|
||||||
/// <param name="promptColor">Optional console color for the prompt text.</param>
|
|
||||||
/// <returns>The line read from the console, or <c>null</c> if no input is available.</returns>
|
|
||||||
public async Task<string?> ReadLineAsync(string? prompt = null, ConsoleColor? promptColor = null)
|
|
||||||
{
|
|
||||||
await this._spinner.StopAsync();
|
|
||||||
|
|
||||||
if (prompt is not null)
|
|
||||||
{
|
|
||||||
System.Console.WriteLine();
|
|
||||||
AnsiConsole.Write(this.CreateModeRule());
|
|
||||||
|
|
||||||
if (promptColor.HasValue)
|
|
||||||
{
|
|
||||||
System.Console.ForegroundColor = promptColor.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
System.Console.Write($" {prompt}");
|
|
||||||
|
|
||||||
if (promptColor.HasValue)
|
|
||||||
{
|
|
||||||
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
string? input = System.Console.ReadLine();
|
|
||||||
|
|
||||||
if (prompt is not null)
|
|
||||||
{
|
|
||||||
AnsiConsole.Write(this.CreateModeRule());
|
|
||||||
}
|
|
||||||
|
|
||||||
this._lastWasText = false;
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Presents a selection prompt with the given choices, plus an option to type a custom response.
|
|
||||||
/// Uses Spectre.Console <see cref="SelectionPrompt{T}"/> for interactive arrow-key selection.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="title">The title/question displayed above the selection list.</param>
|
|
||||||
/// <param name="choices">The list of choices to present.</param>
|
|
||||||
/// <returns>The selected choice text, or the custom-typed response.</returns>
|
|
||||||
public async Task<string> ReadSelectionAsync(string title, IList<string> choices)
|
|
||||||
{
|
|
||||||
await this._spinner.StopAsync();
|
|
||||||
|
|
||||||
AnsiConsole.Write(this.CreateModeRule());
|
|
||||||
|
|
||||||
const string FreeformOption = "✏️ Type a custom response...";
|
|
||||||
var allChoices = choices.Concat([FreeformOption]).ToList();
|
|
||||||
|
|
||||||
var prompt = new SelectionPrompt<string>()
|
|
||||||
.Title($" [bold]{Markup.Escape(title)}[/]")
|
|
||||||
.PageSize(10)
|
|
||||||
.AddChoices(allChoices);
|
|
||||||
|
|
||||||
string selection = AnsiConsole.Prompt(prompt);
|
|
||||||
|
|
||||||
if (selection == FreeformOption)
|
|
||||||
{
|
|
||||||
var textPrompt = new TextPrompt<string>(" [grey]Response:[/]");
|
|
||||||
selection = AnsiConsole.Prompt(textPrompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
AnsiConsole.MarkupLine($" [dim]→ {Markup.Escape(selection)}[/]");
|
|
||||||
AnsiConsole.Write(this.CreateModeRule());
|
|
||||||
|
|
||||||
this._lastWasText = false;
|
|
||||||
return selection;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes the stream-complete footer (handles "no text response" fallback, resets color).
|
|
||||||
/// </summary>
|
|
||||||
public async Task WriteStreamFooterAsync(bool hasFollowUpMessages)
|
|
||||||
{
|
|
||||||
await this._spinner.StopAsync();
|
|
||||||
|
|
||||||
if (!this._hasReceivedAnyText && !hasFollowUpMessages)
|
|
||||||
{
|
|
||||||
System.Console.ForegroundColor = ConsoleColor.DarkYellow;
|
|
||||||
System.Console.Write("\n (no text response from agent)");
|
|
||||||
}
|
|
||||||
|
|
||||||
System.Console.ResetColor();
|
|
||||||
System.Console.WriteLine();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
this._spinner.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the console color associated with a mode name, using the provided color map.
|
|
||||||
/// </summary>
|
|
||||||
internal static ConsoleColor GetModeColor(string? mode, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
|
||||||
{
|
|
||||||
if (mode is null)
|
|
||||||
{
|
|
||||||
return ConsoleColor.Gray;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modeColors is not null && modeColors.TryGetValue(mode, out var color))
|
|
||||||
{
|
|
||||||
return color;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ConsoleColor.Gray;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a <see cref="Rule"/> styled with the current mode color.
|
|
||||||
/// </summary>
|
|
||||||
internal Rule CreateModeRule()
|
|
||||||
{
|
|
||||||
var spectreColor = ToSpectreColor(GetModeColor(this.CurrentMode, this._modeColors));
|
|
||||||
return new Rule().RuleStyle(new Style(spectreColor));
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Color ToSpectreColor(ConsoleColor consoleColor) => consoleColor switch
|
|
||||||
{
|
|
||||||
ConsoleColor.Black => Color.Black,
|
|
||||||
ConsoleColor.DarkBlue => Color.Blue,
|
|
||||||
ConsoleColor.DarkGreen => Color.Green,
|
|
||||||
ConsoleColor.DarkCyan => Color.Teal,
|
|
||||||
ConsoleColor.DarkRed => Color.Red,
|
|
||||||
ConsoleColor.DarkMagenta => Color.Purple,
|
|
||||||
ConsoleColor.DarkYellow => Color.Olive,
|
|
||||||
ConsoleColor.Gray => Color.Silver,
|
|
||||||
ConsoleColor.DarkGray => Color.Grey,
|
|
||||||
ConsoleColor.Blue => Color.Blue1,
|
|
||||||
ConsoleColor.Green => Color.Green1,
|
|
||||||
ConsoleColor.Cyan => Color.Aqua,
|
|
||||||
ConsoleColor.Red => Color.Red1,
|
|
||||||
ConsoleColor.Magenta => Color.Fuchsia,
|
|
||||||
ConsoleColor.Yellow => Color.Yellow,
|
|
||||||
ConsoleColor.White => Color.White,
|
|
||||||
_ => Color.Silver,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using OpenTelemetry;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A simple OpenTelemetry span exporter that writes completed activities (spans) to a text file.
|
||||||
|
/// Each span is formatted as a human-readable block with timestamps, operation name, duration,
|
||||||
|
/// status, and any tags/events.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FileSpanExporter : BaseExporter<Activity>
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public FileSpanExporter(string filePath)
|
||||||
|
{
|
||||||
|
this._filePath = filePath;
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ExportResult Export(in Batch<Activity> batch)
|
||||||
|
{
|
||||||
|
lock (this._lock)
|
||||||
|
{
|
||||||
|
using var writer = new StreamWriter(this._filePath, append: true);
|
||||||
|
foreach (var activity in batch)
|
||||||
|
{
|
||||||
|
WriteActivity(writer, activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExportResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteActivity(StreamWriter writer, Activity activity)
|
||||||
|
{
|
||||||
|
var start = activity.StartTimeUtc.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
|
||||||
|
var duration = activity.Duration.TotalMilliseconds.ToString("F1", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
writer.WriteLine($"[{start}] {activity.OperationName} ({duration}ms) [{activity.Status}]");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(activity.DisplayName) && activity.DisplayName != activity.OperationName)
|
||||||
|
{
|
||||||
|
writer.WriteLine($" DisplayName: {activity.DisplayName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var tag in activity.Tags)
|
||||||
|
{
|
||||||
|
writer.WriteLine($" {tag.Key}: {tag.Value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var ev in activity.Events)
|
||||||
|
{
|
||||||
|
writer.WriteLine($" Event: {ev.Name} @ {ev.Timestamp:HH:mm:ss.fff}");
|
||||||
|
foreach (var tag in ev.Tags)
|
||||||
|
{
|
||||||
|
writer.WriteLine($" {tag.Key}: {tag.Value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an action returned by an observer at the end of an agent turn.
|
||||||
|
/// Subtypes describe either a question to ask the user (<see cref="FollowUpQuestion"/>)
|
||||||
|
/// or a message to add directly to the next agent input (<see cref="FollowUpMessage"/>).
|
||||||
|
/// </summary>
|
||||||
|
public abstract record FollowUpAction;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a question that should be presented to the user. The
|
||||||
|
/// <see cref="Continuation"/> delegate is invoked with the user's answer and the
|
||||||
|
/// UX state driver, and returns an optional <see cref="ChatMessage"/> to add to the
|
||||||
|
/// next agent invocation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Prompt">The question text shown to the user.</param>
|
||||||
|
/// <param name="Continuation">
|
||||||
|
/// Invoked with the user's answer and the UX state driver. The driver lets the
|
||||||
|
/// continuation write output (e.g., an action label like "Approved") in addition
|
||||||
|
/// to producing an optional <see cref="ChatMessage"/> for the next agent invocation.
|
||||||
|
/// </param>
|
||||||
|
public abstract record FollowUpQuestion(
|
||||||
|
string Prompt,
|
||||||
|
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation) : FollowUpAction;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A free-form text question. The user may type any response.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Prompt">The question text shown to the user.</param>
|
||||||
|
/// <param name="Continuation">Continuation that builds the response message.</param>
|
||||||
|
public sealed record TextFollowUpQuestion(
|
||||||
|
string Prompt,
|
||||||
|
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
|
||||||
|
: FollowUpQuestion(Prompt, Continuation);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A choice question. The user picks from <paramref name="Choices"/>, optionally with
|
||||||
|
/// the ability to enter custom text when <paramref name="AllowCustomText"/> is true.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Prompt">The question text shown to the user.</param>
|
||||||
|
/// <param name="Choices">The list of pre-defined choices.</param>
|
||||||
|
/// <param name="AllowCustomText">If true, the user may type a custom response in addition to the listed choices.</param>
|
||||||
|
/// <param name="Continuation">Continuation that builds the response message.</param>
|
||||||
|
public sealed record ChoiceFollowUpQuestion(
|
||||||
|
string Prompt,
|
||||||
|
IReadOnlyList<string> Choices,
|
||||||
|
bool AllowCustomText,
|
||||||
|
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
|
||||||
|
: FollowUpQuestion(Prompt, Continuation);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A message to add directly to the next agent invocation without prompting the user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Message">The chat message to add.</param>
|
||||||
|
public sealed record FollowUpMessage(ChatMessage Message) : FollowUpAction;
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Harness.Shared.Console.Commands;
|
||||||
|
using Harness.Shared.Console.Observers;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestrates agent invocations driven by user-input events from the UI.
|
||||||
|
/// The component invokes the runner's input handlers (<see cref="OnUserInputAsync"/>,
|
||||||
|
/// <see cref="OnStreamingInputAsync"/>, <see cref="StartAgentTurnAsync"/>) directly;
|
||||||
|
/// the runner mutates UI state through the supplied <see cref="IUXStateDriver"/>.
|
||||||
|
/// All per-turn follow-up state (pending questions and accumulated responses) lives
|
||||||
|
/// in the component's state record — the runner reads/writes it exclusively through
|
||||||
|
/// the driver and holds no per-turn fields itself.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HarnessAgentRunner : IDisposable
|
||||||
|
{
|
||||||
|
private readonly AIAgent _agent;
|
||||||
|
private readonly AgentModeProvider? _modeProvider;
|
||||||
|
private readonly MessageInjectingChatClient? _messageInjector;
|
||||||
|
private readonly IReadOnlyList<CommandHandler> _commandHandlers;
|
||||||
|
private readonly IReadOnlyList<ConsoleObserver> _observers;
|
||||||
|
private readonly IUXStateDriver _ux;
|
||||||
|
private readonly SemaphoreSlim _inputGate = new(1, 1);
|
||||||
|
|
||||||
|
private AgentSession _session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="HarnessAgentRunner"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public HarnessAgentRunner(
|
||||||
|
AIAgent agent,
|
||||||
|
AgentSession session,
|
||||||
|
AgentModeProvider? modeProvider,
|
||||||
|
MessageInjectingChatClient? messageInjector,
|
||||||
|
IReadOnlyList<CommandHandler> commandHandlers,
|
||||||
|
IReadOnlyList<ConsoleObserver> observers,
|
||||||
|
IUXStateDriver ux)
|
||||||
|
{
|
||||||
|
this._agent = agent;
|
||||||
|
this._session = session;
|
||||||
|
this._modeProvider = modeProvider;
|
||||||
|
this._messageInjector = messageInjector;
|
||||||
|
this._commandHandlers = commandHandlers;
|
||||||
|
this._observers = observers;
|
||||||
|
this._ux = ux;
|
||||||
|
|
||||||
|
this.HelpText = string.Join(
|
||||||
|
", ",
|
||||||
|
commandHandlers
|
||||||
|
.Select(h => h.GetHelpText())
|
||||||
|
.Where(t => t is not null)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the help text describing all available commands (joined by ", "), suitable
|
||||||
|
/// for display in the mode-and-help bar. Computed from the supplied
|
||||||
|
/// <c>commandHandlers</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string HelpText { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the current session with the specified session. Used by the UX driver
|
||||||
|
/// when importing a serialized session. Acquires the input gate to ensure no
|
||||||
|
/// concurrent agent turn is reading the session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="newSession">The new session to use.</param>
|
||||||
|
internal async Task ReplaceSessionAsync(AgentSession newSession)
|
||||||
|
{
|
||||||
|
await this._inputGate.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
this._session = newSession;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
this._inputGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
// 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),
|
||||||
|
replaceSession: s => this.Runner!.ReplaceSessionAsync(s),
|
||||||
|
modeColors: modeColors);
|
||||||
|
|
||||||
|
this.Runner = runnerFactory(this._uxDriver);
|
||||||
|
|
||||||
|
// Seed help text now that the runner (which knows the registered command handlers)
|
||||||
|
// is available. Direct assignment — no Render is triggered until the caller invokes Render().
|
||||||
|
this.State = this.State with { HelpText = this.Runner.HelpText };
|
||||||
|
|
||||||
|
KeyEventListener.Instance.KeyPressed += this.OnKeyPressed;
|
||||||
|
ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the agent runner that owns the agent loop. Constructed by the factory
|
||||||
|
/// passed to the component's constructor.
|
||||||
|
/// </summary>
|
||||||
|
public HarnessAgentRunner Runner { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Completes when a command handler requests application shutdown (e.g. the user types <c>/exit</c>).
|
||||||
|
/// Awaited by <see cref="HarnessConsole.RunAgentAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public Task ShutdownTask => this._shutdownTcs.Task;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deactivates the component, resetting the scroll region and unsubscribing from events.
|
||||||
|
/// This method is idempotent and safe to call multiple times.
|
||||||
|
/// </summary>
|
||||||
|
public void Deactivate()
|
||||||
|
{
|
||||||
|
if (this._deactivated)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._deactivated = true;
|
||||||
|
this._agentStatus.Dispose();
|
||||||
|
KeyEventListener.Instance.KeyPressed -= this.OnKeyPressed;
|
||||||
|
ConsoleResizeListener.Instance.ConsoleResized -= this.OnConsoleResized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
this.Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases managed resources.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="disposing"><c>true</c> to release managed resources.</param>
|
||||||
|
protected virtual void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
this.Deactivate();
|
||||||
|
this._followUpGate.Dispose();
|
||||||
|
this.Runner.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnKeyPressed(object? sender, KeyPressEventArgs e)
|
||||||
|
{
|
||||||
|
BottomPanelMode mode = this.State!.Mode;
|
||||||
|
if (mode == BottomPanelMode.TextInput)
|
||||||
|
{
|
||||||
|
this.HandleTextInputKey(e);
|
||||||
|
}
|
||||||
|
else if (mode == BottomPanelMode.ListSelection)
|
||||||
|
{
|
||||||
|
this.HandleListSelectionKey(e);
|
||||||
|
}
|
||||||
|
else if (mode == BottomPanelMode.Streaming && this.State.InputEnabled)
|
||||||
|
{
|
||||||
|
this.HandleStreamingInputKey(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleTextInputKey(KeyPressEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||||
|
{
|
||||||
|
string text = this.State!.InputText;
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.SetState(this.State with { InputText = "" });
|
||||||
|
this.DispatchTextInputSubmission(text);
|
||||||
|
}
|
||||||
|
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||||
|
{
|
||||||
|
if (this.State!.InputText.Length > 0)
|
||||||
|
{
|
||||||
|
this.SetState(this.State with { InputText = this.State.InputText[..^1] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
|
||||||
|
{
|
||||||
|
this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleListSelectionKey(KeyPressEventArgs e)
|
||||||
|
{
|
||||||
|
int maxIndex = this.State!.ListSelectionOptions.Count - 1;
|
||||||
|
if (this.State.ListSelectionCustomTextPlaceholder != null)
|
||||||
|
{
|
||||||
|
maxIndex = this.State.ListSelectionOptions.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isOnCustomTextOption = this.State.ListSelectionCustomTextPlaceholder != null
|
||||||
|
&& this.State.ListSelectionIndex == this.State.ListSelectionOptions.Count;
|
||||||
|
|
||||||
|
if (e.KeyInfo.Key == ConsoleKey.UpArrow)
|
||||||
|
{
|
||||||
|
this.SetState(this.State with { ListSelectionIndex = Math.Max(0, this.State.ListSelectionIndex - 1) });
|
||||||
|
}
|
||||||
|
else if (e.KeyInfo.Key == ConsoleKey.DownArrow)
|
||||||
|
{
|
||||||
|
this.SetState(this.State with { ListSelectionIndex = Math.Min(maxIndex, this.State.ListSelectionIndex + 1) });
|
||||||
|
}
|
||||||
|
else if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||||
|
{
|
||||||
|
string result = isOnCustomTextOption
|
||||||
|
? this.State.ListSelectionCustomInputText
|
||||||
|
: this.State.ListSelectionOptions[this.State.ListSelectionIndex];
|
||||||
|
|
||||||
|
this.SetState(this.State with { ListSelectionCustomInputText = "", ListSelectionIndex = 0 });
|
||||||
|
this.DispatchListSelectionSubmission(result);
|
||||||
|
}
|
||||||
|
else if (isOnCustomTextOption)
|
||||||
|
{
|
||||||
|
if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||||
|
{
|
||||||
|
if (this.State.ListSelectionCustomInputText.Length > 0)
|
||||||
|
{
|
||||||
|
this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText[..^1] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
|
||||||
|
{
|
||||||
|
this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText + e.KeyInfo.KeyChar });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleStreamingInputKey(KeyPressEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||||
|
{
|
||||||
|
string text = this.State!.InputText;
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.SetState(this.State with { InputText = "" });
|
||||||
|
_ = this.Runner.OnStreamingInputAsync(text);
|
||||||
|
}
|
||||||
|
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||||
|
{
|
||||||
|
if (this.State!.InputText.Length > 0)
|
||||||
|
{
|
||||||
|
this.SetState(this.State with { InputText = this.State.InputText[..^1] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
|
||||||
|
{
|
||||||
|
this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DispatchTextInputSubmission(string text)
|
||||||
|
{
|
||||||
|
if (this.State!.PendingQuestions.Count > 0)
|
||||||
|
{
|
||||||
|
_ = this.HandleFollowUpAnswerAsync(text);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_ = this.Runner.OnUserInputAsync(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DispatchListSelectionSubmission(string text)
|
||||||
|
{
|
||||||
|
// List selection is only used to answer FollowUpQuestions.
|
||||||
|
_ = this.HandleFollowUpAnswerAsync(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a user answer to the head of the pending follow-up question queue:
|
||||||
|
/// awaits the question's continuation (which is responsible for echoing both the
|
||||||
|
/// question and answer to the scroll area as it sees fit), appends any returned
|
||||||
|
/// chat message to the response accumulator, advances the queue, and — when the
|
||||||
|
/// queue empties — drains the accumulator and resumes the runner.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleFollowUpAnswerAsync(string text)
|
||||||
|
{
|
||||||
|
IReadOnlyList<ChatMessage>? messagesToSend = null;
|
||||||
|
|
||||||
|
await this._followUpGate.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HarnessConsoleUXStateDriver ux = this._uxDriver;
|
||||||
|
IReadOnlyList<FollowUpQuestion> queue = this.State!.PendingQuestions;
|
||||||
|
if (queue.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FollowUpQuestion head = queue[0];
|
||||||
|
|
||||||
|
ChatMessage? response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await head.Continuation(text, ux).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await ux.WriteInfoLineAsync($"❌ Follow-up handler error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false);
|
||||||
|
response = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response is not null)
|
||||||
|
{
|
||||||
|
ux.AddFollowUpResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
ux.AdvanceFollowUpQuestion();
|
||||||
|
|
||||||
|
if (this.State!.PendingQuestions.Count == 0)
|
||||||
|
{
|
||||||
|
messagesToSend = ux.TakeFollowUpResponses();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
this._followUpGate.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume the agent outside the gate — StartAgentTurnAsync runs the full agent
|
||||||
|
// loop which may queue new follow-up questions (re-entering this method).
|
||||||
|
if (messagesToSend is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await this.Runner.StartAgentTurnAsync([.. messagesToSend]).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await this._uxDriver.WriteInfoLineAsync($"❌ Agent error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e)
|
||||||
|
{
|
||||||
|
this._resizedSinceLastRender = true;
|
||||||
|
this.SetState(this.State! with
|
||||||
|
{
|
||||||
|
ConsoleWidth = e.NewWidth,
|
||||||
|
ConsoleHeight = e.NewHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentState state)
|
||||||
|
{
|
||||||
|
if (this._deactivated)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
listProps = listProps with { Height = bottomChildHeight };
|
||||||
|
this._listSelection.Props = listProps;
|
||||||
|
bottomChild = this._listSelection;
|
||||||
|
}
|
||||||
|
else if (state.Mode == BottomPanelMode.Streaming)
|
||||||
|
{
|
||||||
|
TextInputProps textInputProps;
|
||||||
|
if (state.InputEnabled)
|
||||||
|
{
|
||||||
|
textInputProps = new TextInputProps
|
||||||
|
{
|
||||||
|
Prompt = state.Prompt,
|
||||||
|
Text = state.InputText,
|
||||||
|
Placeholder = state.Placeholder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
textInputProps = new TextInputProps
|
||||||
|
{
|
||||||
|
Prompt = state.Prompt,
|
||||||
|
Text = "",
|
||||||
|
Placeholder = state.StreamingPrompt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
|
||||||
|
textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight };
|
||||||
|
this._textInput.Props = textInputProps;
|
||||||
|
bottomChild = this._textInput;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var textInputProps = new TextInputProps
|
||||||
|
{
|
||||||
|
Prompt = state.Prompt,
|
||||||
|
Text = state.InputText,
|
||||||
|
Placeholder = state.Placeholder,
|
||||||
|
};
|
||||||
|
|
||||||
|
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
|
||||||
|
textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight };
|
||||||
|
this._textInput.Props = textInputProps;
|
||||||
|
bottomChild = this._textInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Invalidate all children so they re-render even if props haven't changed
|
||||||
|
this._rule.Invalidate();
|
||||||
|
this._textScrollPanel.Invalidate();
|
||||||
|
this._textPanel.Invalidate();
|
||||||
|
this._queuedPanel.Invalidate();
|
||||||
|
this._agentStatus.Invalidate();
|
||||||
|
this._modeAndHelp.Invalidate();
|
||||||
|
this._textInput.Invalidate();
|
||||||
|
this._listSelection.Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Props = new TextScrollPanelProps
|
||||||
|
{
|
||||||
|
X = 1,
|
||||||
|
Y = 1,
|
||||||
|
Width = state.ConsoleWidth,
|
||||||
|
Height = scrollBottom,
|
||||||
|
Items = scrollItems,
|
||||||
|
};
|
||||||
|
this._textScrollPanel.Render();
|
||||||
|
|
||||||
|
// Render the text panel for the last (dynamic) item just below the scroll region
|
||||||
|
this._textPanel.Props = new TextPanelProps
|
||||||
|
{
|
||||||
|
X = 1,
|
||||||
|
Y = scrollBottom + 1,
|
||||||
|
Width = state.ConsoleWidth,
|
||||||
|
Height = textPanelHeight,
|
||||||
|
Items = lastItems,
|
||||||
|
};
|
||||||
|
this._textPanel.Render();
|
||||||
|
|
||||||
|
// Render queued input items between text panel and agent status
|
||||||
|
int queuedPanelY = scrollBottom + textPanelHeight + 1;
|
||||||
|
this._queuedPanel.Props = new TextPanelProps
|
||||||
|
{
|
||||||
|
X = 1,
|
||||||
|
Y = queuedPanelY,
|
||||||
|
Width = state.ConsoleWidth,
|
||||||
|
Height = queuedPanelHeight,
|
||||||
|
Items = state.QueuedItems,
|
||||||
|
};
|
||||||
|
this._queuedPanel.Render();
|
||||||
|
|
||||||
|
// Render the agent status line between queued items and rule
|
||||||
|
int agentStatusY = queuedPanelY + queuedPanelHeight;
|
||||||
|
if (showStatusAndHelp)
|
||||||
|
{
|
||||||
|
this._agentStatus.Props = agentStatusProps with
|
||||||
|
{
|
||||||
|
X = 1,
|
||||||
|
Y = agentStatusY,
|
||||||
|
Width = state.ConsoleWidth,
|
||||||
|
Height = agentStatusHeight,
|
||||||
|
};
|
||||||
|
this._agentStatus.Render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the bottom rule + child below the agent status
|
||||||
|
this._rule.Props = ruleProps with
|
||||||
|
{
|
||||||
|
X = 1,
|
||||||
|
Y = agentStatusY + agentStatusHeight,
|
||||||
|
};
|
||||||
|
this._rule.Render();
|
||||||
|
|
||||||
|
// Render the mode-and-help line below the bottom rule
|
||||||
|
if (showStatusAndHelp)
|
||||||
|
{
|
||||||
|
int modeAndHelpY = agentStatusY + agentStatusHeight + ruleHeight;
|
||||||
|
this._modeAndHelp.Props = modeAndHelpProps with
|
||||||
|
{
|
||||||
|
X = 1,
|
||||||
|
Y = modeAndHelpY,
|
||||||
|
Width = state.ConsoleWidth,
|
||||||
|
Height = modeAndHelpHeight,
|
||||||
|
};
|
||||||
|
this._modeAndHelp.Render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the bottom padding line
|
||||||
|
System.Console.Write(AnsiEscapes.MoveAndEraseLine(state.ConsoleHeight));
|
||||||
|
|
||||||
|
// Position cursor for natural typing appearance
|
||||||
|
this.PositionCursor(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PositionCursor(HarnessAppComponentState state)
|
||||||
|
{
|
||||||
|
if (state.Mode == BottomPanelMode.TextInput
|
||||||
|
|| (state.Mode == BottomPanelMode.Streaming && state.InputEnabled))
|
||||||
|
{
|
||||||
|
int promptLength = state.Prompt.Length;
|
||||||
|
int textWidth = state.ConsoleWidth - promptLength;
|
||||||
|
int textLength = state.InputText.Length;
|
||||||
|
|
||||||
|
int textInputY = (this._rule.Props?.Y ?? 0) + 1;
|
||||||
|
|
||||||
|
if (textWidth <= 0 || textLength == 0)
|
||||||
|
{
|
||||||
|
System.Console.Write(AnsiEscapes.MoveCursor(textInputY, promptLength + 1));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int cursorRow = textLength < textWidth ? 0 : 1 + ((textLength - textWidth) / textWidth);
|
||||||
|
int cursorCol = textLength < textWidth ? textLength : (textLength - textWidth) % textWidth;
|
||||||
|
System.Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (state.Mode == BottomPanelMode.ListSelection
|
||||||
|
&& state.ListSelectionCustomTextPlaceholder != null
|
||||||
|
&& state.ListSelectionIndex == state.ListSelectionOptions.Count)
|
||||||
|
{
|
||||||
|
int titleLines = state.ListSelectionTitle?.Split('\n').Length ?? 0;
|
||||||
|
int customOptionY = (this._rule.Props?.Y ?? 0) + 1 + titleLines + state.ListSelectionOptions.Count;
|
||||||
|
int cursorCol = 2 + state.ListSelectionCustomInputText.Length + 1;
|
||||||
|
System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Harness.ConsoleReactiveFramework;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines which component is shown in the bottom panel.
|
||||||
|
/// </summary>
|
||||||
|
public enum BottomPanelMode
|
||||||
|
{
|
||||||
|
/// <summary>Show the text input component for user input.</summary>
|
||||||
|
TextInput,
|
||||||
|
|
||||||
|
/// <summary>Show the list selection component for interactive prompts.</summary>
|
||||||
|
ListSelection,
|
||||||
|
|
||||||
|
/// <summary>Show a disabled input indicator during agent streaming.</summary>
|
||||||
|
Streaming,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal state for <see cref="HarnessAppComponent"/>. All UI fields that may
|
||||||
|
/// change after construction live here; they are mutated exclusively via
|
||||||
|
/// <see cref="ConsoleReactiveComponent{TProps,TState}.SetState"/> by the
|
||||||
|
/// owning <see cref="HarnessConsoleUXStateDriver"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record HarnessAppComponentState : ConsoleReactiveState
|
||||||
|
{
|
||||||
|
// --- Console dimensions ---
|
||||||
|
|
||||||
|
/// <summary>Gets the current console width in columns.</summary>
|
||||||
|
public int ConsoleWidth { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the current console height in rows.</summary>
|
||||||
|
public int ConsoleHeight { get; init; }
|
||||||
|
|
||||||
|
// --- Bottom panel mode ---
|
||||||
|
|
||||||
|
/// <summary>Gets the bottom panel mode.</summary>
|
||||||
|
public BottomPanelMode Mode { get; init; } = BottomPanelMode.TextInput;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the queue of follow-up questions waiting for user answers. The head
|
||||||
|
/// (<c>[0]</c>) is the question currently being displayed; subsequent items
|
||||||
|
/// are dispatched in order as each is answered. While this queue is non-empty,
|
||||||
|
/// the next user submission is treated as the answer to the head question
|
||||||
|
/// instead of going to the agent runner's normal input handler.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<FollowUpQuestion> PendingQuestions { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the accumulated follow-up response messages collected during the
|
||||||
|
/// current agent turn — both direct <see cref="FollowUpMessage"/>s emitted
|
||||||
|
/// by observers and continuation results from answered questions. Consumed
|
||||||
|
/// by the runner via <see cref="IUXStateDriver.TakeFollowUpResponses"/>
|
||||||
|
/// before the next agent invocation.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ChatMessage> AccumulatedFollowUpResponses { get; init; } = [];
|
||||||
|
|
||||||
|
// --- Text input (active in TextInput / Streaming modes) ---
|
||||||
|
|
||||||
|
/// <summary>Gets the prompt string for text input mode.</summary>
|
||||||
|
public string Prompt { get; init; } = "> ";
|
||||||
|
|
||||||
|
/// <summary>Gets the placeholder text shown when the input is empty.</summary>
|
||||||
|
public string Placeholder { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Gets the current input text being typed.</summary>
|
||||||
|
public string InputText { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Gets a value indicating whether input is enabled during streaming.</summary>
|
||||||
|
public bool InputEnabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the prompt to show during streaming when input is disabled.</summary>
|
||||||
|
public string StreamingPrompt { get; init; } = "(agent is running...)";
|
||||||
|
|
||||||
|
// --- List selection (active in ListSelection mode) ---
|
||||||
|
|
||||||
|
/// <summary>Gets the title text displayed above the list selection (for interactive prompts).</summary>
|
||||||
|
public string? ListSelectionTitle { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the list selection options.</summary>
|
||||||
|
public IReadOnlyList<string> ListSelectionOptions { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Gets the highlighted option index in list selection mode.</summary>
|
||||||
|
public int ListSelectionIndex { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the placeholder text for the custom text input option in the list.</summary>
|
||||||
|
public string? ListSelectionCustomTextPlaceholder { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
|
||||||
|
public string ListSelectionCustomInputText { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Gets the highlight color for the active list item.</summary>
|
||||||
|
public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan;
|
||||||
|
|
||||||
|
// --- Scroll / output area ---
|
||||||
|
|
||||||
|
/// <summary>Gets the items rendered in the scroll-area. Each item is a pre-rendered
|
||||||
|
/// console string (may include ANSI escape sequences and newlines).</summary>
|
||||||
|
public IReadOnlyList<string> ScrollAreaContentItems { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Gets the queued input items to display above the rule. Each item is a
|
||||||
|
/// pre-rendered console string (may include ANSI escape sequences and newlines).</summary>
|
||||||
|
public IReadOnlyList<string> QueuedItems { get; init; } = [];
|
||||||
|
|
||||||
|
// --- Agent mode + status display ---
|
||||||
|
|
||||||
|
/// <summary>Gets the foreground color for the rule borders and mode label.</summary>
|
||||||
|
public ConsoleColor? ModeColor { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the current mode name displayed below the bottom rule (e.g. "plan").</summary>
|
||||||
|
public string? ModeText { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the help text displayed below the bottom rule (available commands).</summary>
|
||||||
|
public string? HelpText { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets a value indicating whether the agent status spinner is visible.</summary>
|
||||||
|
public bool ShowSpinner { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Gets the formatted token usage text to display in the status bar.</summary>
|
||||||
|
public string? UsageText { get; init; }
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
using Harness.Shared.Console.Commands;
|
using System.Text;
|
||||||
using Harness.Shared.Console.Observers;
|
using Harness.ConsoleReactiveComponents;
|
||||||
using Microsoft.Agents.AI;
|
using Microsoft.Agents.AI;
|
||||||
using Microsoft.Extensions.AI;
|
|
||||||
|
|
||||||
namespace Harness.Shared.Console;
|
namespace Harness.Shared.Console;
|
||||||
|
|
||||||
@@ -15,200 +14,63 @@ public static class HarnessConsole
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs an interactive console session with the specified agent.
|
/// Runs an interactive console session with the specified agent.
|
||||||
/// Supports streaming output, tool call display, spinner animation,
|
/// Constructs the reactive UI component and the <see cref="HarnessAgentRunner"/>,
|
||||||
/// optional planning UX with structured output, and the <c>/todos</c> command.
|
/// wires them together, and awaits the component's <see cref="HarnessAppComponent.ShutdownTask"/>
|
||||||
|
/// (which completes when the user types <c>/exit</c>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="agent">The agent to interact with.</param>
|
/// <param name="agent">The agent to interact with.</param>
|
||||||
/// <param name="title">The title displayed in the console header.</param>
|
/// <param name="userPrompt">A short prompt to the user, displayed as a placeholder in the input area.</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>
|
/// <param name="options">Optional configuration options for the console session.</param>
|
||||||
public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, HarnessConsoleOptions? options = null)
|
public static async Task RunAgentAsync(AIAgent agent, string userPrompt, HarnessConsoleOptions? options = null)
|
||||||
{
|
{
|
||||||
options ??= new();
|
options ??= new();
|
||||||
|
|
||||||
if (options.EnablePlanningUx
|
System.Console.OutputEncoding = Encoding.UTF8;
|
||||||
&& (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} ===");
|
// Null means use defaults; an explicit (possibly empty) list means use exactly what was provided.
|
||||||
System.Console.WriteLine(userPrompt);
|
var observers = options.Observers
|
||||||
|
?? HarnessConsoleOptions.BuildDefaultObservers();
|
||||||
|
var commandHandlers = options.CommandHandlers
|
||||||
|
?? HarnessConsoleOptions.BuildDefaultCommandHandlers(agent, options.ModeColors);
|
||||||
|
|
||||||
var todoProvider = agent.GetService<TodoProvider>();
|
|
||||||
var modeProvider = agent.GetService<AgentModeProvider>();
|
var modeProvider = agent.GetService<AgentModeProvider>();
|
||||||
|
var messageInjector = agent.GetService<MessageInjectingChatClient>();
|
||||||
|
|
||||||
// Build command handlers.
|
AgentSession session = options.SessionFactory is not null
|
||||||
var commandHandlers = new List<ICommandHandler>
|
? await options.SessionFactory(agent)
|
||||||
|
: await agent.CreateSessionAsync();
|
||||||
|
|
||||||
|
using var component = new HarnessAppComponent(
|
||||||
|
placeholder: userPrompt,
|
||||||
|
initialMode: modeProvider?.GetMode(session),
|
||||||
|
inputEnabled: messageInjector is not null,
|
||||||
|
runnerFactory: ux => new HarnessAgentRunner(
|
||||||
|
agent: agent,
|
||||||
|
session: session,
|
||||||
|
modeProvider: modeProvider,
|
||||||
|
messageInjector: messageInjector,
|
||||||
|
commandHandlers: commandHandlers,
|
||||||
|
observers: observers,
|
||||||
|
ux: ux),
|
||||||
|
modeColors: options.ModeColors);
|
||||||
|
|
||||||
|
// Trigger the initial render of the component now that state is seeded.
|
||||||
|
component.Render();
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
new TodoCommandHandler(todoProvider),
|
await component.ShutdownTask.ConfigureAwait(false);
|
||||||
new ModeCommandHandler(modeProvider, options.ModeColors),
|
}
|
||||||
};
|
finally
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
string prompt = BuildUserPrompt(modeProvider, session);
|
|
||||||
string? userInput = await writer.ReadLineAsync(prompt);
|
|
||||||
|
|
||||||
// Main loop to run a command or agent and get the next user command/input.
|
|
||||||
while (!string.IsNullOrWhiteSpace(userInput) && !userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
// Check command handlers first — first one to handle wins.
|
component.Deactivate();
|
||||||
bool handled = false;
|
|
||||||
foreach (var handler in commandHandlers)
|
|
||||||
{
|
|
||||||
if (handler.TryHandle(userInput, session))
|
|
||||||
{
|
|
||||||
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.ResetColor();
|
||||||
|
System.Console.Write(AnsiEscapes.ResetScrollRegion);
|
||||||
|
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||||
|
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||||
|
System.Console.Write(AnsiEscapes.MoveCursor(1, 1));
|
||||||
System.Console.WriteLine("Goodbye!");
|
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,5 +1,11 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// 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;
|
namespace Harness.Shared.Console;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -8,45 +14,127 @@ namespace Harness.Shared.Console;
|
|||||||
public class HarnessConsoleOptions
|
public class HarnessConsoleOptions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the optional maximum context window size in tokens.
|
/// Gets or sets the list of console observers that participate in the agent response
|
||||||
/// When set, token usage is displayed as a percentage of the budget.
|
/// streaming lifecycle. Use the factory methods on this class to create common observer sets.
|
||||||
|
/// When <see langword="null"/> (the default), a default set of observers is used.
|
||||||
|
/// Set to an empty list to disable all observers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? MaxContextWindowTokens { get; set; }
|
public IReadOnlyList<ConsoleObserver>? Observers { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the optional maximum output tokens.
|
/// Gets or sets the list of command handlers to check before sending user input to the agent.
|
||||||
/// Used with <see cref="MaxContextWindowTokens"/> to show input/output budget breakdown.
|
/// Use <see cref="BuildDefaultCommandHandlers"/> to create the default set.
|
||||||
|
/// When <see langword="null"/> (the default), a default set of handlers is used.
|
||||||
|
/// Set to an empty list to disable all command handlers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? MaxOutputTokens { get; set; }
|
public IReadOnlyList<CommandHandler>? CommandHandlers { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether the planning UX is enabled.
|
/// The default mode-to-color mapping used when no custom <see cref="ModeColors"/> are provided.
|
||||||
/// 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>
|
/// </summary>
|
||||||
/// <value>Defaults to <see langword="false"/>.</value>
|
public static readonly IReadOnlyDictionary<string, ConsoleColor> DefaultModeColors = new ReadOnlyDictionary<string, ConsoleColor>(
|
||||||
public bool EnablePlanningUx { get; set; }
|
new Dictionary<string, ConsoleColor>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
/// <summary>
|
["plan"] = ConsoleColor.Cyan,
|
||||||
/// Gets or sets the name of the agent mode that activates the planning UX.
|
["execute"] = ConsoleColor.Green,
|
||||||
/// 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>
|
/// <summary>
|
||||||
/// Gets or sets a mapping of agent mode names to console colors.
|
/// 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.
|
/// When a mode is not found in this dictionary, the default color (<see cref="ConsoleColor.Gray"/>) is used.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(StringComparer.OrdinalIgnoreCase)
|
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(DefaultModeColors, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets an optional factory for creating the <see cref="AgentSession"/>.
|
||||||
|
/// When <see langword="null"/> (the default), <see cref="AIAgent.CreateSessionAsync"/> is used.
|
||||||
|
/// </summary>
|
||||||
|
public Func<AIAgent, Task<AgentSession>>? SessionFactory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the default set of observers without planning support.
|
||||||
|
/// Includes tool call display, tool approval, error display, reasoning display,
|
||||||
|
/// usage display, and text output.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="maxContextWindowTokens">Optional maximum context window size in tokens for usage display.</param>
|
||||||
|
/// <param name="maxOutputTokens">Optional maximum output tokens for usage display.</param>
|
||||||
|
/// <param name="toolFormatters">Optional tool call formatters. When <see langword="null"/>,
|
||||||
|
/// each observer uses the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/>.</param>
|
||||||
|
/// <returns>A list of observers for a standard (non-planning) console session.</returns>
|
||||||
|
public static List<ConsoleObserver> BuildDefaultObservers(
|
||||||
|
int? maxContextWindowTokens = null,
|
||||||
|
int? maxOutputTokens = null,
|
||||||
|
IReadOnlyList<ToolCallFormatter>? toolFormatters = null)
|
||||||
{
|
{
|
||||||
["plan"] = ConsoleColor.Cyan,
|
return
|
||||||
["execute"] = ConsoleColor.Green,
|
[
|
||||||
};
|
new ToolCallDisplayObserver(toolFormatters),
|
||||||
|
new ToolApprovalObserver(toolFormatters),
|
||||||
|
new ErrorDisplayObserver(),
|
||||||
|
new ReasoningDisplayObserver(),
|
||||||
|
new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens),
|
||||||
|
new TextOutputObserver(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the default set of observers with planning support.
|
||||||
|
/// Includes a <see cref="PlanningOutputObserver"/> instead of <see cref="TextOutputObserver"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agent">The agent, used to resolve <see cref="AgentModeProvider"/>.</param>
|
||||||
|
/// <param name="planModeName">The mode name that represents the planning mode.</param>
|
||||||
|
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
|
||||||
|
/// <param name="modeColors">Optional mode-to-color mapping for display.
|
||||||
|
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
|
||||||
|
/// <param name="maxContextWindowTokens">Optional maximum context window size in tokens for usage display.</param>
|
||||||
|
/// <param name="maxOutputTokens">Optional maximum output tokens for usage display.</param>
|
||||||
|
/// <param name="toolFormatters">Optional tool call formatters. When <see langword="null"/>,
|
||||||
|
/// each observer uses the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/>.</param>
|
||||||
|
/// <returns>A list of observers for a planning-enabled console session.</returns>
|
||||||
|
public static List<ConsoleObserver> BuildObserversWithPlanning(
|
||||||
|
AIAgent agent,
|
||||||
|
string planModeName,
|
||||||
|
string executionModeName,
|
||||||
|
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null,
|
||||||
|
int? maxContextWindowTokens = null,
|
||||||
|
int? maxOutputTokens = null,
|
||||||
|
IReadOnlyList<ToolCallFormatter>? toolFormatters = null)
|
||||||
|
{
|
||||||
|
var modeProvider = agent.GetService<AgentModeProvider>()
|
||||||
|
?? throw new InvalidOperationException("Planning requires an AgentModeProvider service on the agent.");
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new ToolCallDisplayObserver(toolFormatters),
|
||||||
|
new ToolApprovalObserver(toolFormatters),
|
||||||
|
new ErrorDisplayObserver(),
|
||||||
|
new ReasoningDisplayObserver(),
|
||||||
|
new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens),
|
||||||
|
new PlanningOutputObserver(modeProvider, planModeName, executionModeName, modeColors ?? DefaultModeColors),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the default set of command handlers.
|
||||||
|
/// Includes exit, todo, and mode command handlers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agent">The agent, used to resolve <see cref="TodoProvider"/> and <see cref="AgentModeProvider"/>.</param>
|
||||||
|
/// <param name="modeColors">Optional mode-to-color mapping for the mode command display.
|
||||||
|
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
|
||||||
|
/// <returns>A list of command handlers for a standard console session.</returns>
|
||||||
|
public static List<CommandHandler> BuildDefaultCommandHandlers(
|
||||||
|
AIAgent agent,
|
||||||
|
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||||
|
{
|
||||||
|
var todoProvider = agent.GetService<TodoProvider>();
|
||||||
|
var modeProvider = agent.GetService<AgentModeProvider>();
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new ExitCommandHandler(),
|
||||||
|
new TodoCommandHandler(todoProvider),
|
||||||
|
new ModeCommandHandler(modeProvider, modeColors ?? DefaultModeColors),
|
||||||
|
new SessionCommandHandler(agent),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+416
@@ -0,0 +1,416 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Harness.ConsoleReactiveComponents;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="IUXStateDriver"/> implementation. Owned by
|
||||||
|
/// <see cref="HarnessAppComponent"/>; mutates the component's state via a
|
||||||
|
/// <c>SetState</c>-style callback. Each public operation updates state and lets
|
||||||
|
/// the component's render-skip optimization handle the actual draw.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
|
||||||
|
{
|
||||||
|
private readonly Func<HarnessAppComponentState> _getState;
|
||||||
|
private readonly Action<HarnessAppComponentState> _setState;
|
||||||
|
private readonly Action _requestShutdown;
|
||||||
|
private readonly Func<AgentSession, Task> _replaceSession;
|
||||||
|
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||||
|
private readonly List<string> _outputItems = [];
|
||||||
|
private readonly object _stateLock = new();
|
||||||
|
|
||||||
|
private OutputEntryType? _lastEntryType;
|
||||||
|
private bool _hasReceivedAnyText;
|
||||||
|
private OutputEntry? _currentStreamingEntry;
|
||||||
|
private int _currentStreamingEntryIndex = -1;
|
||||||
|
private string? _currentMode;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="HarnessConsoleUXStateDriver"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="getState">Returns the component's current state.</param>
|
||||||
|
/// <param name="setState">Replaces the component's state and triggers a re-render.</param>
|
||||||
|
/// <param name="requestShutdown">Callback invoked when a command handler requests application shutdown.</param>
|
||||||
|
/// <param name="replaceSession">Callback invoked to replace the current agent session (e.g., on import).</param>
|
||||||
|
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||||
|
public HarnessConsoleUXStateDriver(
|
||||||
|
Func<HarnessAppComponentState> getState,
|
||||||
|
Action<HarnessAppComponentState> setState,
|
||||||
|
Action requestShutdown,
|
||||||
|
Func<AgentSession, Task> replaceSession,
|
||||||
|
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||||
|
{
|
||||||
|
this._getState = getState;
|
||||||
|
this._setState = setState;
|
||||||
|
this._requestShutdown = requestShutdown;
|
||||||
|
this._replaceSession = replaceSession;
|
||||||
|
this._modeColors = modeColors;
|
||||||
|
this._currentMode = getState().ModeText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string? CurrentMode
|
||||||
|
{
|
||||||
|
get => this._currentMode;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
this.UpdateState(s =>
|
||||||
|
{
|
||||||
|
this._currentMode = value;
|
||||||
|
return s with
|
||||||
|
{
|
||||||
|
ModeColor = ModeColors.Get(value, this._modeColors),
|
||||||
|
ModeText = value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void BeginStreaming() =>
|
||||||
|
this.UpdateState(s => s with
|
||||||
|
{
|
||||||
|
Mode = BottomPanelMode.Streaming,
|
||||||
|
ShowSpinner = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void StopSpinner() =>
|
||||||
|
this.UpdateState(s => s with { ShowSpinner = false });
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void EndStreaming() =>
|
||||||
|
this.UpdateState(s => s with
|
||||||
|
{
|
||||||
|
Mode = BottomPanelMode.TextInput,
|
||||||
|
ShowSpinner = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void BeginStreamingOutput()
|
||||||
|
{
|
||||||
|
lock (this._stateLock)
|
||||||
|
{
|
||||||
|
this._hasReceivedAnyText = false;
|
||||||
|
this._currentStreamingEntry = null;
|
||||||
|
this._currentStreamingEntryIndex = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SetUsageText(string usageText) =>
|
||||||
|
this.UpdateState(s => s with { UsageText = usageText });
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void SetQueuedMessages(IReadOnlyList<ChatMessage> pending)
|
||||||
|
{
|
||||||
|
var newQueued = new List<string>(pending.Count);
|
||||||
|
foreach (var msg in pending)
|
||||||
|
{
|
||||||
|
string text = msg.Text ?? string.Empty;
|
||||||
|
newQueued.Add(RenderEntry($" 💬 {text}\n", ConsoleColor.DarkGray));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.UpdateState(s => s with { QueuedItems = newQueued });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void QueueFollowUpQuestions(IReadOnlyList<FollowUpQuestion> questions)
|
||||||
|
{
|
||||||
|
if (questions.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.UpdateState(s =>
|
||||||
|
{
|
||||||
|
bool wasEmpty = s.PendingQuestions.Count == 0;
|
||||||
|
|
||||||
|
var combined = new List<FollowUpQuestion>(s.PendingQuestions.Count + questions.Count);
|
||||||
|
combined.AddRange(s.PendingQuestions);
|
||||||
|
combined.AddRange(questions);
|
||||||
|
|
||||||
|
HarnessAppComponentState next = s with { PendingQuestions = combined };
|
||||||
|
|
||||||
|
if (wasEmpty)
|
||||||
|
{
|
||||||
|
next = this.ConfigureForHeadQuestion(next, combined[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void AddFollowUpResponse(ChatMessage response)
|
||||||
|
{
|
||||||
|
this.UpdateState(s =>
|
||||||
|
{
|
||||||
|
var combined = new List<ChatMessage>(s.AccumulatedFollowUpResponses.Count + 1);
|
||||||
|
combined.AddRange(s.AccumulatedFollowUpResponses);
|
||||||
|
combined.Add(response);
|
||||||
|
return s with { AccumulatedFollowUpResponses = combined };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void AdvanceFollowUpQuestion()
|
||||||
|
{
|
||||||
|
this.UpdateState(s =>
|
||||||
|
{
|
||||||
|
if (s.PendingQuestions.Count == 0)
|
||||||
|
{
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining = s.PendingQuestions.Skip(1).ToList();
|
||||||
|
HarnessAppComponentState next = s with { PendingQuestions = remaining };
|
||||||
|
|
||||||
|
if (remaining.Count > 0)
|
||||||
|
{
|
||||||
|
return this.ConfigureForHeadQuestion(next, remaining[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next with
|
||||||
|
{
|
||||||
|
Mode = BottomPanelMode.TextInput,
|
||||||
|
ListSelectionOptions = [],
|
||||||
|
ListSelectionTitle = null,
|
||||||
|
ListSelectionCustomTextPlaceholder = null,
|
||||||
|
ListSelectionIndex = 0,
|
||||||
|
ListSelectionCustomInputText = "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public IReadOnlyList<ChatMessage> TakeFollowUpResponses()
|
||||||
|
{
|
||||||
|
return this.UpdateState(s =>
|
||||||
|
{
|
||||||
|
IReadOnlyList<ChatMessage> responses = s.AccumulatedFollowUpResponses;
|
||||||
|
if (responses.Count == 0)
|
||||||
|
{
|
||||||
|
return (s, responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (s with { AccumulatedFollowUpResponses = [] }, responses);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the bottom-panel display fields on the supplied state for the
|
||||||
|
/// given head question. For text questions, also writes the prompt as an
|
||||||
|
/// info line above the input row as a side effect.
|
||||||
|
/// </summary>
|
||||||
|
private HarnessAppComponentState ConfigureForHeadQuestion(HarnessAppComponentState state, FollowUpQuestion question)
|
||||||
|
{
|
||||||
|
if (question is ChoiceFollowUpQuestion choice)
|
||||||
|
{
|
||||||
|
return state with
|
||||||
|
{
|
||||||
|
Mode = BottomPanelMode.ListSelection,
|
||||||
|
ListSelectionOptions = choice.Choices.ToList(),
|
||||||
|
ListSelectionTitle = choice.Prompt,
|
||||||
|
ListSelectionCustomTextPlaceholder = choice.AllowCustomText ? "✏️ Type a custom response..." : null,
|
||||||
|
ListSelectionIndex = 0,
|
||||||
|
ListSelectionCustomInputText = "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text question — prompt is rendered as an info line above the input row.
|
||||||
|
// We append entries and capture the scroll snapshot inline so the caller's
|
||||||
|
// single _setState picks up both the new output and the UI mode change.
|
||||||
|
ConsoleColor ruleColor = ModeColors.Get(this._currentMode, this._modeColors);
|
||||||
|
List<string> scrollSnapshot = this.AppendOutputEntriesAndSnapshot(
|
||||||
|
new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor),
|
||||||
|
new OutputEntry(OutputEntryType.InfoLine, $" {question.Prompt}", ruleColor));
|
||||||
|
|
||||||
|
return state with
|
||||||
|
{
|
||||||
|
Mode = BottomPanelMode.TextInput,
|
||||||
|
ListSelectionOptions = [],
|
||||||
|
ListSelectionTitle = null,
|
||||||
|
ListSelectionCustomTextPlaceholder = null,
|
||||||
|
ListSelectionIndex = 0,
|
||||||
|
ListSelectionCustomInputText = "",
|
||||||
|
ScrollAreaContentItems = scrollSnapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void WriteUserInputEcho(string text)
|
||||||
|
{
|
||||||
|
this.UpdateState(s =>
|
||||||
|
{
|
||||||
|
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
||||||
|
OutputEntryType.UserInput,
|
||||||
|
$"\nYou: {text}\n\n",
|
||||||
|
ConsoleColor.Green));
|
||||||
|
return s with { ScrollAreaContentItems = snapshot };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task WriteInfoAsync(string text, ConsoleColor? color = null) =>
|
||||||
|
this.WriteInfoCoreAsync(text, color, newLine: false);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) =>
|
||||||
|
this.WriteInfoCoreAsync(text, color, newLine: true);
|
||||||
|
|
||||||
|
private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
|
||||||
|
{
|
||||||
|
this.UpdateState(s =>
|
||||||
|
{
|
||||||
|
// Add a blank line separator when transitioning from streaming text or user input.
|
||||||
|
string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter
|
||||||
|
? "\n "
|
||||||
|
: " ";
|
||||||
|
|
||||||
|
string fullText = newLine ? prefix + text + "\n\n" : prefix + text;
|
||||||
|
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
||||||
|
OutputEntryType.InfoLine,
|
||||||
|
fullText,
|
||||||
|
color ?? ModeColors.Get(this._currentMode, this._modeColors)));
|
||||||
|
return s with { ScrollAreaContentItems = snapshot };
|
||||||
|
});
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task WriteTextAsync(string text, ConsoleColor? color = null)
|
||||||
|
{
|
||||||
|
this.UpdateState(s =>
|
||||||
|
{
|
||||||
|
this._lastEntryType = OutputEntryType.StreamingText;
|
||||||
|
this._hasReceivedAnyText = true;
|
||||||
|
|
||||||
|
ConsoleColor effectiveColor = color ?? ModeColors.Get(this._currentMode, this._modeColors);
|
||||||
|
|
||||||
|
if (this._currentStreamingEntry is not null
|
||||||
|
&& this._currentStreamingEntryIndex == this._outputItems.Count - 1)
|
||||||
|
{
|
||||||
|
// The streaming entry is still the last item — safe to replace in place.
|
||||||
|
this._currentStreamingEntry = this._currentStreamingEntry with
|
||||||
|
{
|
||||||
|
Text = this._currentStreamingEntry.Text + text,
|
||||||
|
};
|
||||||
|
this._outputItems[^1] = RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Either the first text delta or other entries (tool calls, info lines)
|
||||||
|
// were appended after the previous streaming entry — start a fresh one.
|
||||||
|
const string Prefix = "\n";
|
||||||
|
this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor);
|
||||||
|
this._outputItems.Add(RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color));
|
||||||
|
this._currentStreamingEntryIndex = this._outputItems.Count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return s with { ScrollAreaContentItems = new List<string>(this._outputItems) };
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task EndStreamingOutputAsync()
|
||||||
|
{
|
||||||
|
this.UpdateState(s =>
|
||||||
|
{
|
||||||
|
if (this._hasReceivedAnyText)
|
||||||
|
{
|
||||||
|
this._outputItems.Add(RenderEntry("\n", null));
|
||||||
|
this._currentStreamingEntry = null;
|
||||||
|
this._lastEntryType = OutputEntryType.StreamFooter;
|
||||||
|
return s with { ScrollAreaContentItems = new List<string>(this._outputItems) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task WriteNoTextWarningAsync(bool hasFollowUpActions)
|
||||||
|
{
|
||||||
|
if (!this._hasReceivedAnyText && !hasFollowUpActions)
|
||||||
|
{
|
||||||
|
this.UpdateState(s =>
|
||||||
|
{
|
||||||
|
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
||||||
|
OutputEntryType.StreamFooter,
|
||||||
|
" (no text response from agent)\n",
|
||||||
|
ConsoleColor.DarkYellow));
|
||||||
|
return s with { ScrollAreaContentItems = snapshot };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps the supplied text with ANSI foreground color escape sequences (or returns
|
||||||
|
/// the text unchanged when no color is specified). Output is appended to
|
||||||
|
/// <see cref="_outputItems"/> and consumed verbatim by <see cref="TextScrollPanel"/>
|
||||||
|
/// and <see cref="TextPanel"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static string RenderEntry(string text, ConsoleColor? color) =>
|
||||||
|
color.HasValue
|
||||||
|
? $"{AnsiEscapes.SetForegroundColor(color.Value)}{text}{AnsiEscapes.ResetAttributes}"
|
||||||
|
: text;
|
||||||
|
|
||||||
|
private void UpdateState(Func<HarnessAppComponentState, HarnessAppComponentState> update)
|
||||||
|
{
|
||||||
|
lock (this._stateLock)
|
||||||
|
{
|
||||||
|
this._setState(update(this._getState()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private T UpdateState<T>(Func<HarnessAppComponentState, (HarnessAppComponentState State, T Result)> update)
|
||||||
|
{
|
||||||
|
lock (this._stateLock)
|
||||||
|
{
|
||||||
|
var (newState, result) = update(this._getState());
|
||||||
|
this._setState(newState);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends one or more output entries to the output list, updates
|
||||||
|
/// <see cref="_lastEntryType"/> to the last entry's type, and returns a
|
||||||
|
/// snapshot of <see cref="_outputItems"/>. Must be called inside a locked
|
||||||
|
/// context (e.g. within an <see cref="UpdateState"/> callback).
|
||||||
|
/// </summary>
|
||||||
|
private List<string> AppendOutputEntriesAndSnapshot(params OutputEntry[] entries)
|
||||||
|
{
|
||||||
|
this.AppendOutputEntriesCore(entries);
|
||||||
|
return new List<string>(this._outputItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendOutputEntriesCore(OutputEntry[] entries)
|
||||||
|
{
|
||||||
|
foreach (OutputEntry entry in entries)
|
||||||
|
{
|
||||||
|
this._outputItems.Add(RenderEntry(entry.Text, entry.Color));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.Length > 0)
|
||||||
|
{
|
||||||
|
this._lastEntryType = entries[^1].Type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void RequestShutdown() => this._requestShutdown();
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task ReplaceSessionAsync(AgentSession newSession) => this._replaceSession(newSession);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
#pragma warning disable VSTHRD002 // Synchronous waits are required by OpenTelemetry enrichment callbacks.
|
||||||
|
|
||||||
|
using OpenTelemetry;
|
||||||
|
using OpenTelemetry.Trace;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides factory methods for creating pre-configured OpenTelemetry tracing for harness samples.
|
||||||
|
/// </summary>
|
||||||
|
public static class HarnessTracing
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a <see cref="TracerProvider"/> that captures spans from the specified source and HTTP client activity,
|
||||||
|
/// enriching HTTP spans with full request/response headers and bodies, and exports all spans to a timestamped
|
||||||
|
/// text file in the application base directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sourceName">The activity source name to subscribe to (e.g., "Harness.Research").</param>
|
||||||
|
/// <returns>A configured <see cref="TracerProvider"/>, or <see langword="null"/> if the builder returns null.</returns>
|
||||||
|
public static TracerProvider? CreateFileTracerProvider(string sourceName)
|
||||||
|
{
|
||||||
|
var traceLogPath = Path.Combine(AppContext.BaseDirectory, $"traces_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid()}.log");
|
||||||
|
|
||||||
|
return Sdk.CreateTracerProviderBuilder()
|
||||||
|
.AddSource(sourceName)
|
||||||
|
.AddHttpClientInstrumentation((options) =>
|
||||||
|
{
|
||||||
|
options.EnrichWithHttpRequestMessage = (activity, request) =>
|
||||||
|
{
|
||||||
|
activity.SetTag("http.request.headers", request.Headers.ToString());
|
||||||
|
if (request.Content != null)
|
||||||
|
{
|
||||||
|
activity.SetTag("http.request.content.headers", request.Content.Headers.ToString());
|
||||||
|
var content = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||||
|
activity.SetTag("http.request.content.body", content);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
options.EnrichWithHttpResponseMessage = (activity, response) =>
|
||||||
|
{
|
||||||
|
activity.SetTag("http.response.headers", response.Headers.ToString());
|
||||||
|
if (response.Content != null)
|
||||||
|
{
|
||||||
|
activity.SetTag("http.response.content.headers", response.Content.Headers.ToString());
|
||||||
|
var content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||||
|
activity.SetTag("http.response.content.body", content);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.AddProcessor(new SimpleActivityExportProcessor(new FileSpanExporter(traceLogPath)))
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -8,11 +8,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Spectre.Console" />
|
<PackageReference Include="OpenTelemetry" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||||
|
<ProjectReference Include="..\ConsoleReactiveFramework\ConsoleReactiveFramework.csproj" />
|
||||||
|
<ProjectReference Include="..\ConsoleReactiveComponents\ConsoleReactiveComponents.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstraction over the harness UI state. All callers (observers, command handlers,
|
||||||
|
/// the agent runner) interact with the UI exclusively through this interface, which
|
||||||
|
/// internally translates each operation into a <c>SetState</c> call on the underlying
|
||||||
|
/// reactive component.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This interface is intentionally narrow: it does not expose blocking input methods.
|
||||||
|
/// The agent runner orchestrates input flow via <see cref="FollowUpQuestion"/>
|
||||||
|
/// objects returned from observers.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IUXStateDriver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the current agent mode (e.g. "plan", "execute"). Setting also
|
||||||
|
/// refreshes the rule colour and bottom-panel prompt to match the new mode.
|
||||||
|
/// </summary>
|
||||||
|
string? CurrentMode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Echoes a submitted user input as a regular user-input entry in the output area.
|
||||||
|
/// </summary>
|
||||||
|
void WriteUserInputEcho(string text);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes informational output as an output entry, without a trailing newline.
|
||||||
|
/// </summary>
|
||||||
|
Task WriteInfoAsync(string text, ConsoleColor? color = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes informational output as an output entry, followed by a newline.
|
||||||
|
/// </summary>
|
||||||
|
Task WriteInfoLineAsync(string text, ConsoleColor? color = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes streaming text output from the agent. Successive calls accumulate into a
|
||||||
|
/// single streaming entry that is re-rendered by the text panel.
|
||||||
|
/// </summary>
|
||||||
|
Task WriteTextAsync(string text, ConsoleColor? color = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a blank-line separator to visually close the streaming output section.
|
||||||
|
/// </summary>
|
||||||
|
Task EndStreamingOutputAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows a "(no text response from agent)" warning if no text was received
|
||||||
|
/// and no observer produced follow-up actions.
|
||||||
|
/// </summary>
|
||||||
|
Task WriteNoTextWarningAsync(bool hasFollowUpActions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Switches the bottom panel to streaming mode and starts the spinner.
|
||||||
|
/// </summary>
|
||||||
|
void BeginStreaming();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the spinner without leaving streaming mode.
|
||||||
|
/// </summary>
|
||||||
|
void StopSpinner();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Switches the bottom panel back to text-input mode and stops the spinner.
|
||||||
|
/// </summary>
|
||||||
|
void EndStreaming();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resets per-turn streaming bookkeeping in preparation for a new agent turn.
|
||||||
|
/// </summary>
|
||||||
|
void BeginStreamingOutput();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the formatted usage text shown on the agent status bar.
|
||||||
|
/// </summary>
|
||||||
|
void SetUsageText(string usageText);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the queued-message display with one entry per pending message.
|
||||||
|
/// </summary>
|
||||||
|
void SetQueuedMessages(IReadOnlyList<ChatMessage> pending);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends the supplied questions to the pending follow-up question queue in
|
||||||
|
/// component state. If the queue was empty, the bottom-panel display is
|
||||||
|
/// reconfigured to present the new head question.
|
||||||
|
/// </summary>
|
||||||
|
void QueueFollowUpQuestions(IReadOnlyList<FollowUpQuestion> questions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends a message to the accumulated follow-up response list in component state.
|
||||||
|
/// Called by the runner for direct <see cref="FollowUpMessage"/> outputs and by
|
||||||
|
/// the component when a question's continuation produces a response.
|
||||||
|
/// </summary>
|
||||||
|
void AddFollowUpResponse(ChatMessage response);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pops the head of the pending follow-up question queue. Reconfigures the
|
||||||
|
/// bottom-panel display for the new head, or restores the default text-input
|
||||||
|
/// mode if the queue is now empty.
|
||||||
|
/// </summary>
|
||||||
|
void AdvanceFollowUpQuestion();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the current accumulated follow-up responses and clears them in state.
|
||||||
|
/// Called by the runner immediately before invoking the next agent turn.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<ChatMessage> TakeFollowUpResponses();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signals that the application should shut down. Completes the shutdown task
|
||||||
|
/// on the owning component.
|
||||||
|
/// </summary>
|
||||||
|
void RequestShutdown();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the current agent session with the specified session (e.g., after importing
|
||||||
|
/// a serialized session from a file).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="newSession">The new session to use.</param>
|
||||||
|
Task ReplaceSessionAsync(AgentSession newSession);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helpers for resolving console colours associated with agent modes.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ModeColors
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the console color associated with a mode name, using the provided color map.
|
||||||
|
/// Falls back to <see cref="ConsoleColor.Gray"/> when the mode is <see langword="null"/>
|
||||||
|
/// or not present in the map.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="mode">The mode name, or <see langword="null"/> if no mode is active.</param>
|
||||||
|
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||||
|
public static ConsoleColor Get(string? mode, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||||
|
{
|
||||||
|
if (mode is null)
|
||||||
|
{
|
||||||
|
return ConsoleColor.Gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeColors is not null && modeColors.TryGetValue(mode, out var color))
|
||||||
|
{
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConsoleColor.Gray;
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-17
@@ -18,36 +18,41 @@ public abstract class ConsoleObserver
|
|||||||
/// Override to set options such as <see cref="AgentRunOptions.ResponseFormat"/>.
|
/// Override to set options such as <see cref="AgentRunOptions.ResponseFormat"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="options">The run options to configure.</param>
|
/// <param name="options">The run options to configure.</param>
|
||||||
public virtual void ConfigureRunOptions(AgentRunOptions options)
|
/// <param name="agent">The agent being interacted with.</param>
|
||||||
|
/// <param name="session">The current agent session.</param>
|
||||||
|
public virtual void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Called for each <see cref="AIContent"/> item in the response stream.
|
/// Called for each <see cref="AIContent"/> item in the response stream.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="writer">The console writer for rendering output.</param>
|
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||||
/// <param name="content">The content item from the stream.</param>
|
/// <param name="content">The content item from the stream.</param>
|
||||||
public virtual Task OnContentAsync(ConsoleWriter writer, AIContent content) => Task.CompletedTask;
|
/// <param name="agent">The agent being interacted with.</param>
|
||||||
|
/// <param name="session">The current agent session.</param>
|
||||||
|
public virtual Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) => Task.CompletedTask;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Called for each text update in the response stream.
|
/// Called for each text update in the response stream.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="writer">The console writer for rendering output.</param>
|
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||||
/// <param name="text">The text from the update.</param>
|
/// <param name="text">The text from the update.</param>
|
||||||
public virtual Task OnTextAsync(ConsoleWriter writer, string text) => Task.CompletedTask;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called after the response stream completes. Returns messages to include in the
|
|
||||||
/// next agent invocation, or <see langword="null"/> if no re-invocation is needed.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="writer">The console writer for rendering output.</param>
|
|
||||||
/// <param name="agent">The agent being interacted with.</param>
|
/// <param name="agent">The agent being interacted with.</param>
|
||||||
/// <param name="session">The current agent session.</param>
|
/// <param name="session">The current agent session.</param>
|
||||||
/// <param name="options">The console options.</param>
|
public virtual Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session) => Task.CompletedTask;
|
||||||
/// <returns>Messages to send to the agent, or <see langword="null"/> if no action is needed.</returns>
|
|
||||||
public virtual Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
/// <summary>
|
||||||
ConsoleWriter writer,
|
/// Called after the response stream completes. Returns a heterogeneous list of
|
||||||
|
/// follow-up actions (questions to ask the user, and/or messages to add directly to
|
||||||
|
/// the next agent invocation), or <see langword="null"/> if no follow-up is needed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||||
|
/// <param name="agent">The agent being interacted with.</param>
|
||||||
|
/// <param name="session">The current agent session.</param>
|
||||||
|
/// <returns>Follow-up actions to process after the stream completes, or <see langword="null"/>.</returns>
|
||||||
|
public virtual Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
|
||||||
|
IUXStateDriver ux,
|
||||||
AIAgent agent,
|
AIAgent agent,
|
||||||
AgentSession session,
|
AgentSession session) => Task.FromResult<IList<FollowUpAction>?>(null);
|
||||||
HarnessConsoleOptions options) => Task.FromResult<IList<ChatMessage>?>(null);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -1,5 +1,6 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
namespace Harness.Shared.Console.Observers;
|
namespace Harness.Shared.Console.Observers;
|
||||||
@@ -7,10 +8,10 @@ namespace Harness.Shared.Console.Observers;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Displays error content (❌) from the response stream.
|
/// Displays error content (❌) from the response stream.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class ErrorDisplayObserver : ConsoleObserver
|
public sealed class ErrorDisplayObserver : ConsoleObserver
|
||||||
{
|
{
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||||
{
|
{
|
||||||
if (content is ErrorContent errorContent)
|
if (content is ErrorContent errorContent)
|
||||||
{
|
{
|
||||||
@@ -25,7 +26,7 @@ internal sealed class ErrorDisplayObserver : ConsoleObserver
|
|||||||
errorText += $" details: {errorContent.Details}";
|
errorText += $" details: {errorContent.Details}";
|
||||||
}
|
}
|
||||||
|
|
||||||
await writer.WriteInfoLineAsync(errorText, ConsoleColor.Red);
|
await ux.WriteInfoLineAsync(errorText, ConsoleColor.Red);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-79
@@ -2,51 +2,77 @@
|
|||||||
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Harness.ConsoleReactiveComponents;
|
||||||
using Microsoft.Agents.AI;
|
using Microsoft.Agents.AI;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
namespace Harness.Shared.Console.Observers;
|
namespace Harness.Shared.Console.Observers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Planning observer that configures structured output, collects streamed text,
|
/// Planning observer that is mode-aware: in planning mode it configures structured
|
||||||
/// and deserializes it as a <see cref="PlanningResponse"/>. Renders clarification
|
/// JSON output, collects streamed text, and deserializes it as a <see cref="PlanningResponse"/>;
|
||||||
/// questions and approval prompts, and manages mode switching when the user approves a plan.
|
/// in execution mode it passes text straight through to <see cref="IUXStateDriver.WriteTextAsync"/>
|
||||||
|
/// for live streaming display.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class PlanningOutputObserver : ConsoleObserver
|
public sealed class PlanningOutputObserver : ConsoleObserver
|
||||||
{
|
{
|
||||||
private readonly StringBuilder _textCollector = new();
|
private readonly StringBuilder _textCollector = new();
|
||||||
private readonly AgentModeProvider _modeProvider;
|
private readonly AgentModeProvider _modeProvider;
|
||||||
|
private readonly string _planModeName;
|
||||||
|
private readonly string _executionModeName;
|
||||||
|
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="PlanningOutputObserver"/> class.
|
/// Initializes a new instance of the <see cref="PlanningOutputObserver"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="modeProvider">The mode provider for switching modes on approval.</param>
|
/// <param name="modeProvider">The mode provider for switching modes on approval.</param>
|
||||||
public PlanningOutputObserver(AgentModeProvider modeProvider)
|
/// <param name="planModeName">The mode name that represents the planning mode.</param>
|
||||||
|
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
|
||||||
|
/// <param name="modeColors">Optional mode-to-color mapping for display.</param>
|
||||||
|
public PlanningOutputObserver(AgentModeProvider modeProvider, string planModeName, string executionModeName, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||||
{
|
{
|
||||||
this._modeProvider = modeProvider;
|
this._modeProvider = modeProvider;
|
||||||
|
this._planModeName = planModeName;
|
||||||
|
this._executionModeName = executionModeName;
|
||||||
|
this._modeColors = modeColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override void ConfigureRunOptions(AgentRunOptions options)
|
public override void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
|
||||||
{
|
{
|
||||||
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
|
if (this.IsPlanningMode(this._modeProvider.GetMode(session)))
|
||||||
|
{
|
||||||
|
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override Task OnTextAsync(ConsoleWriter writer, string text)
|
public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session)
|
||||||
{
|
{
|
||||||
// Collect text silently instead of displaying it.
|
if (this.IsPlanningMode(ux.CurrentMode))
|
||||||
this._textCollector.Append(text);
|
{
|
||||||
return Task.CompletedTask;
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
public override async Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
|
||||||
ConsoleWriter writer,
|
IUXStateDriver ux,
|
||||||
AIAgent agent,
|
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.
|
// Read collected text from our stream observation.
|
||||||
string collectedText = this._textCollector.ToString();
|
string collectedText = this._textCollector.ToString();
|
||||||
this._textCollector.Clear();
|
this._textCollector.Clear();
|
||||||
@@ -64,21 +90,20 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
|
|||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
await writer.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
|
await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
|
||||||
await writer.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
|
await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (planningResponse is null)
|
if (planningResponse is null)
|
||||||
{
|
{
|
||||||
await writer.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
|
await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render based on response type.
|
|
||||||
if (planningResponse.Type == PlanningResponseType.Clarification)
|
if (planningResponse.Type == PlanningResponseType.Clarification)
|
||||||
{
|
{
|
||||||
return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(writer, planningResponse));
|
return BuildClarificationActions(planningResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (planningResponse.Type == PlanningResponseType.Approval)
|
if (planningResponse.Type == PlanningResponseType.Approval)
|
||||||
@@ -86,92 +111,91 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
|
|||||||
var question = planningResponse.Questions.FirstOrDefault();
|
var question = planningResponse.Questions.FirstOrDefault();
|
||||||
if (question is null)
|
if (question is null)
|
||||||
{
|
{
|
||||||
await writer.WriteInfoLineAsync("(approval response had no content)", ConsoleColor.DarkYellow);
|
await ux.WriteInfoLineAsync("(approval response had no content)", ConsoleColor.DarkYellow);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
string response = await this.RenderApprovalAndCollectResponseAsync(writer, question, options);
|
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
|
||||||
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 writer.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
|
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IList<ChatMessage>? AsUserMessages(string? text) =>
|
private static List<FollowUpAction> BuildClarificationActions(PlanningResponse response)
|
||||||
text is not null ? [new ChatMessage(ChatRole.User, text)] : null;
|
|
||||||
|
|
||||||
private async Task<string?> RenderClarificationsAndCollectResponsesAsync(ConsoleWriter writer, PlanningResponse response)
|
|
||||||
{
|
{
|
||||||
var answers = new List<string>();
|
var actions = new List<FollowUpAction>(response.Questions.Count);
|
||||||
|
|
||||||
foreach (var question in response.Questions)
|
foreach (var question in response.Questions)
|
||||||
{
|
{
|
||||||
await writer.WriteInfoLineAsync(string.Empty);
|
string prompt = question.Message;
|
||||||
await writer.WriteInfoLineAsync(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}");
|
||||||
|
}
|
||||||
|
|
||||||
string? answer;
|
|
||||||
if (question.Choices is { Count: > 0 })
|
if (question.Choices is { Count: > 0 })
|
||||||
{
|
{
|
||||||
answer = await writer.ReadSelectionAsync(
|
actions.Add(new ChoiceFollowUpQuestion(
|
||||||
"Choose an option:",
|
Prompt: prompt,
|
||||||
question.Choices);
|
Choices: question.Choices,
|
||||||
|
AllowCustomText: true,
|
||||||
|
Continuation: Continuation));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
answer = (await writer.ReadLineAsync("Response: "))?.Trim();
|
actions.Add(new TextFollowUpQuestion(
|
||||||
}
|
Prompt: prompt,
|
||||||
|
Continuation: Continuation));
|
||||||
if (!string.IsNullOrWhiteSpace(answer))
|
|
||||||
{
|
|
||||||
answers.Add($"Q: {question.Message}\nA: {answer}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return answers.Count > 0 ? string.Join("\n\n", answers) : null;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> RenderApprovalAndCollectResponseAsync(ConsoleWriter writer, PlanningQuestion question, HarnessConsoleOptions options)
|
private ChoiceFollowUpQuestion BuildApprovalAction(PlanningQuestion question, AgentSession session)
|
||||||
{
|
{
|
||||||
await writer.WriteInfoLineAsync(question.Message);
|
const string ApproveOption = "Approve and switch to execute mode";
|
||||||
|
var choices = new List<string> { ApproveOption };
|
||||||
|
|
||||||
var choices = new List<string>
|
return new ChoiceFollowUpQuestion(
|
||||||
{
|
Prompt: question.Message,
|
||||||
"Approve and switch to execute mode",
|
Choices: choices,
|
||||||
"Suggest changes",
|
AllowCustomText: true,
|
||||||
};
|
Continuation: async (selection, ux) =>
|
||||||
|
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
// Treat empty feedback as no changes — re-prompt the agent with the plan.
|
string formatted = $"🔹 {question.Message}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{selection}{AnsiEscapes.ResetAttributes}";
|
||||||
return "No changes suggested. Please re-present the plan for approval.";
|
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
|
||||||
}
|
|
||||||
|
|
||||||
return feedback;
|
if (selection == ApproveOption)
|
||||||
}
|
{
|
||||||
|
this._modeProvider.SetMode(session, this._executionModeName);
|
||||||
|
await ux.WriteInfoLineAsync(
|
||||||
|
$"✅ Switched to {this._executionModeName} mode.",
|
||||||
|
ModeColors.Get(this._executionModeName, this._modeColors)).ConfigureAwait(false);
|
||||||
|
return new ChatMessage(ChatRole.User, "Approved");
|
||||||
|
}
|
||||||
|
|
||||||
// Custom freeform input — treat as suggested changes.
|
// Custom freeform input — treat as suggested changes.
|
||||||
return selection;
|
return new ChatMessage(ChatRole.User, selection);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <see langword="true"/> when the current mode matches the configured plan mode name.
|
||||||
|
/// A <see langword="null"/> mode (no mode provider) is also treated as planning mode.
|
||||||
|
/// </summary>
|
||||||
|
private bool IsPlanningMode(string? currentMode) =>
|
||||||
|
currentMode is null || string.Equals(currentMode, this._planModeName, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -1,5 +1,6 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
namespace Harness.Shared.Console.Observers;
|
namespace Harness.Shared.Console.Observers;
|
||||||
@@ -7,14 +8,14 @@ namespace Harness.Shared.Console.Observers;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Displays reasoning content in dark magenta from the response stream.
|
/// Displays reasoning content in dark magenta from the response stream.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class ReasoningDisplayObserver : ConsoleObserver
|
public sealed class ReasoningDisplayObserver : ConsoleObserver
|
||||||
{
|
{
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||||
{
|
{
|
||||||
if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text))
|
if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text))
|
||||||
{
|
{
|
||||||
await writer.WriteTextAsync(reasoning.Text, ConsoleColor.DarkMagenta);
|
await ux.WriteTextAsync(reasoning.Text, ConsoleColor.DarkMagenta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -1,16 +1,18 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
|
||||||
namespace Harness.Shared.Console.Observers;
|
namespace Harness.Shared.Console.Observers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Streams agent text output directly to the console.
|
/// Streams agent text output directly to the console.
|
||||||
/// Used in normal (non-planning) mode.
|
/// Used in normal (non-planning) mode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class TextOutputObserver : ConsoleObserver
|
public sealed class TextOutputObserver : ConsoleObserver
|
||||||
{
|
{
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override async Task OnTextAsync(ConsoleWriter writer, string text)
|
public override async Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session)
|
||||||
{
|
{
|
||||||
await writer.WriteTextAsync(text);
|
await ux.WriteTextAsync(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-49
@@ -1,5 +1,7 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Harness.ConsoleReactiveComponents;
|
||||||
|
using Harness.Shared.Console.ToolFormatters;
|
||||||
using Microsoft.Agents.AI;
|
using Microsoft.Agents.AI;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
@@ -7,86 +9,103 @@ namespace Harness.Shared.Console.Observers;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Collects <see cref="ToolApprovalRequestContent"/> items during the response stream,
|
/// Collects <see cref="ToolApprovalRequestContent"/> items during the response stream,
|
||||||
/// displays approval-needed notifications inline, and prompts the user for approval
|
/// displays approval-needed notifications inline, and after the stream completes returns
|
||||||
/// decisions after the stream completes.
|
/// one <see cref="ChoiceFollowUpQuestion"/> per pending approval request. Each question's
|
||||||
|
/// continuation produces a separate <see cref="ChatMessage"/> carrying the approval
|
||||||
|
/// response content.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class ToolApprovalObserver : ConsoleObserver
|
public sealed class ToolApprovalObserver : ConsoleObserver
|
||||||
{
|
{
|
||||||
private readonly List<ToolApprovalRequestContent> _approvalRequests = [];
|
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/>
|
/// <inheritdoc/>
|
||||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||||
{
|
{
|
||||||
if (content is ToolApprovalRequestContent approvalRequest)
|
if (content is ToolApprovalRequestContent approvalRequest)
|
||||||
{
|
{
|
||||||
this._approvalRequests.Add(approvalRequest);
|
this._approvalRequests.Add(approvalRequest);
|
||||||
string toolName = approvalRequest.ToolCall is FunctionCallContent fc
|
string toolName = approvalRequest.ToolCall is FunctionCallContent fc
|
||||||
? ToolCallFormatter.Format(fc)
|
? ToolCallFormatter.Format(this._formatters, fc)
|
||||||
: approvalRequest.ToolCall?.ToString() ?? "unknown";
|
: approvalRequest.ToolCall?.ToString() ?? "unknown";
|
||||||
await writer.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
|
await ux.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
public override Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
|
||||||
ConsoleWriter writer,
|
IUXStateDriver ux,
|
||||||
AIAgent agent,
|
AIAgent agent,
|
||||||
AgentSession session,
|
AgentSession session)
|
||||||
HarnessConsoleOptions options)
|
|
||||||
{
|
{
|
||||||
if (this._approvalRequests.Count == 0)
|
if (this._approvalRequests.Count == 0)
|
||||||
{
|
{
|
||||||
return null;
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
var messages = await PromptForApprovalsAsync(writer, this._approvalRequests);
|
|
||||||
this._approvalRequests.Clear();
|
this._approvalRequests.Clear();
|
||||||
return messages;
|
return Task.FromResult<IList<FollowUpAction>?>(actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<ChatMessage>?> PromptForApprovalsAsync(ConsoleWriter writer, List<ToolApprovalRequestContent> approvalRequests)
|
private ChoiceFollowUpQuestion BuildApprovalQuestion(ToolApprovalRequestContent request)
|
||||||
{
|
{
|
||||||
if (approvalRequests.Count == 0)
|
string toolName = request.ToolCall is FunctionCallContent fc
|
||||||
|
? ToolCallFormatter.Format(this._formatters, fc)
|
||||||
|
: request.ToolCall?.ToString() ?? "unknown";
|
||||||
|
|
||||||
|
var choices = new List<string>
|
||||||
{
|
{
|
||||||
return null;
|
"Approve this call",
|
||||||
}
|
"Always approve this tool (any arguments)",
|
||||||
|
"Always approve this tool with these arguments",
|
||||||
|
"Deny",
|
||||||
|
};
|
||||||
|
|
||||||
var responses = new List<AIContent>();
|
string prompt = $"🔐 Tool approval: {toolName}";
|
||||||
foreach (var request in approvalRequests)
|
|
||||||
{
|
|
||||||
string toolName = request.ToolCall is FunctionCallContent fc
|
|
||||||
? ToolCallFormatter.Format(fc)
|
|
||||||
: request.ToolCall?.ToString() ?? "unknown";
|
|
||||||
|
|
||||||
var choices = new List<string>
|
return new ChoiceFollowUpQuestion(
|
||||||
|
Prompt: prompt,
|
||||||
|
Choices: choices,
|
||||||
|
AllowCustomText: false,
|
||||||
|
Continuation: async (selection, ux) =>
|
||||||
{
|
{
|
||||||
"Approve this call",
|
AIContent response = selection switch
|
||||||
"Always approve this tool (any arguments)",
|
{
|
||||||
"Always approve this tool with these arguments",
|
"Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
|
||||||
"Deny",
|
"Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
|
||||||
};
|
"Deny" => request.CreateResponse(approved: false, reason: "User denied"),
|
||||||
|
_ => request.CreateResponse(approved: true, reason: "User approved"),
|
||||||
|
};
|
||||||
|
|
||||||
string selection = await writer.ReadSelectionAsync($"🔐 Tool approval: {toolName}", choices);
|
string action = selection switch
|
||||||
AIContent response = selection switch
|
{
|
||||||
{
|
"Always approve this tool (any arguments)" => "✅ Always approved (any args)",
|
||||||
"Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
|
"Always approve this tool with these arguments" => "✅ Always approved (these args)",
|
||||||
"Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
|
"Deny" => "❌ Denied",
|
||||||
"Deny" => request.CreateResponse(approved: false, reason: "User denied"),
|
_ => "✅ Approved",
|
||||||
_ => request.CreateResponse(approved: true, reason: "User approved"),
|
};
|
||||||
};
|
|
||||||
|
|
||||||
string action = selection switch
|
ConsoleColor answerColor = selection == "Deny" ? ConsoleColor.Red : ConsoleColor.Green;
|
||||||
{
|
string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(answerColor)}{action}{AnsiEscapes.ResetAttributes}";
|
||||||
"Always approve this tool (any arguments)" => "✅ Always approved (any args)",
|
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
|
||||||
"Always approve this tool with these arguments" => "✅ Always approved (these args)",
|
|
||||||
"Deny" => "❌ Denied",
|
|
||||||
_ => "✅ Approved",
|
|
||||||
};
|
|
||||||
await writer.WriteInfoLineAsync($" {action}", ConsoleColor.DarkGray);
|
|
||||||
|
|
||||||
responses.Add(response);
|
return new ChatMessage(ChatRole.User, [response]);
|
||||||
}
|
});
|
||||||
|
|
||||||
return [new ChatMessage(ChatRole.User, responses)];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-4
@@ -1,5 +1,7 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Harness.Shared.Console.ToolFormatters;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
namespace Harness.Shared.Console.Observers;
|
namespace Harness.Shared.Console.Observers;
|
||||||
@@ -8,18 +10,34 @@ namespace Harness.Shared.Console.Observers;
|
|||||||
/// Displays tool call notifications (🔧) for <see cref="FunctionCallContent"/>
|
/// Displays tool call notifications (🔧) for <see cref="FunctionCallContent"/>
|
||||||
/// and <see cref="ToolCallContent"/> items in the response stream.
|
/// and <see cref="ToolCallContent"/> items in the response stream.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class ToolCallDisplayObserver : ConsoleObserver
|
public sealed class ToolCallDisplayObserver : ConsoleObserver
|
||||||
{
|
{
|
||||||
|
private readonly IReadOnlyList<ToolCallFormatter> _formatters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ToolCallDisplayObserver"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="formatters">Optional list of tool formatters. When <see langword="null"/>,
|
||||||
|
/// the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/> are used.</param>
|
||||||
|
public ToolCallDisplayObserver(IReadOnlyList<ToolCallFormatter>? formatters = null)
|
||||||
|
{
|
||||||
|
this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||||
{
|
{
|
||||||
if (content is FunctionCallContent functionCall)
|
if (content is FunctionCallContent functionCall)
|
||||||
{
|
{
|
||||||
await writer.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow);
|
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(this._formatters, functionCall)}...", ConsoleColor.DarkYellow);
|
||||||
|
}
|
||||||
|
else if (content is WebSearchToolCallContent)
|
||||||
|
{
|
||||||
|
// Handled by OpenAIResponsesWebSearchDisplayObserver when present; skip here to avoid duplication.
|
||||||
}
|
}
|
||||||
else if (content is ToolCallContent toolCall)
|
else if (content is ToolCallContent toolCall)
|
||||||
{
|
{
|
||||||
await writer.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
|
await ux.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-288
@@ -1,288 +0,0 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
|
||||||
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Extensions.AI;
|
|
||||||
|
|
||||||
namespace Harness.Shared.Console.Observers;
|
|
||||||
|
|
||||||
/// <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), "…");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-4
@@ -1,5 +1,6 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
namespace Harness.Shared.Console.Observers;
|
namespace Harness.Shared.Console.Observers;
|
||||||
@@ -7,7 +8,7 @@ namespace Harness.Shared.Console.Observers;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Displays token usage statistics (📊) from the response stream.
|
/// Displays token usage statistics (📊) from the response stream.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class UsageDisplayObserver : ConsoleObserver
|
public sealed class UsageDisplayObserver : ConsoleObserver
|
||||||
{
|
{
|
||||||
private readonly int? _maxContextWindowTokens;
|
private readonly int? _maxContextWindowTokens;
|
||||||
private readonly int? _maxOutputTokens;
|
private readonly int? _maxOutputTokens;
|
||||||
@@ -24,19 +25,21 @@ internal sealed class UsageDisplayObserver : ConsoleObserver
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
public override Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||||
{
|
{
|
||||||
if (content is UsageContent usage)
|
if (content is UsageContent usage)
|
||||||
{
|
{
|
||||||
if (usage.Details is not null)
|
if (usage.Details is not null)
|
||||||
{
|
{
|
||||||
await writer.WriteInfoLineAsync(this.FormatUsageBreakdown(usage.Details), ConsoleColor.DarkGray);
|
ux.SetUsageText(this.FormatUsageBreakdown(usage.Details));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await writer.WriteInfoLineAsync("📊 Tokens —", ConsoleColor.DarkGray);
|
ux.SetUsageText("📊 Tokens —");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatUsageBreakdown(UsageDetails details)
|
private string FormatUsageBreakdown(UsageDetails details)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the type of an output entry in the console conversation.
|
||||||
|
/// </summary>
|
||||||
|
internal enum OutputEntryType
|
||||||
|
{
|
||||||
|
/// <summary>User input echo (e.g. "You: hello").</summary>
|
||||||
|
UserInput,
|
||||||
|
|
||||||
|
/// <summary>In-progress streaming text from the agent (accumulated chunk by chunk).</summary>
|
||||||
|
StreamingText,
|
||||||
|
|
||||||
|
/// <summary>Informational line (tool calls, errors, usage, approval requests, etc.).</summary>
|
||||||
|
InfoLine,
|
||||||
|
|
||||||
|
/// <summary>Stream footer (e.g. "(no text response from agent)").</summary>
|
||||||
|
StreamFooter,
|
||||||
|
|
||||||
|
/// <summary>Pending injected message notification.</summary>
|
||||||
|
PendingMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single output entry in the console conversation history.
|
||||||
|
/// Used internally by <see cref="HarnessConsoleUXStateDriver"/> to track
|
||||||
|
/// the in-progress streaming entry and last-entry type for spacing decisions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Type">The type of output entry.</param>
|
||||||
|
/// <param name="Text">The text content of the entry.</param>
|
||||||
|
/// <param name="Color">Optional foreground color for rendering.</param>
|
||||||
|
internal sealed record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null);
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
|
||||||
|
|
||||||
namespace Harness.Shared.Console;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A restartable spinner that can be started and stopped multiple times.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class Spinner : IDisposable
|
|
||||||
{
|
|
||||||
private static readonly string[] s_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
||||||
|
|
||||||
private CancellationTokenSource? _cts;
|
|
||||||
private Task? _task;
|
|
||||||
|
|
||||||
public void Start()
|
|
||||||
{
|
|
||||||
if (this._task is not null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._cts = new CancellationTokenSource();
|
|
||||||
this._task = RunAsync(this._cts.Token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task StopAsync()
|
|
||||||
{
|
|
||||||
if (this._cts is null || this._task is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._cts.Cancel();
|
|
||||||
await this._task;
|
|
||||||
this._cts.Dispose();
|
|
||||||
this._cts = null;
|
|
||||||
this._task = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (this._cts is not null && this._task is not null)
|
|
||||||
{
|
|
||||||
this._cts.Cancel();
|
|
||||||
|
|
||||||
// Block briefly to let the spinner task clean up.
|
|
||||||
// This prevents the background task from writing to the console after disposal.
|
|
||||||
#pragma warning disable VSTHRD002 // Synchronous wait in Dispose is acceptable here — the spinner task completes quickly on cancellation.
|
|
||||||
this._task.Wait();
|
|
||||||
#pragma warning restore VSTHRD002
|
|
||||||
}
|
|
||||||
|
|
||||||
this._cts?.Dispose();
|
|
||||||
this._cts = null;
|
|
||||||
this._task = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task RunAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
int i = 0;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
System.Console.Write(s_frames[i % s_frames.Length]);
|
|
||||||
await Task.Delay(80, cancellationToken);
|
|
||||||
System.Console.Write("\b \b");
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
// Clear the last spinner frame left on screen.
|
|
||||||
System.Console.Write("\b \b");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console.ToolFormatters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats <c>BackgroundAgents_*</c> tool calls with human-readable details
|
||||||
|
/// for task start, continue, wait, and result retrieval operations.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BackgroundAgentToolFormatter : ToolCallFormatter
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("BackgroundAgents_", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||||
|
{
|
||||||
|
"BackgroundAgents_StartTask" => FormatStartBackgroundTask(call),
|
||||||
|
"BackgroundAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
|
||||||
|
"BackgroundAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
|
||||||
|
"BackgroundAgents_ContinueTask" => FormatContinueTask(call),
|
||||||
|
"BackgroundAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string? FormatStartBackgroundTask(FunctionCallContent call)
|
||||||
|
{
|
||||||
|
string? agentName = GetStringArgumentValue(call, "agentName");
|
||||||
|
string? description = GetStringArgumentValue(call, "description");
|
||||||
|
|
||||||
|
if (agentName is null && description is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
if (agentName is not null && description is not null)
|
||||||
|
{
|
||||||
|
sb.Append($"\n ├─ Agent: {agentName}");
|
||||||
|
sb.Append($"\n └─ \"{Truncate(description, 80)}\"");
|
||||||
|
}
|
||||||
|
else if (agentName is not null)
|
||||||
|
{
|
||||||
|
sb.Append($"\n └─ Agent: {agentName}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append($"\n └─ \"{Truncate(description!, 80)}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
|
||||||
|
{
|
||||||
|
List<int>? ids = GetIntListArgumentValue(call, paramName);
|
||||||
|
if (ids is null || ids.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < ids.Count; i++)
|
||||||
|
{
|
||||||
|
string connector = i < ids.Count - 1 ? "├─" : "└─";
|
||||||
|
sb.Append($"\n {connector} {verb} #{ids[i]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FormatSingleId(FunctionCallContent call, string paramName)
|
||||||
|
{
|
||||||
|
int? id = GetIntArgumentValue(call, paramName);
|
||||||
|
return id.HasValue ? $"(task #{id.Value})" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FormatContinueTask(FunctionCallContent call)
|
||||||
|
{
|
||||||
|
int? taskId = GetIntArgumentValue(call, "taskId");
|
||||||
|
string? text = GetStringArgumentValue(call, "text");
|
||||||
|
|
||||||
|
if (!taskId.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text is not null)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append($"\n ├─ Task #{taskId.Value}");
|
||||||
|
sb.Append($"\n └─ \"{Truncate(text, 80)}\"");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"\n └─ Task #{taskId.Value}";
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console.ToolFormatters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Catch-all formatter that handles any tool not matched by a more specific formatter.
|
||||||
|
/// Displays a generic summary of the tool's arguments. This formatter should always be
|
||||||
|
/// placed last in the formatter list.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FallbackToolFormatter : ToolCallFormatter
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override bool CanFormat(FunctionCallContent call) => true;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override string? FormatDetail(FunctionCallContent call)
|
||||||
|
{
|
||||||
|
if (call.Arguments is null || call.Arguments.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = new List<string>();
|
||||||
|
foreach (var kvp in call.Arguments)
|
||||||
|
{
|
||||||
|
string? stringValue = kvp.Value switch
|
||||||
|
{
|
||||||
|
JsonElement je => je.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => je.GetString(),
|
||||||
|
JsonValueKind.Number => je.GetRawText(),
|
||||||
|
JsonValueKind.True => "true",
|
||||||
|
JsonValueKind.False => "false",
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
not null => kvp.Value.ToString(),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (stringValue is not null)
|
||||||
|
{
|
||||||
|
parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console.ToolFormatters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats <c>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" => FormatCompleteTodos(call),
|
||||||
|
"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? FormatCompleteTodos(FunctionCallContent call)
|
||||||
|
{
|
||||||
|
if (call.Arguments?.TryGetValue("items", out object? itemsObj) != true || itemsObj is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = new List<(int Id, string? Reason)>();
|
||||||
|
|
||||||
|
if (itemsObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (JsonElement item in jsonArray.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!item.TryGetProperty("id", out JsonElement idElement) || !idElement.TryGetInt32(out int id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? reason = item.TryGetProperty("reason", out JsonElement reasonElement)
|
||||||
|
? reasonElement.GetString()
|
||||||
|
: null;
|
||||||
|
entries.Add((id, reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < entries.Count; i++)
|
||||||
|
{
|
||||||
|
string connector = i < entries.Count - 1 ? "├─" : "└─";
|
||||||
|
sb.Append($"\n {connector} Complete #{entries[i].Id}");
|
||||||
|
if (!string.IsNullOrEmpty(entries[i].Reason))
|
||||||
|
{
|
||||||
|
sb.Append($" — {Truncate(entries[i].Reason!, 80)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
|
||||||
|
{
|
||||||
|
List<int>? ids = GetIntListArgumentValue(call, paramName);
|
||||||
|
if (ids is null || ids.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < ids.Count; i++)
|
||||||
|
{
|
||||||
|
string connector = i < ids.Count - 1 ? "├─" : "└─";
|
||||||
|
sb.Append($"\n {connector} {verb} #{ids[i]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console.ToolFormatters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for tool call formatters that produce human-readable display strings
|
||||||
|
/// for <see cref="FunctionCallContent"/> items shown in the console.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class ToolCallFormatter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <see langword="true"/> if this formatter can handle the given function call.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="call">The function call content to check.</param>
|
||||||
|
/// <returns><see langword="true"/> if this formatter should be used; otherwise <see langword="false"/>.</returns>
|
||||||
|
public abstract bool CanFormat(FunctionCallContent call);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the detail portion of the formatted output for the given tool call,
|
||||||
|
/// or <see langword="null"/> if only the tool name should be displayed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="call">The function call content to format.</param>
|
||||||
|
/// <returns>A detail string to append after the tool name, or <see langword="null"/>.</returns>
|
||||||
|
public abstract string? FormatDetail(FunctionCallContent call);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a tool call using the first matching formatter from the provided list.
|
||||||
|
/// Returns <c>"{toolName} {detail}"</c> when a formatter produces detail,
|
||||||
|
/// or just <c>"{toolName}"</c> otherwise.
|
||||||
|
/// </summary>
|
||||||
|
internal static string Format(IReadOnlyList<ToolCallFormatter> formatters, FunctionCallContent call)
|
||||||
|
{
|
||||||
|
foreach (var formatter in formatters)
|
||||||
|
{
|
||||||
|
if (formatter.CanFormat(call))
|
||||||
|
{
|
||||||
|
string? detail = formatter.FormatDetail(call);
|
||||||
|
return detail is not null ? $"{call.Name} {detail}" : call.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return call.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the default list of tool call formatters. The <see cref="FallbackToolFormatter"/>
|
||||||
|
/// is always last. Users can call this method and combine the result with their own formatters.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A list of all built-in tool call formatters.</returns>
|
||||||
|
public static List<ToolCallFormatter> BuildDefaultToolFormatters()
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new TodoToolFormatter(),
|
||||||
|
new ModeToolFormatter(),
|
||||||
|
new BackgroundAgentToolFormatter(),
|
||||||
|
new FileMemoryToolFormatter(),
|
||||||
|
new WebSearchToolFormatter(),
|
||||||
|
new FallbackToolFormatter(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts a string argument value from a function call.
|
||||||
|
/// </summary>
|
||||||
|
protected static string? GetStringArgumentValue(FunctionCallContent call, string paramName)
|
||||||
|
{
|
||||||
|
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
|
||||||
|
string s => s,
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts an integer argument value from a function call.
|
||||||
|
/// </summary>
|
||||||
|
protected static int? GetIntArgumentValue(FunctionCallContent call, string paramName)
|
||||||
|
{
|
||||||
|
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
|
||||||
|
int i => i,
|
||||||
|
_ => int.TryParse(value.ToString(), out int parsed) ? parsed : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts a list of integer argument values from a function call.
|
||||||
|
/// </summary>
|
||||||
|
protected static List<int>? GetIntListArgumentValue(FunctionCallContent call, string paramName)
|
||||||
|
{
|
||||||
|
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<int>();
|
||||||
|
|
||||||
|
if (value is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (JsonElement item in je.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.ValueKind == JsonValueKind.Number)
|
||||||
|
{
|
||||||
|
result.Add(item.GetInt32());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Count > 0 ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Truncates a string to the specified maximum length, appending an ellipsis if truncated.
|
||||||
|
/// </summary>
|
||||||
|
protected static string Truncate(string text, int maxLength)
|
||||||
|
{
|
||||||
|
return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…");
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Harness.Shared.Console.ToolFormatters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats <c>web_search</c> tool calls, showing the search query.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WebSearchToolFormatter : ToolCallFormatter
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override bool CanFormat(FunctionCallContent call) =>
|
||||||
|
call.Name is "web_search";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override string? FormatDetail(FunctionCallContent call)
|
||||||
|
{
|
||||||
|
string? value = GetStringArgumentValue(call, "query");
|
||||||
|
return value is not null ? $"({value})" : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Harness.Shared.Console.ToolFormatters;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace SampleApp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats <c>DownloadUri</c> tool calls, showing the target URI.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DownloadUriToolFormatter : ToolCallFormatter
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override bool CanFormat(FunctionCallContent call) =>
|
||||||
|
call.Name is "DownloadUri";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override string? FormatDetail(FunctionCallContent call)
|
||||||
|
{
|
||||||
|
string? value = GetStringArgumentValue(call, "uri");
|
||||||
|
return value is not null ? $"({value})" : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||||
|
|
||||||
|
using System.Text;
|
||||||
|
using Harness.Shared.Console;
|
||||||
|
using Harness.Shared.Console.Observers;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using OpenAI.Responses;
|
||||||
|
|
||||||
|
namespace SampleApp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Displays web search activity in the scroll area. Shows search queries,
|
||||||
|
/// page opens, and find-in-page actions as they stream in from the API.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
|
||||||
|
{
|
||||||
|
private const int MaxQueryDisplayLength = 120;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||||
|
{
|
||||||
|
if (content is WebSearchToolResultContent resultContent
|
||||||
|
&& resultContent.RawRepresentation is WebSearchCallResponseItem wscri)
|
||||||
|
{
|
||||||
|
await WriteActionAsync(ux, wscri, resultContent.Outputs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteActionAsync(IUXStateDriver ux, WebSearchCallResponseItem wscri, IList<AIContent>? outputs)
|
||||||
|
{
|
||||||
|
WebSearchAction? action = wscri.Action;
|
||||||
|
if (action is null)
|
||||||
|
{
|
||||||
|
await ux.WriteInfoLineAsync("🌐 Web Search Tool (no action details)", ConsoleColor.DarkCyan);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case WebSearchFindInPageAction findInPage:
|
||||||
|
await WriteFindInPageAsync(ux, findInPage);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WebSearchOpenPageAction openPage:
|
||||||
|
await WriteOpenPageAsync(ux, openPage);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WebSearchSearchAction search:
|
||||||
|
await WriteSearchAsync(ux, search, outputs);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
await ux.WriteInfoLineAsync("🌐 Web Search Tool (unknown action)", ConsoleColor.DarkCyan);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteSearchAsync(IUXStateDriver ux, WebSearchSearchAction search, IList<AIContent>? outputs)
|
||||||
|
{
|
||||||
|
// Read queries directly from the typed action.
|
||||||
|
IList<string> queries = search.Queries;
|
||||||
|
|
||||||
|
if (queries.Count == 0)
|
||||||
|
{
|
||||||
|
await ux.WriteInfoLineAsync("🌐 Web Search Tool: search", ConsoleColor.DarkCyan);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("🌐 Web Search Tool: search");
|
||||||
|
|
||||||
|
// Show the search queries.
|
||||||
|
bool hasResults = outputs is { Count: > 0 };
|
||||||
|
for (int i = 0; i < queries.Count; i++)
|
||||||
|
{
|
||||||
|
string connector = (i < queries.Count - 1 || hasResults) ? "├─" : "└─";
|
||||||
|
string query = Truncate(queries[i], MaxQueryDisplayLength);
|
||||||
|
sb.Append($"\n {connector} \"{query}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show search result sources (URLs + titles) when available.
|
||||||
|
// Sources come from M.E.AI's Outputs when IncludedResponseProperty.WebSearchCallActionSources is set,
|
||||||
|
// or directly from the SDK's WebSearchSearchAction.Sources.
|
||||||
|
if (hasResults)
|
||||||
|
{
|
||||||
|
sb.Append("\n │");
|
||||||
|
for (int i = 0; i < outputs!.Count; i++)
|
||||||
|
{
|
||||||
|
string connector = i < outputs.Count - 1 ? "├─" : "└─";
|
||||||
|
string line = FormatOutput(outputs[i]);
|
||||||
|
sb.Append($"\n {connector} {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (search.Sources is { Count: > 0 } sources)
|
||||||
|
{
|
||||||
|
sb.Append("\n │");
|
||||||
|
for (int i = 0; i < sources.Count; i++)
|
||||||
|
{
|
||||||
|
string connector = i < sources.Count - 1 ? "├─" : "└─";
|
||||||
|
string line = FormatSource(sources[i]);
|
||||||
|
sb.Append($"\n {connector} {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ux.WriteInfoLineAsync(sb.ToString(), ConsoleColor.DarkCyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteOpenPageAsync(IUXStateDriver ux, WebSearchOpenPageAction openPage)
|
||||||
|
{
|
||||||
|
string url = openPage.Uri?.AbsoluteUri ?? "(unknown)";
|
||||||
|
await ux.WriteInfoLineAsync(
|
||||||
|
$"🌐 Web Search Tool: open page\n └─ {url}",
|
||||||
|
ConsoleColor.DarkCyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteFindInPageAsync(IUXStateDriver ux, WebSearchFindInPageAction findInPage)
|
||||||
|
{
|
||||||
|
string url = findInPage.Uri?.AbsoluteUri ?? "(unknown)";
|
||||||
|
string pattern = findInPage.Pattern ?? "(unknown)";
|
||||||
|
|
||||||
|
await ux.WriteInfoLineAsync(
|
||||||
|
$"🌐 Web Search Tool: find in page\n ├─ \"{Truncate(pattern, MaxQueryDisplayLength)}\"\n └─ {url}",
|
||||||
|
ConsoleColor.DarkCyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a single search result source from the SDK's <see cref="WebSearchActionSource"/> for display.
|
||||||
|
/// </summary>
|
||||||
|
private static string FormatSource(WebSearchActionSource source)
|
||||||
|
{
|
||||||
|
if (source is WebSearchActionUriSource uriSource)
|
||||||
|
{
|
||||||
|
string url = uriSource.Uri?.AbsoluteUri ?? "(unknown)";
|
||||||
|
|
||||||
|
// WebSearchActionUriSource doesn't expose a title property,
|
||||||
|
// but the API may include one in the raw response JSON.
|
||||||
|
string? title = GetTitleFromRawRepresentation(uriSource);
|
||||||
|
|
||||||
|
return title is not null
|
||||||
|
? $"{Truncate(title, MaxQueryDisplayLength)} — {url}"
|
||||||
|
: url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.ToString() ?? "(unknown source)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a single search result output from M.E.AI's <see cref="AIContent"/> for display.
|
||||||
|
/// </summary>
|
||||||
|
private static string FormatOutput(AIContent output)
|
||||||
|
{
|
||||||
|
if (output is UriContent uriContent)
|
||||||
|
{
|
||||||
|
string url = uriContent.Uri?.AbsoluteUri ?? "(unknown)";
|
||||||
|
|
||||||
|
// Try to extract a title from the raw JSON of the source.
|
||||||
|
// The SDK's WebSearchActionUriSource doesn't expose a title property,
|
||||||
|
// but the API may include one in the raw response.
|
||||||
|
string? title = GetTitleFromRawRepresentation(uriContent.RawRepresentation)
|
||||||
|
?? (uriContent.AdditionalProperties?.TryGetValue("title", out var t) is true ? t?.ToString() : null);
|
||||||
|
|
||||||
|
return title is not null
|
||||||
|
? $"{Truncate(title, MaxQueryDisplayLength)} — {url}"
|
||||||
|
: url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.ToString() ?? "(unknown output)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to extract a "title" field from a raw representation object by serializing it to JSON.
|
||||||
|
/// The SDK's <see cref="WebSearchActionUriSource"/> doesn't expose a title property,
|
||||||
|
/// but the API may include one in the raw JSON — this is forward-compatible for when
|
||||||
|
/// the SDK adds title support.
|
||||||
|
/// </summary>
|
||||||
|
private static string? GetTitleFromRawRepresentation(object? rawRepresentation)
|
||||||
|
{
|
||||||
|
if (rawRepresentation is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(rawRepresentation);
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(data);
|
||||||
|
if (doc.RootElement.TryGetProperty("title", out var titleEl)
|
||||||
|
&& titleEl.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return titleEl.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Serialization may not be supported for this object type.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string text, int maxLength)
|
||||||
|
=> text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength - 1), "…");
|
||||||
|
}
|
||||||
@@ -1,190 +1,118 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
// This sample demonstrates how to use a ChatClientAgent with the Harness AIContextProviders
|
// This sample demonstrates how to use a HarnessAgent for interactive research tasks.
|
||||||
// (TodoProvider and AgentModeProvider) for interactive research tasks with web search
|
// The HarnessAgent comes pre-configured with TodoProvider, AgentModeProvider, FileMemoryProvider,
|
||||||
// capabilities powered by Azure AI Foundry.
|
// ToolApproval, WebSearch, and OpenTelemetry — so this sample only needs custom instructions
|
||||||
|
// and a WebBrowsingTool.
|
||||||
// The agent plans research tasks, creates a todo list, gets user approval,
|
// The agent plans research tasks, creates a todo list, gets user approval,
|
||||||
// and then executes each step — all within an interactive conversation loop.
|
// and then executes each step — all within an interactive conversation loop.
|
||||||
//
|
//
|
||||||
// Special commands:
|
// Special commands:
|
||||||
// /todos — Display the current todo list without invoking the agent.
|
// /todos — Display the current todo list without invoking the agent.
|
||||||
// exit — End the session.
|
// /mode — Get or set the current agent mode.
|
||||||
|
// /exit — End the session.
|
||||||
|
|
||||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||||
|
|
||||||
using System.ClientModel.Primitives;
|
using System.ClientModel.Primitives;
|
||||||
|
using Azure.AI.Projects;
|
||||||
using Azure.Identity;
|
using Azure.Identity;
|
||||||
using Harness.Shared.Console;
|
using Harness.Shared.Console;
|
||||||
|
using Harness.Shared.Console.ToolFormatters;
|
||||||
using Microsoft.Agents.AI;
|
using Microsoft.Agents.AI;
|
||||||
using Microsoft.Agents.AI.Compaction;
|
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using OpenAI;
|
|
||||||
using OpenAI.Responses;
|
|
||||||
using SampleApp;
|
using SampleApp;
|
||||||
|
|
||||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
|
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
||||||
|
|
||||||
const int MaxContextWindowTokens = 1_050_000;
|
const int MaxContextWindowTokens = 1_050_000;
|
||||||
const int MaxOutputTokens = 128_000;
|
const int MaxOutputTokens = 128_000;
|
||||||
|
const string TracingSourceName = "Harness.Research";
|
||||||
|
|
||||||
// Create a ChatClientAgent with the Harness providers (TodoProvider and AgentModeProvider)
|
// Set up OpenTelemetry tracing that writes spans to a text file.
|
||||||
|
// This captures all agent activity (tool calls, model invocations, compaction, etc.)
|
||||||
|
// as well as HTTP requests made by the underlying HttpClient transport.
|
||||||
|
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
|
||||||
|
|
||||||
|
// Create a HarnessAgent with the Harness providers (TodoProvider and AgentModeProvider)
|
||||||
// and research-focused instructions including the mandatory planning workflow.
|
// and research-focused instructions including the mandatory planning workflow.
|
||||||
var instructions =
|
var instructions =
|
||||||
"""
|
"""
|
||||||
|
## Research Assistant Instructions
|
||||||
|
|
||||||
You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing.
|
You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing.
|
||||||
Use your knowledge to form good search queries and hypotheses, but always verify claims with the tools available to you rather than relying on memory alone.
|
Use your knowledge to form good search queries and hypotheses, but always verify claims with the tools available to you rather than relying on memory alone.
|
||||||
|
|
||||||
## Mandatory planning workflow
|
### Research quality
|
||||||
|
|
||||||
For every new substantive user request, including short factual questions, your behavior is determined by the mode you are in.
|
|
||||||
If you are in plan mode, start with the *Plan Mode* steps, and if you are in execute mode, skip directly to the *Execute Mode* steps below.
|
|
||||||
|
|
||||||
*Plan Mode*
|
|
||||||
|
|
||||||
1. Analyze the request with the purpose of building a research plan.
|
|
||||||
2. Create a list of todo items.
|
|
||||||
3. If needed, use the provided tools to do some exploratory checks to help build a plan and determine what clarifying questions you may need from the user.
|
|
||||||
4. Ask for clarifications from the user where needed.
|
|
||||||
1. Ask each clarification one by one.
|
|
||||||
2. When asking for clarification and you have specific options in mind, present them to the user, so they can choose the option instead of having to retype the entire response.
|
|
||||||
3. Do not proceed until you have received all the needed clarifications.
|
|
||||||
4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user.
|
|
||||||
5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes.
|
|
||||||
6. Present the plan to the user and ask for approval to switch to execute mode and process the plan.
|
|
||||||
7. When approval is granted, always switch to execute mode (using the `AgentMode_Set` tool), and follow the steps for *Execute mode*.
|
|
||||||
|
|
||||||
*Execute Mode*
|
|
||||||
|
|
||||||
1. If you don't have a plan or tasks yet, analyse the user request and create tasks and a plan. (**Skip this step if you came from plan mode**)
|
|
||||||
2. Work autonomously — use your best judgement to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns.
|
|
||||||
3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going.
|
|
||||||
4. Mark tasks as completed as you finish them.
|
|
||||||
5. Continue working, thinking and calling tools until you have the research result for the user.
|
|
||||||
|
|
||||||
## General Instructions
|
|
||||||
|
|
||||||
- You must check the current mode after any user input, since the user may have changed the mode themselves,
|
|
||||||
e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution.
|
|
||||||
- Explain your reasoning and thought process as you work through tasks.
|
|
||||||
- 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.
|
|
||||||
- Avoid making more than 4 tool calls in a row without explaining what you are doing.
|
|
||||||
- Do not answer the underlying question before the plan has been presented and approved.
|
|
||||||
- This rule applies even when the answer seems obvious or the task seems small.
|
|
||||||
- For short requests, use a brief micro-plan rather than skipping planning. The only exceptions are:
|
|
||||||
- greetings,
|
|
||||||
- pure acknowledgments,
|
|
||||||
- clarification questions needed to form the plan,
|
|
||||||
- follow-up questions about results you have already presented,
|
|
||||||
- meta-discussion about the workflow itself.
|
|
||||||
|
|
||||||
**Todo management**
|
|
||||||
|
|
||||||
Mark each todo complete as you finish it so the list stays current.
|
|
||||||
If a todo turns out to be unnecessary or is blocked, remove it and briefly explain why.
|
|
||||||
Once the user finishes with a topic and moves onto a new one, clean up old completed todos by deleting them.
|
|
||||||
|
|
||||||
**Research quality**
|
|
||||||
|
|
||||||
Consult multiple sources when possible and cross-reference key claims.
|
Consult multiple sources when possible and cross-reference key claims.
|
||||||
When sources disagree, note the discrepancy and explain which source you consider more reliable and why.
|
When sources disagree, note the discrepancy and explain which source you consider more reliable and why.
|
||||||
If a web page fails to load or a search returns irrelevant results, try alternative search queries or sources before moving on.
|
If a web page fails to load or a search returns irrelevant results, try alternative search queries or sources before moving on.
|
||||||
Track your sources — you will need them when presenting results.
|
Track your sources — you will need them when presenting results.
|
||||||
|
|
||||||
**Presenting results**
|
### Presenting results
|
||||||
|
|
||||||
When presenting your final findings:
|
When presenting your final findings:
|
||||||
|
- Use Markdown formatting for clarity.
|
||||||
- Use clear sections with headings for each major topic or sub-question.
|
- Use clear sections with headings for each major topic or sub-question.
|
||||||
- Cite your sources inline (e.g., "According to [source name](URL), ...").
|
- Cite your sources inline (e.g., "According to [source name](URL), ...").
|
||||||
- End with a brief summary of key takeaways.
|
- End with a brief summary of key takeaways.
|
||||||
- Save the final research report to file memory so it survives compaction and can be referenced later.
|
- In addition to returning the results to the user, save the final research report to file memory so it survives compaction and can be referenced later.
|
||||||
|
|
||||||
**File memory**
|
|
||||||
|
|
||||||
Use the FileMemory_* tools to:
|
|
||||||
- Store downloaded search results or web pages.
|
|
||||||
- Store plans.
|
|
||||||
- Read the current plan to make sure tasks were done according to plan.
|
|
||||||
- Store findings.
|
|
||||||
- Check for relevant previously downloaded data / findings before starting new research.
|
|
||||||
""";
|
""";
|
||||||
|
|
||||||
// Create a compaction strategy based on the model's context window.
|
// Create the agent using AsHarnessAgent, which pre-configures function invocation,
|
||||||
// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens.
|
// per-service-call chat history persistence, in-loop compaction, TodoProvider, AgentModeProvider,
|
||||||
// Defaults: tool result eviction at 50% of input budget, truncation at 80%.
|
// FileMemoryProvider, ToolApproval, WebSearch, AgentSkillsProvider, and OpenTelemetry.
|
||||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
// Only custom instructions, a WebBrowsingTool, and FileAccess opt-out are needed.
|
||||||
maxContextWindowTokens: MaxContextWindowTokens,
|
|
||||||
maxOutputTokens: MaxOutputTokens);
|
|
||||||
|
|
||||||
AIAgent agent =
|
AIAgent agent =
|
||||||
// Create an OpenAIClient that communicates with the Foundry responses service.
|
// Create an OpenAIClient that communicates with the Foundry responses service.
|
||||||
new OpenAIClient(
|
new AIProjectClient(
|
||||||
|
new Uri(endpoint),
|
||||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||||
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
new DefaultAzureCredential(),
|
||||||
new OpenAIClientOptions()
|
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }) // Enable retries to improve resiliency.
|
||||||
{
|
.GetProjectOpenAIClient()
|
||||||
Endpoint = new Uri(endpoint),
|
|
||||||
RetryPolicy = new ClientRetryPolicy(3) // Enable retries to improve resiliency.
|
|
||||||
})
|
|
||||||
.GetResponsesClient()
|
.GetResponsesClient()
|
||||||
.AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves.
|
.AsIChatClient(deploymentName)
|
||||||
|
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||||
// Build a ChatClient Pipeline
|
{
|
||||||
.AsBuilder()
|
Name = "ResearchAgent",
|
||||||
.UseFunctionInvocation() // We are building our own stack from scratch so we need to include Function Invocation ourselves.
|
Description = "A research assistant that plans and executes research tasks.",
|
||||||
.UsePerServiceCallChatHistoryPersistence() // Save chat history updates to the session after each service call, rather than only at the end of the run.
|
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||||
.UseAIContextProviders(new CompactionProvider(compactionStrategy)) // Add Compaction before each service call to responses so that long function invocation loops don't overflow the context.
|
OpenTelemetrySourceName = TracingSourceName, // Use our custom source name so spans are captured by the TracerProvider above.
|
||||||
|
FileMemoryStore = new FileSystemAgentFileStore( // Configure the file memory provider to store files in a local folder called "agent-files".
|
||||||
// Build our agent on top of the ChatClient Pipeline
|
Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||||
.BuildAIAgent(
|
ChatOptions = new ChatOptions
|
||||||
new ChatClientAgentOptions
|
|
||||||
{
|
{
|
||||||
Name = "ResearchAgent",
|
Instructions = instructions,
|
||||||
Description = "A research assistant that plans and executes research tasks.",
|
Tools =
|
||||||
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 =
|
|
||||||
[
|
[
|
||||||
new TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session.
|
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
|
||||||
new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session.
|
new WebBrowsingToolOptions { AllowPublicNetworks = true }),
|
||||||
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
|
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
|
||||||
{
|
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||||
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.
|
|
||||||
],
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Run the interactive console session using the shared HarnessConsole helper.
|
// Run the interactive console session using the shared HarnessConsole helper.
|
||||||
await HarnessConsole.RunAgentAsync(
|
await HarnessConsole.RunAgentAsync(
|
||||||
agent,
|
agent,
|
||||||
title: "Research Assistant",
|
|
||||||
userPrompt: "Enter a research topic to get started.",
|
userPrompt: "Enter a research topic to get started.",
|
||||||
new HarnessConsoleOptions
|
new HarnessConsoleOptions
|
||||||
{
|
{
|
||||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
Observers = [
|
||||||
MaxOutputTokens = MaxOutputTokens,
|
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||||
EnablePlanningUx = true,
|
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||||
PlanningModeName = "plan",
|
agent,
|
||||||
ExecutionModeName = "execute"
|
planModeName: "plan",
|
||||||
|
executionModeName: "execute",
|
||||||
|
maxContextWindowTokens: MaxContextWindowTokens,
|
||||||
|
maxOutputTokens: MaxOutputTokens,
|
||||||
|
toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()])],
|
||||||
|
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# What this sample demonstrates
|
# What this sample demonstrates
|
||||||
|
|
||||||
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.
|
This sample demonstrates how to use a `HarnessAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Azure AI Foundry. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and context-window compaction.
|
||||||
|
|
||||||
Key features showcased:
|
Key features showcased:
|
||||||
|
|
||||||
- **ChatClientAgent** — configured directly with Harness providers for planning and task management
|
- **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction
|
||||||
|
- **ToolApproval** — the agent is wrapped with `UseToolApproval()` to allow auto-approving tools once confirmed
|
||||||
- **Web Search** — the agent can search the web for current information via `ResponseTool.CreateWebSearchTool()`
|
- **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
|
- **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)
|
- **AgentModeProvider** — the agent switches between "plan" mode (breaking down the topic) and "execute" mode (answering each research question)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
@@ -10,11 +11,23 @@ namespace SampleApp;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An AI function that downloads HTML pages and converts them to markdown.
|
/// An AI function that downloads HTML pages and converts them to markdown.
|
||||||
|
/// Access is controlled by <see cref="WebBrowsingToolOptions"/> — by default, no hosts are accessible.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed partial class WebBrowsingTool : AIFunction
|
internal sealed partial class WebBrowsingTool : AIFunction
|
||||||
{
|
{
|
||||||
private static readonly HttpClient s_httpClient = new();
|
private static readonly HttpClient s_httpClient = new();
|
||||||
private readonly AIFunction _inner = AIFunctionFactory.Create(DownloadUriAsync);
|
private readonly AIFunction _inner;
|
||||||
|
private readonly WebBrowsingToolOptions _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="WebBrowsingTool"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Options controlling which URLs are permitted. By default, no hosts are accessible.</param>
|
||||||
|
public WebBrowsingTool(WebBrowsingToolOptions options)
|
||||||
|
{
|
||||||
|
this._options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
this._inner = AIFunctionFactory.Create(this.DownloadUriAsync);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override string Name => this._inner.Name;
|
public override string Name => this._inner.Name;
|
||||||
@@ -32,7 +45,7 @@ internal sealed partial class WebBrowsingTool : AIFunction
|
|||||||
this._inner.InvokeAsync(arguments, cancellationToken);
|
this._inner.InvokeAsync(arguments, cancellationToken);
|
||||||
|
|
||||||
[Description("Fetch the html from the given url as markdown")]
|
[Description("Fetch the html from the given url as markdown")]
|
||||||
private static async Task<string> DownloadUriAsync(
|
private async Task<string> DownloadUriAsync(
|
||||||
[Description("The URL to download")] string uri,
|
[Description("The URL to download")] string uri,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -46,9 +59,12 @@ internal sealed partial class WebBrowsingTool : AIFunction
|
|||||||
return $"Error: Only HTTP and HTTPS URLs are supported. Got: '{parsedUri.Scheme}'.";
|
return $"Error: Only HTTP and HTTPS URLs are supported. Got: '{parsedUri.Scheme}'.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: In production scenarios, consider also blocking requests to private/internal IP
|
// Check access policy.
|
||||||
// ranges (e.g., 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.0.0.1, 169.254.169.254)
|
string? accessError = await this.CheckAccessAsync(parsedUri, cancellationToken);
|
||||||
// to prevent SSRF attacks via prompt injection in web content.
|
if (accessError is not null)
|
||||||
|
{
|
||||||
|
return accessError;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -61,6 +77,142 @@ internal sealed partial class WebBrowsingTool : AIFunction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether the given URI is permitted by the configured access policy.
|
||||||
|
/// Returns null if allowed, or an error message string if blocked.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string?> CheckAccessAsync(Uri uri, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string host = uri.Host;
|
||||||
|
|
||||||
|
// 1. Check AllowedHosts.
|
||||||
|
if (this._options.AllowedHosts is { Count: > 0 } allowedHosts)
|
||||||
|
{
|
||||||
|
foreach (string pattern in allowedHosts)
|
||||||
|
{
|
||||||
|
if (HostMatchesPattern(host, pattern))
|
||||||
|
{
|
||||||
|
return null; // Allowed by explicit host list.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Short-circuit when the policy is guaranteed to block.
|
||||||
|
if (!this._options.AllowPublicNetworks &&
|
||||||
|
!this._options.AllowPrivateNetworks &&
|
||||||
|
!this._options.AllowAllHosts)
|
||||||
|
{
|
||||||
|
return $"Error: Access to '{host}' is blocked by the current access policy. Configure WebBrowsingToolOptions to allow access.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Resolve DNS to determine if the host is public or private.
|
||||||
|
IPAddress[] addresses;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
return $"Error: Could not resolve host '{host}'.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addresses.Length == 0)
|
||||||
|
{
|
||||||
|
return $"Error: Could not resolve host '{host}'.";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPrivate = Array.Exists(addresses, IsPrivateAddress);
|
||||||
|
|
||||||
|
// 4. If public and AllowPublicNetworks is true → allow.
|
||||||
|
if (!isPrivate && this._options.AllowPublicNetworks)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. If private and AllowPrivateNetworks is true → allow.
|
||||||
|
if (isPrivate && this._options.AllowPrivateNetworks)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. If AllowAllHosts is true → allow.
|
||||||
|
if (this._options.AllowAllHosts)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Block.
|
||||||
|
string networkType = isPrivate ? "private/internal network" : "public network";
|
||||||
|
return $"Error: Access to '{host}' is blocked. The host resolves to a {networkType} address and the current access policy does not permit this. " +
|
||||||
|
"Configure WebBrowsingToolOptions to allow access.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a host matches a pattern. Supports exact match and wildcard prefix (e.g., "*.example.com").
|
||||||
|
/// </summary>
|
||||||
|
private static bool HostMatchesPattern(string host, string pattern)
|
||||||
|
{
|
||||||
|
if (string.Equals(host, pattern, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wildcard prefix: "*.example.com" matches "sub.example.com" and "a.b.example.com".
|
||||||
|
if (pattern.StartsWith("*.", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
string suffix = pattern[1..]; // ".example.com"
|
||||||
|
return host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether an IP address is private, loopback, or link-local.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsPrivateAddress(IPAddress address)
|
||||||
|
{
|
||||||
|
if (address.IsIPv4MappedToIPv6)
|
||||||
|
{
|
||||||
|
address = address.MapToIPv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IPAddress.IsLoopback(address))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||||
|
{
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
return bytes[0] switch
|
||||||
|
{
|
||||||
|
10 => true, // 10.0.0.0/8
|
||||||
|
172 => bytes[1] >= 16 && bytes[1] <= 31, // 172.16.0.0/12
|
||||||
|
192 => bytes[1] == 168, // 192.168.0.0/16
|
||||||
|
169 => bytes[1] == 254, // 169.254.0.0/16 (link-local + metadata)
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (address.AddressFamily == AddressFamily.InterNetworkV6)
|
||||||
|
{
|
||||||
|
// fe80::/10 (link-local) or fc00::/7 (unique local).
|
||||||
|
byte[] bytes = address.GetAddressBytes();
|
||||||
|
if (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80)
|
||||||
|
{
|
||||||
|
return true; // Link-local
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((bytes[0] & 0xfe) == 0xfc)
|
||||||
|
{
|
||||||
|
return true; // Unique local
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A simple HTML to Markdown converter using regex-based transformations.
|
/// A simple HTML to Markdown converter using regex-based transformations.
|
||||||
/// Handles the most common HTML elements without requiring external dependencies.
|
/// Handles the most common HTML elements without requiring external dependencies.
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace SampleApp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Options that control which URLs the <see cref="WebBrowsingTool"/> is permitted to access.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// By default, <b>no hosts are accessible</b>. You must explicitly opt in to one or more
|
||||||
|
/// of the access modes below. The validation order is:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description>If the host matches an entry in <see cref="AllowedHosts"/>, the request is allowed.</description></item>
|
||||||
|
/// <item><description>If the resolved IP is a public address and <see cref="AllowPublicNetworks"/> is <see langword="true"/>, the request is allowed.</description></item>
|
||||||
|
/// <item><description>If the resolved IP is a private/loopback/link-local address and <see cref="AllowPrivateNetworks"/> is <see langword="true"/>, the request is allowed.</description></item>
|
||||||
|
/// <item><description>If <see cref="AllowAllHosts"/> is <see langword="true"/>, the request is allowed.</description></item>
|
||||||
|
/// <item><description>Otherwise, the request is blocked.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class WebBrowsingToolOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a list of host patterns that are always permitted, regardless of other settings.
|
||||||
|
/// Patterns support wildcard prefix matching (e.g., <c>"*.example.com"</c> matches <c>"docs.example.com"</c>).
|
||||||
|
/// Exact host names (e.g., <c>"docs.microsoft.com"</c>) are also supported.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This has the highest priority — if a host matches, it is allowed immediately.</remarks>
|
||||||
|
public IReadOnlyList<string>? AllowedHosts { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether public internet hosts (non-private, non-loopback, non-link-local IPs) are permitted.
|
||||||
|
/// Default is <see langword="false"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool AllowPublicNetworks { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether private network hosts are permitted.
|
||||||
|
/// This includes RFC 1918 addresses (10.x.x.x, 172.16-31.x.x, 192.168.x.x),
|
||||||
|
/// loopback (127.x.x.x, ::1), link-local (169.254.x.x, fe80::),
|
||||||
|
/// and cloud metadata endpoints (169.254.169.254).
|
||||||
|
/// Default is <see langword="false"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Warning:</b> Enabling this allows the agent to make requests to internal services,
|
||||||
|
/// localhost, and cloud metadata endpoints. Only enable this if you understand the SSRF risks.
|
||||||
|
/// </remarks>
|
||||||
|
public bool AllowPrivateNetworks { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether all hosts are permitted without any restriction.
|
||||||
|
/// Default is <see langword="false"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>⚠️ UNSAFE:</b> Enabling this disables all network boundary checks and allows the agent
|
||||||
|
/// to access any URL, including internal services, cloud metadata endpoints, and localhost.
|
||||||
|
/// Only use this for trusted, isolated environments where SSRF is not a concern.
|
||||||
|
/// </remarks>
|
||||||
|
public bool AllowAllHosts { get; set; }
|
||||||
|
}
|
||||||
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// This sample demonstrates how to use the BackgroundAgentsProvider to delegate work to background agents.
|
||||||
|
// A parent agent is given a list of stock tickers and instructed to find the closing price
|
||||||
|
// for each ticker on December 31, 2025. It delegates the web searches to a background agent.
|
||||||
|
// The HarnessAgent provides built-in WebSearch (HostedWebSearchTool) so no manual web search
|
||||||
|
// tool configuration is needed on the background agent.
|
||||||
|
//
|
||||||
|
// Special commands:
|
||||||
|
// /exit — End the session.
|
||||||
|
|
||||||
|
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||||
|
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||||
|
|
||||||
|
using System.ClientModel.Primitives;
|
||||||
|
using Azure.AI.Projects;
|
||||||
|
using Azure.Identity;
|
||||||
|
using Harness.Shared.Console;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_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;
|
||||||
|
const string TracingSourceName = "Harness.SubAgents";
|
||||||
|
|
||||||
|
// Set up OpenTelemetry tracing that writes spans to a text file.
|
||||||
|
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
|
||||||
|
|
||||||
|
// Create the AIProjectClient for communicating with the Foundry responses service.
|
||||||
|
var projectClient = new AIProjectClient(
|
||||||
|
new Uri(endpoint),
|
||||||
|
new DefaultAzureCredential(),
|
||||||
|
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) });
|
||||||
|
|
||||||
|
// --- Background agent: Web Search Agent ---
|
||||||
|
// This agent uses the HarnessAgent's built-in HostedWebSearchTool to search the web.
|
||||||
|
// Features not needed by this sub-agent are disabled.
|
||||||
|
AIAgent webSearchAgent =
|
||||||
|
projectClient
|
||||||
|
.GetProjectOpenAIClient()
|
||||||
|
.GetResponsesClient()
|
||||||
|
.AsIChatClient(deploymentName)
|
||||||
|
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||||
|
{
|
||||||
|
Name = "WebSearchAgent",
|
||||||
|
Description = "An agent that can search the web to find information.",
|
||||||
|
OpenTelemetrySourceName = TracingSourceName,
|
||||||
|
DisableTodoProvider = true,
|
||||||
|
DisableAgentModeProvider = true,
|
||||||
|
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
|
||||||
|
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||||
|
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
|
||||||
|
ChatOptions = new ChatOptions
|
||||||
|
{
|
||||||
|
Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Parent agent: Stock Price Researcher ---
|
||||||
|
// This agent orchestrates the background agent to look up stock prices in parallel.
|
||||||
|
var parentInstructions =
|
||||||
|
"""
|
||||||
|
You are a stock price research assistant. You have access to a web search background agent that can look up information on the web.
|
||||||
|
|
||||||
|
When given a list of stock tickers, your job is to find the closing price for each ticker on December 31, 2025.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. For each ticker, start a background task on the WebSearchAgent asking it to find the closing price on December 31, 2025.
|
||||||
|
- Start all background tasks before waiting for any of them to complete, so they run concurrently.
|
||||||
|
2. Wait for all background tasks to complete.
|
||||||
|
3. Retrieve the results from each background task.
|
||||||
|
4. Present a summary table with the ticker symbol and closing price for each stock.
|
||||||
|
5. Clear all completed tasks to free memory.
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
- Always delegate web searches to the WebSearchAgent background agent. Do not try to answer from memory.
|
||||||
|
- If a background task fails or returns unclear results, continue the task with a more specific query.
|
||||||
|
- Present results in a clean markdown table format.
|
||||||
|
""";
|
||||||
|
|
||||||
|
// --- Parent agent: Stock Price Researcher ---
|
||||||
|
// This agent orchestrates the sub-agent to look up stock prices in parallel.
|
||||||
|
// Most features are disabled since the parent only needs SubAgentsProvider.
|
||||||
|
AIAgent parentAgent =
|
||||||
|
projectClient
|
||||||
|
.GetProjectOpenAIClient()
|
||||||
|
.GetResponsesClient()
|
||||||
|
.AsIChatClient(deploymentName)
|
||||||
|
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||||
|
{
|
||||||
|
Name = "StockPriceResearcher",
|
||||||
|
Description = "An agent that researches stock prices using background agents.",
|
||||||
|
OpenTelemetrySourceName = TracingSourceName,
|
||||||
|
DisableTodoProvider = true,
|
||||||
|
DisableAgentModeProvider = true,
|
||||||
|
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
|
||||||
|
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||||
|
DisableToolApproval = true, // If enabled, this allows don't-ask-again approval functionality.
|
||||||
|
DisableWebSearch = true,
|
||||||
|
AIContextProviders =
|
||||||
|
[
|
||||||
|
new BackgroundAgentsProvider([webSearchAgent]),
|
||||||
|
],
|
||||||
|
ChatOptions = new ChatOptions
|
||||||
|
{
|
||||||
|
Instructions = parentInstructions,
|
||||||
|
MaxOutputTokens = 16_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run the interactive console session.
|
||||||
|
await HarnessConsole.RunAgentAsync(
|
||||||
|
parentAgent,
|
||||||
|
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
|
||||||
+15
-15
@@ -1,24 +1,24 @@
|
|||||||
# Harness Step 02 — SubAgents (Stock Price Research)
|
# Harness Step 02 — BackgroundAgents (Stock Price Research)
|
||||||
|
|
||||||
This sample demonstrates how to use the **SubAgentsProvider** to delegate work from a parent agent to sub-agents.
|
This sample demonstrates how to use the **BackgroundAgentsProvider** to delegate work from a parent agent to background agents. Both agents use `HarnessAgent` for pre-configured function invocation, per-service-call persistence, and context-window compaction.
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
A parent agent receives a list of stock tickers and uses a web-search sub-agent to find the closing price for each ticker on December 31, 2025. The sub-tasks run concurrently, and results are presented in a summary table.
|
A parent agent receives a list of stock tickers and uses a web-search background agent to find the closing price for each ticker on December 31, 2025. The background tasks run concurrently, and results are presented in a summary table.
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────┐
|
┌────────────────────────────────────────┐
|
||||||
│ StockPriceResearcher │
|
│ StockPriceResearcher │
|
||||||
│ (Parent Agent) │
|
│ (Parent Agent) │
|
||||||
│ │
|
│ │
|
||||||
│ SubAgentsProvider │
|
│ BackgroundAgentsProvider │
|
||||||
│ ├─ SubAgents_StartTask │
|
│ ├─ BackgroundAgents_StartTask │
|
||||||
│ ├─ SubAgents_WaitFor... │
|
│ ├─ BackgroundAgents_WaitFor... │
|
||||||
│ ├─ SubAgents_GetTaskResults │
|
│ ├─ BackgroundAgents_GetTaskResults │
|
||||||
│ └─ ... │
|
│ └─ ... │
|
||||||
└────────────┬────────────────────┘
|
└────────────┬───────────────────────────┘
|
||||||
│ delegates to
|
│ delegates to
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────┐
|
┌─────────────────────────────────┐
|
||||||
@@ -40,7 +40,7 @@ A parent agent receives a list of stock tickers and uses a web-search sub-agent
|
|||||||
## Running the Sample
|
## Running the Sample
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents
|
cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents
|
||||||
dotnet run
|
dotnet run
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -50,4 +50,4 @@ When prompted, enter a list of stock tickers such as:
|
|||||||
BAC, MSFT, BA
|
BAC, MSFT, BA
|
||||||
```
|
```
|
||||||
|
|
||||||
The parent agent will delegate each ticker lookup to the web search sub-agent concurrently and present the results in a table.
|
The parent agent will delegate each ticker lookup to the web search background agent concurrently and present the results in a table.
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
|
||||||
|
|
||||||
// This sample demonstrates how to use the SubAgentsProvider to delegate work to sub-agents.
|
|
||||||
// A parent agent is given a list of stock tickers and instructed to find the closing price
|
|
||||||
// for each ticker on December 31, 2025. It delegates the web searches to a sub-agent
|
|
||||||
// equipped with Foundry's hosted web search tool.
|
|
||||||
//
|
|
||||||
// Special commands:
|
|
||||||
// exit — End the session.
|
|
||||||
|
|
||||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
|
||||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
|
||||||
|
|
||||||
using System.ClientModel.Primitives;
|
|
||||||
using Azure.Identity;
|
|
||||||
using Harness.Shared.Console;
|
|
||||||
using Microsoft.Agents.AI;
|
|
||||||
using Microsoft.Extensions.AI;
|
|
||||||
using OpenAI;
|
|
||||||
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";
|
|
||||||
|
|
||||||
// --- 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 =
|
|
||||||
new OpenAIClient(
|
|
||||||
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
|
||||||
new OpenAIClientOptions()
|
|
||||||
{
|
|
||||||
Endpoint = new Uri(endpoint),
|
|
||||||
RetryPolicy = new ClientRetryPolicy(3)
|
|
||||||
})
|
|
||||||
.GetResponsesClient()
|
|
||||||
.AsIChatClientWithStoredOutputDisabled(deploymentName)
|
|
||||||
.AsAIAgent(
|
|
||||||
new ChatClientAgentOptions
|
|
||||||
{
|
|
||||||
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.
|
|
||||||
var parentInstructions =
|
|
||||||
"""
|
|
||||||
You are a stock price research assistant. You have access to a web search sub-agent that can look up information on the web.
|
|
||||||
|
|
||||||
When given a list of stock tickers, your job is to find the closing price for each ticker on December 31, 2025.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. For each ticker, start a sub-task on the WebSearchAgent asking it to find the closing price on December 31, 2025.
|
|
||||||
- Start all sub-tasks before waiting for any of them to complete, so they run concurrently.
|
|
||||||
2. Wait for all sub-tasks to complete.
|
|
||||||
3. Retrieve the results from each sub-task.
|
|
||||||
4. Present a summary table with the ticker symbol and closing price for each stock.
|
|
||||||
5. Clear all completed tasks to free memory.
|
|
||||||
|
|
||||||
## Important
|
|
||||||
|
|
||||||
- Always delegate web searches to the WebSearchAgent sub-agent. Do not try to answer from memory.
|
|
||||||
- If a sub-task fails or returns unclear results, continue the task with a more specific query.
|
|
||||||
- Present results in a clean markdown table format.
|
|
||||||
""";
|
|
||||||
|
|
||||||
AIAgent parentAgent =
|
|
||||||
new OpenAIClient(
|
|
||||||
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
|
||||||
new OpenAIClientOptions()
|
|
||||||
{
|
|
||||||
Endpoint = new Uri(endpoint),
|
|
||||||
RetryPolicy = new ClientRetryPolicy(3)
|
|
||||||
})
|
|
||||||
.GetResponsesClient()
|
|
||||||
.AsIChatClientWithStoredOutputDisabled(deploymentName)
|
|
||||||
.AsAIAgent(
|
|
||||||
new ChatClientAgentOptions
|
|
||||||
{
|
|
||||||
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):");
|
|
||||||
+3
-2
@@ -13,12 +13,13 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="data\**\*" CopyToOutputDirectory="PreserveNewest" />
|
<Content Include="working\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
// This sample demonstrates how to use a ChatClientAgent with the FileAccessProvider
|
// This sample demonstrates how to use a HarnessAgent with the default FileAccessProvider
|
||||||
// to give an agent access to a folder of CSV data files. The agent can read, analyze,
|
// 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.
|
// and extract information from the data, then write results back as new files.
|
||||||
//
|
//
|
||||||
// The sample includes a pre-populated `data/` folder with sales transaction data.
|
// The sample includes a pre-populated `working/` folder with sales transaction data.
|
||||||
|
// The HarnessAgent's default FileAccessProvider uses `{cwd}/working` as its working directory,
|
||||||
|
// which matches this sample's folder layout.
|
||||||
// Ask the agent to analyze the data, produce summaries, or create new output files.
|
// Ask the agent to analyze the data, produce summaries, or create new output files.
|
||||||
//
|
//
|
||||||
// Special commands:
|
// 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 OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||||
|
|
||||||
using System.ClientModel.Primitives;
|
using System.ClientModel.Primitives;
|
||||||
|
using Azure.AI.Projects;
|
||||||
using Azure.Identity;
|
using Azure.Identity;
|
||||||
using Harness.Shared.Console;
|
using Harness.Shared.Console;
|
||||||
using Microsoft.Agents.AI;
|
using Microsoft.Agents.AI;
|
||||||
using Microsoft.Agents.AI.Compaction;
|
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using OpenAI;
|
|
||||||
using OpenAI.Responses;
|
|
||||||
|
|
||||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
|
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
||||||
|
|
||||||
const int MaxContextWindowTokens = 1_050_000;
|
const int MaxContextWindowTokens = 1_050_000;
|
||||||
const int MaxOutputTokens = 128_000;
|
const int MaxOutputTokens = 128_000;
|
||||||
|
const string TracingSourceName = "Harness.DataProcessing";
|
||||||
|
|
||||||
// Point the file store at the data/ folder that ships with the sample.
|
// Set up OpenTelemetry tracing that writes spans to a text file.
|
||||||
var dataFolder = Path.Combine(AppContext.BaseDirectory, "data");
|
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
|
||||||
var fileStore = new FileSystemAgentFileStore(dataFolder);
|
|
||||||
|
|
||||||
var instructions =
|
var instructions =
|
||||||
"""
|
"""
|
||||||
@@ -57,54 +57,35 @@ 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.
|
- 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 a compaction strategy based on the model's context window.
|
// Create the agent using AsHarnessAgent. The FileAccessStore is explicitly set to the
|
||||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
// sample's working/ folder (copied to the output directory) so it works regardless of cwd.
|
||||||
maxContextWindowTokens: MaxContextWindowTokens,
|
// Unused features are disabled.
|
||||||
maxOutputTokens: MaxOutputTokens);
|
|
||||||
|
|
||||||
AIAgent agent =
|
AIAgent agent =
|
||||||
new OpenAIClient(
|
new AIProjectClient(
|
||||||
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
new Uri(endpoint),
|
||||||
new OpenAIClientOptions()
|
new DefaultAzureCredential(),
|
||||||
{
|
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) })
|
||||||
Endpoint = new Uri(endpoint),
|
.GetProjectOpenAIClient()
|
||||||
RetryPolicy = new ClientRetryPolicy(3)
|
|
||||||
})
|
|
||||||
.GetResponsesClient()
|
.GetResponsesClient()
|
||||||
.AsIChatClientWithStoredOutputDisabled(deploymentName)
|
.AsIChatClient(deploymentName)
|
||||||
|
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
|
||||||
.AsBuilder()
|
{
|
||||||
.UseFunctionInvocation()
|
Name = "DataAnalyst",
|
||||||
.UsePerServiceCallChatHistoryPersistence()
|
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
|
||||||
.UseAIContextProviders(new CompactionProvider(compactionStrategy))
|
OpenTelemetrySourceName = TracingSourceName,
|
||||||
|
FileAccessStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "working")),
|
||||||
.BuildAIAgent(
|
DisableTodoProvider = true,
|
||||||
new ChatClientAgentOptions
|
DisableAgentModeProvider = true,
|
||||||
|
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
|
||||||
|
DisableWebSearch = true,
|
||||||
|
ChatOptions = new ChatOptions
|
||||||
{
|
{
|
||||||
Name = "DataAnalyst",
|
Instructions = instructions,
|
||||||
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
|
MaxOutputTokens = MaxOutputTokens,
|
||||||
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.
|
// Run the interactive console session.
|
||||||
await HarnessConsole.RunAgentAsync(
|
await HarnessConsole.RunAgentAsync(
|
||||||
agent,
|
agent,
|
||||||
title: "Data Processing Assistant",
|
|
||||||
userPrompt: "Ask me to analyze the data files, produce summaries, or create output files.");
|
userPrompt: "Ask me to analyze the data files, produce summaries, or create output files.");
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# What this sample demonstrates
|
# What this sample demonstrates
|
||||||
|
|
||||||
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.
|
This sample demonstrates how to use a `HarnessAgent` with the default `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, in-loop compaction, tool approval, and OpenTelemetry — so the sample only needs to supply the chat client, token limits, custom instructions, and opt out of unused features.
|
||||||
|
|
||||||
Key features showcased:
|
Key features showcased:
|
||||||
|
|
||||||
- **FileAccessProvider** — gives the agent tools to read, write, list, search, and delete files in a shared data folder
|
- **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction
|
||||||
|
- **FileAccessProvider** — the HarnessAgent's default file access provider uses `{cwd}/working` as its working directory, matching this sample's `working/` folder
|
||||||
- **CSV data processing** — the agent reads sales transaction data and performs analysis on demand
|
- **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
|
- **Output file creation** — the agent can write summaries, filtered data, or reports back to the data folder
|
||||||
- **Streaming output** — responses are streamed token-by-token for a natural experience
|
- **Streaming output** — responses are streamed token-by-token for a natural experience
|
||||||
@@ -38,7 +39,7 @@ dotnet run --project samples/02-agents/Harness/Harness_Step03_DataProcessing
|
|||||||
|
|
||||||
## What to Expect
|
## What to Expect
|
||||||
|
|
||||||
The sample starts an interactive conversation with a data analyst agent. The `data/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson).
|
The sample starts an interactive conversation with a data analyst agent. The `working/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson).
|
||||||
|
|
||||||
You can ask the agent to:
|
You can ask the agent to:
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ E.g. try the following prompt `Please process the sales.csv file by first filter
|
|||||||
|
|
||||||
## Sample Data
|
## Sample Data
|
||||||
|
|
||||||
The included `data/sales.csv` contains sales transactions from January to March 2025 with the following columns:
|
The included `working/sales.csv` contains sales transactions from January to March 2025 with the following columns:
|
||||||
|
|
||||||
| Column | Description |
|
| Column | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.AI.Projects" />
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
<PackageReference Include="Hyperlight.HyperlightSandbox.Guest.Python" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
|
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user