Compare commits

..
702 changed files with 8746 additions and 69679 deletions
+3 -9
View File
@@ -17,7 +17,7 @@ runs:
using: "composite"
steps:
- name: Set up uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
uses: astral-sh/setup-uv@v6
with:
version-file: "python/pyproject.toml"
enable-cache: true
@@ -32,13 +32,7 @@ runs:
if grep -q "name = \"$pkg\"" "$f"; then
pkg_dir=$(dirname "$f" | sed 's|python/||')
echo "Excluding workspace package: $pkg ($pkg_dir)"
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 '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
sed -i.bak '/'"$pkg"' = { workspace = true }/d' python/pyproject.toml
fi
done
@@ -46,4 +40,4 @@ runs:
- name: Install the project
shell: bash
run: |
cd python && uv sync --all-packages --all-extras --dev --prerelease=if-necessary-or-explicit
cd python && uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit
@@ -24,7 +24,7 @@ runs:
using: "composite"
steps:
- name: Set up Node.js environment
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@v6
with:
node-version: 22
@@ -37,7 +37,7 @@ runs:
run: copilot --version && copilot -p "What can you do in one sentence?"
- name: Azure CLI Login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ inputs.azure-client-id }}
tenant-id: ${{ inputs.azure-tenant-id }}
+3 -9
View File
@@ -44,15 +44,9 @@ updates:
# Maintain dependencies for github-actions
- package-ecosystem: "github-actions"
# Cover both the standard workflow location and our composite actions.
# With `directory: "/"` Dependabot only scans `.github/workflows/*.{yml,yaml}`
# plus a root-level `action.yml/action.yaml`. It does NOT recurse into
# `.github/actions/*/action.yml`, so the glob below is required to keep the
# composite actions in `.github/actions/<name>/` up to date as well.
# Ref: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#directories-or-directory--
directories:
- "/"
- "/.github/actions/*"
# Workflow files stored in the
# default location of `.github/workflows`
directory: "/"
schedule:
interval: "weekly"
day: "sunday"
+4 -4
View File
@@ -32,13 +32,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -51,7 +51,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/autobuild@v4
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -64,6 +64,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
+5 -5
View File
@@ -66,7 +66,7 @@ jobs:
- name: Check PR author team membership
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
env:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
@@ -116,7 +116,7 @@ jobs:
steps:
# Safe checkout: base repo only, not the untrusted PR head.
- name: Checkout target repo base
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
fetch-depth: 0
@@ -125,7 +125,7 @@ jobs:
# Private DevFlow checkout: the PAT/token grants access to this repo's code.
- name: Checkout DevFlow
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
repository: ${{ env.DEVFLOW_REPOSITORY }}
ref: ${{ env.DEVFLOW_REF }}
@@ -135,12 +135,12 @@ jobs:
path: devflow
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@v7
with:
version: "0.11.x"
enable-cache: true
+32 -144
View File
@@ -38,11 +38,9 @@ jobs:
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
functionsChanged: ${{ steps.filter.outputs.functions }}
coreChanged: ${{ steps.filter.outputs.core }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
@@ -66,24 +64,6 @@ jobs:
- 'dotnet/Directory.Packages.props'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1'
- '.github/workflows/dotnet-build-and-test.yml'
functions:
- 'dotnet/src/Microsoft.Agents.AI.DurableTask/**'
- 'dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/**'
- 'dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/**'
- 'dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/**'
- '.github/actions/azure-functions-integration-setup/**'
- '.github/workflows/dotnet-build-and-test.yml'
core:
- 'dotnet/src/Microsoft.Agents.AI/**'
- 'dotnet/src/Microsoft.Agents.AI.Abstractions/**'
- 'dotnet/src/Microsoft.Agents.AI.OpenAI/**'
- 'dotnet/src/Microsoft.Agents.AI.Workflows/**'
- 'dotnet/src/Microsoft.Agents.AI.Workflows.Generators/**'
- 'dotnet/eng/scripts/New-FilteredSolution.ps1'
- 'dotnet/tests/Directory.Build.props'
- 'dotnet/Directory.Packages.props'
- 'dotnet/global.json'
- '.github/workflows/dotnet-build-and-test.yml'
# run only if 'dotnet' files were changed
- name: dotnet tests
if: steps.filter.outputs.dotnet == 'true'
@@ -111,7 +91,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
@@ -122,7 +102,7 @@ jobs:
declarative-agents
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Build dotnet solutions
@@ -181,7 +161,7 @@ jobs:
runs-on: ${{ matrix.os }}
environment: ${{ matrix.environment }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
@@ -202,7 +182,7 @@ jobs:
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
@@ -231,11 +211,10 @@ jobs:
Verbose = $true
}
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
-TestProjectNameIncludeFilter "*UnitTests*" `
-TestProjectNameFilter "*UnitTests*" `
-OutputPath dotnet/filtered-unit.slnx
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
-TestProjectNameIncludeFilter "*IntegrationTests*" `
-TestProjectNameExcludeFilter "*DurableTask.IntegrationTests*","*AzureFunctions.IntegrationTests*" `
-TestProjectNameFilter "*IntegrationTests*" `
-OutputPath dotnet/filtered-integration.slnx
- name: Run Unit Tests
@@ -271,12 +250,20 @@ jobs:
- name: Azure CLI Login
if: github.event_name != 'pull_request' && matrix.integration-tests
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# This setup action is required for both Durable Task and Azure Functions integration tests.
# We only run it on Ubuntu since the Durable Task and Azure Functions features are not available
# on .NET Framework (net472) which is what we use the Windows runner for.
- name: Set up Durable Task and Azure Functions Integration Test Emulators
if: github.event_name != 'pull_request' && matrix.integration-tests && matrix.os == 'ubuntu-latest'
uses: ./.github/actions/azure-functions-integration-setup
id: azure-functions-setup
- name: Run Integration Tests
shell: pwsh
working-directory: dotnet
@@ -318,7 +305,7 @@ jobs:
# Generate test reports and check coverage
- name: Generate test reports
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
uses: danielpalme/ReportGenerator-GitHub-Action@2a82782178b2816d9d6960a7345fdd164791b323 # 5.5.3
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.3
with:
reports: "./TestResults/Coverage/**/*.cobertura.xml"
targetdir: "./TestResults/Reports"
@@ -326,7 +313,7 @@ jobs:
- name: Upload coverage report artifact
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name
path: ./TestResults/Reports # Directory containing files to upload
@@ -338,7 +325,7 @@ jobs:
- name: Upload integration test results
if: always() && github.event_name != 'pull_request' && matrix.integration-tests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: dotnet-test-results-${{ matrix.targetFramework }}-${{ matrix.os }}
path: IntegrationTestResults/**/*.junit
@@ -356,7 +343,7 @@ jobs:
env:
configuration: Release
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
@@ -366,7 +353,7 @@ jobs:
python
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
@@ -381,7 +368,7 @@ jobs:
run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --warnaserror
- name: Azure CLI Login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -429,110 +416,11 @@ jobs:
AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }}
# IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step.
# DurableTask and AzureFunctions integration tests (ubuntu/net10.0 only).
# Split from main dotnet-test job for path-based filtering and parallelism.
dotnet-test-functions:
needs: [paths-filter]
if: >
github.event_name != 'pull_request' &&
(needs.paths-filter.outputs.functionsChanged == 'true' ||
needs.paths-filter.outputs.coreChanged == 'true' ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
environment: integration
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
sparse-checkout: |
.
.github
dotnet
python
declarative-agents
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # 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@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: dotnet-test-results-functions-net10.0-ubuntu-latest
path: IntegrationTestResults/**/*.junit
if-no-files-found: ignore
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
dotnet-build-and-test-check:
if: always()
runs-on: ubuntu-latest
needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it, dotnet-test-functions]
needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it]
steps:
- name: Get Date
shell: bash
@@ -560,14 +448,14 @@ jobs:
- name: Fail workflow if tests failed
id: check_tests_failed
if: contains(join(needs.*.result, ','), 'failure')
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Failed!')
- name: Fail workflow if tests cancelled
id: check_tests_cancelled
if: contains(join(needs.*.result, ','), 'cancelled')
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Cancelled!')
@@ -579,13 +467,13 @@ jobs:
github.event_name != 'pull_request' &&
(contains(join(needs.*.result, ','), 'success') ||
contains(join(needs.*.result, ','), 'failure'))
needs: [dotnet-test, dotnet-test-functions]
needs: [dotnet-test]
runs-on: ubuntu-latest
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
@@ -597,12 +485,12 @@ jobs:
python-version: "3.13"
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@v4
with:
pattern: dotnet-test-results-*
path: dotnet-test-results/
- name: Restore report history cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/restore@v4
with:
path: python/dotnet-integration-report-history.json
key: dotnet-integration-report-history-${{ github.run_id }}
@@ -619,13 +507,13 @@ jobs:
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save report history cache
if: always()
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: dotnet-integration-test-report
path: |
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
@@ -42,7 +42,7 @@ jobs:
- name: Get changed files
id: changed-files
if: github.event_name == 'pull_request'
uses: jitterbit/get-changed-files@b17fbb00bdc0c0f63fcf166580804b4d2cdc2a42 # v1
uses: jitterbit/get-changed-files@v1
continue-on-error: true
- name: No C# files changed
@@ -29,7 +29,7 @@ jobs:
environment: integration
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -50,7 +50,7 @@ jobs:
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
@@ -63,7 +63,7 @@ jobs:
done
- name: Azure CLI Login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+4 -4
View File
@@ -41,7 +41,7 @@ jobs:
environment: 'integration'
timeout-minutes: 90
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
@@ -52,13 +52,13 @@ jobs:
declarative-agents
- name: Setup dotnet
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -123,7 +123,7 @@ jobs:
- name: Upload results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: verify-samples-results
path: |
+26 -26
View File
@@ -1,8 +1,12 @@
name: Issue Triage
on:
issues:
types: [opened, labeled]
workflow_dispatch:
inputs:
issue_number:
description: Issue number to triage
required: true
type: string
permissions:
contents: read
@@ -10,13 +14,7 @@ permissions:
id-token: write
concurrency:
group: >-
issue-triage-${{ github.repository }}-${{
((github.event.action == 'opened' && contains(github.event.issue.labels.*.name, 'bug'))
|| (github.event.action == 'labeled' && github.event.label.name == 'bug'))
&& github.event.issue.number
|| github.run_id
}}
group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }}
cancel-in-progress: true
env:
@@ -28,7 +26,6 @@ env:
jobs:
team_check:
runs-on: ubuntu-latest
if: ${{ (github.event.action == 'opened' && contains(github.event.issue.labels.*.name, 'bug')) || (github.event.action == 'labeled' && github.event.label.name == 'bug') }}
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
issue_number: ${{ steps.issue.outputs.issue_number }}
@@ -39,13 +36,18 @@ jobs:
shell: bash
env:
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
run: |
set -euo pipefail
issue_number="${ISSUE_NUMBER_EVENT}"
if [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
issue_number="${ISSUE_NUMBER_EVENT}"
else
issue_number="${ISSUE_NUMBER_INPUT}"
fi
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
echo "Could not determine issue number from event payload." >&2
echo "Could not determine issue number; for workflow_dispatch runs, the 'issue_number' input is required." >&2
exit 1
fi
@@ -53,7 +55,7 @@ jobs:
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
sparse-checkout: .github/scripts
fetch-depth: 1
@@ -61,7 +63,7 @@ jobs:
- name: Check issue author team membership
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
env:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
@@ -93,7 +95,7 @@ jobs:
steps:
# Safe checkout: base repo only.
- name: Checkout target repo base
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
@@ -101,7 +103,7 @@ jobs:
# Private DevFlow (maf-dashboard) checkout.
- name: Checkout DevFlow
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v6
with:
repository: ${{ env.DEVFLOW_REPOSITORY }}
ref: ${{ env.DEVFLOW_REF }}
@@ -111,12 +113,12 @@ jobs:
path: devflow
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@v7
with:
version: "0.11.x"
enable-cache: true
@@ -126,7 +128,7 @@ jobs:
run: uv sync --frozen
- name: Azure CLI Login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -137,7 +139,6 @@ jobs:
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
@@ -150,22 +151,21 @@ jobs:
--apply-labels
- name: Stop after spam gate
if: ${{ steps.spam.outputs.allow_triage != 'true' }}
if: ${{ steps.spam.outputs.decision != 'allow' }}
shell: bash
env:
SPAM_DECISION: ${{ steps.spam.outputs.decision }}
run: |
echo "Stopping: issue triage preflight did not allow automation."
echo "Stopping: spam gate decided: ${SPAM_DECISION}"
exit 1
- name: Reproduce reported issue
if: ${{ steps.spam.outputs.allow_triage == 'true' }}
if: ${{ steps.spam.outputs.decision == 'allow' }}
id: repro
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
# Not seen by the agent prompt; used only to push a paper-trail
# branch back to maf-dashboard at run end.
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
permissions:
issues: write
steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- uses: actions/github-script@v8
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
script: |
+1 -1
View File
@@ -16,6 +16,6 @@ jobs:
pull-requests: write
steps:
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
- uses: actions/labeler@v6
with:
repo-token: "${{ secrets.GH_ACTIONS_PR_WRITE }}"
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- uses: actions/github-script@v8
name: "Issue/PR: update title"
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -19,13 +19,13 @@ jobs:
runs-on: ubuntu-22.04
# check out the latest version of the code
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
persist-credentials: false
# Checks the status of hyperlinks in all files
- name: Run linkspector
uses: umbrelladocs/action-linkspector@963b6264d7de32c904942a70b488d3407453049e # v1
uses: umbrelladocs/action-linkspector@v1
with:
reporter: local
filter_mode: nofilter
+13 -95
View File
@@ -2,7 +2,7 @@ name: Merge Gatekeeper
on:
pull_request:
branches: ["main", "feature*"]
branches: [ "main", "feature*" ]
merge_group:
branches: ["main"]
@@ -13,105 +13,23 @@ concurrency:
jobs:
merge-gatekeeper:
runs-on: ubuntu-latest
# Restrict permissions of the GITHUB_TOKEN.
# Docs: https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs
permissions:
checks: read
statuses: read
steps:
- name: Wait for required checks
- name: Run Merge Gatekeeper
# NOTE: v1 is updated to reflect the latest v1.x.y. Please use any tag/branch that suits your needs:
# https://github.com/upsidr/merge-gatekeeper/tags
# https://github.com/upsidr/merge-gatekeeper/branches
uses: upsidr/merge-gatekeeper@v1
if: github.event_name == 'pull_request'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
TIMEOUT_SECONDS: "3600"
INTERVAL_SECONDS: "30"
SELF_JOB_NAME: ${{ github.job }}
with:
token: ${{ secrets.GITHUB_TOKEN }}
timeout: 3600
interval: 30
# "Cleanup artifacts", "Agent", "Prepare", and "Upload results" are check runs
# created by an org-level GitHub App (MSDO), not by any workflow in this repo.
# They are outside our control and their transient failures should not block merges.
IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results"
with:
script: |
const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS);
const intervalSeconds = Number(process.env.INTERVAL_SECONDS);
const selfName = process.env.SELF_JOB_NAME;
const ignored = new Set(
process.env.IGNORED_NAMES.split(',').map((s) => s.trim()).filter(Boolean),
);
const sha = context.payload.pull_request.head.sha;
const { owner, repo } = context.repo;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Mirrors upsidr/merge-gatekeeper: merge combined-statuses and check-runs
// for the PR head SHA, with combined-statuses winning on name collision.
async function collectChecks() {
const merged = new Map();
const combined = await github.rest.repos.getCombinedStatusForRef({
owner, repo, ref: sha, per_page: 100,
});
for (const s of combined.data.statuses ?? []) {
if (!merged.has(s.context)) {
// Combined-status states: success | pending | error | failure
merged.set(s.context, { name: s.context, state: s.state });
}
}
const runs = await github.paginate(github.rest.checks.listForRef, {
owner, repo, ref: sha, per_page: 100,
});
for (const r of runs) {
if (merged.has(r.name)) continue;
let state;
if (r.status !== 'completed') {
state = 'pending';
} else if (r.conclusion === 'skipped') {
continue; // Skipped runs are dropped, matching the original action.
} else if (r.conclusion === 'success' || r.conclusion === 'neutral') {
state = 'success';
} else {
// cancelled | timed_out | action_required | stale | failure
state = 'error';
}
merged.set(r.name, { name: r.name, state });
}
return [...merged.values()];
}
function evaluate(entries) {
const failed = [];
const pending = [];
const succeeded = [];
for (const e of entries) {
if (e.name === selfName || ignored.has(e.name)) continue;
if (e.state === 'success') succeeded.push(e.name);
else if (e.state === 'error' || e.state === 'failure') failed.push(e.name);
else pending.push(e.name);
}
return { failed, pending, succeeded };
}
const deadline = Date.now() + timeoutSeconds * 1000;
for (;;) {
const entries = await collectChecks();
const { failed, pending, succeeded } = evaluate(entries);
core.info(
`succeeded=${succeeded.length} pending=${pending.length} failed=${failed.length}`,
);
if (failed.length) {
core.setFailed(`Failing checks: ${failed.join(', ')}`);
return;
}
if (pending.length === 0) {
core.info(`All required checks passed: ${succeeded.join(', ') || '(none)'}`);
return;
}
if (Date.now() > deadline) {
core.setFailed(`Timed out waiting for: ${pending.join(', ')}`);
return;
}
core.info(`Waiting on (${pending.length}): ${pending.slice(0, 10).join(', ')}${pending.length > 10 ? ', …' : ''}`);
await sleep(intervalSeconds * 1000);
}
ignored: CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results
+6 -6
View File
@@ -27,7 +27,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -38,11 +38,11 @@ jobs:
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
- uses: actions/cache@v5
with:
path: ~/.cache/prek
key: prek|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
- uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1
- uses: j178/prek-action@v1
name: Run Pre-commit Hooks (excluding poe-check)
env:
SKIP: poe-check
@@ -64,7 +64,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -93,7 +93,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -124,7 +124,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -22,7 +22,7 @@ jobs:
UV_PYTHON: "3.13"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -44,7 +44,7 @@ jobs:
- name: Upload dependency range report
# Always publish the report so failures are inspectable even when validation fails.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: dependency-range-results
path: python/scripts/dependencies/dependency-range-results.json
@@ -53,7 +53,7 @@ jobs:
- name: Create issues for failed dependency candidates
# Always process the report so failed candidates create actionable tracking issues.
if: always()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with:
script: |
const fs = require("fs")
@@ -18,7 +18,7 @@ jobs:
UV_PYTHON: "3.13"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
fetch-depth: 0
+2 -2
View File
@@ -24,9 +24,9 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@v7
with:
version-file: "python/pyproject.toml"
enable-cache: true
+27 -27
View File
@@ -36,7 +36,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -69,7 +69,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -90,7 +90,7 @@ jobs:
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-openai
path: ./python/pytest.xml
@@ -112,7 +112,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -123,7 +123,7 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -141,7 +141,7 @@ jobs:
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-azure-openai
path: ./python/pytest.xml
@@ -163,7 +163,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -177,7 +177,7 @@ jobs:
run: curl -fsSL https://ollama.com/install.sh | sh
working-directory: .
- name: Cache Ollama models
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@v4
with:
path: ~/.ollama/models
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
@@ -231,7 +231,7 @@ jobs:
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-misc
path: ./python/pytest.xml
@@ -283,7 +283,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -294,7 +294,7 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -315,7 +315,7 @@ jobs:
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-functions
path: ./python/pytest.xml
@@ -341,7 +341,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -352,7 +352,7 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -369,7 +369,7 @@ jobs:
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-foundry
path: ./python/pytest.xml
@@ -388,7 +388,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -399,7 +399,7 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -416,7 +416,7 @@ jobs:
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-foundry-hosting
path: ./python/pytest.xml
@@ -443,7 +443,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -468,7 +468,7 @@ jobs:
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-cosmos
path: ./python/pytest.xml
@@ -496,7 +496,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
@@ -506,12 +506,12 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@v4
with:
pattern: test-results-*
path: test-results/
- name: Restore report history cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/restore@v4
with:
path: python/integration-report-history.json
key: integration-report-history-integration-${{ github.run_id }}
@@ -528,13 +528,13 @@ jobs:
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save report history cache
if: always()
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/save@v4
with:
path: python/integration-report-history.json
key: integration-report-history-integration-${{ github.run_id }}
- name: Upload unified trend report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: integration-test-report
path: |
@@ -558,12 +558,12 @@ jobs:
steps:
- name: Fail workflow if tests failed
if: contains(join(needs.*.result, ','), 'failure')
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Failed!')
- name: Fail workflow if tests cancelled
if: contains(join(needs.*.result, ','), 'cancelled')
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Cancelled!')
+4 -4
View File
@@ -24,8 +24,8 @@ jobs:
outputs:
pythonChanges: ${{ steps.filter.outputs.python}}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
@@ -59,7 +59,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
@@ -94,7 +94,7 @@ jobs:
# Surface failing tests
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/packages/lab/**.xml
summary: true
+37 -37
View File
@@ -41,8 +41,8 @@ jobs:
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
@@ -106,7 +106,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -123,7 +123,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -153,7 +153,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -177,7 +177,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -186,7 +186,7 @@ jobs:
title: OpenAI integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-openai
path: ./python/pytest.xml
@@ -214,7 +214,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -223,7 +223,7 @@ jobs:
os: ${{ runner.os }}
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -247,7 +247,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -256,7 +256,7 @@ jobs:
title: Azure OpenAI integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-azure-openai
path: ./python/pytest.xml
@@ -284,7 +284,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -295,7 +295,7 @@ jobs:
run: curl -fsSL https://ollama.com/install.sh | sh
working-directory: .
- name: Cache Ollama models
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@v4
with:
path: ~/.ollama/models
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
@@ -370,7 +370,7 @@ jobs:
kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" 2>/dev/null || true
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -379,7 +379,7 @@ jobs:
title: Misc integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-misc
path: ./python/pytest.xml
@@ -417,7 +417,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -426,7 +426,7 @@ jobs:
os: ${{ runner.os }}
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -448,7 +448,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -457,7 +457,7 @@ jobs:
title: Functions integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-functions
path: ./python/pytest.xml
@@ -488,7 +488,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -497,7 +497,7 @@ jobs:
os: ${{ runner.os }}
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -515,7 +515,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -524,7 +524,7 @@ jobs:
title: Test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-foundry
path: ./python/pytest.xml
@@ -549,7 +549,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -558,7 +558,7 @@ jobs:
os: ${{ runner.os }}
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -576,7 +576,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -585,7 +585,7 @@ jobs:
title: Foundry Hosting integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-foundry-hosting
path: ./python/pytest.xml
@@ -620,7 +620,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -643,7 +643,7 @@ jobs:
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/pytest.xml
summary: true
@@ -652,7 +652,7 @@ jobs:
title: Cosmos integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: test-results-cosmos
path: ./python/pytest.xml
@@ -680,19 +680,19 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@v4
with:
pattern: test-results-*
path: test-results/
- name: Restore report history cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/restore@v4
with:
path: python/integration-report-history.json
key: integration-report-history-merge-${{ github.run_id }}
@@ -709,13 +709,13 @@ jobs:
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save report history cache
if: always()
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/save@v4
with:
path: python/integration-report-history.json
key: integration-report-history-merge-${{ github.run_id }}
- name: Upload unified trend report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: integration-test-report
path: |
@@ -740,13 +740,13 @@ jobs:
- name: Fail workflow if tests failed
id: check_tests_failed
if: contains(join(needs.*.result, ','), 'failure')
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Failed!')
- name: Fail workflow if tests cancelled
id: check_tests_cancelled
if: contains(join(needs.*.result, ','), 'cancelled')
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Cancelled!')
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -56,7 +56,7 @@ jobs:
- name: Build the package
run: uv run poe --directory packages/${{ env.PACKAGE }} build
- name: Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
uses: softprops/action-gh-release@v2
with:
files: |
python/dist/*
+37 -37
View File
@@ -29,7 +29,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -49,7 +49,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 01-get-started --save-report --report-name 01-get-started
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-01-get-started
@@ -82,7 +82,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -111,7 +111,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents --exclude providers --save-report --report-name 02-agents
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents
@@ -130,7 +130,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -152,7 +152,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/openai --save-report --report-name 02-agents-openai
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-openai
@@ -170,7 +170,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -191,7 +191,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/azure --save-report --report-name 02-agents-azure
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-azure
@@ -208,7 +208,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -228,7 +228,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/anthropic --save-report --report-name 02-agents-anthropic
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-anthropic
@@ -242,7 +242,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -257,7 +257,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/github_copilot --save-report --report-name 02-agents-github-copilot
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-github-copilot
@@ -274,7 +274,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -289,7 +289,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/amazon --save-report --report-name 02-agents-amazon
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-amazon
@@ -306,7 +306,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -321,7 +321,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/ollama --save-report --report-name 02-agents-ollama
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-ollama
@@ -341,7 +341,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -363,7 +363,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/foundry --save-report --report-name 02-agents-foundry
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-foundry
@@ -383,7 +383,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -405,7 +405,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/copilotstudio --save-report --report-name 02-agents-copilotstudio
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-copilotstudio
@@ -419,7 +419,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -434,7 +434,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/custom --save-report --report-name 02-agents-custom
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents-custom
@@ -451,7 +451,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -471,7 +471,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 03-workflows --save-report --report-name 03-workflows
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-03-workflows
@@ -491,7 +491,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -506,7 +506,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 04-hosting --save-report --report-name 04-hosting
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-04-hosting
@@ -534,7 +534,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -549,7 +549,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 05-end-to-end --save-report --report-name 05-end-to-end
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-05-end-to-end
@@ -574,7 +574,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -599,7 +599,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-autogen-migration
@@ -633,7 +633,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/sample-validation-setup
@@ -662,7 +662,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration
- name: Upload validation report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-semantic-kernel-migration
@@ -690,10 +690,10 @@ jobs:
- validate-autogen-migration
- validate-semantic-kernel-migration
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Download all validation reports
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@v7
with:
pattern: validation-report-*
path: reports/
@@ -701,7 +701,7 @@ jobs:
- name: Restore validation history
id: cache-restore
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/restore@v4
with:
path: validation-history/
key: validation-history-${{ github.run_id }}
@@ -719,13 +719,13 @@ jobs:
run: cat trend-report.md >> "$GITHUB_STEP_SUMMARY"
- name: Save validation history
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/save@v4
with:
path: validation-history/
key: validation-history-${{ github.run_id }}
- name: Upload trend report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-trend-report
@@ -19,9 +19,9 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Download coverage report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
uses: actions/download-artifact@v8
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
run-id: ${{ github.event.workflow_run.id }}
@@ -46,7 +46,7 @@ jobs:
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
- name: Pytest coverage comment
id: coverageComment
uses: MishaKav/pytest-coverage-comment@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0
uses: MishaKav/pytest-coverage-comment@v1.6.0
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
issue-number: ${{ env.PR_NUMBER }}
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
env:
UV_PYTHON: "3.11"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
# Save the PR number to a file since the workflow_run event
# in the coverage report workflow does not have access to it
- name: Save PR number
@@ -42,7 +42,7 @@ jobs:
- name: Check coverage threshold
run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
- name: Upload coverage report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
path: |
python/python-coverage.xml
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -46,7 +46,7 @@ jobs:
# Surface failing tests
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/pytest.xml
summary: true
+2 -2
View File
@@ -31,9 +31,9 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v6
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- uses: actions/setup-python@v5
with:
python-version: '3.13'
-2
View File
@@ -246,5 +246,3 @@ dotnet/filtered-*.slnx
# Local tool state
.omc/
.omx/
**/issues/
@@ -1,84 +0,0 @@
---
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.
-145
View File
@@ -1,145 +0,0 @@
---
status: proposed
contact: eavanvalkenburg
date: 2026-06-11
deciders: eavanvalkenburg
---
# Python minimal hosting core and pluggable channels
## Context and Problem Statement
Agent Framework has several protocol-specific hosting surfaces. App authors who want one agent or workflow on multiple protocols must compose servers, routes, middleware, session handling, and lifecycle code by hand.
We will introduce a small Python hosting core that owns the common server shape and leaves protocol details inside channel packages. The first public contract must be intentionally narrow so Python can ship a base contract before adding identity linking, proactive delivery, or multicast behavior. Other language implementations may reuse the same conceptual boundary, but this ADR records the Python decision.
## Decision Drivers
- Keep the first host easy to explain: one app, one hostable target, one or more channels.
- Reuse Agent Framework's existing agent, workflow, session, history, and checkpoint primitives.
- Let channel packages own protocol parsing, protocol responses, authentication details, and native command surfaces.
- Make session continuity explicit through a channel-supplied `ChannelSession(isolation_key=...)`.
- Avoid approving cross-channel identity and delivery semantics before their safety model is reviewed.
## Considered Options
1. Keep only protocol-specific hosts.
2. Ship a large hosting core with identity linking, authorization, background delivery, active-channel routing, and multicast in v1.
3. Ship a minimal host/channel core now and track linking/multicast as follow-up work.
### Keep only protocol-specific hosts
- Good: no new abstraction or package surface.
- Neutral: each protocol can continue evolving independently.
- Bad: every multi-channel app still has to compose servers, lifecycle, and session handling by hand.
### Ship the large cross-channel host in v1
- Good: the richest cross-channel scenarios are available immediately.
- Neutral: the host becomes the natural place to demonstrate identity and delivery policy.
- Bad: v1 becomes a security-sensitive identity and delivery system before the safety model is reviewed.
### Ship the minimal core now
- Good: the host/channel boundary can be implemented, tested, and explained without solving linking and durable delivery at the same time.
- Neutral: apps that need richer behavior must build it locally or wait for ADR-0028 follow-up work.
- Bad: proactive delivery and multicast scenarios are deliberately absent from v1.
## Decision Outcome
Chosen option: **minimal host/channel core now, follow-up enhancements later**.
`AgentFrameworkHost` owns:
- one application object,
- one hostable target (`SupportsAgentRun` agent-compatible object or a `Workflow`), and
- one or more channels.
Channels own:
- contributed routes, middleware, commands, and lifecycle callbacks,
- protocol-native request parsing into `ChannelRequest`,
- protocol-native rendering of the originating response, and
- any channel-specific authentication or signature validation.
The host owns:
- route/lifecycle aggregation,
- invocation of the target,
- `ChannelSession(isolation_key=...)` to `AgentSession` resolution and caching,
- `reset_session(isolation_key=...)`,
- host-level middleware, including Foundry isolation middleware only when the Foundry hosting environment flag is present,
- invocation of per-channel hooks (`ChannelRunHook`, `ChannelResponseHook`, `ChannelStreamUpdateHook`), and
- workflow checkpoint wiring through an explicit `checkpoint_location`.
`ChannelIdentity`, when present, is request metadata only. In v1 it is not a linking, authorization, or delivery key.
### Trust boundary for `isolation_key`
The host treats `ChannelSession.isolation_key` as a session partition key, not as proof of identity. Channels or host middleware must authenticate and authorize any externally supplied value before passing it to the host. For example, a Responses caller must not be allowed to choose an arbitrary `previous_response_id` or header-derived key unless the platform or middleware has already established that the caller owns that conversation. The host deliberately does not infer that trust from the string itself.
### Hook ownership
Channels provide hook configuration and protocol-native context. The host invokes those hooks as part of the common invocation pipeline:
- `ChannelRunHook` runs after channel parsing and before target invocation.
- `ChannelResponseHook` runs after target invocation and before the originating channel serializes its response.
- `ChannelStreamUpdateHook` is applied by the host while the channel consumes streamed updates because streaming serialization is protocol-specific.
`ChannelStreamUpdateHook` is an update hook, not a final-response sanitizer. Channels that use it for redaction or filtering must also apply equivalent policy to any final response they render. Channels choose whether the response is streaming before run hooks execute.
This keeps hook call conventions centralized while leaving protocol payload parsing and response formatting in channel packages.
### State owned by v1
`state_dir` is limited to host-owned local files for reset-session aliases and workflow checkpoint path derivation. It does not store linked identities, active-channel state, response-routing state, continuation records, durable runner queues, or delivery attempts. Those storage concerns belong to ADR-0028.
## Non-goals for v1
The following are deliberately **not** part of the v1 contract:
- cross-channel identity linking (`IdentityLinker`, `local_identity_link`, or `agent-framework-hosting-entra`),
- identity allowlists or authorization policy (`IdentityAllowlist`, `AuthPolicy`),
- response routing beyond the originating channel (`ResponseTarget`, active channel, specific linked channel, `all_linked`),
- push or payload codecs (`ChannelPush`, `ChannelPushCodec`),
- background/continuation delivery,
- durable task runners (`DurableTaskRunner`, `InProcessTaskRunner`),
- retry/replay policy (`RetryPolicy`),
- fan-out, multicast, or all-linked delivery,
- confidentiality tiers and `LinkPolicy`, and
- a host-level multi-agent router.
These areas are follow-up enhancements covered by [ADR-0028](0028-hosting-linking-multicast-enhancements.md). They are not prerequisites for shipping or using the v1 host.
## Consequences
Positive:
- The host/channel model can be implemented and tested without designing a security-sensitive identity graph.
- Existing and new channel packages can share one Starlette app, middleware stack, lifecycle, and target invocation path.
- Session continuity is explicit and debuggable: two channels share history only when they produce the same `isolation_key`.
- Hook invocation is centralized in the host, so channels do not each invent the call convention.
Negative:
- Apps that need OAuth linking, allowlists, proactive messages, or multicast must continue to implement those behaviors outside the v1 host.
- Some richer cross-channel scenarios from the original design move to a separate decision and validation cycle.
- The host must document `isolation_key` trust clearly because it now provides the shared session boundary.
## Validation Gates
Before this ADR is accepted:
- A sample can expose one target on multiple channels with one `AgentFrameworkHost` and no handwritten Starlette route composition.
- Built-in channel tests prove that routes, commands, startup, and shutdown callbacks are contributed by channels and aggregated by the host.
- Session tests prove that identical `ChannelSession.isolation_key` values resolve to the same cached `AgentSession`, and `reset_session` rotates that mapping.
- Channel tests prove that each channel renders only its own originating response; there is no host-level push, multicast, or active-channel delivery path.
- Workflow tests or samples use an explicit `checkpoint_location`.
- Foundry isolation middleware is documented and covered by integration or contract tests, including the non-Foundry case where raw isolation headers are ignored.
- The v1 API and packages do not expose the removed symbols or packages listed in [Non-goals for v1](#non-goals-for-v1).
- The Python spec is updated to match this simplified contract and uses "public", "stable", or "released" terminology for Agent Framework APIs.
## More Information
- Python v1 specification: [SPEC-002](../specs/002-python-hosting-channels.md)
- Follow-up linking and multicast ADR: [ADR-0028](0028-hosting-linking-multicast-enhancements.md)
@@ -1,132 +0,0 @@
---
status: proposed
contact: eavanvalkenburg
date: 2026-06-11
deciders: eavanvalkenburg
---
# Hosting linking and multicast enhancements
## Context and Problem Statement
[ADR-0027](0027-hosting-channels.md) defines the minimal v1 hosting core: originating-channel responses, explicit `ChannelSession.isolation_key`, and no host-level identity linking, push, multicast, background delivery, or durable runners.
This ADR tracks the richer cross-channel behaviors that were removed from v1. These enhancements are **follow-up work** and are **not prerequisites** for shipping, using, or stabilizing the v1 host/channel core.
## Decision Drivers
- Cross-channel continuity must not create accidental cross-user, cross-tenant, or cross-channel data leaks.
- Non-originating delivery must be observable, idempotent, retryable, and supportable.
- Protocol payloads must remain channel-native while still being safe to persist and replay.
- App authors need opt-in policy controls, not hidden defaults.
- The enhancement stack should layer on top of the v1 host without reshaping the minimal channel contract.
## Enhancement Areas
The follow-up design should cover these capabilities together because they share identity, storage, delivery, and replay concerns:
- **Cross-channel identity linking** — a user can connect multiple `ChannelIdentity` values to one channel-neutral `isolation_key`.
- **Authorization and allowlist policy** — channels or hosts can require verified identity, allow specific native identities or claims, and deny unknown callers.
- **Non-originating response delivery** — a run can respond somewhere other than the request's originating protocol when explicitly configured.
- **Active-channel routing** — delivery can target the most recently observed linked channel for an `isolation_key`.
- **Multicast / all-linked delivery** — delivery can fan out to every linked channel or a selected set.
- **Background runs and continuation tokens** — long-running requests can return immediately and complete later, with a polling/status fallback.
- **Durable delivery runners** — delivery work can survive process restarts and support dead-letter handling.
- **Retry and replay semantics** — delivery attempts are bounded, deduplicated, and safe to replay.
- **Payload serialization** — channel-specific payloads can be persisted, redacted, versioned, and reconstructed without losing protocol fidelity.
Candidate API names from the broader design (`IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, `ResponseTarget`, `ChannelPush`, `ChannelPushCodec`, `DurableTaskRunner`, `InProcessTaskRunner`, `RetryPolicy`, `LinkPolicy`) remain design vocabulary for this ADR. They are not approved v1 APIs.
## Considered Options
### Option A — Leave all behavior to applications
Applications implement linking, authorization, push, retry, and serialization independently.
- Good: the hosting core stays very small.
- Neutral: advanced apps can still build what they need.
- Bad: every app must solve the same security and delivery problems, likely inconsistently.
### Option B — Add the full enhancement stack to v1
The first host release includes linking, authorization, active channel, multicast, background runs, durable runners, and codecs.
- Good: the original cross-channel experience is available immediately.
- Neutral: samples can demonstrate rich end-to-end flows.
- Bad: v1 becomes security-sensitive, storage-heavy, and harder to stabilize.
### Option C — Layer opt-in enhancement packages after v1
Ship the minimal host first, then add linking, authorization, and delivery packages behind explicit configuration.
- Good: v1 remains simple while leaving room for a reviewed, supportable enhancement stack.
- Neutral: apps that need advanced delivery wait for follow-up packages.
- Bad: the first release does not satisfy proactive or all-linked scenarios.
### Option D — Build only platform-specific integrations
Implement linking and proactive delivery separately in Telegram, Activity Protocol, Discord, and future channels.
- Good: each package can match its protocol exactly.
- Neutral: some shared abstractions may emerge later.
- Bad: cross-channel behavior becomes fragmented and hard to reason about.
## Decision Outcome
Proposed direction: **Option C — layered opt-in enhancement packages after v1**.
The minimal host remains the foundation. Follow-up packages may add linking, authorization, delivery, and durable execution, but must be explicitly enabled and must pass the validation gates below before becoming part of the public contract.
## Safety Requirements
### Threat model
The design must account for:
- spoofed channel-native identities,
- stolen or replayed link challenges,
- cross-tenant or cross-confidentiality data leakage,
- unsolicited proactive messages,
- malicious payloads persisted for replay,
- denial-of-service through fan-out or retry storms, and
- privacy leakage through logs, metrics, or support tooling.
Required mitigations include verified identity claims where available, signed and expiring link challenges, explicit user consent, per-channel capability checks, default-deny policy options, tenant partitioning, and uninformative denial messages on shared channels.
### Idempotency and replay
Exactly-once delivery is not a realistic guarantee. The design must provide:
- stable run, continuation, and delivery-attempt identifiers,
- channel-level idempotency keys where protocols support them,
- bounded retry with jitter and explicit terminal states,
- replay windows and expiration,
- duplicate suppression for persisted attempts, and
- clear semantics for "delivered", "accepted by platform", and "observed by user".
### Storage
Enhancement storage must stay distinct from v1 `AgentSession` history and workflow checkpoints unless an implementation deliberately backs them with the same physical store.
Stored data should be schema-versioned, minimized, encrypted or otherwise protected as appropriate, and partitioned by tenant/project. Link records, continuation records, active-channel state, delivery attempts, dead letters, and serialized payloads need independent TTL and deletion policies.
### Observability and support
The design must include structured logs, traces, and metrics for link attempts, authorization decisions, delivery scheduling, retries, replay, and dead-letter outcomes. Logs must avoid message content and sensitive identity claims by default. Operators need a way to inspect, revoke, replay, or purge stuck records safely.
## Validation Gates
Before these enhancements are accepted:
- A reviewed threat model covers identity linking, authorization, non-originating delivery, multicast, and replay.
- Cross-channel linking tests prove a verified identity can link two channels and that unlink/deny paths do not leak information.
- Authorization tests cover native-id allowlists, verified-claim allowlists, default-deny behavior, and misconfiguration failures.
- Delivery tests cover originating-only, specific-channel, active-channel, selected-channel, and all-linked routing.
- Background/continuation tests cover polling fallback, cancellation or expiration, process restart, retry, and dead-letter behavior.
- Codec tests prove payloads are versioned, redacted where needed, backward compatible, and rejected safely when unknown.
- Multicast tests prove fan-out is bounded, independently retried, and idempotent per destination.
- Observability tests or manual validation prove support operators can correlate a request to delivery attempts without exposing sensitive content.
## Relationship to ADR-0027
ADR-0027 remains valid without any of these enhancements. This ADR extends the hosting model only after the safety, storage, and support requirements above are satisfied.
-320
View File
@@ -1,320 +0,0 @@
---
status: proposed
contact: eavanvalkenburg
date: 2026-06-11
deciders: eavanvalkenburg
---
# Python hosting core and pluggable channels
## Scope
This specification is the Python implementation plan for [ADR-0027](../decisions/0027-hosting-channels.md). It documents the simplified v1 host/channel contract only.
The v1 contract is:
- `AgentFrameworkHost` owns one Starlette app, one hostable target, and one or more channels.
- A hostable target is either a `SupportsAgentRun`-compatible agent or a `Workflow`.
- Channels contribute routes, middleware, commands, and lifecycle callbacks.
- Channels parse protocol-native input into `ChannelRequest`.
- Channels render their own originating response.
- Session continuity is explicit: a channel supplies `ChannelSession(isolation_key=...)`, and the host resolves/caches an `AgentSession` for that key.
- The host invokes `ChannelRunHook` and `ChannelResponseHook`; channels provide hook configuration and protocol context.
The host does not link identities, route responses to other channels, run background continuations, or multicast in v1. Those enhancements are tracked in [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md).
## Goals
- Let an app expose one agent or workflow on multiple protocols without handwritten Starlette composition.
- Keep protocol parsing and response formatting inside channel packages.
- Provide one session-resolution path shared by all channels.
- Keep the channel authoring surface small enough for new channels to implement.
- Preserve full-fidelity agent and workflow results until a channel decides how to render them.
## Non-goals for v1
The following are removed from the v1 implementation pass:
- `IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, and `LinkPolicy`
- `ResponseTarget`, active-channel routing, `all_linked`, fan-out, and multicast
- `ChannelPush` and `ChannelPushCodec`
- `DurableTaskRunner`, `InProcessTaskRunner`, and `RetryPolicy`
- continuation tokens and background delivery
- confidentiality tiers
- `agent-framework-hosting-entra`
- `local_identity_link`
These are follow-up design topics, not hidden requirements of the v1 host.
## Packages
| Package | Import surface | Contents |
|---|---|---|
| `agent-framework-hosting` | `agent_framework_hosting` | `AgentFrameworkHost`, channel protocols, key request/result types, hooks, `reset_session`, state-path helpers. |
| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | `ResponsesChannel`. |
| `agent-framework-hosting-invocations` | `agent_framework_hosting_invocations` | `InvocationsChannel`. |
| `agent-framework-hosting-telegram` | `agent_framework_hosting_telegram` | `TelegramChannel` and Telegram command helpers. |
| `agent-framework-hosting-activity-protocol` | `agent_framework_hosting_activity_protocol` | `ActivityProtocolChannel` for Activity Protocol over Azure Bot Service. |
| `agent-framework-hosting-discord` | `agent_framework_hosting_discord` | `DiscordChannel` and Discord command/interaction helpers. |
| `agent-framework-foundry-hosting` | `agent_framework.foundry_hosting` | Foundry isolation middleware and Foundry-backed hosting helpers usable with the v1 host. |
Channel packages may depend on their native SDKs. The core hosting package should not depend on channel SDKs or on top-level legacy protocol hosts.
## Key Types
### `AgentFrameworkHost`
The host constructor accepts:
- `target`: one `SupportsAgentRun`-compatible object or one `Workflow`
- `channels`: one or more `Channel` instances
- optional Starlette middleware
- optional `state_dir`
- optional workflow `checkpoint_location`
The host exposes:
- `app`: the canonical Starlette ASGI application
- `serve(...)`: a convenience wrapper for local serving
- `reset_session(isolation_key: str)`: rotate the cached `AgentSession` for a host-tracked conversation
`state_dir` is narrowed to v1 host-owned local files only:
- session aliases (`isolation_key` to current `AgentSession` id), and
- workflow checkpoint paths when the app chooses the host-provided file layout.
It is not a store for identity links, continuations, active-channel state, delivery attempts, or multicast payloads.
Externally supplied isolation keys are trusted only after the channel or host middleware has authenticated and authorized the caller. The host uses `isolation_key` as a partition key; the string itself is not proof of identity or ownership.
### `Channel`
A channel implements a small protocol:
- declare a stable channel id/name,
- contribute routes, middleware, commands, and lifecycle callbacks,
- parse inbound protocol data into `ChannelRequest`,
- call the host through `ChannelContext.run(...)` or `ChannelContext.run_stream(...)`, and
- serialize the returned result to the originating protocol response.
Channels own protocol authentication, signature validation, native command registration, and protocol-specific error bodies.
### `ChannelContribution`
`ChannelContribution` is the channel's host-facing contribution:
- Starlette routes and optional middleware,
- native command descriptors,
- startup and shutdown callbacks, and
- any channel-local metadata needed by the package.
The host aggregates contributions but does not interpret protocol payloads.
### `ChannelRequest`
`ChannelRequest` is the host-neutral request envelope produced by a channel. It carries:
- target input,
- optional `ChannelSession`,
- optional `ChannelIdentity`,
- options and attributes produced by the channel, and
- request metadata useful to hooks and context providers.
The host may pass attributes through to context providers and middleware. Channels should treat attributes as a documented extension bag, not as a cross-channel delivery contract.
### `ChannelSession`
`ChannelSession(isolation_key=...)` is the only v1 session-continuity mechanism.
When a request contains an isolation key:
1. The host looks up or creates the cached `AgentSession` for that key.
2. The target runs with that `AgentSession` when the target is an agent.
3. `reset_session(isolation_key)` rotates the alias so the next request starts a new conversation.
If two channels produce the same isolation key on the same host, they share the same cached session. If they produce different keys, they do not share session state.
### `ChannelIdentity`
`ChannelIdentity` is optional request metadata such as channel id, native user id, tenant id, claims, or display attributes.
In v1, `ChannelIdentity` does not link channels, authorize callers, select delivery destinations, or imply that two identities should share an `AgentSession`. A channel that wants shared history must still produce the same `ChannelSession.isolation_key`.
### Hooks
Hooks are optional and channel-owned:
- `ChannelRunHook`: runs after channel parsing and before host invocation; returns the `ChannelRequest` to execute.
- `ChannelResponseHook`: runs after target completion and before the originating channel renders a one-shot response.
- `ChannelStreamUpdateHook`: the host applies it to streamed updates before the originating channel serializes the stream.
Common uses include adapting chat text into workflow inputs, enforcing deployment-specific options, flattening rich output for text-only protocols, or filtering streamed updates for a protocol. Stream update hooks are update-only; they do not automatically sanitize `get_final_response()` output. Channels choose their response transport from the parsed protocol request before invoking run hooks.
### `HostedRunResult`
`HostedRunResult[T]` wraps the target's full-fidelity result plus the resolved `AgentSession | None`.
- Agent targets produce `HostedRunResult[AgentResponse]`.
- Workflow targets produce `HostedRunResult[WorkflowRunResult]`.
The host does not flatten, filter, or translate the result. Each channel decides how much of the result its protocol can carry.
## Host Behavior
1. `AgentFrameworkHost` builds one Starlette app and asks each channel for its contribution.
2. A channel route receives a protocol-native request.
3. The channel validates/parses the native payload and creates `ChannelRequest`.
4. The channel passes the request, optional `ChannelRunHook`, and protocol-native context to the host.
5. The host invokes `ChannelRunHook`, if configured, and receives the prepared request.
6. The host resolves an `AgentSession` from `ChannelSession.isolation_key` when present.
7. The host invokes the agent or workflow target.
8. The host wraps the result in `HostedRunResult` or the streaming equivalent.
9. The host invokes `ChannelResponseHook`, if configured, for non-streaming/final response shaping.
10. The host applies stream update hooks while the channel consumes streams; the channel renders the originating protocol response.
There is no host-level route from one channel's request to another channel's response in v1.
## Workflow Checkpoints
Workflow checkpointing is explicit. Apps either configure checkpoint storage on the workflow itself or pass a `checkpoint_location` to the host so the workflow dispatch path can use the intended file location.
`state_dir` may provide a conventional location for workflow checkpoint files, but checkpointing is still opt-in and separate from agent session history. Checkpoints are workflow-runtime state, not channel state and not identity-link state.
## Foundry Isolation Middleware
V1 keeps Foundry isolation as middleware rather than as a channel-linking feature.
The middleware is installed only when the Foundry hosting environment flag is present. In that environment it reads Foundry-provided isolation values at the trusted hosting boundary, exposes them as read-only request context for Foundry-aware history or memory providers, and rejects unsafe session resumes when the live isolation context does not match persisted session context. Outside Foundry, raw isolation headers are ignored unless an app supplies its own trusted middleware.
This middleware does not create cross-channel identity links and does not authorize non-Foundry channels.
## Current Channels
### Responses
`ResponsesChannel` exposes the OpenAI-compatible Responses API shape. It maps request body fields such as input, options, and conversation identifiers into `ChannelRequest`, and it renders Responses-compatible one-shot or streaming responses.
Responses session continuity uses a channel-selected `isolation_key`, commonly derived from a response/conversation id, caller-provided session id, Foundry isolation context, or deployment-specific request metadata.
### Invocations
`InvocationsChannel` exposes an invocation endpoint for server-side callers and tools. It maps the request body into `ChannelRequest` and renders the invocation result on the same HTTP response.
Invocations is useful for typed workflow inputs because a `ChannelRunHook` can translate the request body into the workflow's expected input type.
### Telegram
`TelegramChannel` supports webhook or polling transport, native command registration, and message rendering back to the originating Telegram chat.
The channel chooses a default `isolation_key` from Telegram-native data such as chat id, user id, or a configured user/chat scope. A `/new` or equivalent command may call `reset_session` for that isolation key.
### Activity Protocol
`ActivityChannel` supports Activity Protocol requests, typically through Azure Bot Service for Teams, Web Chat, and other Bot Framework-fronted surfaces.
The channel maps incoming `Activity` objects to `ChannelRequest` and renders a reply activity to the originating conversation. Proactive Activity delivery, active-channel routing, and all-linked fan-out are not v1 host semantics.
### Discord
`DiscordChannel` supports Discord messages, slash commands, and interactions as channel-native input.
The channel maps Discord-native user, guild, channel, thread, and interaction data into `ChannelRequest` metadata and a configured `ChannelSession.isolation_key`. It renders the result to the originating Discord response path.
## High-level Samples
### One agent on Responses
```python
host = AgentFrameworkHost(
target=agent,
channels=[ResponsesChannel()],
)
app = host.app
```
### One agent on multiple channels
```python
host = AgentFrameworkHost(
target=agent,
channels=[
ResponsesChannel(),
InvocationsChannel(),
TelegramChannel(bot_token=os.environ["TELEGRAM_BOT_TOKEN"]),
],
)
host.serve(host="localhost", port=8000)
```
The host owns one Starlette app. Each channel contributes its own routes and renders its own response.
### Adapting a request before execution
```python
from dataclasses import replace
def enforce_options(request: ChannelRequest) -> ChannelRequest:
options = dict(request.options or {})
options["temperature"] = 0
return replace(request, options=options)
host = AgentFrameworkHost(
target=agent,
channels=[ResponsesChannel(run_hook=enforce_options)],
)
```
### Workflow with explicit checkpoints
```python
host = AgentFrameworkHost(
target=workflow,
channels=[InvocationsChannel(run_hook=adapt_to_workflow_input)],
checkpoint_location=Path("./.af-hosting/workflow_checkpoints"),
)
```
The hook adapts channel-native input to the workflow's typed input. Checkpoints use the explicit workflow checkpoint location, not identity-link or delivery storage.
### Message channel reset command
```python
async def new_chat(context):
if context.request.session is not None:
await context.host.reset_session(context.request.session.isolation_key)
await context.reply("Started a new conversation.")
```
Telegram, Activity Protocol, and Discord can expose equivalent native commands when their protocols support them.
## Follow-up Enhancements
See [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md) for the deferred design covering:
- cross-channel identity linking,
- authorization and allowlists,
- non-originating response delivery,
- active-channel routing,
- multicast and all-linked delivery,
- background runs and continuation tokens,
- durable delivery runners,
- retry/replay semantics, and
- payload serialization.
Those enhancements must layer on top of this v1 contract without requiring v1 users to adopt them.
## Validation Gates
The Python implementation should be considered complete when:
- a sample uses one `AgentFrameworkHost` with multiple channels and no manual Starlette route composition,
- each current channel has contract tests for route contribution, lifecycle, request parsing, hooks, and originating response rendering,
- session tests prove shared `isolation_key` values share an `AgentSession` and `reset_session` rotates it,
- workflow tests or samples use explicit `checkpoint_location`,
- Foundry isolation middleware is covered by integration or contract tests,
- no v1 package exposes the removed linking, multicast, durable-runner, or continuation APIs, and
- this spec and ADR-0027 remain aligned.
+5 -6
View File
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<!-- Aspire.* -->
<PackageVersion Include="Anthropic" Version="12.20.0" />
<PackageVersion Include="Anthropic" Version="12.13.0" />
<PackageVersion Include="Anthropic.Foundry" Version="0.5.0" />
<PackageVersion Include="Aspire.Hosting" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
@@ -26,10 +26,10 @@
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.2" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.1" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageVersion Include="Azure.Core" Version="1.55.0" />
<PackageVersion Include="Azure.Core" Version="1.53.0" />
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
@@ -44,7 +44,7 @@
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
<PackageVersion Include="System.ClientModel" Version="1.11.0" />
<PackageVersion Include="System.ClientModel" Version="1.10.0" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
@@ -112,7 +112,6 @@
<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 -->
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
@@ -195,4 +194,4 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
</Project>
+2 -20
View File
@@ -1,4 +1,4 @@
<Solution>
<Solution>
<Configurations>
<BuildType Name="Debug" />
<BuildType Name="Publish" />
@@ -65,7 +65,6 @@
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment/Agent_Step21_ShellWithEnvironment.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
@@ -121,13 +120,9 @@
<Folder Name="/Samples/02-agents/Harness/">
<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_OpenAI/Harness_Shared_Console_OpenAI.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
<Project Path="samples/02-agents/Harness/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 Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
@@ -244,7 +239,6 @@
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
@@ -301,7 +295,6 @@
</Folder>
<Folder Name="/Samples/03-workflows/Evaluation/">
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Evaluation_WorkflowEval.csproj" />
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/Evaluation_WorkflowExpectedOutputs.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/">
</Folder>
@@ -329,15 +322,9 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
</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/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
</Folder>
@@ -592,7 +579,6 @@
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
<Project Path="src/Microsoft.Agents.AI.Harness/Microsoft.Agents.AI.Harness.csproj" />
<Project Path="src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
@@ -604,7 +590,6 @@
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
<Project Path="src/Microsoft.Agents.AI.Tools.Shell/Microsoft.Agents.AI.Tools.Shell.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
@@ -627,7 +612,6 @@
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Tools.Shell.IntegrationTests/Microsoft.Agents.AI.Tools.Shell.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj" />
@@ -647,7 +631,6 @@
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Harness.UnitTests/Microsoft.Agents.AI.Harness.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj" />
@@ -657,7 +640,6 @@
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/Microsoft.Agents.AI.Tools.Shell.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
-1
View File
@@ -7,7 +7,6 @@
"src\\Microsoft.Agents.AI.AGUI\\Microsoft.Agents.AI.AGUI.csproj",
"src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.csproj",
"src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj",
"src\\Microsoft.Agents.AI.Harness\\Microsoft.Agents.AI.Harness.csproj",
"src\\Microsoft.Agents.AI.AzureAI.Persistent\\Microsoft.Agents.AI.AzureAI.Persistent.csproj",
"src\\Microsoft.Agents.AI.Foundry\\Microsoft.Agents.AI.Foundry.csproj",
"src\\Microsoft.Agents.AI.Foundry.Hosting\\Microsoft.Agents.AI.Foundry.Hosting.csproj",
+4 -32
View File
@@ -21,15 +21,10 @@
.PARAMETER Configuration
Optional MSBuild configuration used when querying TargetFrameworks. Defaults to Debug.
.PARAMETER TestProjectNameIncludeFilter
.PARAMETER TestProjectNameFilter
Optional wildcard pattern to filter test project names (e.g., *UnitTests*, *IntegrationTests*).
When specified, only test projects whose filename matches this pattern are kept.
.PARAMETER TestProjectNameExcludeFilter
Optional wildcard pattern(s) to exclude test projects by name (e.g., *DurableTask.IntegrationTests*).
When specified, test projects whose filename matches any of these patterns are removed.
Applied after TestProjectNameIncludeFilter. Can be a single string or an array of strings.
.PARAMETER ExcludeSamples
When specified, removes all projects under the samples/ directory from the solution.
@@ -43,15 +38,11 @@
.EXAMPLE
# Generate a solution with only unit test projects
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameIncludeFilter "*UnitTests*" -OutputPath filtered-unit.slnx
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameFilter "*UnitTests*" -OutputPath filtered-unit.slnx
.EXAMPLE
# Inline usage with dotnet test (PowerShell)
dotnet test --solution (./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net472) --no-build -f net472
.EXAMPLE
# Generate integration tests excluding DurableTask and AzureFunctions
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameIncludeFilter "*IntegrationTests*" -TestProjectNameExcludeFilter "*DurableTask.IntegrationTests*","*AzureFunctions.IntegrationTests*" -OutputPath filtered-other-integration.slnx
#>
[CmdletBinding()]
@@ -64,9 +55,7 @@ param(
[string]$Configuration = "Debug",
[string]$TestProjectNameIncludeFilter,
[string[]]$TestProjectNameExcludeFilter,
[string]$TestProjectNameFilter,
[switch]$ExcludeSamples,
@@ -111,30 +100,13 @@ foreach ($proj in $allProjects) {
$isTestProject = $projRelPath -like "*tests/*"
# Filter test projects by name pattern if specified
if ($isTestProject -and $TestProjectNameIncludeFilter -and ($projFileName -notlike $TestProjectNameIncludeFilter)) {
if ($isTestProject -and $TestProjectNameFilter -and ($projFileName -notlike $TestProjectNameFilter)) {
Write-Verbose "Removing (name filter): $projRelPath"
$removed += $projRelPath
$proj.ParentNode.RemoveChild($proj) | Out-Null
continue
}
# Exclude test projects matching any exclusion pattern
if ($isTestProject -and $TestProjectNameExcludeFilter) {
$excluded = $false
foreach ($pattern in $TestProjectNameExcludeFilter) {
if ($projFileName -like $pattern) {
$excluded = $true
break
}
}
if ($excluded) {
Write-Verbose "Removing (exclude filter): $projRelPath"
$removed += $projRelPath
$proj.ParentNode.RemoveChild($proj) | Out-Null
continue
}
}
if (-not (Test-Path $projFullPath)) {
Write-Verbose "Project not found, keeping in solution: $projRelPath"
$kept += $projRelPath
@@ -478,17 +478,6 @@ internal static class WorkflowSamples
ExpectedOutputDescription = ["The output should show a workflow invoking a function tool (e.g. a menu plugin) to answer a question about the soup of the day."],
},
new SampleDefinition
{
Name = "Workflow_Declarative_InvokeFoundryToolboxMcp",
ProjectPath = "samples/03-workflows/Declarative/InvokeFoundryToolboxMcp",
RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME", "FOUNDRY_TOOLBOX_NAME", "FOUNDRY_AGENT_TOOLSET_API_VERSION"],
Inputs = ["How do I use Azure OpenAI with my data?"],
InputDelayMs = 3000,
ExpectedOutputDescription = ["The output should show a workflow using Foundry Toolbox MCP tools to search Microsoft Learn documentation and web search to provide a summary of results."],
},
new SampleDefinition
{
Name = "Workflow_Declarative_InvokeMcpTool",
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.6.2</VersionPrefix>
<VersionPrefix>1.5.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260521</DateSuffix>
<DateSuffix>260507</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.6.2</GitTag>
<GitTag>1.5.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
</ItemGroup>
</Project>
@@ -1,130 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// Shell tool with environment-aware system prompt
//
// WARNING: This sample uses LocalShellExecutor, which executes real commands
// against the shell on this machine. Approval gating is disabled here so
// the demo runs unattended; in any real application keep approval on
// (the default), or use DockerShellExecutor for container isolation. The
// commands the model emits below are read-only or scoped (echo, cd into
// a temp folder, set a process-local env var) but a different model or
// prompt could choose to do something destructive. Run this only in an
// environment where you are comfortable with the agent typing into your
// terminal.
//
// Demonstrates LocalShellExecutor in both modes paired with
// ShellEnvironmentProvider, an AIContextProvider that probes the live
// shell (OS, family, version, CWD, common CLIs) and injects authoritative
// system-prompt instructions so the agent emits commands in the right
// idiom (PowerShell vs POSIX).
//
// Two runs:
// 1) Stateless mode: each tool call runs in a fresh shell. Useful when
// commands are independent (read-only scripts, version checks, file
// listings) and you want strong isolation between calls. Side
// effects in one call (cd, exported variables) do NOT carry to the
// next.
// 2) Persistent mode: a single long-lived shell is reused across calls,
// so working directory and exported environment variables are
// preserved. Useful for multi-step workflows that build state
// (cd into a folder and run a sequence of commands there; set a
// token in one step and read it in the next).
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Tools.Shell;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetChatClient(deploymentName);
const string Instructions = """
You are an agent with a single tool: run_shell. Use it to satisfy the
user's request. Do not describe what you would do actually run the
commands. Reply with the final answer derived from real output.
""";
// --------------------------------------------------------------------
// 1. Stateless mode — each call gets a fresh shell.
// --------------------------------------------------------------------
Console.WriteLine("### Stateless mode\n");
await using (var statelessShell = new LocalShellExecutor(new() { Mode = ShellMode.Stateless, AcknowledgeUnsafe = true }))
{
var envProvider = new ShellEnvironmentProvider(statelessShell);
var statelessAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new()
{
Instructions = Instructions,
Tools = [statelessShell.AsAIFunction(requireApproval: false)],
},
AIContextProviders = [envProvider],
});
var statelessSession = await statelessAgent.CreateSessionAsync();
Console.WriteLine(await statelessAgent.RunAsync("Print the current working directory.", statelessSession));
Console.WriteLine();
// Show that side effects do NOT carry between stateless calls: ask the
// agent to cd into the system temp directory in one call, then ask
// for the CWD in a second call. Stateless mode means the cd is gone.
Console.WriteLine(await statelessAgent.RunAsync("Change directory into the system temp folder, then print the current working directory.", statelessSession));
Console.WriteLine();
Console.WriteLine(await statelessAgent.RunAsync("In a NEW shell call, print the current working directory again. Tell me whether it matches the temp folder from the previous call.", statelessSession));
Console.WriteLine();
PrintSnapshot(envProvider.CurrentSnapshot!);
}
// --------------------------------------------------------------------
// 2. Persistent mode — one shell, reused across calls. State carries.
// --------------------------------------------------------------------
Console.WriteLine("\n### Persistent mode\n");
await using (var persistentShell = new LocalShellExecutor(new() { Mode = ShellMode.Persistent, AcknowledgeUnsafe = true }))
{
var envProvider = new ShellEnvironmentProvider(persistentShell);
var persistentAgent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new()
{
Instructions = Instructions,
Tools = [persistentShell.AsAIFunction(requireApproval: false)],
},
AIContextProviders = [envProvider],
});
var persistentSession = await persistentAgent.CreateSessionAsync();
// State carries across calls in persistent mode: cd into temp, then
// verify the next call sees the new CWD.
Console.WriteLine(await persistentAgent.RunAsync("Change directory into the system temp folder, then print the current working directory.", persistentSession));
Console.WriteLine();
Console.WriteLine(await persistentAgent.RunAsync("In a NEW shell call, print the current working directory again. Tell me whether it still matches the temp folder.", persistentSession));
Console.WriteLine();
// Same idea with an exported variable: set in one call, read in the next.
Console.WriteLine(await persistentAgent.RunAsync("Set the environment variable DEMO_TOKEN to the value 'hello-world'.", persistentSession));
Console.WriteLine();
Console.WriteLine(await persistentAgent.RunAsync("Print the current value of DEMO_TOKEN. Tell me exactly what value the shell reports.", persistentSession));
Console.WriteLine();
PrintSnapshot(envProvider.CurrentSnapshot!);
}
static void PrintSnapshot(ShellEnvironmentSnapshot snap)
{
Console.WriteLine("--- Captured environment snapshot ---");
Console.WriteLine($" Family: {snap.Family}");
Console.WriteLine($" OS: {snap.OSDescription}");
Console.WriteLine($" Shell: {snap.ShellVersion ?? "(unknown)"}");
Console.WriteLine($" CWD: {snap.WorkingDirectory}");
foreach (var (tool, version) in snap.ToolVersions)
{
Console.WriteLine($" {tool,-8} {version ?? "(not installed)"}");
}
}
@@ -20,18 +20,22 @@ using OpenAI.Responses;
#pragma warning disable OPENAI001 // Experimental API
#pragma warning disable AAIP001 // AgentToolboxes is experimental
// Name of the toolbox to create and connect to.
// Must match the `<name>` segment of FOUNDRY_TOOLBOX_ENDPOINT.
const string ToolboxName = "research_toolbox";
const string Query = "What tools do you have access to?";
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
string toolboxEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_ENDPOINT")
?? throw new InvalidOperationException(
"FOUNDRY_TOOLBOX_ENDPOINT is not set. Example: " +
"https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=2025-05-01-preview");
TokenCredential credential = new DefaultAzureCredential();
// Comment out if the toolbox already exists in your Foundry project.
var toolboxEndpoint = await CreateSampleToolboxAsync(ToolboxName, endpoint, credential);
await CreateSampleToolboxAsync(ToolboxName, endpoint, credential);
// Inject a fresh Azure AI bearer token on every MCP request.
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
@@ -47,11 +51,6 @@ await using McpClient mcpClient = await McpClient.CreateAsync(
{
Endpoint = new Uri(toolboxEndpoint),
Name = "foundry_toolbox",
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string>
{
["Foundry-Features"] = "Toolboxes=V1Preview",
},
},
httpClient));
@@ -75,7 +74,7 @@ Console.WriteLine($"Assistant: {await agent.RunAsync(Query)}");
// ---------------------------------------------------------------------------
// Helper: create (or replace) a sample toolbox so the sample runs end-to-end
// ---------------------------------------------------------------------------
static async Task<string> CreateSampleToolboxAsync(string name, string endpoint, TokenCredential credential)
static async Task CreateSampleToolboxAsync(string name, string endpoint, TokenCredential credential)
{
// Toolboxes are normally configured in the Foundry portal or a deployment
// script, not the application itself. This helper exists so the sample can
@@ -104,13 +103,12 @@ static async Task<string> CreateSampleToolboxAsync(string name, string endpoint,
serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync(
var created = (await toolboxClient.CreateToolboxVersionAsync(
name: name,
tools: [mcpTool],
description: "Sample toolbox with an MCP tool — created by Agent_Step25 sample.")).Value;
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
return $"{endpoint}/toolboxes/{created.Name}/mcp?api-version=v{created.Version}";
}
// ---------------------------------------------------------------------------
@@ -19,11 +19,10 @@ Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini"
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project/toolsets/research_toolbox/mcp?api-version=2025-05-01-preview"
```
The sample creates a toolbox named `research_toolbox` in your Foundry project on
startup, then connects to its MCP endpoint at
`{AZURE_AI_PROJECT_ENDPOINT}/toolboxes/research_toolbox/mcp?api-version=v{version}`.
The `<name>` segment of `FOUNDRY_TOOLBOX_ENDPOINT` must match the `ToolboxName` constant in `Program.cs`.
## Run the sample
@@ -1,95 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Harness.ConsoleReactiveComponents;
/// <summary>
/// Provides descriptive helpers for common ANSI/VT100 escape sequences used
/// in the split-console layout (DECSTBM scroll regions, cursor movement, line erasure).
/// </summary>
public static class AnsiEscapes
{
/// <summary>
/// Sets the scrollable region to rows 1 through <paramref name="bottom"/> (DECSTBM).
/// Content outside this region will not scroll.
/// </summary>
public static string SetScrollRegion(int bottom) => $"\x1b[1;{bottom}r";
/// <summary>
/// Resets the scroll region to the full terminal height (DECSTBM reset).
/// </summary>
public static string ResetScrollRegion => "\x1b[r";
/// <summary>
/// Moves the cursor to the specified 1-based <paramref name="row"/> and <paramref name="column"/> (CUP).
/// </summary>
public static string MoveCursor(int row, int column) => $"\x1b[{row};{column}H";
/// <summary>
/// Erases the 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
};
}
@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
</ItemGroup>
</Project>
@@ -1,151 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveFramework;
namespace Harness.ConsoleReactiveComponents;
/// <summary>
/// A component that renders a selectable list of items with a cursor indicator.
/// The selected item is indicated with a "&gt;" prefix and rendered in the highlight color.
/// Optionally includes a title above the list and a custom text input option at the bottom.
/// </summary>
public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, ConsoleReactiveState>
{
/// <summary>
/// Calculates the height (in rows) required to render the list,
/// including the optional title and custom text input row.
/// </summary>
/// <param name="props">The list selection props.</param>
/// <returns>The number of rows needed.</returns>
public static int CalculateHeight(ListSelectionProps props)
{
int height = props.Items.Count;
if (props.CustomTextPlaceholder != null)
{
height++;
}
height += GetTitleLineCount(props.Title);
return height;
}
/// <inheritdoc />
public override void RenderCore(ListSelectionProps props, ConsoleReactiveState state)
{
int row = 0;
// Render the title lines (if any)
if (props.Title is not null)
{
foreach (string line in props.Title.Split('\n'))
{
Console.Write(AnsiEscapes.MoveCursor(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; } = "";
}
@@ -1,101 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveFramework;
namespace Harness.ConsoleReactiveComponents;
/// <summary>
/// Props for <see cref="TextInput"/>.
/// </summary>
public record TextInputProps : ConsoleReactiveProps
{
/// <summary>Gets the prompt string displayed on the left (e.g. "&gt; " or "user &gt; ").</summary>
public string Prompt { get; init; } = "> ";
/// <summary>Gets the text content to render to the right of the prompt.</summary>
public string Text { get; init; } = "";
/// <summary>Gets the placeholder text shown in dark grey when <see cref="Text"/> is empty.</summary>
public string Placeholder { get; init; } = "";
}
/// <summary>
/// A component that renders a prompt with text input. Supports multi-line text
/// where continuation lines are indented to align with the text start position
/// (i.e. the column after the prompt).
/// </summary>
public class TextInput : ConsoleReactiveComponent<TextInputProps, ConsoleReactiveState>
{
/// <summary>
/// Calculates the height (in rows) required to render the prompt and text
/// given the available width.
/// </summary>
/// <param name="props">The text input props.</param>
/// <param name="availableWidth">The total available width in columns.</param>
/// <returns>The number of rows needed.</returns>
public static int CalculateHeight(TextInputProps props, int availableWidth)
{
int promptLength = props.Prompt.Length;
int textWidth = availableWidth - promptLength;
if (textWidth <= 0 || props.Text.Length == 0)
{
return 1;
}
int lines = 1;
int remaining = props.Text.Length - textWidth;
while (remaining > 0)
{
lines++;
remaining -= textWidth;
}
return lines;
}
/// <inheritdoc />
public override void RenderCore(TextInputProps props, ConsoleReactiveState state)
{
int promptLength = props.Prompt.Length;
int textWidth = 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++;
}
}
}
@@ -1,94 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveFramework;
namespace Harness.ConsoleReactiveComponents;
/// <summary>
/// Props for <see cref="TextPanel"/>.
/// </summary>
public record TextPanelProps : ConsoleReactiveProps
{
/// <summary>Gets the items to render in the panel. Each item is a pre-rendered
/// console string (may include ANSI escape sequences and newlines).</summary>
public IReadOnlyList<string> Items { get; init; } = [];
}
/// <summary>
/// A component that renders a list of pre-rendered string items vertically.
/// Designed for rendering dynamic items in a non-scroll region that may be
/// re-rendered on each update. If the component's <see cref="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;
}
}
@@ -1,66 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveFramework;
namespace Harness.ConsoleReactiveComponents;
/// <summary>
/// Props for <see cref="TextScrollPanel"/>.
/// </summary>
public record TextScrollPanelProps : ConsoleReactiveProps
{
/// <summary>Gets the items to render in the scroll panel. Each item is a pre-rendered
/// console string (may include ANSI escape sequences and newlines).</summary>
public IReadOnlyList<string> Items { get; init; } = [];
}
/// <summary>
/// State for <see cref="TextScrollPanel"/>.
/// </summary>
/// <param name="RenderedCount">The number of items already rendered.</param>
public record TextScrollPanelState(int RenderedCount = 0) : ConsoleReactiveState;
/// <summary>
/// A component that renders pre-rendered string items within a scroll area.
/// All items are considered finalized — only new items since the last render are output.
/// Use <see cref="Reset"/> to force a full re-render.
/// </summary>
public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, TextScrollPanelState>
{
/// <summary>
/// Initializes a new instance of the <see cref="TextScrollPanel"/> class.
/// </summary>
public TextScrollPanel()
{
this.State = new TextScrollPanelState();
}
/// <summary>
/// Resets the panel so all items will be re-rendered on the next Render call.
/// </summary>
public void Reset()
{
this.State = new TextScrollPanelState();
}
/// <inheritdoc />
public override void RenderCore(TextScrollPanelProps props, TextScrollPanelState state)
{
if (props.Items.Count == 0)
{
return;
}
// Move cursor to the bottom of the scroll area
Console.Write(AnsiEscapes.MoveCursor(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);
}
}
@@ -1,83 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveFramework;
namespace Harness.ConsoleReactiveComponents;
/// <summary>
/// Props for <see cref="TopBottomRule"/>.
/// </summary>
public record TopBottomRuleProps : ConsoleReactiveProps
{
/// <summary>Gets the 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);
}
}
}
@@ -1,140 +0,0 @@
// 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;
@@ -1,10 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
@@ -1,83 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Harness.ConsoleReactiveFramework;
/// <summary>
/// Event args for console resize events, containing the old and new dimensions.
/// </summary>
public class ConsoleResizeEventArgs : EventArgs
{
/// <summary>Gets the previous console width.</summary>
public int OldWidth { get; }
/// <summary>Gets the previous console height.</summary>
public int OldHeight { get; }
/// <summary>Gets the new console width.</summary>
public int NewWidth { get; }
/// <summary>Gets the new console height.</summary>
public int NewHeight { get; }
/// <summary>
/// Initializes a new instance of the <see cref="ConsoleResizeEventArgs"/> class.
/// </summary>
/// <param name="oldWidth">The previous width.</param>
/// <param name="oldHeight">The previous height.</param>
/// <param name="newWidth">The new width.</param>
/// <param name="newHeight">The new height.</param>
public ConsoleResizeEventArgs(int oldWidth, int oldHeight, int newWidth, int newHeight)
{
this.OldWidth = oldWidth;
this.OldHeight = oldHeight;
this.NewWidth = newWidth;
this.NewHeight = newHeight;
}
}
/// <summary>
/// Singleton that polls console dimensions every 16ms and raises the
/// <see cref="ConsoleResized"/> event when the window size changes.
/// </summary>
public sealed class ConsoleResizeListener
{
#pragma warning disable IDE0052 // Remove unread private members
private readonly Task _task;
#pragma warning restore IDE0052 // Remove unread private members
private int _lastWidth;
private int _lastHeight;
private ConsoleResizeListener()
{
this._lastWidth = Console.WindowWidth;
this._lastHeight = Console.WindowHeight;
this._task = this.ListenForResizeAsync();
}
/// <summary>Gets the singleton instance of <see cref="ConsoleResizeListener"/>.</summary>
public static ConsoleResizeListener Instance { get; } = new ConsoleResizeListener();
/// <summary>Raised when the console window is resized.</summary>
public event EventHandler<ConsoleResizeEventArgs>? ConsoleResized;
private async Task ListenForResizeAsync()
{
while (true)
{
int currentWidth = Console.WindowWidth;
int currentHeight = Console.WindowHeight;
if (currentWidth != this._lastWidth || currentHeight != this._lastHeight)
{
int oldWidth = this._lastWidth;
int oldHeight = this._lastHeight;
this._lastWidth = currentWidth;
this._lastHeight = currentHeight;
this.ConsoleResized?.Invoke(this, new ConsoleResizeEventArgs(oldWidth, oldHeight, currentWidth, currentHeight));
}
await Task.Delay(16);
}
}
}
@@ -1,57 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Harness.ConsoleReactiveFramework;
/// <summary>
/// Event args for key press events, wrapping a <see cref="ConsoleKeyInfo"/>.
/// </summary>
public class KeyPressEventArgs : EventArgs
{
/// <summary>Gets the key information for the pressed key.</summary>
public ConsoleKeyInfo KeyInfo { get; }
/// <summary>
/// Initializes a new instance of the <see cref="KeyPressEventArgs"/> class.
/// </summary>
/// <param name="keyInfo">The key information.</param>
public KeyPressEventArgs(ConsoleKeyInfo keyInfo)
{
this.KeyInfo = keyInfo;
}
}
/// <summary>
/// Singleton that polls for console key presses every 16ms and raises the
/// <see cref="KeyPressed"/> event when a key is detected.
/// </summary>
public sealed class KeyEventListener
{
#pragma warning disable IDE0052 // Remove unread private members
private readonly Task _task;
#pragma warning restore IDE0052 // Remove unread private members
private KeyEventListener()
{
this._task = this.ListenForKeyPressesAsync();
}
/// <summary>Gets the singleton instance of <see cref="KeyEventListener"/>.</summary>
public static KeyEventListener Instance { get; } = new KeyEventListener();
/// <summary>Raised when a key is pressed in the console.</summary>
public event EventHandler<KeyPressEventArgs>? KeyPressed;
private async Task ListenForKeyPressesAsync()
{
while (true)
{
while (Console.KeyAvailable)
{
var keyInfo = Console.ReadKey(intercept: true);
this.KeyPressed?.Invoke(this, new KeyPressEventArgs(keyInfo));
}
await Task.Delay(16);
}
}
}
@@ -1,26 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles the <c>/exit</c> command to shut down the console application.
/// </summary>
public sealed class ExitCommandHandler : CommandHandler
{
/// <inheritdoc/>
public override string? GetHelpText() => "/exit (quit)";
/// <inheritdoc/>
public override ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
{
if (!input.Equals("/exit", StringComparison.OrdinalIgnoreCase))
{
return new ValueTask<bool>(false);
}
ux.RequestShutdown();
return new ValueTask<bool>(true);
}
}
@@ -5,25 +5,24 @@ using Microsoft.Agents.AI;
namespace Harness.Shared.Console.Commands;
/// <summary>
/// Base class for console command handlers (e.g., /todos, /mode). Command handlers
/// are checked in order before user input is sent to the agent. The first handler
/// that accepts the input prevents further handlers from being checked.
/// Handles a console command (e.g., /todos, /mode). Command handlers are checked
/// in order before user input is sent to the agent. The first handler that
/// accepts the input prevents further handlers from being checked.
/// </summary>
public abstract class CommandHandler
public interface ICommandHandler
{
/// <summary>
/// Gets the help text for this command, displayed in the mode-and-help bar.
/// Gets the help text for this command, displayed in the console header.
/// Returns <see langword="null"/> if the command is not currently available.
/// </summary>
/// <returns>Help text like <c>"/todos (show todo list)"</c>, or <see langword="null"/>.</returns>
public abstract string? GetHelpText();
string? GetHelpText();
/// <summary>
/// Attempts to handle the given user input.
/// </summary>
/// <param name="input">The raw user input string.</param>
/// <param name="session">The current agent session.</param>
/// <param name="ux">The UX state driver for rendering output.</param>
/// <returns><see langword="true"/> if this handler handled the input; <see langword="false"/> otherwise.</returns>
public abstract ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux);
ValueTask<bool> TryHandleAsync(string input, AgentSession session);
}
@@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles the <c>/mode</c> command to display or switch the current agent mode.
/// </summary>
public sealed class ModeCommandHandler : CommandHandler
internal sealed class ModeCommandHandler : ICommandHandler
{
private readonly AgentModeProvider? _modeProvider;
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
@@ -24,28 +24,28 @@ public sealed class ModeCommandHandler : CommandHandler
}
/// <inheritdoc/>
public override string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
public string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
/// <inheritdoc/>
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
public ValueTask<bool> TryHandleAsync(string input, AgentSession session)
{
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
{
return false;
return ValueTask.FromResult(false);
}
if (this._modeProvider is null)
{
await ux.WriteInfoLineAsync("AgentModeProvider is not available.").ConfigureAwait(false);
return true;
System.Console.WriteLine("AgentModeProvider is not available.");
return ValueTask.FromResult(true);
}
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
{
string current = this._modeProvider.GetMode(session);
await ux.WriteInfoLineAsync($"Current mode: {current}").ConfigureAwait(false);
return true;
System.Console.WriteLine($"\n Current mode: {current}\n");
return ValueTask.FromResult(true);
}
string newMode = parts[1];
@@ -53,14 +53,17 @@ public sealed class ModeCommandHandler : CommandHandler
try
{
this._modeProvider.SetMode(session, newMode);
ux.CurrentMode = newMode;
await ux.WriteInfoLineAsync($"Switched to {newMode} mode.", ModeColors.Get(newMode, this._modeColors)).ConfigureAwait(false);
System.Console.ForegroundColor = ConsoleWriter.GetModeColor(newMode, this._modeColors);
System.Console.WriteLine($"\n Switched to {newMode} mode.\n");
System.Console.ResetColor();
}
catch (ArgumentException ex)
{
await ux.WriteInfoLineAsync(ex.Message, ConsoleColor.Red).ConfigureAwait(false);
System.Console.ForegroundColor = ConsoleColor.Red;
System.Console.WriteLine($"\n {ex}\n");
System.Console.ResetColor();
}
return true;
return ValueTask.FromResult(true);
}
}
@@ -1,98 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Agents.AI;
namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles <c>/session-export &lt;filename&gt;</c> and <c>/session-import &lt;filename&gt;</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);
}
}
}
@@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles the <c>/todos</c> command to display the current todo list.
/// </summary>
public sealed class TodoCommandHandler : CommandHandler
internal sealed class TodoCommandHandler : ICommandHandler
{
private readonly TodoProvider? _todoProvider;
@@ -21,10 +21,10 @@ public sealed class TodoCommandHandler : CommandHandler
}
/// <inheritdoc/>
public override string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
public string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
/// <inheritdoc/>
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
public async ValueTask<bool> TryHandleAsync(string input, AgentSession session)
{
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
{
@@ -33,28 +33,34 @@ public sealed class TodoCommandHandler : CommandHandler
if (this._todoProvider is null)
{
await ux.WriteInfoLineAsync("TodoProvider is not available.").ConfigureAwait(false);
System.Console.WriteLine("TodoProvider is not available.");
return true;
}
var todos = await this._todoProvider.GetAllTodosAsync(session).ConfigureAwait(false);
if (todos.Count == 0)
{
await ux.WriteInfoLineAsync("No todos yet.").ConfigureAwait(false);
System.Console.WriteLine("\n No todos yet.\n");
return true;
}
await ux.WriteInfoLineAsync("── Todo List ──").ConfigureAwait(false);
System.Console.WriteLine();
System.Console.WriteLine(" ── Todo List ──");
foreach (var item in todos)
{
string status = item.IsComplete ? "✓" : "○";
ConsoleColor color = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White;
string description = string.IsNullOrWhiteSpace(item.Description)
? string.Empty
: $" — {item.Description}";
await ux.WriteInfoLineAsync($"[{status}] #{item.Id} {item.Title}{description}", color).ConfigureAwait(false);
System.Console.ForegroundColor = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White;
System.Console.Write($" [{status}] #{item.Id} {item.Title}");
if (!string.IsNullOrWhiteSpace(item.Description))
{
System.Console.Write($" — {item.Description}");
}
System.Console.WriteLine();
}
System.Console.ResetColor();
System.Console.WriteLine();
return true;
}
}
@@ -1,71 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Harness.ConsoleReactiveFramework;
namespace Harness.Shared.Console.Components;
/// <summary>
/// Props for <see cref="AgentModeAndHelp"/>.
/// </summary>
public record AgentModeAndHelpProps : ConsoleReactiveProps
{
/// <summary>Gets or sets the current mode name (e.g. "plan", "execute"), or <see langword="null"/> if no mode is active.</summary>
public string? Mode { get; set; }
/// <summary>Gets or sets the foreground color for the mode label.</summary>
public ConsoleColor? ModeColor { get; set; }
/// <summary>Gets or sets the help text to display (e.g. available commands and exit info).</summary>
public string? HelpText { get; set; }
}
/// <summary>
/// A component that renders a single fixed line below the bottom rule showing
/// the current agent mode (in the mode colour) and available commands (in dark grey).
/// </summary>
public class AgentModeAndHelp : ConsoleReactiveComponent<AgentModeAndHelpProps, ConsoleReactiveState>
{
/// <summary>
/// Calculates the height of the component.
/// </summary>
/// <param name="props">The component props.</param>
/// <returns>1 if there is content to display; otherwise 0.</returns>
public static int CalculateHeight(AgentModeAndHelpProps props) =>
(props.Mode is not null || !string.IsNullOrEmpty(props.HelpText)) ? 1 : 0;
/// <inheritdoc />
public override void RenderCore(AgentModeAndHelpProps props, ConsoleReactiveState state)
{
if (props.Mode is null && string.IsNullOrEmpty(props.HelpText))
{
return;
}
System.Console.Write(AnsiEscapes.SaveCursor);
System.Console.Write(AnsiEscapes.MoveAndEraseLine(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);
}
}
@@ -1,126 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Harness.ConsoleReactiveFramework;
namespace Harness.Shared.Console.Components;
/// <summary>
/// Props for <see cref="AgentStatus"/>.
/// </summary>
public record AgentStatusProps : ConsoleReactiveProps
{
/// <summary>Gets or sets a value indicating whether the spinner is visible.</summary>
public bool ShowSpinner { get; set; }
/// <summary>Gets or sets the formatted token usage text to display.</summary>
public string? UsageText { get; set; }
}
/// <summary>
/// State for <see cref="AgentStatus"/>.
/// </summary>
/// <param name="SpinnerIndex">The current spinner animation frame index.</param>
public record AgentStatusState(int SpinnerIndex = 0) : ConsoleReactiveState;
/// <summary>
/// A component that renders a single-line agent status bar with an animated spinner
/// and token usage statistics. Positioned above the rule in the non-scrolling area.
/// </summary>
public class AgentStatus : ConsoleReactiveComponent<AgentStatusProps, AgentStatusState>, IDisposable
{
private static readonly string[] s_spinnerFrames =
[
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
];
private readonly Timer _timer;
private AgentStatusProps? _previousProps;
/// <summary>
/// Initializes a new instance of the <see cref="AgentStatus"/> class.
/// </summary>
public AgentStatus()
{
this.State = new AgentStatusState();
this._timer = new Timer(this.OnTimerTick, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(100));
}
/// <summary>
/// Calculates the height of the agent status component.
/// </summary>
/// <param name="props">The component props.</param>
/// <returns>1 if the spinner or usage text is visible; otherwise 0.</returns>
public static int CalculateHeight(AgentStatusProps props)
{
return (props.ShowSpinner || !string.IsNullOrEmpty(props.UsageText)) ? 1 : 0;
}
/// <summary>
/// Disposes the internal spinner timer.
/// </summary>
public void Dispose()
{
this.Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases managed resources.
/// </summary>
/// <param name="disposing"><c>true</c> to release managed resources.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
this._timer.Dispose();
}
}
/// <inheritdoc />
public override void RenderCore(AgentStatusProps props, AgentStatusState state)
{
if (!props.ShowSpinner && string.IsNullOrEmpty(props.UsageText))
{
return;
}
System.Console.Write(AnsiEscapes.SaveCursor);
System.Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
if (props != this._previousProps)
{
System.Console.Write(AnsiEscapes.EraseToEndOfLine);
this._previousProps = props;
}
if (props.ShowSpinner)
{
string frame = s_spinnerFrames[state.SpinnerIndex];
System.Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.Cyan));
System.Console.Write($" {frame} ");
System.Console.Write(AnsiEscapes.ResetAttributes);
}
else
{
System.Console.Write(" ");
}
if (!string.IsNullOrEmpty(props.UsageText))
{
System.Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
System.Console.Write(props.UsageText);
System.Console.Write(AnsiEscapes.ResetAttributes);
}
System.Console.Write(AnsiEscapes.RestoreCursor);
}
private void OnTimerTick(object? timerState)
{
if (this.Props is { ShowSpinner: true })
{
int nextIndex = ((this.State?.SpinnerIndex ?? 0) + 1) % s_spinnerFrames.Length;
this.SetState(new AgentStatusState(nextIndex));
}
}
}
@@ -0,0 +1,278 @@
// Copyright (c) Microsoft. All rights reserved.
using Spectre.Console;
namespace Harness.Shared.Console;
/// <summary>
/// Centralizes all console output and spinner management for the harness console.
/// Observers write through this class so the spinner is automatically paused before output.
/// </summary>
public sealed class ConsoleWriter : IDisposable
{
private readonly Spinner _spinner = new();
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
private bool _lastWasText;
private bool _hasReceivedAnyText;
/// <summary>
/// Initializes a new instance of the <see cref="ConsoleWriter"/> class.
/// </summary>
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
public ConsoleWriter(IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
this._modeColors = modeColors;
}
/// <summary>
/// Gets or sets the current agent mode (e.g., "plan", "execute").
/// Used to determine the console color for mode-prefixed output.
/// </summary>
public string? CurrentMode { get; set; }
/// <summary>
/// Writes the agent response header (e.g., "[plan] Agent: ") and starts the spinner.
/// </summary>
public void WriteResponseHeader()
{
if (this.CurrentMode is not null)
{
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
System.Console.Write($"\n[{this.CurrentMode}] Agent: ");
}
else
{
System.Console.Write("\nAgent: ");
}
this._lastWasText = true;
this._hasReceivedAnyText = false;
this._spinner.Start();
}
/// <summary>
/// Writes informational output with automatic prefix spacing, without a trailing newline.
/// Use when continuation content will be appended on the same line.
/// </summary>
/// <param name="text">The informational text to write (without leading newline/indent — added automatically).</param>
/// <param name="color">Optional console color for the text.</param>
public async Task WriteInfoAsync(string text, ConsoleColor? color = null)
{
await this.WriteInfoCoreAsync(text, color, newLine: false);
}
/// <summary>
/// Writes informational output with automatic prefix spacing, followed by a newline.
/// </summary>
/// <param name="text">The informational text to write (without leading newline/indent — added automatically).</param>
/// <param name="color">Optional console color for the text.</param>
public async Task WriteInfoLineAsync(string text, ConsoleColor? color = null)
{
await this.WriteInfoCoreAsync(text, color, newLine: true);
}
private async Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
{
await this._spinner.StopAsync();
string prefix = this._lastWasText ? "\n\n " : " ";
this._lastWasText = false;
System.Console.ForegroundColor = color ?? GetModeColor(this.CurrentMode, this._modeColors);
if (newLine)
{
System.Console.WriteLine(prefix + text);
}
else
{
System.Console.Write(prefix + text);
}
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
this._spinner.Start();
}
/// <summary>
/// Writes text output from the agent, managing line break state.
/// Ensures a newline is written before the first text output.
/// </summary>
/// <param name="text">The text to write.</param>
/// <param name="color">Optional console color override for this text.</param>
public async Task WriteTextAsync(string text, ConsoleColor? color = null)
{
await this._spinner.StopAsync();
if (!this._lastWasText)
{
System.Console.Write("\n");
this._lastWasText = true;
}
this._hasReceivedAnyText = true;
if (color.HasValue)
{
System.Console.ForegroundColor = color.Value;
}
System.Console.Write(text);
if (color.HasValue)
{
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
}
this._spinner.Start();
}
/// <summary>
/// Reads a line of input from the console, pausing the spinner while waiting for input.
/// Optionally displays a prompt before reading. The prompt is rendered between
/// two horizontal rules for visual clarity.
/// </summary>
/// <param name="prompt">Optional prompt text to display before reading input.</param>
/// <param name="promptColor">Optional console color for the prompt text.</param>
/// <returns>The line read from the console, or <c>null</c> if no input is available.</returns>
public async Task<string?> ReadLineAsync(string? prompt = null, ConsoleColor? promptColor = null)
{
await this._spinner.StopAsync();
if (prompt is not null)
{
System.Console.WriteLine();
AnsiConsole.Write(this.CreateModeRule());
if (promptColor.HasValue)
{
System.Console.ForegroundColor = promptColor.Value;
}
System.Console.Write($" {prompt}");
if (promptColor.HasValue)
{
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
}
}
string? input = System.Console.ReadLine();
if (prompt is not null)
{
AnsiConsole.Write(this.CreateModeRule());
}
this._lastWasText = false;
return input;
}
/// <summary>
/// Presents a selection prompt with the given choices, plus an option to type a custom response.
/// Uses Spectre.Console <see cref="SelectionPrompt{T}"/> for interactive arrow-key selection.
/// </summary>
/// <param name="title">The title/question displayed above the selection list.</param>
/// <param name="choices">The list of choices to present.</param>
/// <returns>The selected choice text, or the custom-typed response.</returns>
public async Task<string> ReadSelectionAsync(string title, IList<string> choices)
{
await this._spinner.StopAsync();
AnsiConsole.Write(this.CreateModeRule());
const string FreeformOption = "✏️ Type a custom response...";
var allChoices = choices.Concat([FreeformOption]).ToList();
var prompt = new SelectionPrompt<string>()
.Title($" [bold]{Markup.Escape(title)}[/]")
.PageSize(10)
.AddChoices(allChoices);
string selection = AnsiConsole.Prompt(prompt);
if (selection == FreeformOption)
{
var textPrompt = new TextPrompt<string>(" [grey]Response:[/]");
selection = AnsiConsole.Prompt(textPrompt);
}
AnsiConsole.MarkupLine($" [dim]→ {Markup.Escape(selection)}[/]");
AnsiConsole.Write(this.CreateModeRule());
this._lastWasText = false;
return selection;
}
/// <summary>
/// Writes the stream-complete footer (handles "no text response" fallback, resets color).
/// </summary>
public async Task WriteStreamFooterAsync(bool hasFollowUpMessages)
{
await this._spinner.StopAsync();
if (!this._hasReceivedAnyText && !hasFollowUpMessages)
{
System.Console.ForegroundColor = ConsoleColor.DarkYellow;
System.Console.Write("\n (no text response from agent)");
}
System.Console.ResetColor();
System.Console.WriteLine();
}
/// <inheritdoc/>
public void Dispose()
{
this._spinner.Dispose();
}
/// <summary>
/// Gets the console color associated with a mode name, using the provided color map.
/// </summary>
internal static ConsoleColor GetModeColor(string? mode, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
if (mode is null)
{
return ConsoleColor.Gray;
}
if (modeColors is not null && modeColors.TryGetValue(mode, out var color))
{
return color;
}
return ConsoleColor.Gray;
}
/// <summary>
/// Creates a <see cref="Rule"/> styled with the current mode color.
/// </summary>
internal Rule CreateModeRule()
{
var spectreColor = ToSpectreColor(GetModeColor(this.CurrentMode, this._modeColors));
return new Rule().RuleStyle(new Style(spectreColor));
}
internal static Color ToSpectreColor(ConsoleColor consoleColor) => consoleColor switch
{
ConsoleColor.Black => Color.Black,
ConsoleColor.DarkBlue => Color.Blue,
ConsoleColor.DarkGreen => Color.Green,
ConsoleColor.DarkCyan => Color.Teal,
ConsoleColor.DarkRed => Color.Red,
ConsoleColor.DarkMagenta => Color.Purple,
ConsoleColor.DarkYellow => Color.Olive,
ConsoleColor.Gray => Color.Silver,
ConsoleColor.DarkGray => Color.Grey,
ConsoleColor.Blue => Color.Blue1,
ConsoleColor.Green => Color.Green1,
ConsoleColor.Cyan => Color.Aqua,
ConsoleColor.Red => Color.Red1,
ConsoleColor.Magenta => Color.Fuchsia,
ConsoleColor.Yellow => Color.Yellow,
ConsoleColor.White => Color.White,
_ => Color.Silver,
};
}
@@ -1,67 +0,0 @@
// 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();
}
}
@@ -1,59 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Represents an action returned by an observer at the end of an agent turn.
/// Subtypes describe either a question to ask the user (<see cref="FollowUpQuestion"/>)
/// or a message to add directly to the next agent input (<see cref="FollowUpMessage"/>).
/// </summary>
public abstract record FollowUpAction;
/// <summary>
/// Represents a question that should be presented to the user. The
/// <see cref="Continuation"/> delegate is invoked with the user's answer and the
/// UX state driver, and returns an optional <see cref="ChatMessage"/> to add to the
/// next agent invocation.
/// </summary>
/// <param name="Prompt">The question text shown to the user.</param>
/// <param name="Continuation">
/// Invoked with the user's answer and the UX state driver. The driver lets the
/// continuation write output (e.g., an action label like "Approved") in addition
/// to producing an optional <see cref="ChatMessage"/> for the next agent invocation.
/// </param>
public abstract record FollowUpQuestion(
string Prompt,
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation) : FollowUpAction;
/// <summary>
/// A free-form text question. The user may type any response.
/// </summary>
/// <param name="Prompt">The question text shown to the user.</param>
/// <param name="Continuation">Continuation that builds the response message.</param>
public sealed record TextFollowUpQuestion(
string Prompt,
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
: FollowUpQuestion(Prompt, Continuation);
/// <summary>
/// A choice question. The user picks from <paramref name="Choices"/>, optionally with
/// the ability to enter custom text when <paramref name="AllowCustomText"/> is true.
/// </summary>
/// <param name="Prompt">The question text shown to the user.</param>
/// <param name="Choices">The list of pre-defined choices.</param>
/// <param name="AllowCustomText">If true, the user may type a custom response in addition to the listed choices.</param>
/// <param name="Continuation">Continuation that builds the response message.</param>
public sealed record ChoiceFollowUpQuestion(
string Prompt,
IReadOnlyList<string> Choices,
bool AllowCustomText,
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
: FollowUpQuestion(Prompt, Continuation);
/// <summary>
/// A message to add directly to the next agent invocation without prompting the user.
/// </summary>
/// <param name="Message">The chat message to add.</param>
public sealed record FollowUpMessage(ChatMessage Message) : FollowUpAction;
@@ -1,303 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.Shared.Console.Commands;
using Harness.Shared.Console.Observers;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Orchestrates agent invocations driven by user-input events from the UI.
/// The component invokes the runner's input handlers (<see cref="OnUserInputAsync"/>,
/// <see cref="OnStreamingInputAsync"/>, <see cref="StartAgentTurnAsync"/>) directly;
/// the runner mutates UI state through the supplied <see cref="IUXStateDriver"/>.
/// All per-turn follow-up state (pending questions and accumulated responses) lives
/// in the component's state record — the runner reads/writes it exclusively through
/// the driver and holds no per-turn fields itself.
/// </summary>
public sealed class HarnessAgentRunner : IDisposable
{
private readonly AIAgent _agent;
private readonly 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);
}
}
foreach (var observer in this._observers)
{
await observer.OnResponseUpdateAsync(this._ux, update, this._agent, this._session).ConfigureAwait(false);
}
if (!string.IsNullOrEmpty(update.Text))
{
foreach (var observer in this._observers)
{
await observer.OnTextAsync(this._ux, update.Text, this._agent, this._session).ConfigureAwait(false);
}
}
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
}
}
catch (Exception ex)
{
await this._ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red).ConfigureAwait(false);
}
// Final sync after streaming.
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
this._ux.StopSpinner();
await this._ux.EndStreamingOutputAsync().ConfigureAwait(false);
// Collect FollowUpActions from each observer.
var directMessages = new List<ChatMessage>();
var questions = new List<FollowUpQuestion>();
foreach (var observer in this._observers)
{
var actions = await observer.OnStreamCompleteAsync(this._ux, this._agent, this._session).ConfigureAwait(false);
if (actions is null)
{
continue;
}
foreach (var action in actions)
{
switch (action)
{
case FollowUpMessage msg:
directMessages.Add(msg.Message);
break;
case FollowUpQuestion q:
questions.Add(q);
break;
}
}
}
bool hasFollowUpActions = directMessages.Count > 0 || questions.Count > 0;
await this._ux.WriteNoTextWarningAsync(hasFollowUpActions).ConfigureAwait(false);
// Add any direct messages to the accumulator regardless of whether questions follow —
// they're sent on the next agent invocation, either by us (if no questions) or by
// the component (after the user finishes answering, via StartAgentTurnAsync).
foreach (var msg in directMessages)
{
this._ux.AddFollowUpResponse(msg);
}
if (questions.Count > 0)
{
// Pause: hand control back to the UX to collect answers.
this._ux.QueueFollowUpQuestions(questions);
return;
}
// No questions to ask — drain anything we just accumulated and loop with it.
IReadOnlyList<ChatMessage> drained = this._ux.TakeFollowUpResponses();
nextMessages = drained.Count > 0 ? [.. drained] : null;
}
this.CompleteTurn();
}
private void CompleteTurn()
{
this._ux.EndStreaming();
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
}
/// <summary>
/// Synchronizes the queued items display with the message injector's pending messages.
/// Messages that have been consumed (drained by the service) are echoed to the output
/// area as regular user-input entries.
/// </summary>
private void SyncQueuedMessageDisplay(ref IReadOnlyList<ChatMessage> lastPendingMessages)
{
if (this._messageInjector is null)
{
return;
}
var pending = this._messageInjector.GetPendingMessages(this._session);
int consumedCount = lastPendingMessages.Count - pending.Count;
for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++)
{
string text = lastPendingMessages[i].Text ?? string.Empty;
this._ux.WriteUserInputEcho(text);
}
lastPendingMessages = pending;
this._ux.SetQueuedMessages(pending);
}
}
@@ -1,589 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Harness.ConsoleReactiveFramework;
using Harness.Shared.Console.Components;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// The main application component for the Harness console. Manages the scroll region
/// and bottom panel (text input, list selection, or streaming indicator). Owns the
/// <see cref="HarnessConsoleUXStateDriver"/> and routes user input events to the
/// registered <see cref="HarnessAgentRunner"/>.
/// </summary>
public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps, HarnessAppComponentState>, IDisposable
{
private readonly TopBottomRule _rule = new();
private readonly ListSelection _listSelection = new();
private readonly TextInput _textInput = new();
private readonly TextScrollPanel _textScrollPanel = new();
private readonly TextPanel _textPanel = new();
private readonly TextPanel _queuedPanel = new();
private readonly AgentStatus _agentStatus = new();
private readonly AgentModeAndHelp _modeAndHelp = new();
private readonly HarnessConsoleUXStateDriver _uxDriver;
private readonly TaskCompletionSource<bool> _shutdownTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly SemaphoreSlim _followUpGate = new(1, 1);
private int _scrollRegionBottom;
private bool _resizedSinceLastRender = true;
private bool _deactivated;
/// <summary>
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
/// </summary>
/// <param name="placeholder">Placeholder text shown when the input is empty.</param>
/// <param name="initialMode">The current agent mode, used to colour the rule and prompt.</param>
/// <param name="inputEnabled">Whether the bottom-panel input accepts keystrokes during streaming.</param>
/// <param name="runnerFactory">Factory invoked with the component's <see cref="IUXStateDriver"/>
/// to construct the <see cref="HarnessAgentRunner"/> that owns the agent loop.</param>
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
public HarnessAppComponent(
string placeholder,
string? initialMode,
bool inputEnabled,
Func<IUXStateDriver, HarnessAgentRunner> runnerFactory,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
this.Props = new ConsoleReactiveProps();
this.State = new HarnessAppComponentState
{
Mode = BottomPanelMode.TextInput,
Prompt = "> ",
Placeholder = placeholder,
ModeColor = ModeColors.Get(initialMode, modeColors),
ModeText = initialMode,
InputEnabled = inputEnabled,
ConsoleWidth = System.Console.WindowWidth,
ConsoleHeight = System.Console.WindowHeight,
};
this._uxDriver = new HarnessConsoleUXStateDriver(
getState: () => this.State!,
setState: s => this.SetState(s),
requestShutdown: () => this._shutdownTcs.TrySetResult(true),
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));
}
}
}
@@ -1,125 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveFramework;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
/// <summary>
/// Determines which component is shown in the bottom panel.
/// </summary>
public enum BottomPanelMode
{
/// <summary>Show the text input component for user input.</summary>
TextInput,
/// <summary>Show the list selection component for interactive prompts.</summary>
ListSelection,
/// <summary>Show a disabled input indicator during agent streaming.</summary>
Streaming,
}
/// <summary>
/// Internal state for <see cref="HarnessAppComponent"/>. All UI fields that may
/// change after construction live here; they are mutated exclusively via
/// <see cref="ConsoleReactiveComponent{TProps,TState}.SetState"/> by the
/// owning <see cref="HarnessConsoleUXStateDriver"/>.
/// </summary>
public record HarnessAppComponentState : ConsoleReactiveState
{
// --- Console dimensions ---
/// <summary>Gets the current console width in columns.</summary>
public int ConsoleWidth { get; init; }
/// <summary>Gets the current console height in rows.</summary>
public int ConsoleHeight { get; init; }
// --- Bottom panel mode ---
/// <summary>Gets the bottom panel mode.</summary>
public BottomPanelMode Mode { get; init; } = BottomPanelMode.TextInput;
/// <summary>
/// Gets the queue of follow-up questions waiting for user answers. The head
/// (<c>[0]</c>) is the question currently being displayed; subsequent items
/// are dispatched in order as each is answered. While this queue is non-empty,
/// the next user submission is treated as the answer to the head question
/// instead of going to the agent runner's normal input handler.
/// </summary>
public IReadOnlyList<FollowUpQuestion> PendingQuestions { get; init; } = [];
/// <summary>
/// Gets the accumulated follow-up response messages collected during the
/// current agent turn — both direct <see cref="FollowUpMessage"/>s emitted
/// by observers and continuation results from answered questions. Consumed
/// by the runner via <see cref="IUXStateDriver.TakeFollowUpResponses"/>
/// before the next agent invocation.
/// </summary>
public IReadOnlyList<ChatMessage> AccumulatedFollowUpResponses { get; init; } = [];
// --- Text input (active in TextInput / Streaming modes) ---
/// <summary>Gets the prompt string for text input mode.</summary>
public string Prompt { get; init; } = "> ";
/// <summary>Gets the placeholder text shown when the input is empty.</summary>
public string Placeholder { get; init; } = "";
/// <summary>Gets the current input text being typed.</summary>
public string InputText { get; init; } = "";
/// <summary>Gets a value indicating whether input is enabled during streaming.</summary>
public bool InputEnabled { get; init; }
/// <summary>Gets the prompt to show during streaming when input is disabled.</summary>
public string StreamingPrompt { get; init; } = "(agent is running...)";
// --- List selection (active in ListSelection mode) ---
/// <summary>Gets the title text displayed above the list selection (for interactive prompts).</summary>
public string? ListSelectionTitle { get; init; }
/// <summary>Gets the list selection options.</summary>
public IReadOnlyList<string> ListSelectionOptions { get; init; } = [];
/// <summary>Gets the highlighted option index in list selection mode.</summary>
public int ListSelectionIndex { get; init; }
/// <summary>Gets the placeholder text for the custom text input option in the list.</summary>
public string? ListSelectionCustomTextPlaceholder { get; init; }
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
public string ListSelectionCustomInputText { get; init; } = "";
/// <summary>Gets the highlight color for the active list item.</summary>
public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan;
// --- Scroll / output area ---
/// <summary>Gets the items rendered in the scroll-area. Each item is a pre-rendered
/// console string (may include ANSI escape sequences and newlines).</summary>
public IReadOnlyList<string> ScrollAreaContentItems { get; init; } = [];
/// <summary>Gets the queued input items to display above the rule. Each item is a
/// pre-rendered console string (may include ANSI escape sequences and newlines).</summary>
public IReadOnlyList<string> QueuedItems { get; init; } = [];
// --- Agent mode + status display ---
/// <summary>Gets the foreground color for the rule borders and mode label.</summary>
public ConsoleColor? ModeColor { get; init; }
/// <summary>Gets the current mode name displayed below the bottom rule (e.g. "plan").</summary>
public string? ModeText { get; init; }
/// <summary>Gets the help text displayed below the bottom rule (available commands).</summary>
public string? HelpText { get; init; }
/// <summary>Gets a value indicating whether the agent status spinner is visible.</summary>
public bool ShowSpinner { get; init; }
/// <summary>Gets the formatted token usage text to display in the status bar.</summary>
public string? UsageText { get; init; }
}
@@ -1,8 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using Harness.ConsoleReactiveComponents;
using Harness.Shared.Console.Commands;
using Harness.Shared.Console.Observers;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
@@ -14,63 +15,200 @@ public static class HarnessConsole
{
/// <summary>
/// Runs an interactive console session with the specified agent.
/// Constructs the reactive UI component and the <see cref="HarnessAgentRunner"/>,
/// wires them together, and awaits the component's <see cref="HarnessAppComponent.ShutdownTask"/>
/// (which completes when the user types <c>/exit</c>).
/// Supports streaming output, tool call display, spinner animation,
/// optional planning UX with structured output, and the <c>/todos</c> command.
/// </summary>
/// <param name="agent">The agent to interact with.</param>
/// <param name="userPrompt">A short prompt to the user, displayed as a placeholder in the input area.</param>
/// <param name="title">The title displayed in the console header.</param>
/// <param name="userPrompt">A short prompt to the user, displayed below the title.</param>
/// <param name="options">Optional configuration options for the console session.</param>
public static async Task RunAgentAsync(AIAgent agent, string userPrompt, HarnessConsoleOptions? options = null)
public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, HarnessConsoleOptions? options = null)
{
options ??= new();
System.Console.OutputEncoding = Encoding.UTF8;
// Null means use defaults; an explicit (possibly empty) list means use exactly what was provided.
var observers = options.Observers
?? HarnessConsoleOptions.BuildDefaultObservers();
var commandHandlers = options.CommandHandlers
?? HarnessConsoleOptions.BuildDefaultCommandHandlers(agent, options.ModeColors);
var modeProvider = agent.GetService<AgentModeProvider>();
var messageInjector = agent.GetService<MessageInjectingChatClient>();
AgentSession session = options.SessionFactory is not null
? await options.SessionFactory(agent)
: await agent.CreateSessionAsync();
using var component = new HarnessAppComponent(
placeholder: userPrompt,
initialMode: modeProvider?.GetMode(session),
inputEnabled: messageInjector is not null,
runnerFactory: ux => new HarnessAgentRunner(
agent: agent,
session: session,
modeProvider: modeProvider,
messageInjector: messageInjector,
commandHandlers: commandHandlers,
observers: observers,
ux: ux),
modeColors: options.ModeColors);
// Trigger the initial render of the component now that state is seeded.
component.Render();
try
if (options.EnablePlanningUx
&& (string.IsNullOrWhiteSpace(options.PlanningModeName) || string.IsNullOrWhiteSpace(options.ExecutionModeName)))
{
await component.ShutdownTask.ConfigureAwait(false);
throw new ArgumentException(
"When EnablePlanningUx is true, both PlanningModeName and ExecutionModeName must be configured.",
nameof(options));
}
finally
System.Console.WriteLine($"=== {title} ===");
System.Console.WriteLine(userPrompt);
var todoProvider = agent.GetService<TodoProvider>();
var modeProvider = agent.GetService<AgentModeProvider>();
// Build command handlers.
var commandHandlers = new List<ICommandHandler>
{
component.Deactivate();
new TodoCommandHandler(todoProvider),
new ModeCommandHandler(modeProvider, options.ModeColors),
};
var commands = commandHandlers
.Select(h => h.GetHelpText())
.Where(t => t is not null)
.Append("exit (quit)");
System.Console.WriteLine($"Commands: {string.Join(", ", commands)}");
System.Console.WriteLine();
AgentSession session = await agent.CreateSessionAsync();
using var writer = new ConsoleWriter(options.ModeColors);
writer.CurrentMode = modeProvider?.GetMode(session);
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.
bool handled = false;
foreach (var handler in commandHandlers)
{
if (await handler.TryHandleAsync(userInput, session).ConfigureAwait(false))
{
handled = true;
break;
}
}
if (!handled)
{
await RunAgentTurnAsync(agent, session, modeProvider, options, writer, userInput);
}
writer.CurrentMode = modeProvider?.GetMode(session);
prompt = BuildUserPrompt(modeProvider, session);
userInput = await writer.ReadLineAsync(prompt);
}
System.Console.ResetColor();
System.Console.Write(AnsiEscapes.ResetScrollRegion);
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
System.Console.Write(AnsiEscapes.EraseEntireScreen);
System.Console.Write(AnsiEscapes.MoveCursor(1, 1));
System.Console.WriteLine("Goodbye!");
}
/// <summary>
/// Runs one or more agent invocations for a single user turn, using the current
/// observers. Re-invokes automatically for tool approvals and mode-driven follow-ups
/// (e.g., planning clarification loops).
/// </summary>
private static async Task RunAgentTurnAsync(
AIAgent agent,
AgentSession session,
AgentModeProvider? modeProvider,
HarnessConsoleOptions options,
ConsoleWriter writer,
string userInput)
{
IList<ChatMessage>? nextMessages = [new ChatMessage(ChatRole.User, userInput)];
while (nextMessages is not null)
{
// Build observers for this invocation (may change between iterations due to mode changes).
var observers = CreateObservers(options, modeProvider, session);
// Build run options — observers may inject ResponseFormat, etc.
var runOptions = new AgentRunOptions();
foreach (var observer in observers)
{
observer.ConfigureRunOptions(runOptions);
}
// Stream the response, fanning out to all observers.
writer.CurrentMode = modeProvider?.GetMode(session);
writer.WriteResponseHeader();
try
{
await foreach (var update in agent.RunStreamingAsync(nextMessages, session, runOptions))
{
// Update mode color if the mode changed during streaming.
if (modeProvider is not null)
{
string currentMode = modeProvider.GetMode(session);
if (currentMode != writer.CurrentMode)
{
writer.CurrentMode = currentMode;
}
}
foreach (var content in update.Contents)
{
foreach (var observer in observers)
{
await observer.OnContentAsync(writer, content);
}
}
if (!string.IsNullOrEmpty(update.Text))
{
foreach (var observer in observers)
{
await observer.OnTextAsync(writer, update.Text);
}
}
}
}
catch (Exception ex)
{
await writer.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red);
}
// Collect messages from all observers.
var combinedMessages = new List<ChatMessage>();
bool hasObserverMessages = false;
foreach (var observer in observers)
{
var messages = await observer.OnStreamCompleteAsync(writer, agent, session, options);
if (messages is { Count: > 0 })
{
combinedMessages.AddRange(messages);
hasObserverMessages = true;
}
}
await writer.WriteStreamFooterAsync(hasFollowUpMessages: hasObserverMessages);
nextMessages = combinedMessages.Count > 0 ? combinedMessages : null;
}
}
private static List<ConsoleObserver> CreateObservers(HarnessConsoleOptions options, AgentModeProvider? modeProvider, AgentSession session)
{
var observers = new List<ConsoleObserver>
{
new ToolCallDisplayObserver(),
new ToolApprovalObserver(),
new ErrorDisplayObserver(),
new ReasoningDisplayObserver(),
new UsageDisplayObserver(options.MaxContextWindowTokens, options.MaxOutputTokens),
};
// Add the appropriate output observer based on the current mode.
if (options.EnablePlanningUx
&& modeProvider is not null
&& string.Equals(modeProvider.GetMode(session), options.PlanningModeName, StringComparison.OrdinalIgnoreCase))
{
observers.Add(new PlanningOutputObserver(modeProvider));
}
else
{
observers.Add(new TextOutputObserver());
}
return observers;
}
private static string BuildUserPrompt(AgentModeProvider? modeProvider, AgentSession session)
{
if (modeProvider is not null)
{
string mode = modeProvider.GetMode(session);
return $"[{mode}] You: ";
}
return "You: ";
}
}
@@ -1,11 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.ObjectModel;
using Harness.Shared.Console.Commands;
using Harness.Shared.Console.Observers;
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
namespace Harness.Shared.Console;
/// <summary>
@@ -14,127 +8,45 @@ namespace Harness.Shared.Console;
public class HarnessConsoleOptions
{
/// <summary>
/// Gets or sets the list of console observers that participate in the agent response
/// streaming lifecycle. Use the factory methods on this class to create common observer sets.
/// When <see langword="null"/> (the default), a default set of observers is used.
/// Set to an empty list to disable all observers.
/// Gets or sets the optional maximum context window size in tokens.
/// When set, token usage is displayed as a percentage of the budget.
/// </summary>
public IReadOnlyList<ConsoleObserver>? Observers { get; set; }
public int? MaxContextWindowTokens { get; set; }
/// <summary>
/// Gets or sets the list of command handlers to check before sending user input to the agent.
/// Use <see cref="BuildDefaultCommandHandlers"/> to create the default set.
/// When <see langword="null"/> (the default), a default set of handlers is used.
/// Set to an empty list to disable all command handlers.
/// Gets or sets the optional maximum output tokens.
/// Used with <see cref="MaxContextWindowTokens"/> to show input/output budget breakdown.
/// </summary>
public IReadOnlyList<CommandHandler>? CommandHandlers { get; set; }
public int? MaxOutputTokens { get; set; }
/// <summary>
/// The default mode-to-color mapping used when no custom <see cref="ModeColors"/> are provided.
/// Gets or sets a value indicating whether the planning UX is enabled.
/// When <see langword="true"/> and the agent is in the mode specified by <see cref="PlanningModeName"/>,
/// the console uses structured output to present clarification questions and approval requests
/// instead of streaming free-form text.
/// </summary>
public static readonly IReadOnlyDictionary<string, ConsoleColor> DefaultModeColors = new ReadOnlyDictionary<string, ConsoleColor>(
new Dictionary<string, ConsoleColor>(StringComparer.OrdinalIgnoreCase)
{
["plan"] = ConsoleColor.Cyan,
["execute"] = ConsoleColor.Green,
});
/// <value>Defaults to <see langword="false"/>.</value>
public bool EnablePlanningUx { get; set; }
/// <summary>
/// Gets or sets the name of the agent mode that activates the planning UX.
/// Must be set when <see cref="EnablePlanningUx"/> is <see langword="true"/>.
/// </summary>
public string? PlanningModeName { get; set; }
/// <summary>
/// Gets or sets the name of the agent mode to switch to when the user approves a plan.
/// Must be set when <see cref="EnablePlanningUx"/> is <see langword="true"/>.
/// </summary>
public string? ExecutionModeName { get; set; }
/// <summary>
/// Gets or sets a mapping of agent mode names to console colors.
/// When a mode is not found in this dictionary, the default color (<see cref="ConsoleColor.Gray"/>) is used.
/// </summary>
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(DefaultModeColors, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 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)
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(StringComparer.OrdinalIgnoreCase)
{
return
[
new ToolCallDisplayObserver(toolFormatters),
new ToolApprovalObserver(toolFormatters),
new ErrorDisplayObserver(),
new ReasoningDisplayObserver(),
new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens),
new TextOutputObserver(),
];
}
/// <summary>
/// Creates the default set of observers with planning support.
/// Includes a <see cref="PlanningOutputObserver"/> instead of <see cref="TextOutputObserver"/>.
/// </summary>
/// <param name="agent">The agent, used to resolve <see cref="AgentModeProvider"/>.</param>
/// <param name="planModeName">The mode name that represents the planning mode.</param>
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
/// <param name="modeColors">Optional mode-to-color mapping for display.
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
/// <param name="maxContextWindowTokens">Optional maximum context window size in tokens for usage display.</param>
/// <param name="maxOutputTokens">Optional maximum output tokens for usage display.</param>
/// <param name="toolFormatters">Optional tool call formatters. When <see langword="null"/>,
/// each observer uses the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/>.</param>
/// <returns>A list of observers for a planning-enabled console session.</returns>
public static List<ConsoleObserver> BuildObserversWithPlanning(
AIAgent agent,
string planModeName,
string executionModeName,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null,
int? maxContextWindowTokens = null,
int? maxOutputTokens = null,
IReadOnlyList<ToolCallFormatter>? toolFormatters = null)
{
var modeProvider = agent.GetService<AgentModeProvider>()
?? throw new InvalidOperationException("Planning requires an AgentModeProvider service on the agent.");
return
[
new ToolCallDisplayObserver(toolFormatters),
new ToolApprovalObserver(toolFormatters),
new ErrorDisplayObserver(),
new ReasoningDisplayObserver(),
new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens),
new PlanningOutputObserver(modeProvider, planModeName, executionModeName, modeColors ?? DefaultModeColors),
];
}
/// <summary>
/// Creates the default set of command handlers.
/// Includes exit, todo, and mode command handlers.
/// </summary>
/// <param name="agent">The agent, used to resolve <see cref="TodoProvider"/> and <see cref="AgentModeProvider"/>.</param>
/// <param name="modeColors">Optional mode-to-color mapping for the mode command display.
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
/// <returns>A list of command handlers for a standard console session.</returns>
public static List<CommandHandler> BuildDefaultCommandHandlers(
AIAgent agent,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
var todoProvider = agent.GetService<TodoProvider>();
var modeProvider = agent.GetService<AgentModeProvider>();
return
[
new ExitCommandHandler(),
new TodoCommandHandler(todoProvider),
new ModeCommandHandler(modeProvider, modeColors ?? DefaultModeColors),
new SessionCommandHandler(agent),
];
}
["plan"] = ConsoleColor.Cyan,
["execute"] = ConsoleColor.Green,
};
}
@@ -1,416 +0,0 @@
// 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);
}
@@ -1,55 +0,0 @@
// 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();
}
}
@@ -8,14 +8,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="Spectre.Console" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\ConsoleReactiveFramework\ConsoleReactiveFramework.csproj" />
<ProjectReference Include="..\ConsoleReactiveComponents\ConsoleReactiveComponents.csproj" />
</ItemGroup>
</Project>
@@ -1,128 +0,0 @@
// 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);
}
@@ -1,31 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Harness.Shared.Console;
/// <summary>
/// Helpers for resolving console colours associated with agent modes.
/// </summary>
internal static class ModeColors
{
/// <summary>
/// Gets the console color associated with a mode name, using the provided color map.
/// Falls back to <see cref="ConsoleColor.Gray"/> when the mode is <see langword="null"/>
/// or not present in the map.
/// </summary>
/// <param name="mode">The mode name, or <see langword="null"/> if no mode is active.</param>
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
public static ConsoleColor Get(string? mode, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
if (mode is null)
{
return ConsoleColor.Gray;
}
if (modeColors is not null && modeColors.TryGetValue(mode, out var color))
{
return color;
}
return ConsoleColor.Gray;
}
}
@@ -18,52 +18,36 @@ public abstract class ConsoleObserver
/// Override to set options such as <see cref="AgentRunOptions.ResponseFormat"/>.
/// </summary>
/// <param name="options">The run options to configure.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
public virtual void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
public virtual void ConfigureRunOptions(AgentRunOptions options)
{
}
/// <summary>
/// Called for each <see cref="AgentResponseUpdate"/> in the response stream, regardless of
/// whether it contains content. Override to inspect update-level metadata such as
/// <see cref="AgentResponseUpdate.RawRepresentation"/> for provider-specific events.
/// </summary>
/// <param name="ux">The UX state driver, used for rendering output.</param>
/// <param name="update">The streaming response update.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
public virtual Task OnResponseUpdateAsync(IUXStateDriver ux, AgentResponseUpdate update, AIAgent agent, AgentSession session) => Task.CompletedTask;
/// <summary>
/// Called for each <see cref="AIContent"/> item in the response stream.
/// </summary>
/// <param name="ux">The UX state driver, used for rendering output.</param>
/// <param name="writer">The console writer for rendering output.</param>
/// <param name="content">The content item from the stream.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
public virtual Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) => Task.CompletedTask;
public virtual Task OnContentAsync(ConsoleWriter writer, AIContent content) => Task.CompletedTask;
/// <summary>
/// Called for each text update in the response stream.
/// </summary>
/// <param name="ux">The UX state driver, used for rendering output.</param>
/// <param name="writer">The console writer for rendering output.</param>
/// <param name="text">The text from the update.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
public virtual Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session) => Task.CompletedTask;
public virtual Task OnTextAsync(ConsoleWriter writer, string text) => Task.CompletedTask;
/// <summary>
/// Called after the response stream completes. Returns a heterogeneous list of
/// follow-up actions (questions to ask the user, and/or messages to add directly to
/// the next agent invocation), or <see langword="null"/> if no follow-up is needed.
/// Called after the response stream completes. Returns messages to include in the
/// next agent invocation, or <see langword="null"/> if no re-invocation is needed.
/// </summary>
/// <param name="ux">The UX state driver, used for rendering output.</param>
/// <param name="writer">The console writer for rendering output.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
/// <returns>Follow-up actions to process after the stream completes, or <see langword="null"/>.</returns>
public virtual Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
IUXStateDriver ux,
/// <param name="options">The console options.</param>
/// <returns>Messages to send to the agent, or <see langword="null"/> if no action is needed.</returns>
public virtual Task<IList<ChatMessage>?> OnStreamCompleteAsync(
ConsoleWriter writer,
AIAgent agent,
AgentSession session) => Task.FromResult<IList<FollowUpAction>?>(null);
AgentSession session,
HarnessConsoleOptions options) => Task.FromResult<IList<ChatMessage>?>(null);
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
@@ -8,10 +7,10 @@ namespace Harness.Shared.Console.Observers;
/// <summary>
/// Displays error content (❌) from the response stream.
/// </summary>
public sealed class ErrorDisplayObserver : ConsoleObserver
internal sealed class ErrorDisplayObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
{
if (content is ErrorContent errorContent)
{
@@ -26,7 +25,7 @@ public sealed class ErrorDisplayObserver : ConsoleObserver
errorText += $" details: {errorContent.Details}";
}
await ux.WriteInfoLineAsync(errorText, ConsoleColor.Red);
await writer.WriteInfoLineAsync(errorText, ConsoleColor.Red);
}
}
}
@@ -2,77 +2,51 @@
using System.Text;
using System.Text.Json;
using Harness.ConsoleReactiveComponents;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
/// <summary>
/// Planning observer that is mode-aware: in planning mode it configures structured
/// JSON output, collects streamed text, and deserializes it as a <see cref="PlanningResponse"/>;
/// in execution mode it passes text straight through to <see cref="IUXStateDriver.WriteTextAsync"/>
/// for live streaming display.
/// Planning observer that configures structured output, collects streamed text,
/// and deserializes it as a <see cref="PlanningResponse"/>. Renders clarification
/// questions and approval prompts, and manages mode switching when the user approves a plan.
/// </summary>
public sealed class PlanningOutputObserver : ConsoleObserver
internal sealed class PlanningOutputObserver : ConsoleObserver
{
private readonly StringBuilder _textCollector = new();
private readonly AgentModeProvider _modeProvider;
private readonly string _planModeName;
private readonly string _executionModeName;
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
/// <summary>
/// Initializes a new instance of the <see cref="PlanningOutputObserver"/> class.
/// </summary>
/// <param name="modeProvider">The mode provider for switching modes on approval.</param>
/// <param name="planModeName">The mode name that represents the planning mode.</param>
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
/// <param name="modeColors">Optional mode-to-color mapping for display.</param>
public PlanningOutputObserver(AgentModeProvider modeProvider, string planModeName, string executionModeName, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
public PlanningOutputObserver(AgentModeProvider modeProvider)
{
this._modeProvider = modeProvider;
this._planModeName = planModeName;
this._executionModeName = executionModeName;
this._modeColors = modeColors;
}
/// <inheritdoc/>
public override void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
public override void ConfigureRunOptions(AgentRunOptions options)
{
if (this.IsPlanningMode(this._modeProvider.GetMode(session)))
{
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
}
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
}
/// <inheritdoc/>
public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session)
public override Task OnTextAsync(ConsoleWriter writer, string text)
{
if (this.IsPlanningMode(ux.CurrentMode))
{
// Planning mode: collect text silently for JSON parsing after the stream.
this._textCollector.Append(text);
return Task.CompletedTask;
}
// Execution mode: stream text directly to the console.
return ux.WriteTextAsync(text);
// Collect text silently instead of displaying it.
this._textCollector.Append(text);
return Task.CompletedTask;
}
/// <inheritdoc/>
public override async Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
IUXStateDriver ux,
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
ConsoleWriter writer,
AIAgent agent,
AgentSession session)
AgentSession session,
HarnessConsoleOptions options)
{
if (!this.IsPlanningMode(ux.CurrentMode))
{
// Execution mode: text was already streamed live; nothing to parse.
this._textCollector.Clear();
return null;
}
// Read collected text from our stream observation.
string collectedText = this._textCollector.ToString();
this._textCollector.Clear();
@@ -90,20 +64,21 @@ public sealed class PlanningOutputObserver : ConsoleObserver
}
catch (JsonException ex)
{
await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
await writer.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
await writer.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
return null;
}
if (planningResponse is null)
{
await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
await writer.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
return null;
}
// Render based on response type.
if (planningResponse.Type == PlanningResponseType.Clarification)
{
return BuildClarificationActions(planningResponse);
return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(writer, planningResponse));
}
if (planningResponse.Type == PlanningResponseType.Approval)
@@ -111,91 +86,92 @@ public sealed class PlanningOutputObserver : ConsoleObserver
var question = planningResponse.Questions.FirstOrDefault();
if (question is null)
{
await ux.WriteInfoLineAsync("(approval response had no content)", ConsoleColor.DarkYellow);
await writer.WriteInfoLineAsync("(approval response had no content)", ConsoleColor.DarkYellow);
return null;
}
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
string response = await this.RenderApprovalAndCollectResponseAsync(writer, question, options);
if (response == "Approved")
{
this._modeProvider.SetMode(session, options.ExecutionModeName!);
await writer.WriteInfoLineAsync($"✅ Switched to {options.ExecutionModeName} mode.",
ConsoleWriter.GetModeColor(options.ExecutionModeName, options.ModeColors));
}
return AsUserMessages(response);
}
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
await writer.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
return null;
}
private static List<FollowUpAction> BuildClarificationActions(PlanningResponse response)
private static IList<ChatMessage>? AsUserMessages(string? text) =>
text is not null ? [new ChatMessage(ChatRole.User, text)] : null;
private async Task<string?> RenderClarificationsAndCollectResponsesAsync(ConsoleWriter writer, PlanningResponse response)
{
var actions = new List<FollowUpAction>(response.Questions.Count);
var answers = new List<string>();
foreach (var question in response.Questions)
{
string prompt = question.Message;
async Task<ChatMessage?> Continuation(string answer, IUXStateDriver ux)
{
if (string.IsNullOrWhiteSpace(answer))
{
string noAnswer = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray)}(no answer){AnsiEscapes.ResetAttributes}";
await ux.WriteInfoLineAsync(noAnswer, ConsoleColor.Gray).ConfigureAwait(false);
return null;
}
string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{answer}{AnsiEscapes.ResetAttributes}";
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
return new ChatMessage(ChatRole.User, $"Q: {prompt}\nA: {answer}");
}
await writer.WriteInfoLineAsync(string.Empty);
await writer.WriteInfoLineAsync(question.Message);
string? answer;
if (question.Choices is { Count: > 0 })
{
actions.Add(new ChoiceFollowUpQuestion(
Prompt: prompt,
Choices: question.Choices,
AllowCustomText: true,
Continuation: Continuation));
answer = await writer.ReadSelectionAsync(
"Choose an option:",
question.Choices);
}
else
{
actions.Add(new TextFollowUpQuestion(
Prompt: prompt,
Continuation: Continuation));
answer = (await writer.ReadLineAsync("Response: "))?.Trim();
}
if (!string.IsNullOrWhiteSpace(answer))
{
answers.Add($"Q: {question.Message}\nA: {answer}");
}
}
return actions;
return answers.Count > 0 ? string.Join("\n\n", answers) : null;
}
private ChoiceFollowUpQuestion BuildApprovalAction(PlanningQuestion question, AgentSession session)
private async Task<string> RenderApprovalAndCollectResponseAsync(ConsoleWriter writer, PlanningQuestion question, HarnessConsoleOptions options)
{
const string ApproveOption = "Approve and switch to execute mode";
var choices = new List<string> { ApproveOption };
await writer.WriteInfoLineAsync(question.Message);
return new ChoiceFollowUpQuestion(
Prompt: question.Message,
Choices: choices,
AllowCustomText: true,
Continuation: async (selection, ux) =>
var choices = new List<string>
{
"Approve and switch to execute mode",
"Suggest changes",
};
string selection = await writer.ReadSelectionAsync("What would you like to do?", choices);
if (selection == choices[0])
{
return "Approved";
}
if (selection == choices[1])
{
string? feedback = await writer.ReadLineAsync(
"Your feedback: ",
ConsoleWriter.GetModeColor(options.PlanningModeName, options.ModeColors));
if (string.IsNullOrWhiteSpace(feedback))
{
string formatted = $"🔹 {question.Message}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{selection}{AnsiEscapes.ResetAttributes}";
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
// Treat empty feedback as no changes — re-prompt the agent with the plan.
return "No changes suggested. Please re-present the plan for approval.";
}
if (selection == ApproveOption)
{
this._modeProvider.SetMode(session, this._executionModeName);
await ux.WriteInfoLineAsync(
$"✅ Switched to {this._executionModeName} mode.",
ModeColors.Get(this._executionModeName, this._modeColors)).ConfigureAwait(false);
return new ChatMessage(ChatRole.User, "Approved");
}
return feedback;
}
// Custom freeform input — treat as suggested changes.
return new ChatMessage(ChatRole.User, selection);
});
// Custom freeform input — treat as suggested changes.
return selection;
}
/// <summary>
/// Returns <see langword="true"/> when the current mode matches the configured plan mode name.
/// A <see langword="null"/> mode (no mode provider) is also treated as planning mode.
/// </summary>
private bool IsPlanningMode(string? currentMode) =>
currentMode is null || string.Equals(currentMode, this._planModeName, StringComparison.OrdinalIgnoreCase);
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
@@ -8,14 +7,14 @@ namespace Harness.Shared.Console.Observers;
/// <summary>
/// Displays reasoning content in dark magenta from the response stream.
/// </summary>
public sealed class ReasoningDisplayObserver : ConsoleObserver
internal sealed class ReasoningDisplayObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
{
if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text))
{
await ux.WriteTextAsync(reasoning.Text, ConsoleColor.DarkMagenta);
await writer.WriteTextAsync(reasoning.Text, ConsoleColor.DarkMagenta);
}
}
}
@@ -1,18 +1,16 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
namespace Harness.Shared.Console.Observers;
/// <summary>
/// Streams agent text output directly to the console.
/// Used in normal (non-planning) mode.
/// </summary>
public sealed class TextOutputObserver : ConsoleObserver
internal sealed class TextOutputObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session)
public override async Task OnTextAsync(ConsoleWriter writer, string text)
{
await ux.WriteTextAsync(text);
await writer.WriteTextAsync(text);
}
}
@@ -1,7 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
@@ -9,103 +7,86 @@ namespace Harness.Shared.Console.Observers;
/// <summary>
/// Collects <see cref="ToolApprovalRequestContent"/> items during the response stream,
/// displays approval-needed notifications inline, and after the stream completes returns
/// one <see cref="ChoiceFollowUpQuestion"/> per pending approval request. Each question's
/// continuation produces a separate <see cref="ChatMessage"/> carrying the approval
/// response content.
/// displays approval-needed notifications inline, and prompts the user for approval
/// decisions after the stream completes.
/// </summary>
public sealed class ToolApprovalObserver : ConsoleObserver
internal sealed class ToolApprovalObserver : ConsoleObserver
{
private readonly List<ToolApprovalRequestContent> _approvalRequests = [];
private readonly IReadOnlyList<ToolCallFormatter> _formatters;
/// <summary>
/// Initializes a new instance of the <see cref="ToolApprovalObserver"/> class.
/// </summary>
/// <param name="formatters">Optional list of tool formatters. When <see langword="null"/>,
/// the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/> are used.</param>
public ToolApprovalObserver(IReadOnlyList<ToolCallFormatter>? formatters = null)
{
this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters();
}
/// <inheritdoc/>
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
{
if (content is ToolApprovalRequestContent approvalRequest)
{
this._approvalRequests.Add(approvalRequest);
string toolName = approvalRequest.ToolCall is FunctionCallContent fc
? ToolCallFormatter.Format(this._formatters, fc)
? ToolCallFormatter.Format(fc)
: approvalRequest.ToolCall?.ToString() ?? "unknown";
await ux.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
await writer.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
}
}
/// <inheritdoc/>
public override Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
IUXStateDriver ux,
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
ConsoleWriter writer,
AIAgent agent,
AgentSession session)
AgentSession session,
HarnessConsoleOptions options)
{
if (this._approvalRequests.Count == 0)
{
return Task.FromResult<IList<FollowUpAction>?>(null);
}
var actions = new List<FollowUpAction>(this._approvalRequests.Count);
foreach (var request in this._approvalRequests)
{
actions.Add(this.BuildApprovalQuestion(request));
return null;
}
var messages = await PromptForApprovalsAsync(writer, this._approvalRequests);
this._approvalRequests.Clear();
return Task.FromResult<IList<FollowUpAction>?>(actions);
return messages;
}
private ChoiceFollowUpQuestion BuildApprovalQuestion(ToolApprovalRequestContent request)
private static async Task<List<ChatMessage>?> PromptForApprovalsAsync(ConsoleWriter writer, List<ToolApprovalRequestContent> approvalRequests)
{
string toolName = request.ToolCall is FunctionCallContent fc
? ToolCallFormatter.Format(this._formatters, fc)
: request.ToolCall?.ToString() ?? "unknown";
var choices = new List<string>
if (approvalRequests.Count == 0)
{
"Approve this call",
"Always approve this tool (any arguments)",
"Always approve this tool with these arguments",
"Deny",
};
return null;
}
string prompt = $"🔐 Tool approval: {toolName}";
var responses = new List<AIContent>();
foreach (var request in approvalRequests)
{
string toolName = request.ToolCall is FunctionCallContent fc
? ToolCallFormatter.Format(fc)
: request.ToolCall?.ToString() ?? "unknown";
return new ChoiceFollowUpQuestion(
Prompt: prompt,
Choices: choices,
AllowCustomText: false,
Continuation: async (selection, ux) =>
var choices = new List<string>
{
AIContent response = selection switch
{
"Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
"Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
"Deny" => request.CreateResponse(approved: false, reason: "User denied"),
_ => request.CreateResponse(approved: true, reason: "User approved"),
};
"Approve this call",
"Always approve this tool (any arguments)",
"Always approve this tool with these arguments",
"Deny",
};
string action = selection switch
{
"Always approve this tool (any arguments)" => "✅ Always approved (any args)",
"Always approve this tool with these arguments" => "✅ Always approved (these args)",
"Deny" => "❌ Denied",
_ => "✅ Approved",
};
string selection = await writer.ReadSelectionAsync($"🔐 Tool approval: {toolName}", choices);
AIContent response = selection switch
{
"Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
"Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
"Deny" => request.CreateResponse(approved: false, reason: "User denied"),
_ => request.CreateResponse(approved: true, reason: "User approved"),
};
ConsoleColor answerColor = selection == "Deny" ? ConsoleColor.Red : ConsoleColor.Green;
string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(answerColor)}{action}{AnsiEscapes.ResetAttributes}";
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
string action = selection switch
{
"Always approve this tool (any arguments)" => "✅ Always approved (any args)",
"Always approve this tool with these arguments" => "✅ Always approved (these args)",
"Deny" => "❌ Denied",
_ => "✅ Approved",
};
await writer.WriteInfoLineAsync($" {action}", ConsoleColor.DarkGray);
return new ChatMessage(ChatRole.User, [response]);
});
responses.Add(response);
}
return [new ChatMessage(ChatRole.User, responses)];
}
}
@@ -1,7 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
@@ -10,34 +8,18 @@ namespace Harness.Shared.Console.Observers;
/// Displays tool call notifications (🔧) for <see cref="FunctionCallContent"/>
/// and <see cref="ToolCallContent"/> items in the response stream.
/// </summary>
public sealed class ToolCallDisplayObserver : ConsoleObserver
internal sealed class ToolCallDisplayObserver : ConsoleObserver
{
private readonly IReadOnlyList<ToolCallFormatter> _formatters;
/// <summary>
/// Initializes a new instance of the <see cref="ToolCallDisplayObserver"/> class.
/// </summary>
/// <param name="formatters">Optional list of tool formatters. When <see langword="null"/>,
/// the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/> are used.</param>
public ToolCallDisplayObserver(IReadOnlyList<ToolCallFormatter>? formatters = null)
{
this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters();
}
/// <inheritdoc/>
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
{
if (content is FunctionCallContent functionCall)
{
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(this._formatters, functionCall)}...", ConsoleColor.DarkYellow);
}
else if (content is WebSearchToolCallContent)
{
// Handled by OpenAIResponsesWebSearchDisplayObserver when present; skip here to avoid duplication.
await writer.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow);
}
else if (content is ToolCallContent toolCall)
{
await ux.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
await writer.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
}
}
}
@@ -0,0 +1,288 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
/// <summary>
/// Formats <see cref="FunctionCallContent"/> instances into human-readable strings
/// for console display.
/// </summary>
public static class ToolCallFormatter
{
/// <summary>
/// Returns a formatted string for the given tool call, with human-readable
/// details for known tools (todos, mode, sub-agents, web tools).
/// </summary>
/// <param name="call">The function call content to format.</param>
/// <returns>A formatted string describing the tool call.</returns>
public static string Format(FunctionCallContent call)
{
string? detail = call.Name switch
{
// Todo tools
"TodoList_Add" => FormatAddTodos(call),
"TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
"TodoList_GetRemaining" => null,
"TodoList_GetAll" => null,
// Mode tools
"AgentMode_Set" => FormatStringArg(call, "mode"),
"AgentMode_Get" => null,
// Sub-agent tools
"SubAgents_StartTask" => FormatStartSubTask(call),
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
"SubAgents_GetAllTasks" => null,
"SubAgents_ContinueTask" => FormatContinueTask(call),
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
// File memory tools
"FileMemory_SaveFile" => FormatSaveFile(call),
"FileMemory_ReadFile" => FormatStringArg(call, "fileName"),
"FileMemory_DeleteFile" => FormatStringArg(call, "fileName"),
"FileMemory_ListFiles" => null,
"FileMemory_SearchFiles" => FormatSearchFiles(call),
// External tools
"web_search" => FormatStringArg(call, "query"),
"DownloadUri" => FormatStringArg(call, "uri"),
_ => FormatFallback(call),
};
return detail is not null ? $"{call.Name} {detail}" : call.Name;
}
private static string? FormatAddTodos(FunctionCallContent call)
{
if (call.Arguments?.TryGetValue("todos", out object? todosObj) != true || todosObj is null)
{
return null;
}
var titles = new List<string>();
if (todosObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in jsonArray.EnumerateArray())
{
string? title = item.TryGetProperty("title", out JsonElement titleElement)
? titleElement.GetString()
: null;
if (!string.IsNullOrEmpty(title))
{
titles.Add(title);
}
}
}
if (titles.Count == 0)
{
return null;
}
var sb = new StringBuilder();
sb.Append($"({titles.Count} item{(titles.Count == 1 ? "" : "s")})");
foreach (string title in titles)
{
sb.Append($"\n • {title}");
}
return sb.ToString();
}
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
{
List<int>? ids = GetIntList(call, paramName);
if (ids is null || ids.Count == 0)
{
return null;
}
return $"({verb} #{string.Join(", #", ids)})";
}
private static string? FormatSingleId(FunctionCallContent call, string paramName)
{
int? id = GetInt(call, paramName);
return id.HasValue ? $"(task #{id.Value})" : null;
}
private static string? FormatStartSubTask(FunctionCallContent call)
{
string? agentName = GetString(call, "agentName");
string? description = GetString(call, "description");
if (agentName is null && description is null)
{
return null;
}
var sb = new StringBuilder("(");
if (agentName is not null)
{
sb.Append($"agent: {agentName}");
}
if (description is not null)
{
if (agentName is not null)
{
sb.Append(", ");
}
sb.Append($"\"{Truncate(description, 60)}\"");
}
sb.Append(')');
return sb.ToString();
}
private static string? FormatContinueTask(FunctionCallContent call)
{
int? taskId = GetInt(call, "taskId");
string? text = GetString(call, "text");
if (!taskId.HasValue)
{
return null;
}
return text is not null
? $"(task #{taskId.Value}, \"{Truncate(text, 50)}\")"
: $"(task #{taskId.Value})";
}
private static string? FormatSaveFile(FunctionCallContent call)
{
string? fileName = GetString(call, "fileName");
string? description = GetString(call, "description");
if (fileName is null)
{
return null;
}
return string.IsNullOrEmpty(description)
? $"({fileName})"
: $"({fileName}, with description)";
}
private static string? FormatSearchFiles(FunctionCallContent call)
{
string? pattern = GetString(call, "regexPattern");
string? filePattern = GetString(call, "filePattern");
if (pattern is null)
{
return null;
}
return string.IsNullOrEmpty(filePattern)
? $"(/{pattern}/)"
: $"(/{pattern}/ in {filePattern})";
}
private static string? FormatStringArg(FunctionCallContent call, string paramName)
{
string? value = GetString(call, paramName);
return value is not null ? $"({value})" : null;
}
private static string? FormatFallback(FunctionCallContent call)
{
if (call.Arguments is null || call.Arguments.Count == 0)
{
return null;
}
var parts = new List<string>();
foreach (var kvp in call.Arguments)
{
string? stringValue = kvp.Value switch
{
JsonElement je => je.ValueKind switch
{
JsonValueKind.String => je.GetString(),
JsonValueKind.Number => je.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null,
},
not null => kvp.Value.ToString(),
_ => null,
};
if (stringValue is not null)
{
parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}");
}
}
return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null;
}
private static string? GetString(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
return value switch
{
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
string s => s,
_ => value.ToString(),
};
}
private static int? GetInt(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
return value switch
{
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
int i => i,
_ => int.TryParse(value.ToString(), out int parsed) ? parsed : null,
};
}
private static List<int>? GetIntList(FunctionCallContent call, string paramName)
{
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
{
return null;
}
var result = new List<int>();
if (value is JsonElement je && je.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in je.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number)
{
result.Add(item.GetInt32());
}
}
}
return result.Count > 0 ? result : null;
}
private static string Truncate(string text, int maxLength)
{
return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…");
}
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.Observers;
@@ -8,7 +7,7 @@ namespace Harness.Shared.Console.Observers;
/// <summary>
/// Displays token usage statistics (📊) from the response stream.
/// </summary>
public sealed class UsageDisplayObserver : ConsoleObserver
internal sealed class UsageDisplayObserver : ConsoleObserver
{
private readonly int? _maxContextWindowTokens;
private readonly int? _maxOutputTokens;
@@ -25,21 +24,19 @@ public sealed class UsageDisplayObserver : ConsoleObserver
}
/// <inheritdoc/>
public override Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
{
if (content is UsageContent usage)
{
if (usage.Details is not null)
{
ux.SetUsageText(this.FormatUsageBreakdown(usage.Details));
await writer.WriteInfoLineAsync(this.FormatUsageBreakdown(usage.Details), ConsoleColor.DarkGray);
}
else
{
ux.SetUsageText("📊 Tokens —");
await writer.WriteInfoLineAsync("📊 Tokens —", ConsoleColor.DarkGray);
}
}
return Task.CompletedTask;
}
private string FormatUsageBreakdown(UsageDetails details)
@@ -1,34 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Harness.Shared.Console;
/// <summary>
/// Represents the type of an output entry in the console conversation.
/// </summary>
internal enum OutputEntryType
{
/// <summary>User input echo (e.g. "You: hello").</summary>
UserInput,
/// <summary>In-progress streaming text from the agent (accumulated chunk by chunk).</summary>
StreamingText,
/// <summary>Informational line (tool calls, errors, usage, approval requests, etc.).</summary>
InfoLine,
/// <summary>Stream footer (e.g. "(no text response from agent)").</summary>
StreamFooter,
/// <summary>Pending injected message notification.</summary>
PendingMessage,
}
/// <summary>
/// Represents a single output entry in the console conversation history.
/// Used internally by <see cref="HarnessConsoleUXStateDriver"/> to track
/// the in-progress streaming entry and last-entry type for spacing decisions.
/// </summary>
/// <param name="Type">The type of output entry.</param>
/// <param name="Text">The text content of the entry.</param>
/// <param name="Color">Optional foreground color for rendering.</param>
internal sealed record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null);
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Harness.Shared.Console;
/// <summary>
/// A restartable spinner that can be started and stopped multiple times.
/// </summary>
internal sealed class Spinner : IDisposable
{
private static readonly string[] s_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
private CancellationTokenSource? _cts;
private Task? _task;
public void Start()
{
if (this._task is not null)
{
return;
}
this._cts = new CancellationTokenSource();
this._task = RunAsync(this._cts.Token);
}
public async Task StopAsync()
{
if (this._cts is null || this._task is null)
{
return;
}
this._cts.Cancel();
await this._task;
this._cts.Dispose();
this._cts = null;
this._task = null;
}
public void Dispose()
{
if (this._cts is not null && this._task is not null)
{
this._cts.Cancel();
// Block briefly to let the spinner task clean up.
// This prevents the background task from writing to the console after disposal.
#pragma warning disable VSTHRD002 // Synchronous wait in Dispose is acceptable here — the spinner task completes quickly on cancellation.
this._task.Wait();
#pragma warning restore VSTHRD002
}
this._cts?.Dispose();
this._cts = null;
this._task = null;
}
private static async Task RunAsync(CancellationToken cancellationToken)
{
int i = 0;
try
{
while (!cancellationToken.IsCancellationRequested)
{
System.Console.Write(s_frames[i % s_frames.Length]);
await Task.Delay(80, cancellationToken);
System.Console.Write("\b \b");
i++;
}
}
catch (OperationCanceledException)
{
// Clear the last spinner frame left on screen.
System.Console.Write("\b \b");
}
}
}
@@ -1,101 +0,0 @@
// 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}";
}
}
@@ -1,51 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Catch-all formatter that handles any tool not matched by a more specific formatter.
/// Displays a generic summary of the tool's arguments. This formatter should always be
/// placed last in the formatter list.
/// </summary>
public sealed class FallbackToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => true;
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call)
{
if (call.Arguments is null || call.Arguments.Count == 0)
{
return null;
}
var parts = new List<string>();
foreach (var kvp in call.Arguments)
{
string? stringValue = kvp.Value switch
{
JsonElement je => je.ValueKind switch
{
JsonValueKind.String => je.GetString(),
JsonValueKind.Number => je.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null,
},
not null => kvp.Value.ToString(),
_ => null,
};
if (stringValue is not null)
{
parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}");
}
}
return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null;
}
}
@@ -1,61 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>FileMemory_*</c> tool calls, showing file names and search patterns
/// with tree-view corners for save operations.
/// </summary>
public sealed class FileMemoryToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("FileMemory_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"FileMemory_SaveFile" => FormatSaveFile(call),
"FileMemory_ReadFile" => FormatStringArg(call, "fileName"),
"FileMemory_DeleteFile" => FormatStringArg(call, "fileName"),
"FileMemory_SearchFiles" => FormatSearchFiles(call),
_ => null,
};
private static string? FormatSaveFile(FunctionCallContent call)
{
string? fileName = GetStringArgumentValue(call, "fileName");
string? description = GetStringArgumentValue(call, "description");
if (fileName is null)
{
return null;
}
return string.IsNullOrEmpty(description)
? $"\n └─ {fileName}"
: $"\n └─ {fileName} (with description)";
}
private static string? FormatSearchFiles(FunctionCallContent call)
{
string? pattern = GetStringArgumentValue(call, "regexPattern");
string? filePattern = GetStringArgumentValue(call, "filePattern");
if (pattern is null)
{
return null;
}
return string.IsNullOrEmpty(filePattern)
? $"(/{pattern}/)"
: $"(/{pattern}/ in {filePattern})";
}
private static string? FormatStringArg(FunctionCallContent call, string paramName)
{
string? value = GetStringArgumentValue(call, paramName);
return value is not null ? $"({value})" : null;
}
}
@@ -1,27 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>AgentMode_*</c> tool calls, showing the target mode for Set operations.
/// </summary>
public sealed class ModeToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("AgentMode_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"AgentMode_Set" => FormatStringArg(call, "mode"),
_ => null,
};
private static string? FormatStringArg(FunctionCallContent call, string paramName)
{
string? value = GetStringArgumentValue(call, paramName);
return value is not null ? $"({value})" : null;
}
}
@@ -1,128 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>TodoList_*</c> tool calls with tree-view output for added items
/// and structured output for complete/remove operations.
/// </summary>
public sealed class TodoToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("TodoList_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"TodoList_Add" => FormatAddTodos(call),
"TodoList_Complete" => 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();
}
}
@@ -1,135 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Base class for tool call formatters that produce human-readable display strings
/// for <see cref="FunctionCallContent"/> items shown in the console.
/// </summary>
public abstract class ToolCallFormatter
{
/// <summary>
/// Returns <see langword="true"/> if this formatter can handle the given function call.
/// </summary>
/// <param name="call">The function call content to check.</param>
/// <returns><see langword="true"/> if this formatter should be used; otherwise <see langword="false"/>.</returns>
public abstract bool CanFormat(FunctionCallContent call);
/// <summary>
/// Returns the detail portion of the formatted output for the given tool call,
/// or <see langword="null"/> if only the tool name should be displayed.
/// </summary>
/// <param name="call">The function call content to format.</param>
/// <returns>A detail string to append after the tool name, or <see langword="null"/>.</returns>
public abstract string? FormatDetail(FunctionCallContent call);
/// <summary>
/// Formats a tool call using the first matching formatter from the provided list.
/// Returns <c>"{toolName} {detail}"</c> when a formatter produces detail,
/// or just <c>"{toolName}"</c> otherwise.
/// </summary>
internal static string Format(IReadOnlyList<ToolCallFormatter> formatters, FunctionCallContent call)
{
foreach (var formatter in formatters)
{
if (formatter.CanFormat(call))
{
string? detail = formatter.FormatDetail(call);
return detail is not null ? $"{call.Name} {detail}" : call.Name;
}
}
return call.Name;
}
/// <summary>
/// Creates the default list of tool call formatters. The <see cref="FallbackToolFormatter"/>
/// is always last. Users can call this method and combine the result with their own formatters.
/// </summary>
/// <returns>A list of all built-in tool call formatters.</returns>
public static List<ToolCallFormatter> BuildDefaultToolFormatters()
{
return
[
new TodoToolFormatter(),
new ModeToolFormatter(),
new 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), "…");
}
}
@@ -1,22 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>web_search</c> tool calls, showing the search query.
/// </summary>
public sealed class WebSearchToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) =>
call.Name is "web_search";
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call)
{
string? value = GetStringArgumentValue(call, "query");
return value is not null ? $"({value})" : null;
}
}
@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenAI" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
</Project>
@@ -1,61 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
using Harness.Shared.Console.Observers;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
namespace Harness.Shared.Console.OpenAI;
/// <summary>
/// Detects and displays error/incomplete status from OpenAI Responses API streaming updates.
/// Handles <see cref="StreamingResponseFailedUpdate"/> and <see cref="StreamingResponseIncompleteUpdate"/>
/// which are not surfaced as <see cref="ErrorContent"/> by the chat client.
/// </summary>
/// <remarks>
/// Note: <see cref="StreamingResponseErrorUpdate"/> is already handled by the SDK — it produces
/// an <see cref="ErrorContent"/> which is displayed by <see cref="ErrorDisplayObserver"/>.
/// This observer covers the cases where the SDK does not produce <see cref="ErrorContent"/>.
/// </remarks>
public sealed class OpenAIResponsesErrorObserver : ConsoleObserver
{
/// <inheritdoc/>
public override async Task OnResponseUpdateAsync(IUXStateDriver ux, AgentResponseUpdate update, AIAgent agent, AgentSession session)
{
// AgentResponseUpdate.RawRepresentation is the ChatResponseUpdate,
// whose RawRepresentation is the underlying StreamingResponseUpdate.
object? rawUpdate = (update.RawRepresentation as ChatResponseUpdate)?.RawRepresentation
?? update.RawRepresentation;
switch (rawUpdate)
{
case StreamingResponseFailedUpdate failedUpdate:
// Only display if the response has error details populated.
// When error is null, a follow-up StreamingResponseErrorUpdate typically
// carries the real error — the SDK surfaces that as ErrorContent,
// which is displayed by ErrorDisplayObserver.
if (failedUpdate.Response?.Error is { } error)
{
string errorMessage = error.Message ?? "Unknown error";
string? errorCode = error.Code.ToString();
string errorText = $"❌ Response failed: {errorMessage}";
if (!string.IsNullOrEmpty(errorCode))
{
errorText += $" (code: {errorCode})";
}
await ux.WriteInfoLineAsync(errorText, ConsoleColor.Red);
}
break;
case StreamingResponseIncompleteUpdate incompleteUpdate:
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
break;
}
}
}
@@ -1,205 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
using System.Text;
using Harness.Shared.Console.Observers;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
namespace Harness.Shared.Console.OpenAI;
/// <summary>
/// Displays web search activity in the scroll area. Shows search queries,
/// page opens, and find-in-page actions as they stream in from the API.
/// </summary>
public sealed class OpenAIResponsesWebSearchDisplayObserver : ConsoleObserver
{
private const int MaxQueryDisplayLength = 120;
/// <inheritdoc/>
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
{
if (content is WebSearchToolResultContent resultContent
&& resultContent.RawRepresentation is WebSearchCallResponseItem wscri)
{
await WriteActionAsync(ux, wscri, resultContent.Outputs);
}
}
private static async Task WriteActionAsync(IUXStateDriver ux, WebSearchCallResponseItem wscri, IList<AIContent>? outputs)
{
WebSearchAction? action = wscri.Action;
if (action is null)
{
await ux.WriteInfoLineAsync("🌐 Web Search Tool (no action details)", ConsoleColor.DarkCyan);
return;
}
switch (action)
{
case WebSearchFindInPageAction findInPage:
await WriteFindInPageAsync(ux, findInPage);
break;
case WebSearchOpenPageAction openPage:
await WriteOpenPageAsync(ux, openPage);
break;
case WebSearchSearchAction search:
await WriteSearchAsync(ux, search, outputs);
break;
default:
await ux.WriteInfoLineAsync("🌐 Web Search Tool (unknown action)", ConsoleColor.DarkCyan);
break;
}
}
private static async Task WriteSearchAsync(IUXStateDriver ux, WebSearchSearchAction search, IList<AIContent>? outputs)
{
// Read queries directly from the typed action.
IList<string> queries = search.Queries;
if (queries.Count == 0)
{
await ux.WriteInfoLineAsync("🌐 Web Search Tool: search", ConsoleColor.DarkCyan);
return;
}
var sb = new StringBuilder();
sb.Append("🌐 Web Search Tool: search");
// Show the search queries.
bool hasResults = outputs is { Count: > 0 };
for (int i = 0; i < queries.Count; i++)
{
string connector = (i < queries.Count - 1 || hasResults) ? "├─" : "└─";
string query = Truncate(queries[i], MaxQueryDisplayLength);
sb.Append($"\n {connector} \"{query}\"");
}
// Show search result sources (URLs + titles) when available.
// Sources come from M.E.AI's Outputs when IncludedResponseProperty.WebSearchCallActionSources is set,
// or directly from the SDK's WebSearchSearchAction.Sources.
if (hasResults)
{
sb.Append("\n │");
for (int i = 0; i < outputs!.Count; i++)
{
string connector = i < outputs.Count - 1 ? "├─" : "└─";
string line = FormatOutput(outputs[i]);
sb.Append($"\n {connector} {line}");
}
}
else if (search.Sources is { Count: > 0 } sources)
{
sb.Append("\n │");
for (int i = 0; i < sources.Count; i++)
{
string connector = i < sources.Count - 1 ? "├─" : "└─";
string line = FormatSource(sources[i]);
sb.Append($"\n {connector} {line}");
}
}
await ux.WriteInfoLineAsync(sb.ToString(), ConsoleColor.DarkCyan);
}
private static async Task WriteOpenPageAsync(IUXStateDriver ux, WebSearchOpenPageAction openPage)
{
string url = openPage.Uri?.AbsoluteUri ?? "(unknown)";
await ux.WriteInfoLineAsync(
$"🌐 Web Search Tool: open page\n └─ {url}",
ConsoleColor.DarkCyan);
}
private static async Task WriteFindInPageAsync(IUXStateDriver ux, WebSearchFindInPageAction findInPage)
{
string url = findInPage.Uri?.AbsoluteUri ?? "(unknown)";
string pattern = findInPage.Pattern ?? "(unknown)";
await ux.WriteInfoLineAsync(
$"🌐 Web Search Tool: find in page\n ├─ \"{Truncate(pattern, MaxQueryDisplayLength)}\"\n └─ {url}",
ConsoleColor.DarkCyan);
}
/// <summary>
/// Formats a single search result source from the SDK's <see cref="WebSearchActionSource"/> for display.
/// </summary>
private static string FormatSource(WebSearchActionSource source)
{
if (source is WebSearchActionUriSource uriSource)
{
string url = uriSource.Uri?.AbsoluteUri ?? "(unknown)";
// WebSearchActionUriSource doesn't expose a title property,
// but the API may include one in the raw response JSON.
string? title = GetTitleFromRawRepresentation(uriSource);
return title is not null
? $"{Truncate(title, MaxQueryDisplayLength)} — {url}"
: url;
}
return source.ToString() ?? "(unknown source)";
}
/// <summary>
/// Formats a single search result output from M.E.AI's <see cref="AIContent"/> for display.
/// </summary>
private static string FormatOutput(AIContent output)
{
if (output is UriContent uriContent)
{
string url = uriContent.Uri?.AbsoluteUri ?? "(unknown)";
// Try to extract a title from the raw JSON of the source.
// The SDK's WebSearchActionUriSource doesn't expose a title property,
// but the API may include one in the raw response.
string? title = GetTitleFromRawRepresentation(uriContent.RawRepresentation)
?? (uriContent.AdditionalProperties?.TryGetValue("title", out var t) is true ? t?.ToString() : null);
return title is not null
? $"{Truncate(title, MaxQueryDisplayLength)} — {url}"
: url;
}
return output.ToString() ?? "(unknown output)";
}
/// <summary>
/// Attempts to extract a "title" field from a raw representation object by serializing it to JSON.
/// The SDK's <see cref="WebSearchActionUriSource"/> doesn't expose a title property,
/// but the API may include one in the raw JSON — this is forward-compatible for when
/// the SDK adds title support.
/// </summary>
private static string? GetTitleFromRawRepresentation(object? rawRepresentation)
{
if (rawRepresentation is null)
{
return null;
}
try
{
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(rawRepresentation);
using var doc = System.Text.Json.JsonDocument.Parse(data);
if (doc.RootElement.TryGetProperty("title", out var titleEl)
&& titleEl.ValueKind == System.Text.Json.JsonValueKind.String)
{
return titleEl.GetString();
}
}
catch
{
// Serialization may not be supported for this object type.
}
return null;
}
private static string Truncate(string text, int maxLength)
=> text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength - 1), "…");
}
@@ -1,23 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Extensions.AI;
namespace SampleApp;
/// <summary>
/// Formats <c>DownloadUri</c> tool calls, showing the target URI.
/// </summary>
public sealed class DownloadUriToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) =>
call.Name is "DownloadUri";
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call)
{
string? value = GetStringArgumentValue(call, "uri");
return value is not null ? $"({value})" : null;
}
}
@@ -13,10 +13,8 @@
</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.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
<ProjectReference Include="..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -1,120 +1,191 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a HarnessAgent for interactive research tasks.
// The HarnessAgent comes pre-configured with TodoProvider, AgentModeProvider, FileMemoryProvider,
// ToolApproval, WebSearch, and OpenTelemetry — so this sample only needs custom instructions
// and a WebBrowsingTool.
// This sample demonstrates how to use a ChatClientAgent with the Harness AIContextProviders
// (TodoProvider and AgentModeProvider) for interactive research tasks with web search
// capabilities powered by Azure AI Foundry.
// The agent plans research tasks, creates a todo list, gets user approval,
// and then executes each step — all within an interactive conversation loop.
//
// Special commands:
// /todos — Display the current todo list without invoking the agent.
// /mode — Get or set the current agent mode.
// /exit — End the session.
// exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using Harness.Shared.Console;
using Harness.Shared.Console.OpenAI;
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
using SampleApp;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
const string TracingSourceName = "Harness.Research";
// Set up OpenTelemetry tracing that writes spans to a text file.
// This captures all agent activity (tool calls, model invocations, compaction, etc.)
// as well as HTTP requests made by the underlying HttpClient transport.
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
// Create a HarnessAgent with the Harness providers (TodoProvider and AgentModeProvider)
// Create a ChatClientAgent with the Harness providers (TodoProvider and AgentModeProvider)
// and research-focused instructions including the mandatory planning workflow.
var instructions =
"""
## Research Assistant Instructions
You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing.
Use your knowledge to form good search queries and hypotheses, but always verify claims with the tools available to you rather than relying on memory alone.
### Research quality
## Mandatory planning workflow
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.
When sources disagree, note the discrepancy and explain which source you consider more reliable and why.
If a web page fails to load or a search returns irrelevant results, try alternative search queries or sources before moving on.
Track your sources you will need them when presenting results.
### Presenting results
**Presenting results**
When presenting your final findings:
- Use Markdown formatting for clarity.
- Use clear sections with headings for each major topic or sub-question.
- Cite your sources inline (e.g., "According to [source name](URL), ...").
- End with a brief summary of key takeaways.
- In addition to returning the results to the user, save the final research report to file memory so it survives compaction and can be referenced later.
- 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 the agent using AsHarnessAgent, which pre-configures function invocation,
// per-service-call chat history persistence, in-loop compaction, TodoProvider, AgentModeProvider,
// FileMemoryProvider, ToolApproval, WebSearch, AgentSkillsProvider, and OpenTelemetry.
// Only custom instructions, a WebBrowsingTool, and FileAccess opt-out are needed.
// Create a compaction strategy based on the model's context window.
// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens.
// Defaults: tool result eviction at 50% of input budget, truncation at 80%.
var compactionStrategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens);
AIAgent agent =
// Create an OpenAIClient that communicates with the Foundry responses service.
new AIProjectClient(
new Uri(endpoint),
new OpenAIClient(
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
new DefaultAzureCredential(),
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }) // Enable retries to improve resiliency.
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "ResearchAgent",
Description = "A research assistant that plans and executes research tasks.",
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
OpenTelemetrySourceName = TracingSourceName, // Use our custom source name so spans are captured by the TracerProvider above.
FileMemoryStore = new FileSystemAgentFileStore( // Configure the file memory provider to store files in a local folder called "agent-files".
Path.Combine(AppContext.BaseDirectory, "agent-files")),
ChatOptions = new ChatOptions
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions()
{
Instructions = instructions,
Tools =
Endpoint = new Uri(endpoint),
RetryPolicy = new ClientRetryPolicy(3) // Enable retries to improve resiliency.
})
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves.
// Build a ChatClient Pipeline
.AsBuilder()
.UseFunctionInvocation() // We are building our own stack from scratch so we need to include Function Invocation ourselves.
.UsePerServiceCallChatHistoryPersistence() // Save chat history updates to the session after each service call, rather than only at the end of the run.
.UseAIContextProviders(new CompactionProvider(compactionStrategy)) // Add Compaction before each service call to responses so that long function invocation loops don't overflow the context.
// Build our agent on top of the ChatClient Pipeline
.BuildAIAgent(
new ChatClientAgentOptions
{
Name = "ResearchAgent",
Description = "A research assistant that plans and executes research tasks.",
UseProvidedChatClientAsIs = true, // Since we built our own stack from scratch we need to tell the agent not to also add defaults like Function Invocation.
RequirePerServiceCallChatHistoryPersistence = true, // Since we are added the per service call persistence ChatClient, we need to tell the agent to not also store chat history at the end of the run.
ChatHistoryProvider = new InMemoryChatHistoryProvider( // Store chat history in memory in the session object. Will persist if the session is persisted.
new InMemoryChatHistoryProviderOptions
{
ChatReducer = compactionStrategy.AsChatReducer(), // Run compaction on the InMemory chat history when it gets too large.
}),
AIContextProviders =
[
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
new WebBrowsingToolOptions { AllowPublicNetworks = true }),
new TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session.
new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session.
new FileMemoryProvider( // Add an AIContextProvider that can store memories in files under a session specific working folder.
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
],
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
Reasoning = new() { Effort = ReasoningEffort.Medium },
},
});
ChatOptions = new ChatOptions
{
Instructions = instructions,
Tools =
[
ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service.
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
new WebBrowsingToolOptions { AllowPublicNetworks = true }),
],
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
Reasoning = new() { Effort = ReasoningEffort.Medium },
},
})
.AsBuilder()
.UseToolApproval() // Add the ability to auto approve tools once a user has said they don't want to be asked again. Approval rules are tied to the session.
.Build();
// Run the interactive console session using the shared HarnessConsole helper.
await HarnessConsole.RunAgentAsync(
agent,
title: "Research Assistant",
userPrompt: "Enter a research topic to get started.",
new HarnessConsoleOptions
{
Observers = [
new OpenAIResponsesWebSearchDisplayObserver(),
new OpenAIResponsesErrorObserver(),
.. HarnessConsoleOptions.BuildObserversWithPlanning(
agent,
planModeName: "plan",
executionModeName: "execute",
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens,
toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()])],
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
MaxContextWindowTokens = MaxContextWindowTokens,
MaxOutputTokens = MaxOutputTokens,
EnablePlanningUx = true,
PlanningModeName = "plan",
ExecutionModeName = "execute"
});
@@ -1,11 +1,10 @@
# What this sample demonstrates
This sample demonstrates how to use a `HarnessAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Azure AI Foundry. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and context-window compaction.
This sample demonstrates how to use a `ChatClientAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Azure AI Foundry.
Key features showcased:
- **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction
- **ToolApproval** — the agent is wrapped with `UseToolApproval()` to allow auto-approving tools once confirmed
- **ChatClientAgent** — configured directly with Harness providers for planning and task management
- **Web Search** — the agent can search the web for current information via `ResponseTool.CreateWebSearchTool()`
- **TodoProvider** — the agent creates and manages a todo list to track research questions
- **AgentModeProvider** — the agent switches between "plan" mode (breaking down the topic) and "execute" mode (answering each research question)
@@ -1,121 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use the BackgroundAgentsProvider to delegate work to background agents.
// A parent agent is given a list of stock tickers and instructed to find the closing price
// for each ticker on December 31, 2025. It delegates the web searches to a background agent.
// The HarnessAgent provides built-in WebSearch (HostedWebSearchTool) so no manual web search
// tool configuration is needed on the background agent.
//
// Special commands:
// /exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using Harness.Shared.Console;
using Harness.Shared.Console.OpenAI;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("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,
BackgroundAgents = [webSearchAgent],
ChatOptions = new ChatOptions
{
Instructions = parentInstructions,
MaxOutputTokens = 16_000,
},
});
// Run the interactive console session.
await HarnessConsole.RunAgentAsync(
parentAgent,
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):",
options: new HarnessConsoleOptions
{
Observers = [new OpenAIResponsesErrorObserver(), .. HarnessConsoleOptions.BuildDefaultObservers()],
});

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