mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5769c0e8ab | ||
|
|
bf4c0be52b | ||
|
|
a3362e2896 | ||
|
|
28a86d6d73 | ||
|
|
03f7dc86d3 |
@@ -157,8 +157,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
|
||||
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
|
||||
OLLAMA_MODEL: qwen2.5:1.5b
|
||||
OLLAMA_EMBEDDING_MODEL: nomic-embed-text
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
@@ -173,43 +171,6 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Install Ollama
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
- name: Start Ollama and pull models
|
||||
run: |
|
||||
# Stop any Ollama instance auto-started by the install script
|
||||
pkill ollama || true
|
||||
sleep 2
|
||||
ollama serve &
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# Pull models with retry for transient 429 rate limits
|
||||
for model in qwen2.5:1.5b nomic-embed-text; do
|
||||
pulled=false
|
||||
for attempt in 1 2 3; do
|
||||
if ollama pull "$model"; then
|
||||
pulled=true
|
||||
break
|
||||
fi
|
||||
echo "Retry $attempt for $model (waiting 15s)..."
|
||||
sleep 15
|
||||
done
|
||||
if [ "$pulled" != "true" ]; then
|
||||
echo "ERROR: Failed to pull $model after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
working-directory: .
|
||||
- name: Start local MCP server
|
||||
id: local-mcp
|
||||
uses: ./.github/actions/setup-local-mcp-server
|
||||
@@ -310,7 +271,7 @@ jobs:
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
-x
|
||||
--timeout=480 --session-timeout=900 --timeout_method thread
|
||||
--timeout=360 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
@@ -375,53 +336,6 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Foundry Hosting integration tests
|
||||
python-tests-foundry-hosting:
|
||||
name: Python Integration Tests - Foundry Hosting
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest (Foundry Hosting integration)
|
||||
timeout-minutes: 15
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/foundry_hosting/tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
python-tests-cosmos:
|
||||
name: Python Integration Tests - Cosmos
|
||||
@@ -474,9 +388,9 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||
python-integration-test-report:
|
||||
name: Integration Test Report
|
||||
# Flaky test trend report (aggregates per-job JUnit XML results)
|
||||
python-flaky-test-report:
|
||||
name: Flaky Test Report
|
||||
if: >
|
||||
always() &&
|
||||
(contains(join(needs.*.result, ','), 'success') ||
|
||||
@@ -488,7 +402,6 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -510,36 +423,36 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
- name: Restore flaky report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-integration-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
integration-report-history-integration-
|
||||
flaky-report-history-integration-
|
||||
- name: Generate trend report
|
||||
run: >
|
||||
uv run python scripts/integration_test_report/aggregate.py
|
||||
uv run python scripts/flaky_report/aggregate.py
|
||||
../test-results/
|
||||
integration-report-history.json
|
||||
integration-test-report.md
|
||||
flaky-report-history.json
|
||||
flaky-test-report.md
|
||||
- name: Post to Job Summary
|
||||
if: always()
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save flaky report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-integration-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: integration-test-report
|
||||
name: flaky-test-report
|
||||
path: |
|
||||
python/integration-test-report.md
|
||||
python/integration-report-history.json
|
||||
python/flaky-test-report.md
|
||||
python/flaky-report-history.json
|
||||
|
||||
python-integration-tests-check:
|
||||
if: always()
|
||||
@@ -552,7 +465,6 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos
|
||||
]
|
||||
steps:
|
||||
|
||||
@@ -38,7 +38,6 @@ jobs:
|
||||
miscChanged: ${{ steps.filter.outputs.misc }}
|
||||
functionsChanged: ${{ steps.filter.outputs.functions }}
|
||||
foundryChanged: ${{ steps.filter.outputs.foundry }}
|
||||
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
|
||||
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -81,8 +80,6 @@ jobs:
|
||||
- 'python/packages/foundry/**'
|
||||
- 'python/samples/**/providers/foundry/**'
|
||||
- 'python/samples/02-agents/embeddings/foundry_embeddings.py'
|
||||
foundry_hosting:
|
||||
- 'python/packages/foundry_hosting/**'
|
||||
cosmos:
|
||||
- 'python/packages/azure-cosmos/**'
|
||||
# run only if 'python' files were changed
|
||||
@@ -278,8 +275,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
|
||||
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
|
||||
OLLAMA_MODEL: qwen2.5:1.5b
|
||||
OLLAMA_EMBEDDING_MODEL: nomic-embed-text
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
@@ -291,43 +286,6 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Install Ollama
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
- name: Start Ollama and pull models
|
||||
run: |
|
||||
# Stop any Ollama instance auto-started by the install script
|
||||
pkill ollama || true
|
||||
sleep 2
|
||||
ollama serve &
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# Pull models with retry for transient 429 rate limits
|
||||
for model in qwen2.5:1.5b nomic-embed-text; do
|
||||
pulled=false
|
||||
for attempt in 1 2 3; do
|
||||
if ollama pull "$model"; then
|
||||
pulled=true
|
||||
break
|
||||
fi
|
||||
echo "Retry $attempt for $model (waiting 15s)..."
|
||||
sleep 15
|
||||
done
|
||||
if [ "$pulled" != "true" ]; then
|
||||
echo "ERROR: Failed to pull $model after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
working-directory: .
|
||||
- name: Start local MCP server
|
||||
id: local-mcp
|
||||
uses: ./.github/actions/setup-local-mcp-server
|
||||
@@ -442,7 +400,7 @@ jobs:
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
-x
|
||||
--timeout=480 --session-timeout=900 --timeout_method thread
|
||||
--timeout=360 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
@@ -530,67 +488,6 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Foundry Hosting integration tests
|
||||
python-tests-foundry-hosting:
|
||||
name: Python Tests - Foundry Hosting Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.foundryHostingChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest (Foundry Hosting integration)
|
||||
timeout-minutes: 15
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/foundry_hosting/tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Foundry Hosting integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# TODO: Add python-tests-lab
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
@@ -658,9 +555,9 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||
python-integration-test-report:
|
||||
name: Integration Test Report
|
||||
# Flaky test trend report (aggregates per-job JUnit XML results)
|
||||
python-flaky-test-report:
|
||||
name: Flaky Test Report
|
||||
if: >
|
||||
always() &&
|
||||
(contains(join(needs.*.result, ','), 'success') ||
|
||||
@@ -672,7 +569,6 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -691,36 +587,36 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
- name: Restore flaky report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-merge-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
integration-report-history-merge-
|
||||
flaky-report-history-merge-
|
||||
- name: Generate trend report
|
||||
run: >
|
||||
uv run python scripts/integration_test_report/aggregate.py
|
||||
uv run python scripts/flaky_report/aggregate.py
|
||||
../test-results/
|
||||
integration-report-history.json
|
||||
integration-test-report.md
|
||||
flaky-report-history.json
|
||||
flaky-test-report.md
|
||||
- name: Post to Job Summary
|
||||
if: always()
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save flaky report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-merge-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: integration-test-report
|
||||
name: flaky-test-report
|
||||
path: |
|
||||
python/integration-test-report.md
|
||||
python/integration-report-history.json
|
||||
python/flaky-test-report.md
|
||||
python/flaky-report-history.json
|
||||
|
||||
python-integration-tests-check:
|
||||
if: always()
|
||||
@@ -733,7 +629,6 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
steps:
|
||||
|
||||
@@ -242,7 +242,3 @@ python/dotnet-ref
|
||||
# Generated filtered solution files (created by eng/scripts/New-FilteredSolution.ps1)
|
||||
dotnet/filtered-*.slnx
|
||||
**/*.lscache
|
||||
|
||||
# Local tool state
|
||||
.omc/
|
||||
.omx/
|
||||
|
||||
@@ -6,12 +6,8 @@
|
||||
[](https://learn.microsoft.com/en-us/agent-framework/)
|
||||
[](https://pypi.org/project/agent-framework/)
|
||||
[](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
|
||||
[](https://github.com/microsoft/agent-framework/stargazers)
|
||||
|
||||
|
||||
Microsoft Agent Framework (MAF) is an open, multi-language framework for building **production-grade AI agents and multi-agent workflows** in **.NET and Python**.
|
||||
|
||||
Microsoft Agent Framework is built for teams taking agents from prototype to production. It provides a consistent foundation for building, orchestrating, and operating agent systems across Python and .NET, while keeping architecture choices open as requirements evolve, and supports a broad ecosystem including Microsoft Foundry, Azure OpenAI, OpenAI, and the GitHub Copilot SDK, with samples and hosting patterns for both local development and cloud deployment.
|
||||
Welcome to Microsoft's comprehensive multi-language framework for building, orchestrating, and deploying AI agents with support for both .NET and Python implementations. This framework provides everything from simple chat agents to complex multi-agent workflows with graph-based orchestration.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=AAgdMhftj8w" title="Watch the full Agent Framework introduction (30 min)">
|
||||
@@ -25,54 +21,10 @@ Microsoft Agent Framework is built for teams taking agents from prototype to pro
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Is this the right framework for you?
|
||||
## 📋 Getting Started
|
||||
|
||||
MAF is a strong fit if you:
|
||||
- are building agents and workflows you expect to run in production,
|
||||
- need orchestration beyond a single prompt or stateless chat loop,
|
||||
- want graph-based patterns such as sequential, concurrent, handoff, and group collaboration,
|
||||
- care about durability, restartability, observability, governance, or human-in-the-loop control,
|
||||
- need provider flexibility so your architecture can evolve without major rewrites.
|
||||
### 📦 Installation
|
||||
|
||||
## Key Features
|
||||
Explore new MAF capabilities and real implementation patterns on the [official blog](https://devblogs.microsoft.com/agent-framework/).
|
||||
|
||||
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
|
||||
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
|
||||
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
|
||||
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
|
||||
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
|
||||
- [Python middleware](./python/samples/02-agents/middleware/) | [.NET middleware](./dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/)
|
||||
- **Orchestration Patterns & Workflows**: Build multi-agent systems with graph-based workflows supporting sequential, concurrent, handoff, and group collaboration patterns; includes checkpointing, streaming, human-in-the-loop, and time-travel
|
||||
- [Python workflows](./python/samples/03-workflows/) | [.NET workflows](./dotnet/samples/03-workflows/)
|
||||
- **Foundry Hosted Agents (new)**: Deploy and host your agents to Foundry-hosted infrastructure with just 2 additional lines of code
|
||||
- [Python samples](./python/samples/04-hosting/foundry-hosted-agents/) | [.NET samples](./dotnet/samples/04-hosting/FoundryHostedAgents/)
|
||||
- **Observability**: Built-in OpenTelemetry integration for distributed tracing, monitoring, and debugging
|
||||
- [Python observability](./python/samples/02-agents/observability/) | [.NET telemetry](./dotnet/samples/02-agents/AgentOpenTelemetry/)
|
||||
- **Declarative Agents**: Define agents using YAML for faster setup and versioning
|
||||
- [Declarative agent samples](./declarative-agents/)
|
||||
- **Agent Skills**: Build domain-specific knowledge bases from multiple sources—files, inline code, class libraries—for agents to discover and use
|
||||
- [Skills design](./docs/decisions/0021-agent-skills-design.md)
|
||||
- **AF Labs**: Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives
|
||||
- [Labs directory](./python/packages/lab/)
|
||||
- **DevUI**: Interactive developer UI for agent development, testing, and debugging workflows
|
||||
- [See the DevUI in action](https://www.youtube.com/watch?v=mOAaGY4WPvc)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Getting Started](#getting-started)
|
||||
- [Installation](#installation)
|
||||
- [Learning Resources](#learning-resources)
|
||||
- [Quickstart](#quickstart)
|
||||
- [Basic Agent - Python](#basic-agent---python)
|
||||
- [Basic Agent - .NET](#basic-agent---net)
|
||||
- [More Examples & Samples](#more-examples--samples)
|
||||
- [Community & Feedback](#community--feedback)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Contributor Resources](#contributor-resources)
|
||||
|
||||
## Getting Started
|
||||
### Installation
|
||||
Python
|
||||
|
||||
```bash
|
||||
@@ -85,13 +37,9 @@ pip install agent-framework
|
||||
|
||||
```bash
|
||||
dotnet add package Microsoft.Agents.AI
|
||||
# For Foundry integration (used in the .NET quickstart below):
|
||||
dotnet add package Microsoft.Agents.AI.Foundry
|
||||
dotnet add package Azure.AI.Projects
|
||||
dotnet add package Azure.Identity
|
||||
```
|
||||
|
||||
### Learning Resources
|
||||
### 📚 Documentation
|
||||
|
||||
- **[Overview](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)** - High level overview of the framework
|
||||
- **[Quick Start](https://learn.microsoft.com/agent-framework/tutorials/quick-start)** - Get started with a simple agent
|
||||
@@ -100,9 +48,44 @@ dotnet add package Azure.Identity
|
||||
- **[Migration from Semantic Kernel](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel)** - Guide to migrate from Semantic Kernel
|
||||
- **[Migration from AutoGen](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-autogen)** - Guide to migrate from AutoGen
|
||||
|
||||
### Quickstart
|
||||
Still have questions? Join our [weekly office hours](./COMMUNITY.md#public-community-office-hours) or ask questions in our [Discord channel](https://discord.gg/b5zjErwbQM) to get help from the team and other users.
|
||||
|
||||
#### Basic Agent - Python
|
||||
### ✨ **Highlights**
|
||||
|
||||
- **Graph-based Workflows**: Connect agents and deterministic functions using data flows with streaming, checkpointing, human-in-the-loop, and time-travel capabilities
|
||||
- [Python workflows](./python/samples/03-workflows/) | [.NET workflows](./dotnet/samples/03-workflows/)
|
||||
- **AF Labs**: Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives
|
||||
- [Labs directory](./python/packages/lab/)
|
||||
- **DevUI**: Interactive developer UI for agent development, testing, and debugging workflows
|
||||
- [DevUI package](./python/packages/devui/)
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=mOAaGY4WPvc">
|
||||
<img src="https://img.youtube.com/vi/mOAaGY4WPvc/hqdefault.jpg" alt="See the DevUI in action" width="480">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=mOAaGY4WPvc">
|
||||
See the DevUI in action (1 min)
|
||||
</a>
|
||||
</p>
|
||||
|
||||
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
|
||||
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
|
||||
- **Observability**: Built-in OpenTelemetry integration for distributed tracing, monitoring, and debugging
|
||||
- [Python observability](./python/samples/02-agents/observability/) | [.NET telemetry](./dotnet/samples/02-agents/AgentOpenTelemetry/)
|
||||
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
|
||||
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
|
||||
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
|
||||
- [Python middleware](./python/samples/02-agents/middleware/) | [.NET middleware](./dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/)
|
||||
|
||||
### 💬 **We want your feedback!**
|
||||
|
||||
- For bugs, please file a [GitHub issue](https://github.com/microsoft/agent-framework/issues).
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Basic Agent - Python
|
||||
|
||||
Create a simple Azure Responses Agent that writes a haiku about the Microsoft Agent Framework
|
||||
|
||||
@@ -126,7 +109,7 @@ async def main():
|
||||
# project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
# model=os.environ["FOUNDRY_MODEL_DEPLOYMENT_NAME"],
|
||||
),
|
||||
name="HaikuAgent",
|
||||
name="HaikuBot",
|
||||
instructions="You are an upbeat assistant that writes beautifully.",
|
||||
)
|
||||
|
||||
@@ -136,24 +119,40 @@ if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
#### Basic Agent - .NET
|
||||
Create a simple Agent, using Microsoft Foundry that writes a haiku about the Microsoft Agent Framework
|
||||
### Basic Agent - .NET
|
||||
Create a simple Agent, using Microsoft Foundry with token-based auth, that writes a haiku about the Microsoft Agent Framework
|
||||
|
||||
```c#
|
||||
// This sample shows how to create and run a basic agent with AIProjectClient.AsAIAgent(...).
|
||||
|
||||
// dotnet add package Microsoft.Agents.AI.Foundry
|
||||
// Use `az login` to authenticate with Azure CLI
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using System;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
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";
|
||||
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-mini";
|
||||
|
||||
AIAgent agent =
|
||||
new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.AsAIAgent(model: deploymentName, instructions: "You are an upbeat assistant that writes beautifully.", name: "HaikuAgent");
|
||||
var agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.AsAIAgent(model: deploymentName, name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
|
||||
```
|
||||
|
||||
Create a simple Agent, using OpenAI Responses, that writes a haiku about the Microsoft Agent Framework
|
||||
|
||||
```c#
|
||||
// dotnet add package Microsoft.Agents.AI.OpenAI
|
||||
using System;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
// Replace the <apikey> with your OpenAI API key.
|
||||
var agent = new OpenAIClient("<apikey>")
|
||||
.GetResponsesClient()
|
||||
.AsAIAgent(model: "gpt-5.4-mini", name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
|
||||
|
||||
// Once you have the agent, you can invoke it like any other AIAgent.
|
||||
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
|
||||
```
|
||||
|
||||
@@ -176,12 +175,6 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
|
||||
- [Hosting](./dotnet/samples/04-hosting): A2A, Durable Agents, Durable Workflows
|
||||
- [End-to-End](./dotnet/samples/05-end-to-end): full applications and demos
|
||||
|
||||
## Community & Feedback
|
||||
|
||||
- **Found a bug?** File a [GitHub issue](https://github.com/microsoft/agent-framework/issues) to help us improve.
|
||||
- **Enjoying MAF?** [](https://github.com/microsoft/agent-framework) to show your support and help others discover the project.
|
||||
- **Have questions?** Join our [Discord](https://discord.gg/b5zjErwbQM) or visit [weekly office hours](./COMMUNITY.md#public-community-office-hours).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication
|
||||
@@ -194,7 +187,16 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
|
||||
> **Tip:** `DefaultAzureCredential` is convenient for development but in production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
|
||||
### Environment Variables
|
||||
For environment variable configuration specific to each sample, refer to the README in the sample directory ([Python samples](./python/samples/) | [.NET samples](./dotnet/samples/)).
|
||||
|
||||
The samples typically read configuration from environment variables. Common required variables:
|
||||
|
||||
| Variable | Used by | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI samples | Your Azure OpenAI resource URL |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI samples | Model deployment name (e.g. `gpt-4o-mini`) |
|
||||
| `AZURE_AI_PROJECT_ENDPOINT` | Microsoft Foundry samples | Your Microsoft Foundry project endpoint |
|
||||
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Microsoft Foundry samples | Model deployment name |
|
||||
| `OPENAI_API_KEY` | OpenAI (non-Azure) samples | Your OpenAI platform API key |
|
||||
|
||||
## Contributor Resources
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
|
||||
<!-- Azure.* -->
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.22" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.1" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
@@ -56,15 +56,15 @@
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.14.0" />
|
||||
<!-- Microsoft.AspNetCore.* -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
|
||||
@@ -86,7 +86,6 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
|
||||
@@ -109,8 +108,6 @@
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
|
||||
<!-- MCP -->
|
||||
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<!-- Hyperlight -->
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
|
||||
<!-- Inference SDKs -->
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||
@@ -138,8 +135,6 @@
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
|
||||
<!-- Redis -->
|
||||
<PackageVersion Include="StackExchange.Redis" Version="2.10.1" />
|
||||
<!-- Console UX -->
|
||||
<PackageVersion Include="Spectre.Console" Version="0.49.1" />
|
||||
<!-- Test -->
|
||||
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' == 'net8.0'" Version="8.0.22" />
|
||||
@@ -193,4 +188,4 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -117,13 +117,6 @@
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj" />
|
||||
</Folder>
|
||||
<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_Step01_Research/Harness_Step01_Research.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
|
||||
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
|
||||
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Server/Server.csproj" />
|
||||
@@ -167,19 +160,12 @@
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Evaluation/">
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/Evaluation_ExpectedOutputs.csproj" />
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_Multimodal/Evaluation_Multimodal.csproj" />
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentWithCodeAct/">
|
||||
<File Path="samples/02-agents/AgentWithCodeAct/README.md" />
|
||||
<Project Path="samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentWithMemory/">
|
||||
<File Path="samples/02-agents/AgentWithMemory/README.md" />
|
||||
@@ -239,7 +225,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/InvokeHttpRequest/InvokeHttpRequest.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj" />
|
||||
@@ -361,17 +346,17 @@
|
||||
<File Path="samples/02-agents/A2A/README.md" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/">
|
||||
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
|
||||
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/Evaluation/">
|
||||
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
|
||||
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/Evaluation_FoundryQuality.csproj" />
|
||||
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/Evaluation_MixedProviders.csproj" />
|
||||
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/A2AClientServer/">
|
||||
<File Path="samples/05-end-to-end/A2AClientServer/README.md" />
|
||||
@@ -541,16 +526,6 @@
|
||||
<Folder Name="/Solution Items/src/Shared/StructuredOutput/">
|
||||
<File Path="src/Shared/StructuredOutput/StructuredOutputSchemaUtilities.cs" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/src/Shared/Workflows/" />
|
||||
<Folder Name="/Solution Items/src/Shared/Workflows/Execution/">
|
||||
<File Path="src/Shared/Workflows/Execution/README.md" />
|
||||
<File Path="src/Shared/Workflows/Execution/WorkflowFactory.cs" />
|
||||
<File Path="src/Shared/Workflows/Execution/WorkflowRunner.cs" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/src/Shared/Workflows/Settings/">
|
||||
<File Path="src/Shared/Workflows/Settings/Application.cs" />
|
||||
<File Path="src/Shared/Workflows/Settings/README.md" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/tests/">
|
||||
<File Path="tests/.editorconfig" />
|
||||
<File Path="tests/Directory.Build.props" />
|
||||
@@ -567,8 +542,8 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Declarative/Microsoft.Agents.AI.Declarative.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.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" />
|
||||
@@ -576,7 +551,6 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
|
||||
@@ -598,7 +572,6 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.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.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
|
||||
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
|
||||
@@ -617,14 +590,12 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.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" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.4.0</VersionPrefix>
|
||||
<VersionPrefix>1.2.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260505</DateSuffix>
|
||||
<DateSuffix>260421</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.4.0</GitTag>
|
||||
<GitTag>1.2.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
// This is provided for demonstration purposes only.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Executes file-based skill scripts as local subprocesses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This runner uses the script's absolute path and converts the arguments
|
||||
/// to CLI arguments. When the LLM sends a JSON array, each element is used
|
||||
/// as a positional argument. It is intended for demonstration purposes only.
|
||||
/// This runner uses the script's absolute path, converts the arguments
|
||||
/// to CLI flags, and returns captured output. It is intended for
|
||||
/// demonstration purposes only.
|
||||
/// </remarks>
|
||||
internal static class SubprocessScriptRunner
|
||||
{
|
||||
@@ -24,8 +24,7 @@ internal static class SubprocessScriptRunner
|
||||
public static async Task<object?> RunAsync(
|
||||
AgentFileSkill skill,
|
||||
AgentFileSkillScript script,
|
||||
JsonElement? arguments,
|
||||
IServiceProvider? serviceProvider,
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(script.FullPath))
|
||||
@@ -62,27 +61,24 @@ internal static class SubprocessScriptRunner
|
||||
startInfo.FileName = script.FullPath;
|
||||
}
|
||||
|
||||
if (arguments is { ValueKind: JsonValueKind.Array } json)
|
||||
if (arguments is not null)
|
||||
{
|
||||
// Positional CLI arguments
|
||||
foreach (var element in json.EnumerateArray())
|
||||
foreach (var (key, value) in arguments)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
if (value is bool boolValue)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"File-based skill scripts only accept string CLI arguments but received a JSON element of kind '{element.ValueKind}'. " +
|
||||
"All array elements must be JSON strings.");
|
||||
if (boolValue)
|
||||
{
|
||||
startInfo.ArgumentList.Add(NormalizeKey(key));
|
||||
}
|
||||
}
|
||||
else if (value is not null)
|
||||
{
|
||||
startInfo.ArgumentList.Add(NormalizeKey(key));
|
||||
startInfo.ArgumentList.Add(value.ToString()!);
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add(element.GetString()!);
|
||||
}
|
||||
}
|
||||
else if (arguments is not null && arguments.Value.ValueKind != JsonValueKind.Null && arguments.Value.ValueKind != JsonValueKind.Undefined)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Expected a JSON array of CLI arguments but received {arguments.Value.ValueKind}. " +
|
||||
"File-based skill scripts expect positional arguments as a JSON array of strings.");
|
||||
}
|
||||
|
||||
Process? process = null;
|
||||
try
|
||||
@@ -132,4 +128,10 @@ internal static class SubprocessScriptRunner
|
||||
process?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a parameter key to a consistent --flag format.
|
||||
/// Models may return keys with or without leading dashes (e.g., "value" vs "--value").
|
||||
/// </summary>
|
||||
private static string NormalizeKey(string key) => "--" + key.TrimStart('-');
|
||||
}
|
||||
|
||||
-22
@@ -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.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use HyperlightCodeActProvider as a sandboxed Python
|
||||
// code interpreter: the model can write and execute arbitrary Python code to
|
||||
// answer quantitative questions without calling any additional tools.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using OpenAI.Chat;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
|
||||
|
||||
using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(guestPath));
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "You are a helpful assistant. When the user asks something quantitative, write Python and call `execute_code` instead of guessing." },
|
||||
AIContextProviders = [codeAct],
|
||||
});
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What is the 20th Fibonacci number?"));
|
||||
Console.WriteLine(await agent.RunAsync("Compute the mean and standard deviation of [1, 4, 9, 16, 25, 36]."));
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
# AgentWithCodeAct_Step01_Interpreter
|
||||
|
||||
A minimal CodeAct sample. The agent uses `HyperlightCodeActProvider` as a
|
||||
sandboxed Python interpreter: when the user asks something quantitative, the
|
||||
model writes Python and invokes the `execute_code` tool rather than answering
|
||||
from memory.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Description |
|
||||
|--------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
|
||||
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
|
||||
|
||||
Authentication uses `DefaultAzureCredential`.
|
||||
|
||||
## Getting the guest module
|
||||
|
||||
The Python guest module is built from the
|
||||
[hyperlight-dev/hyperlight-sandbox](https://github.com/hyperlight-dev/hyperlight-sandbox)
|
||||
repository — see its README for the exact `cargo`/`just` invocations and
|
||||
the location of the resulting `.wasm` / `.aot` file. Set
|
||||
`HYPERLIGHT_PYTHON_GUEST_PATH` to the absolute path of that artifact
|
||||
before running the sample.
|
||||
|
||||
Hyperlight requires a hardware virtualization back end on the host:
|
||||
KVM on Linux or WHP (Windows Hypervisor Platform) on Windows.
|
||||
|
||||
## Run
|
||||
|
||||
```shell
|
||||
cd AgentWithCodeAct_Step01_Interpreter
|
||||
dotnet run
|
||||
```
|
||||
-22
@@ -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.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use HyperlightCodeActProvider with provider-owned
|
||||
// tools (exposed inside the sandbox via `call_tool(...)`). The model can
|
||||
// orchestrate those tools in a single Python block, reducing round-trips. A
|
||||
// sensitive tool (`send_email`) is additionally wrapped in
|
||||
// ApprovalRequiredAIFunction so any code that reaches it requires user approval
|
||||
// for the entire execute_code invocation.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
|
||||
|
||||
AIFunction fetchDocs = AIFunctionFactory.Create(
|
||||
(string topic) => $"Docs for {topic}: (...)",
|
||||
name: "fetch_docs",
|
||||
description: "Fetch documentation for a given topic.");
|
||||
|
||||
AIFunction queryData = AIFunctionFactory.Create(
|
||||
(string query) => $"Rows for `{query}`: []",
|
||||
name: "query_data",
|
||||
description: "Run a read-only SQL-like query against the sample store.");
|
||||
|
||||
AIFunction sendEmail = new ApprovalRequiredAIFunction(
|
||||
AIFunctionFactory.Create(
|
||||
(string to, string subject) => $"Sent '{subject}' to {to}.",
|
||||
name: "send_email",
|
||||
description: "Send an email on behalf of the user."));
|
||||
|
||||
var options = HyperlightCodeActProviderOptions.CreateForWasm(guestPath);
|
||||
options.Tools = [fetchDocs, queryData, sendEmail];
|
||||
|
||||
using var codeAct = new HyperlightCodeActProvider(options);
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "You are a helpful assistant. Prefer orchestrating your work in a single `execute_code` block using `call_tool(...)` over issuing many direct tool calls." },
|
||||
AIContextProviders = [codeAct],
|
||||
});
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("Look up docs on 'retries' and query the 'orders' table, then summarize."));
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
# AgentWithCodeAct_Step02_ToolEnabled
|
||||
|
||||
Demonstrates adding provider-owned tools to `HyperlightCodeActProvider`. Those
|
||||
tools are **only** available to code running inside the sandbox via
|
||||
`call_tool("<name>", ...)` — they are never exposed to the model as direct
|
||||
tools. This lets the model orchestrate multiple tool calls in a single Python
|
||||
block.
|
||||
|
||||
One tool (`send_email`) is wrapped in `ApprovalRequiredAIFunction`, which causes
|
||||
the entire `execute_code` invocation to require user approval when that tool
|
||||
is configured.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Description |
|
||||
|--------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
|
||||
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
|
||||
|
||||
## Run
|
||||
|
||||
```shell
|
||||
cd AgentWithCodeAct_Step02_ToolEnabled
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Planned follow-up
|
||||
|
||||
A more realistic "upload a file (e.g. an Excel workbook), have the agent
|
||||
analyze it with code" sample is planned as a separate step that will use
|
||||
`HostInputDirectory` together with a guest tool capable of reading the
|
||||
uploaded file. It will be added in a follow-up PR once the corresponding
|
||||
guest module support is in place.
|
||||
-22
@@ -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.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to wire up CodeAct manually using
|
||||
// HyperlightExecuteCodeFunction rather than the AIContextProvider. Use this
|
||||
// when you want a fixed tool surface for the agent's lifetime and don't need
|
||||
// the per-run snapshot/registry semantics of HyperlightCodeActProvider.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
|
||||
|
||||
AIFunction calculate = AIFunctionFactory.Create(
|
||||
(double a, double b) => a * b,
|
||||
name: "multiply",
|
||||
description: "Multiply two numbers.");
|
||||
|
||||
var options = HyperlightCodeActProviderOptions.CreateForWasm(guestPath);
|
||||
options.Tools = [calculate];
|
||||
|
||||
using var executeCode = new HyperlightExecuteCodeFunction(options);
|
||||
|
||||
var instructions =
|
||||
"You are a helpful assistant. When math is involved, solve it by writing Python "
|
||||
+ "and calling `execute_code` instead of computing values yourself.\n\n"
|
||||
+ executeCode.BuildInstructions(toolsVisibleToModel: false);
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(instructions: instructions, tools: [executeCode]);
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What is 12.3 * 4.5? Use the multiply tool from within `execute_code`."));
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
# AgentWithCodeAct_Step03_ManualWiring
|
||||
|
||||
Shows how to wire CodeAct manually using `HyperlightExecuteCodeFunction` as a
|
||||
direct agent tool instead of via an `AIContextProvider`. This is useful when
|
||||
the sandbox's tool surface and capabilities are fixed for the agent's
|
||||
lifetime, avoiding per-run snapshot/restore of the provider registry.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Description |
|
||||
|--------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
|
||||
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
|
||||
|
||||
## Run
|
||||
|
||||
```shell
|
||||
cd AgentWithCodeAct_Step03_ManualWiring
|
||||
dotnet run
|
||||
```
|
||||
@@ -1,16 +0,0 @@
|
||||
# Agent Framework CodeAct (Hyperlight) Samples
|
||||
|
||||
These samples show how to enable an agent to write and execute code in a
|
||||
Hyperlight-backed sandbox via the CodeAct pattern. Guest code can be pure
|
||||
Python (interpreter mode) or orchestrate host-provided tools through
|
||||
`call_tool(...)` — all inside a secure sandbox with opt-in filesystem and
|
||||
network access.
|
||||
|
||||
|Sample|Description|
|
||||
|---|---|
|
||||
|[Code interpreter](./AgentWithCodeAct_Step01_Interpreter/)|Uses `HyperlightCodeActProvider` as a sandboxed Python interpreter with no host tools.|
|
||||
|[Tool-enabled CodeAct](./AgentWithCodeAct_Step02_ToolEnabled/)|Registers provider-owned tools that guest code can orchestrate via `call_tool(...)`, with an approval-required tool for sensitive actions.|
|
||||
|[Manual wiring](./AgentWithCodeAct_Step03_ManualWiring/)|Uses `HyperlightExecuteCodeFunction` directly as an agent tool when the sandbox configuration is fixed.|
|
||||
|
||||
All samples require a Hyperlight Python guest module. Set
|
||||
`HYPERLIGHT_PYTHON_GUEST_PATH` to its absolute path before running.
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to load a Foundry toolbox and pass its tools as server-side
|
||||
// tools when creating an agent. The Foundry platform handles tool execution — the agent
|
||||
// process does not invoke tools locally.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental API
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental
|
||||
#pragma warning disable CS8321 // Local functions may be commented-out alternatives
|
||||
|
||||
// Replace with your own Foundry toolbox name.
|
||||
const string ToolboxName = "research_toolbox";
|
||||
// Used only by CombineToolboxes — swap in a second toolbox you own.
|
||||
const string SecondToolboxName = "analysis_toolbox";
|
||||
// Replace with any question that exercises the tools configured in your toolbox.
|
||||
const string Query = "Introduce yourself and briefly describe the tools you can use to help me.";
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint.");
|
||||
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
await Main(projectClient, model, endpoint);
|
||||
// await CombineToolboxes(projectClient, model, endpoint);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main: single toolbox
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Main(AIProjectClient projectClient, string model, string endpoint)
|
||||
{
|
||||
Console.WriteLine("=== Foundry Toolbox Server-Side Tools Example ===");
|
||||
|
||||
// Comment out if the toolbox already exists in your Foundry project.
|
||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
||||
|
||||
// Omit the version to resolve the toolbox's current default version at runtime.
|
||||
var tools = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
||||
|
||||
AIAgent agent = projectClient
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a research assistant. Use the available tools to answer questions.",
|
||||
tools: tools.ToList());
|
||||
|
||||
Console.WriteLine($"User: {Query}");
|
||||
Console.WriteLine($"Result: {await agent.RunAsync(Query)}\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alternative: combine tools from multiple toolboxes
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task CombineToolboxes(AIProjectClient projectClient, string model, string endpoint)
|
||||
{
|
||||
Console.WriteLine("=== Combine Toolboxes Example ===");
|
||||
|
||||
// Comment out if the toolboxes already exist in your Foundry project.
|
||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
||||
await CreateSampleToolboxAsync(SecondToolboxName, endpoint);
|
||||
|
||||
var toolboxA = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
||||
var toolboxB = await projectClient.GetToolboxToolsAsync(SecondToolboxName);
|
||||
|
||||
var allTools = toolboxA.Concat(toolboxB).ToList();
|
||||
|
||||
AIAgent agent = projectClient
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a research assistant. Use all available tools to answer questions.",
|
||||
tools: allTools);
|
||||
|
||||
Console.WriteLine($"User: {Query}");
|
||||
Console.WriteLine($"Combined-toolbox result: {await agent.RunAsync(Query)}\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create (or replace) a sample toolbox so the sample works out-of-the-box
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task CreateSampleToolboxAsync(string name, string endpoint)
|
||||
{
|
||||
// Toolboxes are normally configured in the Foundry portal or a deployment
|
||||
// script, not the application itself. This helper exists so the sample can
|
||||
// be run end-to-end without first setting a toolbox up by hand.
|
||||
|
||||
// The Foundry-Features header is currently required for toolbox CRUD operations.
|
||||
var options = new AgentAdministrationClientOptions();
|
||||
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
|
||||
var adminClient = new AgentAdministrationClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential(),
|
||||
options);
|
||||
var toolboxClient = adminClient.GetAgentToolboxes();
|
||||
|
||||
// Delete existing toolbox if present (ignore 404).
|
||||
try
|
||||
{
|
||||
await toolboxClient.DeleteToolboxAsync(name);
|
||||
Console.WriteLine($"Deleted existing toolbox '{name}'");
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Toolbox does not exist — nothing to delete.
|
||||
}
|
||||
|
||||
// Create a fresh version with a single MCP tool.
|
||||
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
|
||||
serverLabel: "api-specs",
|
||||
serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
|
||||
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
|
||||
|
||||
var created = (await toolboxClient.CreateToolboxVersionAsync(
|
||||
name: name,
|
||||
tools: [mcpTool],
|
||||
description: "Sample toolbox with an MCP tool — created by Agent_Step25 sample.")).Value;
|
||||
|
||||
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline policy that adds the Foundry-Features header for toolbox CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
|
||||
{
|
||||
private const string FeatureHeader = "Foundry-Features";
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
# Agent_Step25_ToolboxServerSideTools
|
||||
|
||||
This sample demonstrates loading a named Foundry toolbox and passing its tools as
|
||||
**server-side tools** when creating an agent via `AsAIAgent()`.
|
||||
|
||||
When tools from a toolbox are passed this way, they are sent as tool definitions in
|
||||
the Responses API request. The Foundry platform handles tool execution — the agent
|
||||
process does not invoke tools locally.
|
||||
|
||||
This is the dotnet equivalent of the Python sample:
|
||||
`python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Microsoft Foundry project
|
||||
- `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint
|
||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable set (defaults to `gpt-5.4-mini`)
|
||||
|
||||
The sample recreates the toolbox on each run, replacing any existing toolbox with
|
||||
the same name. Comment out the `CreateSampleToolboxAsync` call if you want to keep
|
||||
an existing toolbox unchanged.
|
||||
|
||||
## How it works
|
||||
|
||||
1. `projectClient.GetToolboxVersionAsync(name)` fetches the toolbox definition from the
|
||||
Foundry project API (resolving the default version if none is specified)
|
||||
2. `ToolboxVersion.ToAITools()` converts each tool definition to an `AITool` instance
|
||||
3. The tools are passed to `AsAIAgent(tools: ...)` which includes them in the Responses
|
||||
API request as server-side tool definitions
|
||||
|
||||
For a one-liner, use `projectClient.GetToolboxToolsAsync(name)` to fetch and convert in one call.
|
||||
|
||||
## Sample flows
|
||||
|
||||
| Flow | Description |
|
||||
|------|-------------|
|
||||
| `Main` (default) | Loads a single toolbox and runs an agent with its tools |
|
||||
| `CombineToolboxes` | Loads two toolboxes and merges their tools into one agent |
|
||||
|
||||
Uncomment the desired flow in the top-level statements to try each one.
|
||||
|
||||
## Running the sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles a console command (e.g., /todos, /mode). Command handlers are checked
|
||||
/// in order before user input is sent to the agent. The first handler that
|
||||
/// accepts the input prevents further handlers from being checked.
|
||||
/// </summary>
|
||||
public interface ICommandHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// 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>
|
||||
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>
|
||||
/// <returns><see langword="true"/> if this handler handled the input; <see langword="false"/> otherwise.</returns>
|
||||
bool TryHandle(string input, AgentSession session);
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <c>/mode</c> command to display or switch the current agent mode.
|
||||
/// </summary>
|
||||
internal sealed class ModeCommandHandler : ICommandHandler
|
||||
{
|
||||
private readonly AgentModeProvider? _modeProvider;
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ModeCommandHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="modeProvider">The mode provider, or <see langword="null"/> if not available.</param>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public ModeCommandHandler(AgentModeProvider? modeProvider, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._modeProvider = modeProvider;
|
||||
this._modeColors = modeColors;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TryHandle(string input, AgentSession session)
|
||||
{
|
||||
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._modeProvider is null)
|
||||
{
|
||||
System.Console.WriteLine("AgentModeProvider is not available.");
|
||||
return true;
|
||||
}
|
||||
|
||||
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
string current = this._modeProvider.GetMode(session);
|
||||
System.Console.WriteLine($"\n Current mode: {current}\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
string newMode = parts[1];
|
||||
|
||||
try
|
||||
{
|
||||
this._modeProvider.SetMode(session, newMode);
|
||||
System.Console.ForegroundColor = ConsoleWriter.GetModeColor(newMode, this._modeColors);
|
||||
System.Console.WriteLine($"\n Switched to {newMode} mode.\n");
|
||||
System.Console.ResetColor();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
System.Console.ForegroundColor = ConsoleColor.Red;
|
||||
System.Console.WriteLine($"\n {ex}\n");
|
||||
System.Console.ResetColor();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <c>/todos</c> command to display the current todo list.
|
||||
/// </summary>
|
||||
internal sealed class TodoCommandHandler : ICommandHandler
|
||||
{
|
||||
private readonly TodoProvider? _todoProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TodoCommandHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="todoProvider">The todo provider, or <see langword="null"/> if not available.</param>
|
||||
public TodoCommandHandler(TodoProvider? todoProvider)
|
||||
{
|
||||
this._todoProvider = todoProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TryHandle(string input, AgentSession session)
|
||||
{
|
||||
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._todoProvider is null)
|
||||
{
|
||||
System.Console.WriteLine("TodoProvider is not available.");
|
||||
return true;
|
||||
}
|
||||
|
||||
var todos = this._todoProvider.GetAllTodos(session);
|
||||
if (todos.Count == 0)
|
||||
{
|
||||
System.Console.WriteLine("\n No todos yet.\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine(" ── Todo List ──");
|
||||
foreach (var item in todos)
|
||||
{
|
||||
string status = item.IsComplete ? "✓" : "○";
|
||||
System.Console.ForegroundColor = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White;
|
||||
System.Console.Write($" [{status}] #{item.Id} {item.Title}");
|
||||
if (!string.IsNullOrWhiteSpace(item.Description))
|
||||
{
|
||||
System.Console.Write($" — {item.Description}");
|
||||
}
|
||||
|
||||
System.Console.WriteLine();
|
||||
}
|
||||
|
||||
System.Console.ResetColor();
|
||||
System.Console.WriteLine();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Centralizes all console output and spinner management for the harness console.
|
||||
/// Observers write through this class so the spinner is automatically paused before output.
|
||||
/// </summary>
|
||||
public sealed class ConsoleWriter : IDisposable
|
||||
{
|
||||
private readonly Spinner _spinner = new();
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
|
||||
private bool _lastWasText;
|
||||
private bool _hasReceivedAnyText;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConsoleWriter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public ConsoleWriter(IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._modeColors = modeColors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current agent mode (e.g., "plan", "execute").
|
||||
/// Used to determine the console color for mode-prefixed output.
|
||||
/// </summary>
|
||||
public string? CurrentMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Writes the agent response header (e.g., "[plan] Agent: ") and starts the spinner.
|
||||
/// </summary>
|
||||
public void WriteResponseHeader()
|
||||
{
|
||||
if (this.CurrentMode is not null)
|
||||
{
|
||||
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
|
||||
System.Console.Write($"\n[{this.CurrentMode}] Agent: ");
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Console.Write("\nAgent: ");
|
||||
}
|
||||
|
||||
this._lastWasText = true;
|
||||
this._hasReceivedAnyText = false;
|
||||
this._spinner.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output with automatic prefix spacing, without a trailing newline.
|
||||
/// Use when continuation content will be appended on the same line.
|
||||
/// </summary>
|
||||
/// <param name="text">The informational text to write (without leading newline/indent — added automatically).</param>
|
||||
/// <param name="color">Optional console color for the text.</param>
|
||||
public async Task WriteInfoAsync(string text, ConsoleColor? color = null)
|
||||
{
|
||||
await this.WriteInfoCoreAsync(text, color, newLine: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output with automatic prefix spacing, followed by a newline.
|
||||
/// </summary>
|
||||
/// <param name="text">The informational text to write (without leading newline/indent — added automatically).</param>
|
||||
/// <param name="color">Optional console color for the text.</param>
|
||||
public async Task WriteInfoLineAsync(string text, ConsoleColor? color = null)
|
||||
{
|
||||
await this.WriteInfoCoreAsync(text, color, newLine: true);
|
||||
}
|
||||
|
||||
private async Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
|
||||
{
|
||||
await this._spinner.StopAsync();
|
||||
|
||||
string prefix = this._lastWasText ? "\n\n " : " ";
|
||||
this._lastWasText = false;
|
||||
|
||||
System.Console.ForegroundColor = color ?? GetModeColor(this.CurrentMode, this._modeColors);
|
||||
|
||||
if (newLine)
|
||||
{
|
||||
System.Console.WriteLine(prefix + text);
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Console.Write(prefix + text);
|
||||
}
|
||||
|
||||
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
|
||||
|
||||
this._spinner.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes text output from the agent, managing line break state.
|
||||
/// Ensures a newline is written before the first text output.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to write.</param>
|
||||
/// <param name="color">Optional console color override for this text.</param>
|
||||
public async Task WriteTextAsync(string text, ConsoleColor? color = null)
|
||||
{
|
||||
await this._spinner.StopAsync();
|
||||
|
||||
if (!this._lastWasText)
|
||||
{
|
||||
System.Console.Write("\n");
|
||||
this._lastWasText = true;
|
||||
}
|
||||
|
||||
this._hasReceivedAnyText = true;
|
||||
|
||||
if (color.HasValue)
|
||||
{
|
||||
System.Console.ForegroundColor = color.Value;
|
||||
}
|
||||
|
||||
System.Console.Write(text);
|
||||
|
||||
if (color.HasValue)
|
||||
{
|
||||
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
|
||||
}
|
||||
|
||||
this._spinner.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a line of input from the console, pausing the spinner while waiting for input.
|
||||
/// Optionally displays a prompt before reading. The prompt is rendered between
|
||||
/// two horizontal rules for visual clarity.
|
||||
/// </summary>
|
||||
/// <param name="prompt">Optional prompt text to display before reading input.</param>
|
||||
/// <param name="promptColor">Optional console color for the prompt text.</param>
|
||||
/// <returns>The line read from the console, or <c>null</c> if no input is available.</returns>
|
||||
public async Task<string?> ReadLineAsync(string? prompt = null, ConsoleColor? promptColor = null)
|
||||
{
|
||||
await this._spinner.StopAsync();
|
||||
|
||||
if (prompt is not null)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
AnsiConsole.Write(this.CreateModeRule());
|
||||
|
||||
if (promptColor.HasValue)
|
||||
{
|
||||
System.Console.ForegroundColor = promptColor.Value;
|
||||
}
|
||||
|
||||
System.Console.Write($" {prompt}");
|
||||
|
||||
if (promptColor.HasValue)
|
||||
{
|
||||
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
|
||||
}
|
||||
}
|
||||
|
||||
string? input = System.Console.ReadLine();
|
||||
|
||||
if (prompt is not null)
|
||||
{
|
||||
AnsiConsole.Write(this.CreateModeRule());
|
||||
}
|
||||
|
||||
this._lastWasText = false;
|
||||
return input;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presents a selection prompt with the given choices, plus an option to type a custom response.
|
||||
/// Uses Spectre.Console <see cref="SelectionPrompt{T}"/> for interactive arrow-key selection.
|
||||
/// </summary>
|
||||
/// <param name="title">The title/question displayed above the selection list.</param>
|
||||
/// <param name="choices">The list of choices to present.</param>
|
||||
/// <returns>The selected choice text, or the custom-typed response.</returns>
|
||||
public async Task<string> ReadSelectionAsync(string title, IList<string> choices)
|
||||
{
|
||||
await this._spinner.StopAsync();
|
||||
|
||||
AnsiConsole.Write(this.CreateModeRule());
|
||||
|
||||
const string FreeformOption = "✏️ Type a custom response...";
|
||||
var allChoices = choices.Concat([FreeformOption]).ToList();
|
||||
|
||||
var prompt = new SelectionPrompt<string>()
|
||||
.Title($" [bold]{Markup.Escape(title)}[/]")
|
||||
.PageSize(10)
|
||||
.AddChoices(allChoices);
|
||||
|
||||
string selection = AnsiConsole.Prompt(prompt);
|
||||
|
||||
if (selection == FreeformOption)
|
||||
{
|
||||
var textPrompt = new TextPrompt<string>(" [grey]Response:[/]");
|
||||
selection = AnsiConsole.Prompt(textPrompt);
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($" [dim]→ {Markup.Escape(selection)}[/]");
|
||||
AnsiConsole.Write(this.CreateModeRule());
|
||||
|
||||
this._lastWasText = false;
|
||||
return selection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the stream-complete footer (handles "no text response" fallback, resets color).
|
||||
/// </summary>
|
||||
public async Task WriteStreamFooterAsync(bool hasFollowUpMessages)
|
||||
{
|
||||
await this._spinner.StopAsync();
|
||||
|
||||
if (!this._hasReceivedAnyText && !hasFollowUpMessages)
|
||||
{
|
||||
System.Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
System.Console.Write("\n (no text response from agent)");
|
||||
}
|
||||
|
||||
System.Console.ResetColor();
|
||||
System.Console.WriteLine();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
this._spinner.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the console color associated with a mode name, using the provided color map.
|
||||
/// </summary>
|
||||
internal static ConsoleColor GetModeColor(string? mode, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
if (mode is null)
|
||||
{
|
||||
return ConsoleColor.Gray;
|
||||
}
|
||||
|
||||
if (modeColors is not null && modeColors.TryGetValue(mode, out var color))
|
||||
{
|
||||
return color;
|
||||
}
|
||||
|
||||
return ConsoleColor.Gray;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="Rule"/> styled with the current mode color.
|
||||
/// </summary>
|
||||
internal Rule CreateModeRule()
|
||||
{
|
||||
var spectreColor = ToSpectreColor(GetModeColor(this.CurrentMode, this._modeColors));
|
||||
return new Rule().RuleStyle(new Style(spectreColor));
|
||||
}
|
||||
|
||||
internal static Color ToSpectreColor(ConsoleColor consoleColor) => consoleColor switch
|
||||
{
|
||||
ConsoleColor.Black => Color.Black,
|
||||
ConsoleColor.DarkBlue => Color.Blue,
|
||||
ConsoleColor.DarkGreen => Color.Green,
|
||||
ConsoleColor.DarkCyan => Color.Teal,
|
||||
ConsoleColor.DarkRed => Color.Red,
|
||||
ConsoleColor.DarkMagenta => Color.Purple,
|
||||
ConsoleColor.DarkYellow => Color.Olive,
|
||||
ConsoleColor.Gray => Color.Silver,
|
||||
ConsoleColor.DarkGray => Color.Grey,
|
||||
ConsoleColor.Blue => Color.Blue1,
|
||||
ConsoleColor.Green => Color.Green1,
|
||||
ConsoleColor.Cyan => Color.Aqua,
|
||||
ConsoleColor.Red => Color.Red1,
|
||||
ConsoleColor.Magenta => Color.Fuchsia,
|
||||
ConsoleColor.Yellow => Color.Yellow,
|
||||
ConsoleColor.White => Color.White,
|
||||
_ => Color.Silver,
|
||||
};
|
||||
}
|
||||
@@ -1,214 +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>
|
||||
/// Provides a reusable interactive console loop for running an <see cref="AIAgent"/>
|
||||
/// with streaming output, extensible observers, and mode-aware interaction strategies.
|
||||
/// </summary>
|
||||
public static class HarnessConsole
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs an interactive console session with the specified agent.
|
||||
/// 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="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 title, string userPrompt, HarnessConsoleOptions? options = null)
|
||||
{
|
||||
options ??= new();
|
||||
|
||||
if (options.EnablePlanningUx
|
||||
&& (string.IsNullOrWhiteSpace(options.PlanningModeName) || string.IsNullOrWhiteSpace(options.ExecutionModeName)))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"When EnablePlanningUx is true, both PlanningModeName and ExecutionModeName must be configured.",
|
||||
nameof(options));
|
||||
}
|
||||
|
||||
System.Console.WriteLine($"=== {title} ===");
|
||||
System.Console.WriteLine(userPrompt);
|
||||
|
||||
var todoProvider = agent.GetService<TodoProvider>();
|
||||
var modeProvider = agent.GetService<AgentModeProvider>();
|
||||
|
||||
// Build command handlers.
|
||||
var commandHandlers = new List<ICommandHandler>
|
||||
{
|
||||
new TodoCommandHandler(todoProvider),
|
||||
new ModeCommandHandler(modeProvider, options.ModeColors),
|
||||
};
|
||||
|
||||
var commands = commandHandlers
|
||||
.Select(h => h.GetHelpText())
|
||||
.Where(t => t is not null)
|
||||
.Append("exit (quit)");
|
||||
|
||||
System.Console.WriteLine($"Commands: {string.Join(", ", commands)}");
|
||||
System.Console.WriteLine();
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
using var writer = new ConsoleWriter(options.ModeColors);
|
||||
writer.CurrentMode = modeProvider?.GetMode(session);
|
||||
|
||||
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 (handler.TryHandle(userInput, session))
|
||||
{
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!handled)
|
||||
{
|
||||
await RunAgentTurnAsync(agent, session, modeProvider, options, writer, userInput);
|
||||
}
|
||||
|
||||
writer.CurrentMode = modeProvider?.GetMode(session);
|
||||
prompt = BuildUserPrompt(modeProvider, session);
|
||||
userInput = await writer.ReadLineAsync(prompt);
|
||||
}
|
||||
|
||||
System.Console.ResetColor();
|
||||
System.Console.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,52 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="HarnessConsole"/>.
|
||||
/// </summary>
|
||||
public class HarnessConsoleOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 int? MaxContextWindowTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the optional maximum output tokens.
|
||||
/// Used with <see cref="MaxContextWindowTokens"/> to show input/output budget breakdown.
|
||||
/// </summary>
|
||||
public int? MaxOutputTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <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(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["plan"] = ConsoleColor.Cyan,
|
||||
["execute"] = ConsoleColor.Green,
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,53 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for console observers that participate in the agent response
|
||||
/// streaming lifecycle. Observers can configure run options, observe streamed content,
|
||||
/// and return messages to re-invoke the agent after the stream completes.
|
||||
/// All methods have default no-op implementations so subclasses only override what they need.
|
||||
/// </summary>
|
||||
public abstract class ConsoleObserver
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures <see cref="AgentRunOptions"/> before the agent is invoked.
|
||||
/// Override to set options such as <see cref="AgentRunOptions.ResponseFormat"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">The run options to configure.</param>
|
||||
public virtual void ConfigureRunOptions(AgentRunOptions options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called for each <see cref="AIContent"/> item in the response stream.
|
||||
/// </summary>
|
||||
/// <param name="writer">The console writer for rendering output.</param>
|
||||
/// <param name="content">The content item from the stream.</param>
|
||||
public virtual Task OnContentAsync(ConsoleWriter writer, AIContent content) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called for each text update in the response stream.
|
||||
/// </summary>
|
||||
/// <param name="writer">The console writer for rendering output.</param>
|
||||
/// <param name="text">The text from the update.</param>
|
||||
public virtual Task OnTextAsync(ConsoleWriter writer, string text) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called after the response stream completes. Returns messages to include in the
|
||||
/// next agent invocation, or <see langword="null"/> if no re-invocation is needed.
|
||||
/// </summary>
|
||||
/// <param name="writer">The console writer for rendering output.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
/// <param name="options">The console options.</param>
|
||||
/// <returns>Messages to send to the agent, or <see langword="null"/> if no action is needed.</returns>
|
||||
public virtual Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
||||
ConsoleWriter writer,
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
HarnessConsoleOptions options) => Task.FromResult<IList<ChatMessage>?>(null);
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Displays error content (❌) from the response stream.
|
||||
/// </summary>
|
||||
internal sealed class ErrorDisplayObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
||||
{
|
||||
if (content is ErrorContent errorContent)
|
||||
{
|
||||
string errorText = $"❌ Error: {errorContent.Message}";
|
||||
if (!string.IsNullOrWhiteSpace(errorContent.ErrorCode))
|
||||
{
|
||||
errorText += $" (code: {errorContent.ErrorCode})";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorContent.Details))
|
||||
{
|
||||
errorText += $" details: {errorContent.Details}";
|
||||
}
|
||||
|
||||
await writer.WriteInfoLineAsync(errorText, ConsoleColor.Red);
|
||||
}
|
||||
}
|
||||
}
|
||||
-177
@@ -1,177 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
internal sealed class PlanningOutputObserver : ConsoleObserver
|
||||
{
|
||||
private readonly StringBuilder _textCollector = new();
|
||||
private readonly AgentModeProvider _modeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PlanningOutputObserver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="modeProvider">The mode provider for switching modes on approval.</param>
|
||||
public PlanningOutputObserver(AgentModeProvider modeProvider)
|
||||
{
|
||||
this._modeProvider = modeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void ConfigureRunOptions(AgentRunOptions options)
|
||||
{
|
||||
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task OnTextAsync(ConsoleWriter writer, string text)
|
||||
{
|
||||
// Collect text silently instead of displaying it.
|
||||
this._textCollector.Append(text);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
||||
ConsoleWriter writer,
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
HarnessConsoleOptions options)
|
||||
{
|
||||
// Read collected text from our stream observation.
|
||||
string collectedText = this._textCollector.ToString();
|
||||
this._textCollector.Clear();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(collectedText))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Deserialize the structured response.
|
||||
PlanningResponse? planningResponse;
|
||||
try
|
||||
{
|
||||
planningResponse = JsonSerializer.Deserialize<PlanningResponse>(collectedText);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
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 writer.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render based on response type.
|
||||
if (planningResponse.Type == PlanningResponseType.Clarification)
|
||||
{
|
||||
return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(writer, planningResponse));
|
||||
}
|
||||
|
||||
if (planningResponse.Type == PlanningResponseType.Approval)
|
||||
{
|
||||
var question = planningResponse.Questions.FirstOrDefault();
|
||||
if (question is null)
|
||||
{
|
||||
await writer.WriteInfoLineAsync("(approval response had no content)", ConsoleColor.DarkYellow);
|
||||
return null;
|
||||
}
|
||||
|
||||
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 writer.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IList<ChatMessage>? AsUserMessages(string? text) =>
|
||||
text is not null ? [new ChatMessage(ChatRole.User, text)] : null;
|
||||
|
||||
private async Task<string?> RenderClarificationsAndCollectResponsesAsync(ConsoleWriter writer, PlanningResponse response)
|
||||
{
|
||||
var answers = new List<string>();
|
||||
|
||||
foreach (var question in response.Questions)
|
||||
{
|
||||
await writer.WriteInfoLineAsync(string.Empty);
|
||||
await writer.WriteInfoLineAsync(question.Message);
|
||||
|
||||
string? answer;
|
||||
if (question.Choices is { Count: > 0 })
|
||||
{
|
||||
answer = await writer.ReadSelectionAsync(
|
||||
"Choose an option:",
|
||||
question.Choices);
|
||||
}
|
||||
else
|
||||
{
|
||||
answer = (await writer.ReadLineAsync("Response: "))?.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(answer))
|
||||
{
|
||||
answers.Add($"Q: {question.Message}\nA: {answer}");
|
||||
}
|
||||
}
|
||||
|
||||
return answers.Count > 0 ? string.Join("\n\n", answers) : null;
|
||||
}
|
||||
|
||||
private async Task<string> RenderApprovalAndCollectResponseAsync(ConsoleWriter writer, PlanningQuestion question, HarnessConsoleOptions options)
|
||||
{
|
||||
await writer.WriteInfoLineAsync(question.Message);
|
||||
|
||||
var choices = new List<string>
|
||||
{
|
||||
"Approve and switch to execute mode",
|
||||
"Suggest changes",
|
||||
};
|
||||
|
||||
string selection = await writer.ReadSelectionAsync("What would you like to do?", choices);
|
||||
|
||||
if (selection == choices[0])
|
||||
{
|
||||
return "Approved";
|
||||
}
|
||||
|
||||
if (selection == choices[1])
|
||||
{
|
||||
string? feedback = await writer.ReadLineAsync(
|
||||
"Your feedback: ",
|
||||
ConsoleWriter.GetModeColor(options.PlanningModeName, options.ModeColors));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(feedback))
|
||||
{
|
||||
// Treat empty feedback as no changes — re-prompt the agent with the plan.
|
||||
return "No changes suggested. Please re-present the plan for approval.";
|
||||
}
|
||||
|
||||
return feedback;
|
||||
}
|
||||
|
||||
// Custom freeform input — treat as suggested changes.
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a structured response from the agent while in planning mode.
|
||||
/// Used with structured output to enable consistent rendering of clarification
|
||||
/// questions and approval requests in the console.
|
||||
/// </summary>
|
||||
public class PlanningResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the type of planning response.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required PlanningResponseType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of questions or items to present to the user.
|
||||
/// For clarification, this contains one or more questions (each with choices).
|
||||
/// For approval, this contains exactly one item with the plan summary.
|
||||
/// </summary>
|
||||
[JsonPropertyName("questions")]
|
||||
[Description("For clarifications, this has one or more questions to ask the user (each with choices). For approvals, this has exactly one item containing the plan summary for the user to approve.")]
|
||||
public required List<PlanningQuestion> Questions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single question or item within a <see cref="PlanningResponse"/>.
|
||||
/// </summary>
|
||||
public class PlanningQuestion
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the message to display to the user.
|
||||
/// For clarification, this is the question. For approval, this is the plan summary.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
[Description("For clarifications, this has the question that needs to be clarified with the user. For approvals, this would contain a summary of the execution plan that the user needs to approve.")]
|
||||
public required string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of choices for the user to pick from.
|
||||
/// Only used for clarification questions. Null when no predefined choices are offered.
|
||||
/// </summary>
|
||||
[JsonPropertyName("choices")]
|
||||
[Description("For clarifications, this has a list of options that the user can choose from. null for approvals.")]
|
||||
public List<string>? Choices { get; set; }
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the type of planning response from the agent.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<PlanningResponseType>))]
|
||||
public enum PlanningResponseType
|
||||
{
|
||||
/// <summary>
|
||||
/// The agent needs clarification and presents options for the user to choose from.
|
||||
/// </summary>
|
||||
[Description("Use this type when you need clarification around the user request and you want to present the user with options to choose from.")]
|
||||
Clarification,
|
||||
|
||||
/// <summary>
|
||||
/// The agent is seeking approval to proceed with execution.
|
||||
/// </summary>
|
||||
[Description("Use this type when you are ready to start execution, but need approval to start executing.")]
|
||||
Approval,
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Displays reasoning content in dark magenta from the response stream.
|
||||
/// </summary>
|
||||
internal sealed class ReasoningDisplayObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
||||
{
|
||||
if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text))
|
||||
{
|
||||
await writer.WriteTextAsync(reasoning.Text, ConsoleColor.DarkMagenta);
|
||||
}
|
||||
}
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Streams agent text output directly to the console.
|
||||
/// Used in normal (non-planning) mode.
|
||||
/// </summary>
|
||||
internal sealed class TextOutputObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnTextAsync(ConsoleWriter writer, string text)
|
||||
{
|
||||
await writer.WriteTextAsync(text);
|
||||
}
|
||||
}
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Collects <see cref="ToolApprovalRequestContent"/> items during the response stream,
|
||||
/// displays approval-needed notifications inline, and prompts the user for approval
|
||||
/// decisions after the stream completes.
|
||||
/// </summary>
|
||||
internal sealed class ToolApprovalObserver : ConsoleObserver
|
||||
{
|
||||
private readonly List<ToolApprovalRequestContent> _approvalRequests = [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
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(fc)
|
||||
: approvalRequest.ToolCall?.ToString() ?? "unknown";
|
||||
await writer.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
||||
ConsoleWriter writer,
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
HarnessConsoleOptions options)
|
||||
{
|
||||
if (this._approvalRequests.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var messages = await PromptForApprovalsAsync(writer, this._approvalRequests);
|
||||
this._approvalRequests.Clear();
|
||||
return messages;
|
||||
}
|
||||
|
||||
private static async Task<List<ChatMessage>?> PromptForApprovalsAsync(ConsoleWriter writer, List<ToolApprovalRequestContent> approvalRequests)
|
||||
{
|
||||
if (approvalRequests.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var responses = new List<AIContent>();
|
||||
foreach (var request in approvalRequests)
|
||||
{
|
||||
string toolName = request.ToolCall is FunctionCallContent fc
|
||||
? ToolCallFormatter.Format(fc)
|
||||
: request.ToolCall?.ToString() ?? "unknown";
|
||||
|
||||
var choices = new List<string>
|
||||
{
|
||||
"Approve this call",
|
||||
"Always approve this tool (any arguments)",
|
||||
"Always approve this tool with these arguments",
|
||||
"Deny",
|
||||
};
|
||||
|
||||
string 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"),
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
responses.Add(response);
|
||||
}
|
||||
|
||||
return [new ChatMessage(ChatRole.User, responses)];
|
||||
}
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Displays tool call notifications (🔧) for <see cref="FunctionCallContent"/>
|
||||
/// and <see cref="ToolCallContent"/> items in the response stream.
|
||||
/// </summary>
|
||||
internal sealed class ToolCallDisplayObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
||||
{
|
||||
if (content is FunctionCallContent functionCall)
|
||||
{
|
||||
await writer.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow);
|
||||
}
|
||||
else if (content is ToolCallContent toolCall)
|
||||
{
|
||||
await writer.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
|
||||
}
|
||||
}
|
||||
}
|
||||
-288
@@ -1,288 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <see cref="FunctionCallContent"/> instances into human-readable strings
|
||||
/// for console display.
|
||||
/// </summary>
|
||||
public static class ToolCallFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a formatted string for the given tool call, with human-readable
|
||||
/// details for known tools (todos, mode, sub-agents, web tools).
|
||||
/// </summary>
|
||||
/// <param name="call">The function call content to format.</param>
|
||||
/// <returns>A formatted string describing the tool call.</returns>
|
||||
public static string Format(FunctionCallContent call)
|
||||
{
|
||||
string? detail = call.Name switch
|
||||
{
|
||||
// Todo tools
|
||||
"TodoList_Add" => FormatAddTodos(call),
|
||||
"TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
|
||||
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
|
||||
"TodoList_GetRemaining" => null,
|
||||
"TodoList_GetAll" => null,
|
||||
|
||||
// Mode tools
|
||||
"AgentMode_Set" => FormatStringArg(call, "mode"),
|
||||
"AgentMode_Get" => null,
|
||||
|
||||
// Sub-agent tools
|
||||
"SubAgents_StartTask" => FormatStartSubTask(call),
|
||||
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
|
||||
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
|
||||
"SubAgents_GetAllTasks" => null,
|
||||
"SubAgents_ContinueTask" => FormatContinueTask(call),
|
||||
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
|
||||
|
||||
// File memory tools
|
||||
"FileMemory_SaveFile" => FormatSaveFile(call),
|
||||
"FileMemory_ReadFile" => FormatStringArg(call, "fileName"),
|
||||
"FileMemory_DeleteFile" => FormatStringArg(call, "fileName"),
|
||||
"FileMemory_ListFiles" => null,
|
||||
"FileMemory_SearchFiles" => FormatSearchFiles(call),
|
||||
|
||||
// External tools
|
||||
"web_search" => FormatStringArg(call, "query"),
|
||||
"DownloadUri" => FormatStringArg(call, "uri"),
|
||||
|
||||
_ => FormatFallback(call),
|
||||
};
|
||||
|
||||
return detail is not null ? $"{call.Name} {detail}" : call.Name;
|
||||
}
|
||||
|
||||
private static string? FormatAddTodos(FunctionCallContent call)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue("todos", out object? todosObj) != true || todosObj is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var titles = new List<string>();
|
||||
|
||||
if (todosObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in jsonArray.EnumerateArray())
|
||||
{
|
||||
string? title = item.TryGetProperty("title", out JsonElement titleElement)
|
||||
? titleElement.GetString()
|
||||
: null;
|
||||
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
titles.Add(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (titles.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"({titles.Count} item{(titles.Count == 1 ? "" : "s")})");
|
||||
foreach (string title in titles)
|
||||
{
|
||||
sb.Append($"\n • {title}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
|
||||
{
|
||||
List<int>? ids = GetIntList(call, paramName);
|
||||
if (ids is null || ids.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"({verb} #{string.Join(", #", ids)})";
|
||||
}
|
||||
|
||||
private static string? FormatSingleId(FunctionCallContent call, string paramName)
|
||||
{
|
||||
int? id = GetInt(call, paramName);
|
||||
return id.HasValue ? $"(task #{id.Value})" : null;
|
||||
}
|
||||
|
||||
private static string? FormatStartSubTask(FunctionCallContent call)
|
||||
{
|
||||
string? agentName = GetString(call, "agentName");
|
||||
string? description = GetString(call, "description");
|
||||
|
||||
if (agentName is null && description is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder("(");
|
||||
if (agentName is not null)
|
||||
{
|
||||
sb.Append($"agent: {agentName}");
|
||||
}
|
||||
|
||||
if (description is not null)
|
||||
{
|
||||
if (agentName is not null)
|
||||
{
|
||||
sb.Append(", ");
|
||||
}
|
||||
|
||||
sb.Append($"\"{Truncate(description, 60)}\"");
|
||||
}
|
||||
|
||||
sb.Append(')');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatContinueTask(FunctionCallContent call)
|
||||
{
|
||||
int? taskId = GetInt(call, "taskId");
|
||||
string? text = GetString(call, "text");
|
||||
|
||||
if (!taskId.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return text is not null
|
||||
? $"(task #{taskId.Value}, \"{Truncate(text, 50)}\")"
|
||||
: $"(task #{taskId.Value})";
|
||||
}
|
||||
|
||||
private static string? FormatSaveFile(FunctionCallContent call)
|
||||
{
|
||||
string? fileName = GetString(call, "fileName");
|
||||
string? description = GetString(call, "description");
|
||||
|
||||
if (fileName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(description)
|
||||
? $"({fileName})"
|
||||
: $"({fileName}, with description)";
|
||||
}
|
||||
|
||||
private static string? FormatSearchFiles(FunctionCallContent call)
|
||||
{
|
||||
string? pattern = GetString(call, "regexPattern");
|
||||
string? filePattern = GetString(call, "filePattern");
|
||||
|
||||
if (pattern is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(filePattern)
|
||||
? $"(/{pattern}/)"
|
||||
: $"(/{pattern}/ in {filePattern})";
|
||||
}
|
||||
|
||||
private static string? FormatStringArg(FunctionCallContent call, string paramName)
|
||||
{
|
||||
string? value = GetString(call, paramName);
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
|
||||
private static string? FormatFallback(FunctionCallContent call)
|
||||
{
|
||||
if (call.Arguments is null || call.Arguments.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var kvp in call.Arguments)
|
||||
{
|
||||
string? stringValue = kvp.Value switch
|
||||
{
|
||||
JsonElement je => je.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => je.GetString(),
|
||||
JsonValueKind.Number => je.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => null,
|
||||
},
|
||||
not null => kvp.Value.ToString(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (stringValue is not null)
|
||||
{
|
||||
parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}");
|
||||
}
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null;
|
||||
}
|
||||
|
||||
private static string? GetString(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
|
||||
string s => s,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
private static int? GetInt(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
|
||||
int i => i,
|
||||
_ => int.TryParse(value.ToString(), out int parsed) ? parsed : null,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<int>? GetIntList(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new List<int>();
|
||||
|
||||
if (value is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in je.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
result.Add(item.GetInt32());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.Count > 0 ? result : null;
|
||||
}
|
||||
|
||||
private static string Truncate(string text, int maxLength)
|
||||
{
|
||||
return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…");
|
||||
}
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Displays token usage statistics (📊) from the response stream.
|
||||
/// </summary>
|
||||
internal sealed class UsageDisplayObserver : ConsoleObserver
|
||||
{
|
||||
private readonly int? _maxContextWindowTokens;
|
||||
private readonly int? _maxOutputTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UsageDisplayObserver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxContextWindowTokens">Optional max context window size in tokens.</param>
|
||||
/// <param name="maxOutputTokens">Optional max output tokens.</param>
|
||||
public UsageDisplayObserver(int? maxContextWindowTokens, int? maxOutputTokens)
|
||||
{
|
||||
this._maxContextWindowTokens = maxContextWindowTokens;
|
||||
this._maxOutputTokens = maxOutputTokens;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
||||
{
|
||||
if (content is UsageContent usage)
|
||||
{
|
||||
if (usage.Details is not null)
|
||||
{
|
||||
await writer.WriteInfoLineAsync(this.FormatUsageBreakdown(usage.Details), ConsoleColor.DarkGray);
|
||||
}
|
||||
else
|
||||
{
|
||||
await writer.WriteInfoLineAsync("📊 Tokens —", ConsoleColor.DarkGray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatUsageBreakdown(UsageDetails details)
|
||||
{
|
||||
int? inputBudget = (this._maxContextWindowTokens is not null && this._maxOutputTokens is not null)
|
||||
? this._maxContextWindowTokens.Value - this._maxOutputTokens.Value
|
||||
: null;
|
||||
|
||||
return $"📊 Tokens — input: {FormatTokenCount(details.InputTokenCount, inputBudget)}"
|
||||
+ $" | output: {FormatTokenCount(details.OutputTokenCount, this._maxOutputTokens)}"
|
||||
+ $" | total: {FormatTokenCount(details.TotalTokenCount, this._maxContextWindowTokens)}";
|
||||
}
|
||||
|
||||
private static string FormatTokenCount(long? count, int? budget)
|
||||
{
|
||||
if (count is null)
|
||||
{
|
||||
return "—";
|
||||
}
|
||||
|
||||
if (budget is not null && budget.Value > 0)
|
||||
{
|
||||
double pct = (double)count.Value / budget.Value * 100;
|
||||
return $"{count.Value:N0}/{budget.Value:N0} ({pct:F1}%)";
|
||||
}
|
||||
|
||||
return $"{count.Value:N0}";
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// A restartable spinner that can be started and stopped multiple times.
|
||||
/// </summary>
|
||||
internal sealed class Spinner : IDisposable
|
||||
{
|
||||
private static readonly string[] s_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _task;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (this._task is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._cts = new CancellationTokenSource();
|
||||
this._task = RunAsync(this._cts.Token);
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (this._cts is null || this._task is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._cts.Cancel();
|
||||
await this._task;
|
||||
this._cts.Dispose();
|
||||
this._cts = null;
|
||||
this._task = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (this._cts is not null && this._task is not null)
|
||||
{
|
||||
this._cts.Cancel();
|
||||
|
||||
// Block briefly to let the spinner task clean up.
|
||||
// This prevents the background task from writing to the console after disposal.
|
||||
#pragma warning disable VSTHRD002 // Synchronous wait in Dispose is acceptable here — the spinner task completes quickly on cancellation.
|
||||
this._task.Wait();
|
||||
#pragma warning restore VSTHRD002
|
||||
}
|
||||
|
||||
this._cts?.Dispose();
|
||||
this._cts = null;
|
||||
this._task = null;
|
||||
}
|
||||
|
||||
private static async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
int i = 0;
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
System.Console.Write(s_frames[i % s_frames.Length]);
|
||||
await Task.Delay(80, cancellationToken);
|
||||
System.Console.Write("\b \b");
|
||||
i++;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Clear the last spinner frame left on screen.
|
||||
System.Console.Write("\b \b");
|
||||
}
|
||||
}
|
||||
}
|
||||
-20
@@ -1,20 +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.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,190 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// 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.
|
||||
// exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
using SampleApp;
|
||||
|
||||
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;
|
||||
|
||||
// Create a ChatClientAgent with the Harness providers (TodoProvider and AgentModeProvider)
|
||||
// and research-focused instructions including the mandatory planning workflow.
|
||||
var 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.
|
||||
|
||||
## 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**
|
||||
|
||||
When presenting your final findings:
|
||||
- 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.
|
||||
- Save the final research report to file memory so it survives compaction and can be referenced later.
|
||||
|
||||
**File memory**
|
||||
|
||||
Use the FileMemory_* tools to:
|
||||
- Store downloaded search results or web pages.
|
||||
- Store plans.
|
||||
- Read the current plan to make sure tasks were done according to plan.
|
||||
- Store findings.
|
||||
- Check for relevant previously downloaded data / findings before starting new research.
|
||||
""";
|
||||
|
||||
// Create a compaction strategy based on the model's context window.
|
||||
// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens.
|
||||
// Defaults: tool result eviction at 50% of input budget, truncation at 80%.
|
||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens);
|
||||
|
||||
AIAgent agent =
|
||||
// Create an OpenAIClient that communicates with the Foundry responses service.
|
||||
new OpenAIClient(
|
||||
// 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 BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
||||
new OpenAIClientOptions()
|
||||
{
|
||||
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 TodoProvider(), // Add an AIContextProvider to allow the agent to create a TODO list, which is stored in the session.
|
||||
new AgentModeProvider(), // Add an AIContextProvider that tracks the agent mode and allows switching mode. Current mode is stored in the session.
|
||||
new FileMemoryProvider( // Add an AIContextProvider that can store memories in files under a session specific working folder.
|
||||
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools =
|
||||
[
|
||||
ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service.
|
||||
new WebBrowsingTool(), // Add a local web browsing tool that converts html to markdown.
|
||||
],
|
||||
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
})
|
||||
.AsBuilder()
|
||||
.UseToolApproval() // Add the ability to auto approve tools once a user has said they don't want to be asked again. Approval rules are tied to the session.
|
||||
.Build();
|
||||
|
||||
// Run the interactive console session using the shared HarnessConsole helper.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
title: "Research Assistant",
|
||||
userPrompt: "Enter a research topic to get started.",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
EnablePlanningUx = true,
|
||||
PlanningModeName = "plan",
|
||||
ExecutionModeName = "execute"
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to use a `ChatClientAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Azure AI Foundry.
|
||||
|
||||
Key features showcased:
|
||||
|
||||
- **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)
|
||||
- **Interactive conversation** — you can review the agent's plan, provide feedback, and approve before execution begins
|
||||
- **Streaming output** — responses are streamed token-by-token for a natural experience
|
||||
- **`/todos` command** — view the current todo list at any time without invoking the agent
|
||||
- **Mode-based coloring** — console output is colored based on the agent's current mode (cyan for plan, green for execute)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`)
|
||||
2. Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```bash
|
||||
# Required: Your Azure AI Foundry OpenAI endpoint
|
||||
export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/"
|
||||
|
||||
# Optional: Model deployment name (defaults to gpt-5.4)
|
||||
export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4"
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet run --project samples/02-agents/Harness/Harness_Step01_Research
|
||||
```
|
||||
|
||||
## What to Expect
|
||||
|
||||
The sample starts an interactive conversation loop. You can:
|
||||
|
||||
1. **Enter a research topic** — the agent will analyze it and create a plan with todos
|
||||
2. **Review and adjust** — provide feedback on the plan, ask for changes, or approve it
|
||||
3. **Type `/todos`** — to see the current todo list at any time
|
||||
4. **Watch execution** — once approved, tell the agent to proceed and it will work through each todo
|
||||
5. **Type `exit`** — to end the session
|
||||
|
||||
The prompt and agent output are colored by the current mode: **cyan** during planning, **green** during execution.
|
||||
@@ -1,287 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// An AI function that downloads HTML pages and converts them to markdown.
|
||||
/// </summary>
|
||||
internal sealed partial class WebBrowsingTool : AIFunction
|
||||
{
|
||||
private static readonly HttpClient s_httpClient = new();
|
||||
private readonly AIFunction _inner = AIFunctionFactory.Create(DownloadUriAsync);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Name => this._inner.Name;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Description => this._inner.Description;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override JsonElement JsonSchema => this._inner.JsonSchema;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<object?> InvokeCoreAsync(
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken) =>
|
||||
this._inner.InvokeAsync(arguments, cancellationToken);
|
||||
|
||||
[Description("Fetch the html from the given url as markdown")]
|
||||
private static async Task<string> DownloadUriAsync(
|
||||
[Description("The URL to download")] string uri,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Uri.TryCreate(uri, UriKind.Absolute, out Uri? parsedUri))
|
||||
{
|
||||
return $"Error: '{uri}' is not a valid URL.";
|
||||
}
|
||||
|
||||
if (parsedUri.Scheme is not "http" and not "https")
|
||||
{
|
||||
return $"Error: Only HTTP and HTTPS URLs are supported. Got: '{parsedUri.Scheme}'.";
|
||||
}
|
||||
|
||||
// NOTE: In production scenarios, consider also blocking requests to private/internal IP
|
||||
// ranges (e.g., 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.0.0.1, 169.254.169.254)
|
||||
// to prevent SSRF attacks via prompt injection in web content.
|
||||
|
||||
try
|
||||
{
|
||||
string html = await s_httpClient.GetStringAsync(parsedUri, cancellationToken);
|
||||
return HtmlToMarkdownConverter.Convert(html);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return $"Error downloading {uri}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple HTML to Markdown converter using regex-based transformations.
|
||||
/// Handles the most common HTML elements without requiring external dependencies.
|
||||
/// </summary>
|
||||
private static partial class HtmlToMarkdownConverter
|
||||
{
|
||||
public static string Convert(string html)
|
||||
{
|
||||
// Extract body content if present, otherwise use the full HTML.
|
||||
var bodyMatch = BodyRegex().Match(html);
|
||||
string content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
|
||||
|
||||
// Remove script, style, and head blocks.
|
||||
content = ScriptRegex().Replace(content, string.Empty);
|
||||
content = StyleRegex().Replace(content, string.Empty);
|
||||
content = HeadRegex().Replace(content, string.Empty);
|
||||
content = CommentRegex().Replace(content, string.Empty);
|
||||
|
||||
// Convert block elements before inline elements.
|
||||
content = ConvertHeadings(content);
|
||||
content = ConvertCodeBlocks(content);
|
||||
content = ConvertBlockquotes(content);
|
||||
content = ConvertLists(content);
|
||||
content = ConvertHorizontalRules(content);
|
||||
|
||||
// Convert inline elements.
|
||||
content = ConvertLinks(content);
|
||||
content = ConvertImages(content);
|
||||
content = ConvertBold(content);
|
||||
content = ConvertItalic(content);
|
||||
content = ConvertInlineCode(content);
|
||||
|
||||
// Convert structural elements.
|
||||
content = ConvertParagraphs(content);
|
||||
content = ConvertLineBreaks(content);
|
||||
|
||||
// Strip remaining HTML tags.
|
||||
content = StripTagsRegex().Replace(content, string.Empty);
|
||||
|
||||
// Decode HTML entities.
|
||||
content = WebUtility.HtmlDecode(content);
|
||||
|
||||
// Clean up excessive whitespace.
|
||||
content = ExcessiveNewlinesRegex().Replace(content, "\n\n");
|
||||
|
||||
return content.Trim();
|
||||
}
|
||||
|
||||
private static string ConvertHeadings(string html)
|
||||
{
|
||||
html = H1Regex().Replace(html, m => $"\n# {StripInnerTags(m.Groups[1].Value).Trim()}\n");
|
||||
html = H2Regex().Replace(html, m => $"\n## {StripInnerTags(m.Groups[1].Value).Trim()}\n");
|
||||
html = H3Regex().Replace(html, m => $"\n### {StripInnerTags(m.Groups[1].Value).Trim()}\n");
|
||||
html = H4Regex().Replace(html, m => $"\n#### {StripInnerTags(m.Groups[1].Value).Trim()}\n");
|
||||
html = H5Regex().Replace(html, m => $"\n##### {StripInnerTags(m.Groups[1].Value).Trim()}\n");
|
||||
html = H6Regex().Replace(html, m => $"\n###### {StripInnerTags(m.Groups[1].Value).Trim()}\n");
|
||||
return html;
|
||||
}
|
||||
|
||||
private static string ConvertLinks(string html) =>
|
||||
LinkRegex().Replace(html, m =>
|
||||
{
|
||||
string href = m.Groups[1].Value;
|
||||
string text = StripInnerTags(m.Groups[2].Value).Trim();
|
||||
|
||||
// Skip javascript and data links.
|
||||
if (href.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) ||
|
||||
href.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(text) ? string.Empty : $"[{text}]({href})";
|
||||
});
|
||||
|
||||
private static string ConvertImages(string html) =>
|
||||
ImageRegex().Replace(html, m =>
|
||||
{
|
||||
string src = m.Groups[1].Value;
|
||||
string alt = m.Groups[2].Value;
|
||||
|
||||
// Truncate data URIs.
|
||||
if (src.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
src = src.Split(',')[0] + "...";
|
||||
}
|
||||
|
||||
return $"";
|
||||
});
|
||||
|
||||
private static string ConvertBold(string html) =>
|
||||
BoldRegex().Replace(html, m => $"**{m.Groups[2].Value}**");
|
||||
|
||||
private static string ConvertItalic(string html) =>
|
||||
ItalicRegex().Replace(html, m => $"*{m.Groups[2].Value}*");
|
||||
|
||||
private static string ConvertInlineCode(string html) =>
|
||||
InlineCodeRegex().Replace(html, m => $"`{m.Groups[1].Value}`");
|
||||
|
||||
private static string ConvertCodeBlocks(string html) =>
|
||||
CodeBlockRegex().Replace(html, m => $"\n```\n{StripInnerTags(m.Groups[1].Value).Trim()}\n```\n");
|
||||
|
||||
private static string ConvertBlockquotes(string html) =>
|
||||
BlockquoteRegex().Replace(html, m =>
|
||||
{
|
||||
string inner = StripInnerTags(m.Groups[1].Value).Trim();
|
||||
// Prefix each line with "> ".
|
||||
string quoted = string.Join("\n", inner.Split('\n').Select(line => $"> {line.Trim()}"));
|
||||
return $"\n{quoted}\n";
|
||||
});
|
||||
|
||||
private static string ConvertLists(string html)
|
||||
{
|
||||
// Unordered lists.
|
||||
html = UlRegex().Replace(html, m =>
|
||||
{
|
||||
string items = LiRegex().Replace(m.Groups[1].Value, li => $"- {StripInnerTags(li.Groups[1].Value).Trim()}\n");
|
||||
return $"\n{items}";
|
||||
});
|
||||
|
||||
// Ordered lists.
|
||||
html = OlRegex().Replace(html, m =>
|
||||
{
|
||||
int index = 1;
|
||||
string items = LiRegex().Replace(m.Groups[1].Value, li => $"{index++}. {StripInnerTags(li.Groups[1].Value).Trim()}\n");
|
||||
return $"\n{items}";
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private static string ConvertHorizontalRules(string html) =>
|
||||
HrRegex().Replace(html, "\n---\n");
|
||||
|
||||
private static string ConvertParagraphs(string html) =>
|
||||
ParagraphRegex().Replace(html, m => $"\n\n{m.Groups[1].Value}\n\n");
|
||||
|
||||
private static string ConvertLineBreaks(string html) =>
|
||||
BrRegex().Replace(html, "\n");
|
||||
|
||||
private static string StripInnerTags(string html) =>
|
||||
StripTagsRegex().Replace(html, string.Empty);
|
||||
|
||||
// Source-generated regex patterns for performance and AOT compatibility.
|
||||
|
||||
[GeneratedRegex(@"<body[^>]*>(.*?)</body>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BodyRegex();
|
||||
|
||||
[GeneratedRegex(@"<script[^>]*>.*?</script>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ScriptRegex();
|
||||
|
||||
[GeneratedRegex(@"<style[^>]*>.*?</style>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex StyleRegex();
|
||||
|
||||
[GeneratedRegex(@"<head[^>]*>.*?</head>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex HeadRegex();
|
||||
|
||||
[GeneratedRegex(@"<!--.*?-->", RegexOptions.Singleline)]
|
||||
private static partial Regex CommentRegex();
|
||||
|
||||
[GeneratedRegex(@"<h1[^>]*>(.*?)</h1>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex H1Regex();
|
||||
|
||||
[GeneratedRegex(@"<h2[^>]*>(.*?)</h2>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex H2Regex();
|
||||
|
||||
[GeneratedRegex(@"<h3[^>]*>(.*?)</h3>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex H3Regex();
|
||||
|
||||
[GeneratedRegex(@"<h4[^>]*>(.*?)</h4>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex H4Regex();
|
||||
|
||||
[GeneratedRegex(@"<h5[^>]*>(.*?)</h5>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex H5Regex();
|
||||
|
||||
[GeneratedRegex(@"<h6[^>]*>(.*?)</h6>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex H6Regex();
|
||||
|
||||
[GeneratedRegex(@"<a\s[^>]*href=[""']([^""']*)[""'][^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LinkRegex();
|
||||
|
||||
[GeneratedRegex(@"<img\s[^>]*src=[""']([^""']*)[""'][^>]*?(?:alt=[""']([^""']*)[""'])?[^>]*/?>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ImageRegex();
|
||||
|
||||
[GeneratedRegex(@"<(strong|b)\b[^>]*>(.*?)</\1>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BoldRegex();
|
||||
|
||||
[GeneratedRegex(@"<(em|i)\b[^>]*>(.*?)</\1>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ItalicRegex();
|
||||
|
||||
[GeneratedRegex(@"<code[^>]*>(.*?)</code>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex InlineCodeRegex();
|
||||
|
||||
[GeneratedRegex(@"<pre[^>]*>(.*?)</pre>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CodeBlockRegex();
|
||||
|
||||
[GeneratedRegex(@"<blockquote[^>]*>(.*?)</blockquote>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BlockquoteRegex();
|
||||
|
||||
[GeneratedRegex(@"<ul[^>]*>(.*?)</ul>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex UlRegex();
|
||||
|
||||
[GeneratedRegex(@"<ol[^>]*>(.*?)</ol>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex OlRegex();
|
||||
|
||||
[GeneratedRegex(@"<li[^>]*>(.*?)</li>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LiRegex();
|
||||
|
||||
[GeneratedRegex(@"<hr\s*/?>", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex HrRegex();
|
||||
|
||||
[GeneratedRegex(@"<p[^>]*>(.*?)</p>", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ParagraphRegex();
|
||||
|
||||
[GeneratedRegex(@"<br\s*/?>", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BrRegex();
|
||||
|
||||
[GeneratedRegex(@"<[^>]+>")]
|
||||
private static partial Regex StripTagsRegex();
|
||||
|
||||
[GeneratedRegex(@"\n{3,}")]
|
||||
private static partial Regex ExcessiveNewlinesRegex();
|
||||
}
|
||||
}
|
||||
-20
@@ -1,20 +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.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,106 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use the SubAgentsProvider to delegate work to sub-agents.
|
||||
// A parent agent is given a list of stock tickers and instructed to find the closing price
|
||||
// for each ticker on December 31, 2025. It delegates the web searches to a sub-agent
|
||||
// equipped with Foundry's hosted web search tool.
|
||||
//
|
||||
// Special commands:
|
||||
// exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
||||
|
||||
// --- Sub-agent: Web Search Agent ---
|
||||
// This agent can search the web and is used by the parent agent to look up stock prices.
|
||||
AIAgent webSearchAgent =
|
||||
new OpenAIClient(
|
||||
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
||||
new OpenAIClientOptions()
|
||||
{
|
||||
Endpoint = new Uri(endpoint),
|
||||
RetryPolicy = new ClientRetryPolicy(3)
|
||||
})
|
||||
.GetResponsesClient()
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName)
|
||||
.AsAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "WebSearchAgent",
|
||||
Description = "An agent that can search the web to find information.",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = "You are a web search assistant. When asked to find information, use the web search tool to look it up and return a concise, factual answer.",
|
||||
Tools =
|
||||
[
|
||||
ResponseTool.CreateWebSearchTool().AsAITool(),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// --- Parent agent: Stock Price Researcher ---
|
||||
// This agent orchestrates the sub-agent to look up stock prices in parallel.
|
||||
var parentInstructions =
|
||||
"""
|
||||
You are a stock price research assistant. You have access to a web search sub-agent that can look up information on the web.
|
||||
|
||||
When given a list of stock tickers, your job is to find the closing price for each ticker on December 31, 2025.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. For each ticker, start a sub-task on the WebSearchAgent asking it to find the closing price on December 31, 2025.
|
||||
- Start all sub-tasks before waiting for any of them to complete, so they run concurrently.
|
||||
2. Wait for all sub-tasks to complete.
|
||||
3. Retrieve the results from each sub-task.
|
||||
4. Present a summary table with the ticker symbol and closing price for each stock.
|
||||
5. Clear all completed tasks to free memory.
|
||||
|
||||
## Important
|
||||
|
||||
- Always delegate web searches to the WebSearchAgent sub-agent. Do not try to answer from memory.
|
||||
- If a sub-task fails or returns unclear results, continue the task with a more specific query.
|
||||
- Present results in a clean markdown table format.
|
||||
""";
|
||||
|
||||
AIAgent parentAgent =
|
||||
new OpenAIClient(
|
||||
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
||||
new OpenAIClientOptions()
|
||||
{
|
||||
Endpoint = new Uri(endpoint),
|
||||
RetryPolicy = new ClientRetryPolicy(3)
|
||||
})
|
||||
.GetResponsesClient()
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName)
|
||||
.AsAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "StockPriceResearcher",
|
||||
Description = "An agent that researches stock prices using sub-agents.",
|
||||
AIContextProviders =
|
||||
[
|
||||
new SubAgentsProvider([webSearchAgent]),
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = parentInstructions,
|
||||
MaxOutputTokens = 16_000,
|
||||
},
|
||||
});
|
||||
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
parentAgent,
|
||||
title: "Stock Price Researcher (SubAgents Demo)",
|
||||
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
|
||||
@@ -1,53 +0,0 @@
|
||||
# Harness Step 02 — SubAgents (Stock Price Research)
|
||||
|
||||
This sample demonstrates how to use the **SubAgentsProvider** to delegate work from a parent agent to sub-agents.
|
||||
|
||||
## What It Does
|
||||
|
||||
A parent agent receives a list of stock tickers and uses a web-search sub-agent to find the closing price for each ticker on December 31, 2025. The sub-tasks run concurrently, and results are presented in a summary table.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ StockPriceResearcher │
|
||||
│ (Parent Agent) │
|
||||
│ │
|
||||
│ SubAgentsProvider │
|
||||
│ ├─ SubAgents_StartTask │
|
||||
│ ├─ SubAgents_WaitFor... │
|
||||
│ ├─ SubAgents_GetTaskResults │
|
||||
│ └─ ... │
|
||||
└────────────┬────────────────────┘
|
||||
│ delegates to
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ WebSearchAgent │
|
||||
│ (Sub-Agent) │
|
||||
│ │
|
||||
│ Tools: │
|
||||
│ └─ web_search (Foundry) │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An Azure AI Foundry endpoint with an OpenAI model deployment
|
||||
- Set the following environment variables:
|
||||
- `AZURE_FOUNDRY_OPENAI_ENDPOINT` — Your Foundry OpenAI endpoint URL
|
||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` — Model deployment name (defaults to `gpt-5.4`)
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents
|
||||
dotnet run
|
||||
```
|
||||
|
||||
When prompted, enter a list of stock tickers such as:
|
||||
|
||||
```
|
||||
BAC, MSFT, BA
|
||||
```
|
||||
|
||||
The parent agent will delegate each ticker lookup to the web search sub-agent concurrently and present the results in a table.
|
||||
-24
@@ -1,24 +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.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="data\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,110 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a ChatClientAgent with the FileAccessProvider
|
||||
// to give an agent access to a folder of CSV data files. The agent can read, analyze,
|
||||
// and extract information from the data, then write results back as new files.
|
||||
//
|
||||
// The sample includes a pre-populated `data/` folder with sales transaction data.
|
||||
// Ask the agent to analyze the data, produce summaries, or create new output files.
|
||||
//
|
||||
// Special commands:
|
||||
// exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
|
||||
|
||||
const int MaxContextWindowTokens = 1_050_000;
|
||||
const int MaxOutputTokens = 128_000;
|
||||
|
||||
// Point the file store at the data/ folder that ships with the sample.
|
||||
var dataFolder = Path.Combine(AppContext.BaseDirectory, "data");
|
||||
var fileStore = new FileSystemAgentFileStore(dataFolder);
|
||||
|
||||
var instructions =
|
||||
"""
|
||||
You are a data analyst assistant. You have access to a folder of data files via the FileAccess_* tools.
|
||||
|
||||
## Getting started
|
||||
- Start by listing available files with FileAccess_ListFiles to see what data is available.
|
||||
- Read the files to understand their structure and contents.
|
||||
|
||||
## Working with data
|
||||
- When asked to analyze data, read the relevant files first, then perform the analysis.
|
||||
- Show your analysis clearly with tables, summaries, and key insights.
|
||||
- When calculations are needed, work through them step by step and show your reasoning.
|
||||
|
||||
## Writing output
|
||||
- When asked to produce output files (e.g., reports, summaries, filtered data), use FileAccess_SaveFile to write them.
|
||||
- Use appropriate file formats: CSV for tabular data, Markdown for reports.
|
||||
- Confirm what you wrote and where.
|
||||
|
||||
## Important
|
||||
- Never modify or delete the original input data files unless explicitly asked to do so.
|
||||
- If asked about data you haven't read yet, read it first before answering.
|
||||
- Always explain your reasoning and thought process as you work through tasks.
|
||||
- Always explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process.
|
||||
""";
|
||||
|
||||
// Create a compaction strategy based on the model's context window.
|
||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens);
|
||||
|
||||
AIAgent agent =
|
||||
new OpenAIClient(
|
||||
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
|
||||
new OpenAIClientOptions()
|
||||
{
|
||||
Endpoint = new Uri(endpoint),
|
||||
RetryPolicy = new ClientRetryPolicy(3)
|
||||
})
|
||||
.GetResponsesClient()
|
||||
.AsIChatClientWithStoredOutputDisabled(deploymentName)
|
||||
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation()
|
||||
.UsePerServiceCallChatHistoryPersistence()
|
||||
.UseAIContextProviders(new CompactionProvider(compactionStrategy))
|
||||
|
||||
.BuildAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "DataAnalyst",
|
||||
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
|
||||
UseProvidedChatClientAsIs = true,
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
ChatHistoryProvider = new InMemoryChatHistoryProvider(
|
||||
new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
ChatReducer = compactionStrategy.AsChatReducer(),
|
||||
}),
|
||||
AIContextProviders =
|
||||
[
|
||||
new FileAccessProvider(fileStore),
|
||||
],
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
},
|
||||
})
|
||||
.AsBuilder()
|
||||
.Build();
|
||||
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
title: "Data Processing Assistant",
|
||||
userPrompt: "Ask me to analyze the data files, produce summaries, or create output files.");
|
||||
@@ -1,65 +0,0 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to use a `ChatClientAgent` with the `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results.
|
||||
|
||||
Key features showcased:
|
||||
|
||||
- **FileAccessProvider** — gives the agent tools to read, write, list, search, and delete files in a shared data folder
|
||||
- **CSV data processing** — the agent reads sales transaction data and performs analysis on demand
|
||||
- **Output file creation** — the agent can write summaries, filtered data, or reports back to the data folder
|
||||
- **Streaming output** — responses are streamed token-by-token for a natural experience
|
||||
- **No planning mode** — this is a simple conversational sample focused on data interaction
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`)
|
||||
2. Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```bash
|
||||
# Required: Your Azure AI Foundry OpenAI endpoint
|
||||
export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/"
|
||||
|
||||
# Optional: Model deployment name (defaults to gpt-5.4)
|
||||
export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4"
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet run --project samples/02-agents/Harness/Harness_Step03_DataProcessing
|
||||
```
|
||||
|
||||
## What to Expect
|
||||
|
||||
The sample starts an interactive conversation with a data analyst agent. The `data/` folder contains a `sales.csv` file with ~50 rows of sales transaction data (date, product, category, quantity, unit price, region, salesperson).
|
||||
|
||||
You can ask the agent to:
|
||||
|
||||
1. **List available files** — "What files do you have?"
|
||||
2. **Analyze the data** — "What are the total sales by region?" or "Which salesperson has the highest revenue?"
|
||||
3. **Create output files** — "Create a summary report as a markdown file" or "Write a CSV with monthly totals"
|
||||
4. **Search for patterns** — "Find all transactions over $1000"
|
||||
5. **Type `exit`** — to end the session
|
||||
|
||||
E.g. try the following prompt `Please process the sales.csv file by first filtering it to only North region sales, and then calculating the sum of sales by person. I'd like to write the results of the processing to north_region_totals.csv`.
|
||||
|
||||
## Sample Data
|
||||
|
||||
The included `data/sales.csv` contains sales transactions from January to March 2025 with the following columns:
|
||||
|
||||
| Column | Description |
|
||||
| --- | --- |
|
||||
| `date` | Transaction date (YYYY-MM-DD) |
|
||||
| `product` | Product name |
|
||||
| `category` | Product category (Electronics, Furniture, Stationery) |
|
||||
| `quantity` | Units sold |
|
||||
| `unit_price` | Price per unit |
|
||||
| `region` | Sales region (North, South, West) |
|
||||
| `salesperson` | Name of the salesperson |
|
||||
@@ -1,50 +0,0 @@
|
||||
date,product,category,quantity,unit_price,region,salesperson
|
||||
2025-01-03,Laptop Pro 15,Electronics,2,1299.99,North,Alice
|
||||
2025-01-05,Ergonomic Chair,Furniture,5,349.50,South,Bob
|
||||
2025-01-07,Wireless Mouse,Electronics,12,24.99,North,Alice
|
||||
2025-01-08,Standing Desk,Furniture,1,599.00,West,Carol
|
||||
2025-01-10,USB-C Hub,Electronics,8,45.99,North,David
|
||||
2025-01-12,Monitor 27in,Electronics,3,429.00,South,Bob
|
||||
2025-01-14,Desk Lamp,Furniture,6,79.95,West,Carol
|
||||
2025-01-15,Keyboard Mech,Electronics,4,149.99,North,Alice
|
||||
2025-01-17,Filing Cabinet,Furniture,2,189.00,South,David
|
||||
2025-01-20,Webcam HD,Electronics,10,89.99,West,Bob
|
||||
2025-01-22,Laptop Pro 15,Electronics,1,1299.99,South,Carol
|
||||
2025-01-24,Ergonomic Chair,Furniture,3,349.50,North,Alice
|
||||
2025-01-25,Notebook Pack,Stationery,20,12.99,South,David
|
||||
2025-01-27,Wireless Mouse,Electronics,15,24.99,West,Carol
|
||||
2025-01-28,Whiteboard,Stationery,4,129.00,North,Bob
|
||||
2025-01-30,Standing Desk,Furniture,2,599.00,South,Alice
|
||||
2025-02-02,USB-C Hub,Electronics,6,45.99,West,David
|
||||
2025-02-04,Monitor 27in,Electronics,2,429.00,North,Carol
|
||||
2025-02-05,Desk Lamp,Furniture,8,79.95,South,Bob
|
||||
2025-02-07,Keyboard Mech,Electronics,5,149.99,West,Alice
|
||||
2025-02-09,Filing Cabinet,Furniture,1,189.00,North,David
|
||||
2025-02-11,Webcam HD,Electronics,7,89.99,South,Carol
|
||||
2025-02-13,Laptop Pro 15,Electronics,3,1299.99,West,Bob
|
||||
2025-02-15,Notebook Pack,Stationery,30,12.99,North,Alice
|
||||
2025-02-17,Ergonomic Chair,Furniture,4,349.50,South,David
|
||||
2025-02-19,Wireless Mouse,Electronics,20,24.99,North,Carol
|
||||
2025-02-20,Whiteboard,Stationery,2,129.00,West,Bob
|
||||
2025-02-22,Standing Desk,Furniture,1,599.00,North,Alice
|
||||
2025-02-24,USB-C Hub,Electronics,10,45.99,South,David
|
||||
2025-02-26,Monitor 27in,Electronics,4,429.00,West,Carol
|
||||
2025-02-28,Desk Lamp,Furniture,3,79.95,North,Bob
|
||||
2025-03-02,Keyboard Mech,Electronics,6,149.99,South,Alice
|
||||
2025-03-04,Filing Cabinet,Furniture,3,189.00,West,David
|
||||
2025-03-06,Webcam HD,Electronics,9,89.99,North,Carol
|
||||
2025-03-08,Laptop Pro 15,Electronics,2,1299.99,South,Bob
|
||||
2025-03-10,Notebook Pack,Stationery,25,12.99,West,Alice
|
||||
2025-03-12,Ergonomic Chair,Furniture,6,349.50,North,David
|
||||
2025-03-14,Wireless Mouse,Electronics,18,24.99,South,Carol
|
||||
2025-03-15,Whiteboard,Stationery,5,129.00,North,Bob
|
||||
2025-03-17,Standing Desk,Furniture,3,599.00,West,Alice
|
||||
2025-03-19,USB-C Hub,Electronics,7,45.99,North,David
|
||||
2025-03-21,Monitor 27in,Electronics,5,429.00,South,Carol
|
||||
2025-03-23,Desk Lamp,Furniture,4,79.95,West,Bob
|
||||
2025-03-25,Keyboard Mech,Electronics,3,149.99,North,Alice
|
||||
2025-03-27,Filing Cabinet,Furniture,2,189.00,South,David
|
||||
2025-03-28,Webcam HD,Electronics,11,89.99,West,Carol
|
||||
2025-03-29,Laptop Pro 15,Electronics,1,1299.99,North,Bob
|
||||
2025-03-30,Notebook Pack,Stationery,15,12.99,South,Alice
|
||||
2025-03-31,Ergonomic Chair,Furniture,2,349.50,West,David
|
||||
|
@@ -1,11 +0,0 @@
|
||||
# Harness Agent Samples
|
||||
|
||||
Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Agents.AI/Harness/) — reusable providers that add planning, task management, and mode tracking to any `ChatClientAgent`.
|
||||
|
||||
## Samples
|
||||
|
||||
| Sample | Description |
|
||||
| --- | --- |
|
||||
| [Harness_Step01_Research](./Harness_Step01_Research/README.md) | Using a ChatClientAgent with TodoProvider and AgentModeProvider for research, showcasing planning mode and todo management |
|
||||
| [Harness_Step02_Research_WithSubAgents](./Harness_Step02_Research_WithSubAgents/README.md) | Using SubAgentsProvider to delegate stock price lookups to a web-search sub-agent concurrently |
|
||||
| [Harness_Step03_DataProcessing](./Harness_Step03_DataProcessing/README.md) | Using FileAccessProvider to give an agent access to CSV data files for reading, analysis, and output generation |
|
||||
@@ -11,13 +11,11 @@ The getting started samples demonstrate the fundamental concepts and functionali
|
||||
| [Agent Providers](./AgentProviders/README.md) | Getting started with creating agents using various providers |
|
||||
| [Agents With Retrieval Augmented Generation (RAG)](./AgentWithRAG/README.md) | Adding Retrieval Augmented Generation (RAG) capabilities to your agents |
|
||||
| [Agents With Memory](./AgentWithMemory/README.md) | Adding memory capabilities to your agents |
|
||||
| [Agents With CodeAct (Hyperlight)](./AgentWithCodeAct/README.md) | Enabling sandboxed code execution (CodeAct) for your agents via Hyperlight |
|
||||
| [Agent Open Telemetry](./AgentOpenTelemetry/README.md) | Getting started with OpenTelemetry for agents |
|
||||
| [Agent With OpenAI exchange types](./AgentWithOpenAI/README.md) | Using OpenAI exchange types with agents |
|
||||
| [Agent With Anthropic](./AgentWithAnthropic/README.md) | Getting started with agents using Anthropic Claude |
|
||||
| [Model Context Protocol](./ModelContextProtocol/README.md) | Getting started with Model Context Protocol |
|
||||
| [Agent Skills](./AgentSkills/README.md) | Getting started with Agent Skills |
|
||||
| [Agent Harness with built-in tools](./Harness/README.md) | Demonstrating how to build an Agent Harness with built-in planning, todo, and mode management tooling |
|
||||
| [Declarative Agents](./DeclarativeAgents) | Loading and executing AI agents from YAML configuration files |
|
||||
| [AG-UI](./AGUI/README.md) | Getting started with AG-UI (Agent UI Protocol) servers and clients |
|
||||
| [Dev UI](./DevUI/README.md) | Interactive web interface for testing and debugging AI agents during development |
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
|
||||
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
|
||||
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="InvokeHttpRequest.yaml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,76 +0,0 @@
|
||||
#
|
||||
# This workflow demonstrates using HttpRequestAction to call a REST API directly
|
||||
# from the workflow without going through an AI agent first.
|
||||
#
|
||||
# HttpRequestAction allows workflows to:
|
||||
# - Fetch data from external HTTP endpoints
|
||||
# - Store the parsed response in workflow variables for later use
|
||||
# - Add the response body to the conversation so a downstream agent can
|
||||
# answer questions based on it
|
||||
#
|
||||
# This sample fetches public metadata for the dotnet/runtime repository from
|
||||
# the GitHub REST API (no authentication required) and uses an agent to
|
||||
# answer follow-up questions about it.
|
||||
#
|
||||
# Example input:
|
||||
# How many subscribers does the repository have?
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_invoke_http_request_demo
|
||||
actions:
|
||||
|
||||
# Capture the original user message for input to the follow-up agent.
|
||||
- kind: SetVariable
|
||||
id: set_user_message
|
||||
variable: Local.InputMessage
|
||||
value: =System.LastMessage
|
||||
|
||||
# Set the repository org/name used to form the request URL.
|
||||
- kind: SetVariable
|
||||
id: set_repo_name
|
||||
variable: Local.RepoName
|
||||
value: microsoft/agent-framework
|
||||
|
||||
# Invoke the GitHub repo API. The response body is parsed into Local.RepoInfo
|
||||
# and also added to the conversation (via conversationId) so the agent below
|
||||
# can answer questions based on it.
|
||||
- kind: HttpRequestAction
|
||||
id: fetch_repo_info
|
||||
conversationId: =System.ConversationId
|
||||
method: GET
|
||||
url: =Concatenate("https://api.github.com/repos/", Local.RepoName)
|
||||
headers:
|
||||
Accept: application/vnd.github+json
|
||||
User-Agent: agent-framework-sample
|
||||
response: Local.RepoInfo
|
||||
|
||||
# Display a confirmation message showing key fields from the parsed response.
|
||||
- kind: SendMessage
|
||||
id: show_repo_summary
|
||||
message: "Fetched repo: visibility={Local.RepoInfo.visibility}, description={Local.RepoInfo.description}"
|
||||
|
||||
# Use the agent to summarize the repo using the conversation context.
|
||||
- kind: InvokeAzureAgent
|
||||
id: summarize_repo
|
||||
conversationId: =System.ConversationId
|
||||
agent:
|
||||
name: GitHubRepoInfoAgent
|
||||
input:
|
||||
messages: =UserMessage("Please provide a brief summary of this GitHub repository based on the data already in the conversation.")
|
||||
output:
|
||||
autoSend: true
|
||||
messages: Local.AgentResponse
|
||||
|
||||
# Allow the user to ask follow-up questions about the repo in a loop.
|
||||
- kind: InvokeAzureAgent
|
||||
id: invoke_followup
|
||||
conversationId: =System.ConversationId
|
||||
agent:
|
||||
name: GitHubRepoInfoAgent
|
||||
input:
|
||||
messages: =Local.InputMessage
|
||||
externalLoop:
|
||||
when: =Upper(System.LastMessage.Text) <> "EXIT"
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shared.Foundry;
|
||||
using Shared.Workflows;
|
||||
|
||||
namespace Demo.Workflows.Declarative.InvokeHttpRequest;
|
||||
|
||||
/// <summary>
|
||||
/// Demonstrates a workflow that uses HttpRequestAction to call a REST API
|
||||
/// directly from the workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The HttpRequestAction allows workflows to issue HTTP requests and:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Fetch data from external REST endpoints</item>
|
||||
/// <item>Store the parsed response in workflow variables</item>
|
||||
/// <item>Add the response body to the conversation so an agent can answer
|
||||
/// questions based on it</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// This sample fetches public metadata for the dotnet/runtime repository from
|
||||
/// the GitHub REST API (no authentication required) and uses a Foundry agent
|
||||
/// to answer follow-up questions about it. Type "EXIT" to end the conversation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the README.md file in the parent folder (../README.md) for detailed
|
||||
/// information about the configuration required to run this sample.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class Program
|
||||
{
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
// Initialize configuration
|
||||
IConfiguration configuration = Application.InitializeConfig();
|
||||
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
|
||||
|
||||
// Ensure sample agent exists in Foundry. The agent has no tools - it answers
|
||||
// questions about the GitHub repository using only the JSON data that the
|
||||
// HttpRequestAction adds to the conversation.
|
||||
await CreateAgentAsync(foundryEndpoint, configuration);
|
||||
|
||||
// Get input from command line or console
|
||||
string workflowInput = Application.GetInput(args);
|
||||
|
||||
// The default HttpRequestHandler is sufficient for this sample because the
|
||||
// GitHub REST endpoint used here does not require authentication. For
|
||||
// authenticated endpoints, supply a custom Func<HttpRequestInfo, ..., HttpClient?>
|
||||
// to DefaultHttpRequestHandler so each request can be routed through a
|
||||
// pre-configured (cached) HttpClient with the appropriate credentials.
|
||||
await using DefaultHttpRequestHandler httpRequestHandler = new();
|
||||
|
||||
// Create the workflow factory with the HTTP request handler
|
||||
WorkflowFactory workflowFactory = new("InvokeHttpRequest.yaml", foundryEndpoint)
|
||||
{
|
||||
HttpRequestHandler = httpRequestHandler
|
||||
};
|
||||
|
||||
// Execute the workflow
|
||||
WorkflowRunner runner = new() { UseJsonCheckpoints = true };
|
||||
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
|
||||
}
|
||||
|
||||
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration)
|
||||
{
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential());
|
||||
|
||||
await aiProjectClient.CreateAgentAsync(
|
||||
agentName: "GitHubRepoInfoAgent",
|
||||
agentDefinition: DefineAgent(configuration),
|
||||
agentDescription: "Answers questions about a GitHub repository using HTTP response data in the conversation");
|
||||
}
|
||||
|
||||
private static DeclarativeAgentDefinition DefineAgent(IConfiguration configuration)
|
||||
{
|
||||
return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel))
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
Answer the user's questions about the GitHub repository using only the
|
||||
JSON data already present in the conversation history.
|
||||
If the answer is not contained in the conversation, say so plainly
|
||||
rather than guessing. Be concise and helpful.
|
||||
"""
|
||||
};
|
||||
}
|
||||
}
|
||||
-47
@@ -65,53 +65,6 @@ Workflow orchestration started for CancelOrder. Orchestration runId: abc123def45
|
||||
>
|
||||
> If not provided, a unique run ID is auto-generated.
|
||||
|
||||
### Wait for the Workflow Result
|
||||
|
||||
By default, the HTTP endpoint returns `202 Accepted` immediately with the run ID. If you want to wait for the workflow to complete and get the result in the response, add the `x-ms-wait-for-response: true` header:
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-H "x-ms-wait-for-response: true" \
|
||||
-d "12345"
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri http://localhost:7071/api/workflows/CancelOrder/run `
|
||||
-ContentType text/plain `
|
||||
-Headers @{ "x-ms-wait-for-response" = "true" } `
|
||||
-Body "12345"
|
||||
```
|
||||
|
||||
The response will contain the workflow result as plain text (200 OK):
|
||||
|
||||
```text
|
||||
Cancellation email sent for order 12345 to jerry@example.com.
|
||||
```
|
||||
|
||||
To get the result as JSON, also include the `Accept: application/json` header:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-H "x-ms-wait-for-response: true" \
|
||||
-H "Accept: application/json" \
|
||||
-d "12345"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"runId": "abc123def456",
|
||||
"workflowStatus": "Completed",
|
||||
"result": "Cancellation email sent for order 12345 to jerry@example.com."
|
||||
}
|
||||
```
|
||||
|
||||
In the function app logs, you will see the sequential execution of each executor:
|
||||
|
||||
```text
|
||||
|
||||
-22
@@ -7,21 +7,6 @@ Content-Type: text/plain
|
||||
|
||||
12345
|
||||
|
||||
### Cancel an order and wait for the result
|
||||
POST {{authority}}/api/workflows/CancelOrder/run
|
||||
Content-Type: text/plain
|
||||
x-ms-wait-for-response: true
|
||||
|
||||
12345
|
||||
|
||||
### Cancel an order and wait for the result (JSON response)
|
||||
POST {{authority}}/api/workflows/CancelOrder/run
|
||||
Content-Type: text/plain
|
||||
Accept: application/json
|
||||
x-ms-wait-for-response: true
|
||||
|
||||
12345
|
||||
|
||||
### Cancel an order with a custom run ID
|
||||
POST {{authority}}/api/workflows/CancelOrder/run?runId=my-custom-id-123
|
||||
Content-Type: text/plain
|
||||
@@ -34,13 +19,6 @@ Content-Type: text/plain
|
||||
|
||||
12345
|
||||
|
||||
### Get order status and wait for the result
|
||||
POST {{authority}}/api/workflows/OrderStatus/run
|
||||
Content-Type: text/plain
|
||||
x-ms-wait-for-response: true
|
||||
|
||||
12345
|
||||
|
||||
### Batch cancel orders with a complex JSON input
|
||||
POST {{authority}}/api/workflows/BatchCancelOrders/run
|
||||
Content-Type: application/json
|
||||
|
||||
-2
@@ -13,8 +13,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.Invocations" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="OpenTelemetry.Api" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
|
||||
|
||||
namespace Azure.AI.Projects;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods on <see cref="AIProjectClient"/> for fetching
|
||||
/// Foundry toolbox definitions as server-side tools.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Provides a single call on the project client to retrieve tools ready for use
|
||||
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class AIProjectClientToolboxExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
|
||||
/// ready for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="projectClient">The <see cref="AIProjectClient"/> to use. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
|
||||
/// <exception cref="System.ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectClient"/> or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
public static async Task<IReadOnlyList<AITool>> GetToolboxToolsAsync(
|
||||
this AIProjectClient projectClient,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(projectClient);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = projectClient.AgentAdministrationClient.GetAgentToolboxes();
|
||||
var toolboxVersion = await FoundryToolbox.GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
return toolboxVersion.ToAITools();
|
||||
}
|
||||
}
|
||||
@@ -77,31 +77,23 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
// 4. Convert input: history + current input → ChatMessage[]
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// Load conversation history only for fresh sessions. When a session already exists
|
||||
// (e.g. resuming a workflow paused at an external-input port), the workflow's
|
||||
// checkpointed state already contains the prior turns' messages — replaying history
|
||||
// would re-drive completed actions and break HITL resume semantics.
|
||||
var isResume = !string.IsNullOrWhiteSpace(sessionConversationId)
|
||||
&& session?.StateBag?.Count > 0;
|
||||
if (!isResume)
|
||||
// Load conversation history if available
|
||||
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (history.Count > 0)
|
||||
{
|
||||
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (history.Count > 0)
|
||||
{
|
||||
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag));
|
||||
}
|
||||
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history));
|
||||
}
|
||||
|
||||
// Load and convert current input items
|
||||
var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (inputItems.Count > 0)
|
||||
{
|
||||
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems, session?.StateBag));
|
||||
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to raw request input
|
||||
messages.AddRange(InputConverter.ConvertInputToMessages(request, session?.StateBag));
|
||||
messages.AddRange(InputConverter.ConvertInputToMessages(request));
|
||||
}
|
||||
|
||||
// 5. Build chat options
|
||||
@@ -199,7 +191,6 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
|
||||
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
|
||||
stream,
|
||||
session?.StateBag,
|
||||
cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
try
|
||||
{
|
||||
@@ -306,7 +297,6 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
var agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
|
||||
if (agent is not null)
|
||||
{
|
||||
FoundryHostingExtensions.TryApplyUserAgent(agent);
|
||||
return FoundryHostingExtensions.ApplyOpenTelemetry(agent);
|
||||
}
|
||||
|
||||
@@ -320,13 +310,12 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
var defaultAgent = this._serviceProvider.GetService<AIAgent>();
|
||||
if (defaultAgent is not null)
|
||||
{
|
||||
FoundryHostingExtensions.TryApplyUserAgent(defaultAgent);
|
||||
return FoundryHostingExtensions.ApplyOpenTelemetry(defaultAgent);
|
||||
}
|
||||
|
||||
var errorMessage = string.IsNullOrEmpty(agentName)
|
||||
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered."
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent) or services.AddKeyedSingleton<AIAgent>(\"{agentName}\", ...).";
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AIAgent.";
|
||||
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
@@ -363,7 +352,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
|
||||
var errorMessage = string.IsNullOrEmpty(agentName)
|
||||
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered."
|
||||
: $"AgentSessionStore for agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent, agentSessionStore) or services.AddKeyedSingleton<AgentSessionStore>(\"{agentName}\", ...).";
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AgentSessionStore.";
|
||||
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a file-system backed implementation of <see cref="AgentSessionStore"/> that persists
|
||||
/// the agent-framework's serialized <see cref="AgentSession"/> state for each (agent, conversation)
|
||||
/// pair to disk. This complements Foundry storage (which owns conversation messages, agent
|
||||
/// definitions, and threads) — it is not a replacement for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The session JSON stored here is the AF runtime's own state (workflow checkpoint manager,
|
||||
/// pending external requests, internal port state) that is required to resume an
|
||||
/// <see cref="AgentSession"/> across HTTP requests or process restarts but is not part of
|
||||
/// Foundry's data model.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When running in a Foundry hosted environment, sessions are stored under the well-known
|
||||
/// <c>/.checkpoints</c> path; locally, they fall under <c>{cwd}/.checkpoints</c>. The session
|
||||
/// JSON produced when the agent serializes the session already contains the workflow's
|
||||
/// in-memory checkpoint manager state, so a single file per (agent, conversation) pair is
|
||||
/// sufficient to resume long-running workflows across process restarts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Files are written atomically via a temp-file + <see cref="File.Move(string, string, bool)"/>
|
||||
/// rename so a partially-written file cannot be observed by a concurrent reader.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FileSystemAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
/// <summary>
|
||||
/// The well-known absolute path used when running inside a Foundry hosted environment.
|
||||
/// </summary>
|
||||
public const string HostedCheckpointDirectory = "/.checkpoints";
|
||||
|
||||
/// <summary>
|
||||
/// The directory name used under the current working directory when running locally.
|
||||
/// </summary>
|
||||
public const string LocalCheckpointDirectoryName = ".checkpoints";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileSystemAgentSessionStore"/> class
|
||||
/// that stores serialized sessions under <paramref name="rootDirectory"/>.
|
||||
/// </summary>
|
||||
/// <param name="rootDirectory">
|
||||
/// The absolute or relative directory where session files will be written.
|
||||
/// The directory is created on first write if it does not already exist.
|
||||
/// </param>
|
||||
public FileSystemAgentSessionStore(string rootDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory);
|
||||
this.RootDirectory = Path.GetFullPath(rootDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the root directory under which session files are written.
|
||||
/// </summary>
|
||||
public string RootDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="FileSystemAgentSessionStore"/> rooted at the default location:
|
||||
/// <see cref="HostedCheckpointDirectory"/> when running in a Foundry hosted environment,
|
||||
/// otherwise <see cref="LocalCheckpointDirectoryName"/> under the current working directory.
|
||||
/// </summary>
|
||||
/// <returns>A new <see cref="FileSystemAgentSessionStore"/> instance.</returns>
|
||||
public static FileSystemAgentSessionStore CreateDefault()
|
||||
{
|
||||
string root = FoundryEnvironment.IsHosted
|
||||
? HostedCheckpointDirectory
|
||||
: Path.Combine(Environment.CurrentDirectory, LocalCheckpointDirectoryName);
|
||||
return new FileSystemAgentSessionStore(root);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Directory.CreateDirectory(this.RootDirectory);
|
||||
|
||||
string path = this.GetSessionPath(agent, conversationId);
|
||||
string? parentDir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(parentDir))
|
||||
{
|
||||
Directory.CreateDirectory(parentDir);
|
||||
}
|
||||
|
||||
// Each save writes to its own temp file before atomically renaming over the
|
||||
// destination. Last writer wins for the final file, but no reader can observe
|
||||
// a torn or partially-written JSON document.
|
||||
string tempPath = $"{path}.{Guid.NewGuid():N}.tmp";
|
||||
|
||||
try
|
||||
{
|
||||
using (FileStream stream = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
using (Utf8JsonWriter writer = new(stream))
|
||||
{
|
||||
serialized.WriteTo(writer);
|
||||
}
|
||||
|
||||
File.Move(tempPath, path, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { File.Delete(tempPath); } catch { /* best-effort cleanup */ }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
|
||||
|
||||
string path = this.GetSessionPath(agent, conversationId);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (bytes.Length == 0)
|
||||
{
|
||||
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Parse and clone so the document buffer can be released.
|
||||
using JsonDocument document = JsonDocument.Parse(bytes);
|
||||
JsonElement element = document.RootElement.Clone();
|
||||
return await agent.DeserializeSessionAsync(element, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string GetSessionPath(AIAgent agent, string conversationId)
|
||||
{
|
||||
// When agent.Name is set we bucket sessions into a per-agent subdirectory so
|
||||
// multiple keyed agents sharing a single in-process default store cannot
|
||||
// collide on the same conversationId. agent.Id is intentionally NOT used
|
||||
// because it is regenerated on every startup for in-memory-defined agents.
|
||||
string fileName = $"{Sanitize(conversationId)}.json";
|
||||
if (string.IsNullOrEmpty(agent.Name))
|
||||
{
|
||||
return Path.Combine(this.RootDirectory, fileName);
|
||||
}
|
||||
|
||||
string agentDir = Path.Combine(this.RootDirectory, Sanitize(agent.Name!));
|
||||
return Path.Combine(agentDir, fileName);
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
{
|
||||
// Percent-encode every character that is invalid in a filename, plus '%' itself
|
||||
// so the encoding is unambiguous. This is reversible and avoids the collision
|
||||
// hazard of a lossy character substitution (e.g. "foo/bar" and "foo_bar" sharing
|
||||
// a sanitized name).
|
||||
char[] invalid = Path.GetInvalidFileNameChars();
|
||||
|
||||
int encodedLength = ComputeEncodedLength(value, invalid);
|
||||
|
||||
// stackalloc is bounded so an externally-controlled length cannot crash the
|
||||
// hosting process with StackOverflowException.
|
||||
const int StackLimit = 512;
|
||||
string sanitized;
|
||||
if (encodedLength <= StackLimit)
|
||||
{
|
||||
Span<char> buffer = stackalloc char[encodedLength];
|
||||
SanitizeCore(value, invalid, buffer);
|
||||
sanitized = new string(buffer);
|
||||
}
|
||||
else
|
||||
{
|
||||
char[] rented = ArrayPool<char>.Shared.Rent(encodedLength);
|
||||
try
|
||||
{
|
||||
Span<char> buffer = rented.AsSpan(0, encodedLength);
|
||||
SanitizeCore(value, invalid, buffer);
|
||||
sanitized = new string(buffer);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<char>.Shared.Return(rented);
|
||||
}
|
||||
}
|
||||
|
||||
// '.' and '..' are valid filename characters but resolve to current/parent
|
||||
// directory when used as a bare path component. Windows additionally strips
|
||||
// trailing dots from filenames, so a segment like "..." would survive on disk
|
||||
// as "" and a partial-encode like "%2E.." would survive as "%2E". Encode every
|
||||
// dot in any all-dot segment so the result has no special meaning to the OS.
|
||||
if (sanitized.Length > 0 && IsAllDots(sanitized))
|
||||
{
|
||||
return string.Concat(Enumerable.Repeat("%2E", sanitized.Length));
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private static int ComputeEncodedLength(string value, char[] invalid)
|
||||
{
|
||||
int extra = 0;
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
char c = value[i];
|
||||
if (c == '%' || Array.IndexOf(invalid, c) >= 0)
|
||||
{
|
||||
extra += 2; // 1 char ('%' or invalid) becomes 3 chars ("%XX")
|
||||
}
|
||||
}
|
||||
return value.Length + extra;
|
||||
}
|
||||
|
||||
private static bool IsAllDots(string value)
|
||||
{
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
if (value[i] != '.')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void SanitizeCore(string value, char[] invalid, Span<char> buffer)
|
||||
{
|
||||
int j = 0;
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
char c = value[i];
|
||||
if (c == '%' || Array.IndexOf(invalid, c) >= 0)
|
||||
{
|
||||
buffer[j++] = '%';
|
||||
buffer[j++] = HexChar((c >> 4) & 0xF);
|
||||
buffer[j++] = HexChar(c & 0xF);
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer[j++] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static char HexChar(int n) => (char)(n < 10 ? '0' + n : 'A' + n - 10);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
|
||||
#pragma warning disable IL2026 // ModelReaderWriter.Read<ResponseTool> uses reflection; suppressed for Azure SDK model types.
|
||||
#pragma warning disable IL3050 // ModelReaderWriter.Read<ResponseTool> requires dynamic code; suppressed for Azure SDK model types.
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods for fetching Foundry toolbox definitions and converting their tools
|
||||
/// to <see cref="AITool"/> instances for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When tools from a toolbox are passed to a Foundry agent (e.g. via <c>AsAIAgent(model, instructions, tools: ...)</c>),
|
||||
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
|
||||
/// handles tool execution — the agent process does not invoke tools locally.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class FoundryToolbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches a toolbox version from the Foundry project and returns the raw SDK <see cref="ToolboxVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
|
||||
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically (requires an additional API call).
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>The <see cref="ToolboxVersion"/> containing tool definitions.</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
|
||||
public static async Task<ToolboxVersion> GetToolboxVersionAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(projectEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = CreateToolboxClient(projectEndpoint, credential);
|
||||
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
|
||||
/// ready for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
|
||||
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
|
||||
public static async Task<IReadOnlyList<AITool>> GetToolsAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var toolboxVersion = await GetToolboxVersionAsync(projectEndpoint, credential, name, version, cancellationToken).ConfigureAwait(false);
|
||||
return toolboxVersion.ToAITools();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the tools in a <see cref="ToolboxVersion"/> to <see cref="AITool"/> instances
|
||||
/// suitable for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="toolboxVersion">The toolbox version whose tools to convert.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="toolboxVersion"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each <see cref="ProjectsAgentTool"/> in the toolbox is cast to <see cref="ResponseTool"/>
|
||||
/// and converted via <c>AsAITool()</c>. Non-function hosted tools (MCP, web_search,
|
||||
/// code_interpreter, etc.) are included as server-side tool definitions — the Foundry
|
||||
/// platform handles their execution.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Non-function tools are sanitized to remove decoration fields (<c>name</c>, <c>description</c>)
|
||||
/// that the toolbox API returns but the Responses API rejects.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<AITool> ToAITools(this ToolboxVersion toolboxVersion)
|
||||
{
|
||||
Throw.IfNull(toolboxVersion);
|
||||
|
||||
if (toolboxVersion.Tools?.Any() != true)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return toolboxVersion.Tools
|
||||
.Select(SanitizeAndConvert)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
#region Internal helpers (visible to unit tests via InternalsVisibleTo)
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a <see cref="ProjectsAgentTool"/> by removing decoration fields that the
|
||||
/// toolbox API returns but the Responses API rejects, then converts to <see cref="AITool"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Azure AI Projects toolbox API may return <c>name</c> and <c>description</c> on
|
||||
/// hosted tool objects (MCP, code_interpreter, file_search, etc.). The Responses API
|
||||
/// rejects at least <c>name</c> with "Unknown parameter: 'tools[0].name'". We strip
|
||||
/// these decoration fields for non-function tools. Function tools keep them since
|
||||
/// <c>name</c> and <c>description</c> are expected parts of the function schema.
|
||||
/// </remarks>
|
||||
internal static AITool SanitizeAndConvert(ProjectsAgentTool tool)
|
||||
{
|
||||
var toolJson = ModelReaderWriter.Write(tool, new ModelReaderWriterOptions("J"));
|
||||
var node = JsonNode.Parse(toolJson.ToString());
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
var toolType = obj["type"]?.GetValue<string>();
|
||||
|
||||
// Function tools need name/description — don't strip
|
||||
if (toolType is "function" or "custom")
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
// Strip decoration fields that the Responses API rejects
|
||||
bool modified = false;
|
||||
modified |= obj.Remove("name");
|
||||
modified |= obj.Remove("description");
|
||||
|
||||
if (!modified)
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
var sanitizedJson = obj.ToJsonString();
|
||||
var sanitizedTool = ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString(sanitizedJson))!;
|
||||
return sanitizedTool.AsAITool();
|
||||
}
|
||||
|
||||
internal static async Task<ToolboxVersion> GetToolboxVersionAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version,
|
||||
AgentAdministrationClientOptions? clientOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Throw.IfNull(projectEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = CreateToolboxClient(projectEndpoint, credential, clientOptions);
|
||||
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal static AgentToolboxes CreateToolboxClient(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
AgentAdministrationClientOptions? clientOptions = null)
|
||||
{
|
||||
clientOptions ??= new AgentAdministrationClientOptions();
|
||||
var adminClient = new AgentAdministrationClient(projectEndpoint, credential, clientOptions);
|
||||
return adminClient.GetAgentToolboxes();
|
||||
}
|
||||
|
||||
internal static async Task<ToolboxVersion> GetToolboxVersionCoreAsync(
|
||||
AgentToolboxes toolboxClient,
|
||||
string name,
|
||||
string? version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (version is null)
|
||||
{
|
||||
var record = await toolboxClient.GetToolboxAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
version = record.Value.DefaultVersion
|
||||
?? throw new InvalidOperationException($"Toolbox '{name}' does not have a default version. Specify an explicit version.");
|
||||
}
|
||||
|
||||
var result = await toolboxClient.GetToolboxVersionAsync(name, version, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline policy that appends the hosted-agent <c>User-Agent</c> segment
|
||||
/// (e.g. <c>"foundry-hosting/agent-framework-dotnet/{version}"</c>) to outgoing requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The supplement value is computed once from the Microsoft.Agents.AI.Foundry.Hosting
|
||||
/// assembly's informational version. The policy is idempotent on retries: if the segment
|
||||
/// is already present in the <c>User-Agent</c> header, the policy does not append it again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This policy is added at request time (per-call <see cref="PipelinePosition"/>)
|
||||
/// by <see cref="UserAgentResponsesClient"/> when invoking the wrapped
|
||||
/// <see cref="OpenAI.Responses.ResponsesClient"/>. It is only registered when an agent is
|
||||
/// resolved by the Foundry hosting layer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
public static HostedAgentUserAgentPolicy Instance { get; } = new HostedAgentUserAgentPolicy();
|
||||
|
||||
private static readonly string s_supplementValue = CreateSupplementValue();
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void AppendHeader(PipelineMessage message)
|
||||
{
|
||||
if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing))
|
||||
{
|
||||
// Guard against double-append on retries or when the policy
|
||||
// is registered on multiple pipeline positions.
|
||||
if (existing.Contains(s_supplementValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
message.Request.Headers.Set("User-Agent", $"{existing} {s_supplementValue}");
|
||||
}
|
||||
else
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", s_supplementValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateSupplementValue()
|
||||
{
|
||||
const string Name = "foundry-hosting/agent-framework-dotnet";
|
||||
|
||||
if (typeof(HostedAgentUserAgentPolicy).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
using SdkTextContent = Azure.AI.AgentServer.Responses.Models.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -21,15 +19,14 @@ internal static class InputConverter
|
||||
/// Converts the SDK <see cref="CreateResponse"/> request input items into a list of <see cref="ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request from the SDK.</param>
|
||||
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
|
||||
/// <returns>A list of chat messages representing the request input.</returns>
|
||||
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request, AgentSessionStateBag? stateBag = null)
|
||||
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in request.GetInputExpanded())
|
||||
{
|
||||
var message = ConvertInputItemToMessage(item, stateBag);
|
||||
var message = ConvertInputItemToMessage(item);
|
||||
if (message is not null)
|
||||
{
|
||||
messages.Add(message);
|
||||
@@ -43,15 +40,14 @@ internal static class InputConverter
|
||||
/// Converts resolved SDK <see cref="Item"/> input items into <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="items">The resolved input items from the SDK context.</param>
|
||||
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
|
||||
/// <returns>A list of chat messages.</returns>
|
||||
public static List<ChatMessage> ConvertItemsToMessages(IReadOnlyList<Item> items, AgentSessionStateBag? stateBag = null)
|
||||
public static List<ChatMessage> ConvertItemsToMessages(IReadOnlyList<Item> items)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var message = ConvertInputItemToMessage(item, stateBag);
|
||||
var message = ConvertInputItemToMessage(item);
|
||||
if (message is not null)
|
||||
{
|
||||
messages.Add(message);
|
||||
@@ -65,15 +61,14 @@ internal static class InputConverter
|
||||
/// Converts resolved SDK <see cref="OutputItem"/> history/input items into <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="items">The resolved output items from the SDK context.</param>
|
||||
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
|
||||
/// <returns>A list of chat messages.</returns>
|
||||
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items, AgentSessionStateBag? stateBag = null)
|
||||
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var message = ConvertOutputItemToMessage(item, stateBag);
|
||||
var message = ConvertOutputItemToMessage(item);
|
||||
if (message is not null)
|
||||
{
|
||||
messages.Add(message);
|
||||
@@ -133,15 +128,13 @@ internal static class InputConverter
|
||||
return markers;
|
||||
}
|
||||
|
||||
private static ChatMessage? ConvertInputItemToMessage(Item item, AgentSessionStateBag? stateBag)
|
||||
private static ChatMessage? ConvertInputItemToMessage(Item item)
|
||||
{
|
||||
return item switch
|
||||
{
|
||||
ItemMessage msg => ConvertItemMessage(msg),
|
||||
FunctionCallOutputItemParam funcOutput => ConvertFunctionCallOutput(funcOutput),
|
||||
ItemFunctionToolCall funcCall => ConvertItemFunctionToolCall(funcCall),
|
||||
ItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest(approvalRequest.Id, approvalRequest.Name, approvalRequest.Arguments),
|
||||
MCPApprovalResponse approvalResponse => ConvertMcpApprovalResponse(approvalResponse.ApprovalRequestId, approvalResponse.Approve, stateBag),
|
||||
ItemReferenceParam => null,
|
||||
_ => null
|
||||
};
|
||||
@@ -159,23 +152,43 @@ internal static class InputConverter
|
||||
case MessageContentInputTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case SdkTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case SummaryTextContent summary:
|
||||
contents.Add(new MeaiTextContent(summary.Text));
|
||||
break;
|
||||
case MessageContentReasoningTextContent reasoning:
|
||||
contents.Add(new TextReasoningContent(reasoning.Text));
|
||||
break;
|
||||
case MessageContentInputImageContent imageContent:
|
||||
AppendImageContent(contents, imageContent.ImageUrl, imageContent.FileId);
|
||||
if (imageContent.ImageUrl is not null)
|
||||
{
|
||||
var url = imageContent.ImageUrl.ToString();
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contents.Add(new DataContent(url, "image/*"));
|
||||
}
|
||||
else
|
||||
{
|
||||
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(imageContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(imageContent.FileId));
|
||||
}
|
||||
|
||||
break;
|
||||
case MessageContentInputFileContent fileContent:
|
||||
AppendFileContent(contents, fileContent.FileUrl, fileContent.FileData, fileContent.FileId, fileContent.Filename);
|
||||
break;
|
||||
case ComputerScreenshotContent screenshot:
|
||||
AppendImageContent(contents, screenshot.ImageUrl, screenshot.FileId);
|
||||
if (fileContent.FileUrl is not null)
|
||||
{
|
||||
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileData))
|
||||
{
|
||||
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(fileContent.FileId));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.Filename))
|
||||
{
|
||||
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -218,63 +231,13 @@ internal static class InputConverter
|
||||
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an inbound <c>mcp_approval_request</c> wire item (from history replay
|
||||
/// or fresh-input) to a <see cref="ToolApprovalRequestContent"/> wrapping a
|
||||
/// <see cref="FunctionCallContent"/>.
|
||||
/// </summary>
|
||||
private static ChatMessage ConvertMcpApprovalRequest(string id, string name, string? arguments)
|
||||
{
|
||||
var functionCall = new FunctionCallContent(id, name, ParseFunctionArgumentsObject(arguments));
|
||||
return new ChatMessage(
|
||||
ChatRole.Assistant,
|
||||
[new ToolApprovalRequestContent(id, functionCall)]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an inbound <c>mcp_approval_response</c> wire item to a
|
||||
/// <see cref="ToolApprovalResponseContent"/>. Looks up the original AF request id
|
||||
/// via <see cref="ToolApprovalIdMap"/>; falls back to the wire id when the mapping
|
||||
/// is unavailable. Carries a placeholder <see cref="FunctionCallContent"/> because
|
||||
/// the original tool-call details are not echoed by clients in the response item.
|
||||
/// </summary>
|
||||
private static ChatMessage ConvertMcpApprovalResponse(string approvalRequestId, bool approve, AgentSessionStateBag? stateBag)
|
||||
{
|
||||
var afRequestId = ToolApprovalIdMap.Resolve(stateBag, approvalRequestId);
|
||||
var placeholderFunctionCall = new FunctionCallContent(afRequestId, "mcp_approval");
|
||||
return new ChatMessage(
|
||||
ChatRole.User,
|
||||
[new ToolApprovalResponseContent(afRequestId, approve, placeholderFunctionCall)]);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing tool-call arguments from SDK input.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing tool-call arguments from SDK input.")]
|
||||
private static Dictionary<string, object?>? ParseFunctionArgumentsObject(string? arguments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arguments))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object?>>(arguments);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new Dictionary<string, object?> { ["_raw"] = arguments };
|
||||
}
|
||||
}
|
||||
|
||||
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item, AgentSessionStateBag? stateBag)
|
||||
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item)
|
||||
{
|
||||
return item switch
|
||||
{
|
||||
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
|
||||
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
|
||||
OutputItemFunctionToolCallOutput funcOutput => ConvertFunctionToolCallOutput(funcOutput),
|
||||
OutputItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest(approvalRequest.Id, approvalRequest.Name, approvalRequest.Arguments),
|
||||
OutputItemMcpApprovalResponseResource approvalResponse => ConvertMcpApprovalResponse(approvalResponse.ApprovalRequestId, approvalResponse.Approve, stateBag),
|
||||
FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput),
|
||||
OutputItemReasoningItem => null,
|
||||
_ => null
|
||||
};
|
||||
@@ -295,26 +258,46 @@ internal static class InputConverter
|
||||
case MessageContentOutputTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case SdkTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case SummaryTextContent summary:
|
||||
contents.Add(new MeaiTextContent(summary.Text));
|
||||
break;
|
||||
case MessageContentReasoningTextContent reasoning:
|
||||
contents.Add(new TextReasoningContent(reasoning.Text));
|
||||
break;
|
||||
case MessageContentRefusalContent refusal:
|
||||
contents.Add(new MeaiTextContent($"[Refusal: {refusal.Refusal}]"));
|
||||
break;
|
||||
case MessageContentInputImageContent imageContent:
|
||||
AppendImageContent(contents, imageContent.ImageUrl, imageContent.FileId);
|
||||
if (imageContent.ImageUrl is not null)
|
||||
{
|
||||
var url = imageContent.ImageUrl.ToString();
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contents.Add(new DataContent(url, "image/*"));
|
||||
}
|
||||
else
|
||||
{
|
||||
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(imageContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(imageContent.FileId));
|
||||
}
|
||||
|
||||
break;
|
||||
case MessageContentInputFileContent fileContent:
|
||||
AppendFileContent(contents, fileContent.FileUrl, fileContent.FileData, fileContent.FileId, fileContent.Filename);
|
||||
break;
|
||||
case ComputerScreenshotContent screenshot:
|
||||
AppendImageContent(contents, screenshot.ImageUrl, screenshot.FileId);
|
||||
if (fileContent.FileUrl is not null)
|
||||
{
|
||||
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileData))
|
||||
{
|
||||
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(fileContent.FileId));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.Filename))
|
||||
{
|
||||
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -327,127 +310,6 @@ internal static class InputConverter
|
||||
return new ChatMessage(role, contents);
|
||||
}
|
||||
|
||||
private static void AppendImageContent(List<AIContent> contents, Uri? imageUrl, string? fileId)
|
||||
{
|
||||
if (imageUrl is not null)
|
||||
{
|
||||
var url = imageUrl.ToString();
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contents.Add(new DataContent(url, "image/*"));
|
||||
}
|
||||
else
|
||||
{
|
||||
contents.Add(new UriContent(imageUrl, "image/*"));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(fileId));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendFileContent(List<AIContent> contents, Uri? fileUrl, string? fileData, string? fileId, string? filename)
|
||||
{
|
||||
if (fileUrl is not null)
|
||||
{
|
||||
var content = new UriContent(fileUrl, "application/octet-stream");
|
||||
if (!string.IsNullOrEmpty(filename))
|
||||
{
|
||||
content.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
|
||||
}
|
||||
contents.Add(content);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(fileData))
|
||||
{
|
||||
// If the data URI carries text/* content, decode it inline as TextContent so
|
||||
// {System.LastMessageText} (and other text-only consumers) sees the file's
|
||||
// body rather than an opaque blob.
|
||||
if (TryDecodeTextDataUri(fileData, filename, out var decodedText))
|
||||
{
|
||||
contents.Add(new MeaiTextContent(decodedText));
|
||||
}
|
||||
else
|
||||
{
|
||||
var dataContent = new DataContent(fileData, "application/octet-stream");
|
||||
if (!string.IsNullOrEmpty(filename))
|
||||
{
|
||||
dataContent.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
|
||||
}
|
||||
contents.Add(dataContent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(fileId))
|
||||
{
|
||||
var hosted = new HostedFileContent(fileId);
|
||||
if (!string.IsNullOrEmpty(filename))
|
||||
{
|
||||
hosted.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
|
||||
}
|
||||
contents.Add(hosted);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filename))
|
||||
{
|
||||
contents.Add(new MeaiTextContent($"[File: {filename}]"));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeTextDataUri(string dataUri, string? filename, out string text)
|
||||
{
|
||||
// Cap the encoded payload so an oversized client-supplied data URI cannot
|
||||
// trigger an unbounded allocation in Convert.FromBase64String. 16 MiB
|
||||
// encoded → ~12 MiB decoded, well above any realistic text/* file we'd
|
||||
// want to inline as content while still bounding the worst case.
|
||||
const int MaxEncodedLength = 16 * 1024 * 1024;
|
||||
|
||||
text = string.Empty;
|
||||
if (!dataUri.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const string Marker = ";base64,";
|
||||
int markerIndex = dataUri.IndexOf(Marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (markerIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string mediaType = dataUri.Substring("data:".Length, markerIndex - "data:".Length);
|
||||
if (!mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string encoded = dataUri.Substring(markerIndex + Marker.Length);
|
||||
if (encoded.Length > MaxEncodedLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] bytes = Convert.FromBase64String(encoded);
|
||||
string decoded = Encoding.UTF8.GetString(bytes);
|
||||
text = string.IsNullOrEmpty(filename) ? decoded : $"[File: {filename}]\n{decoded}";
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK output history.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK output history.")]
|
||||
private static ChatMessage ConvertOutputItemFunctionCall(OutputItemFunctionToolCall funcCall)
|
||||
@@ -470,7 +332,7 @@ internal static class InputConverter
|
||||
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput)
|
||||
private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput)
|
||||
{
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
|
||||
+1
-2
@@ -34,7 +34,6 @@
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -44,7 +43,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.Hosting.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ internal static class OutputConverter
|
||||
/// </summary>
|
||||
/// <param name="updates">The agent response updates to convert.</param>
|
||||
/// <param name="stream">The SDK event stream builder.</param>
|
||||
/// <param name="stateBag">Optional session state bag used to persist tool-approval id mappings across turns.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of SDK response stream events (excluding lifecycle events).</returns>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")]
|
||||
@@ -38,7 +37,6 @@ internal static class OutputConverter
|
||||
public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEventsAsync(
|
||||
IAsyncEnumerable<AgentResponseUpdate> updates,
|
||||
ResponseEventStream stream,
|
||||
AgentSessionStateBag? stateBag = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ResponseUsage? accumulatedUsage = null;
|
||||
@@ -53,11 +51,8 @@ internal static class OutputConverter
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Handle workflow events from RawRepresentation.
|
||||
// If the update also carries Contents (e.g. WorkflowSession unwrapped a
|
||||
// WorkflowErrorEvent or ExecutorFailedEvent into an ErrorContent payload),
|
||||
// fall through to the content-processing path below so those are emitted.
|
||||
if (update.RawRepresentation is WorkflowEvent workflowEvent && update.Contents.Count == 0)
|
||||
// Handle workflow events from RawRepresentation
|
||||
if (update.RawRepresentation is WorkflowEvent workflowEvent)
|
||||
{
|
||||
// Close any open message builder before emitting workflow items
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
@@ -171,54 +166,6 @@ internal static class OutputConverter
|
||||
break;
|
||||
}
|
||||
|
||||
case ToolApprovalRequestContent approvalRequest when approvalRequest.ToolCall is FunctionCallContent approvalFunctionCall:
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
// The Responses API only standardizes the MCP-flavored approval primitive.
|
||||
// We emit the AF tool-approval request as `mcp_approval_request` with
|
||||
// server_label="agent_framework" — declaring the AF runtime as the virtual
|
||||
// server holding this call. The SDK requires a strict {prefix}_{50hex}
|
||||
// wire-id format, so we hash the AF RequestId and persist the
|
||||
// wireId↔afRequestId mapping in the session state bag for later lookup
|
||||
// when the matching `mcp_approval_response` arrives on a subsequent turn.
|
||||
var wireId = ToolApprovalIdMap.ComputeWireId(approvalRequest.RequestId);
|
||||
ToolApprovalIdMap.Record(stateBag, wireId, approvalRequest.RequestId);
|
||||
|
||||
var approvalArguments = approvalFunctionCall.Arguments is not null
|
||||
? JsonSerializer.Serialize(approvalFunctionCall.Arguments)
|
||||
: "{}";
|
||||
|
||||
var approvalItem = new OutputItemMcpApprovalRequest(
|
||||
wireId,
|
||||
"agent_framework",
|
||||
approvalFunctionCall.Name,
|
||||
approvalArguments);
|
||||
|
||||
var approvalBuilder = stream.AddOutputItem<OutputItemMcpApprovalRequest>(wireId);
|
||||
yield return approvalBuilder.EmitAdded(approvalItem);
|
||||
yield return approvalBuilder.EmitDone(approvalItem);
|
||||
break;
|
||||
}
|
||||
|
||||
case ToolApprovalRequestContent:
|
||||
// Approval requests must wrap a FunctionCallContent (handled above).
|
||||
// Any other shape has no representation in the Responses wire format.
|
||||
break;
|
||||
|
||||
case ToolApprovalResponseContent:
|
||||
// Approval responses originate from the client and travel inbound; the
|
||||
// workflow does not re-emit them. Skip silently if encountered.
|
||||
break;
|
||||
|
||||
case UsageContent usageContent when usageContent.Details is not null:
|
||||
{
|
||||
accumulatedUsage = ConvertUsage(usageContent.Details, accumulatedUsage);
|
||||
@@ -304,25 +251,16 @@ internal static class OutputConverter
|
||||
var outputTokens = details.OutputTokenCount ?? 0;
|
||||
var totalTokens = details.TotalTokenCount ?? 0;
|
||||
|
||||
var cachedTokens = details.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cached) ?? false
|
||||
? cached : 0;
|
||||
var reasoningTokens = details.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoning) ?? false
|
||||
? reasoning : 0;
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
inputTokens += existing.InputTokens;
|
||||
outputTokens += existing.OutputTokens;
|
||||
totalTokens += existing.TotalTokens;
|
||||
cachedTokens += existing.InputTokensDetails?.CachedTokens ?? 0;
|
||||
reasoningTokens += existing.OutputTokensDetails?.ReasoningTokens ?? 0;
|
||||
}
|
||||
|
||||
return new ResponseUsage(
|
||||
return AzureAIAgentServerResponsesModelFactory.ResponseUsage(
|
||||
inputTokens: inputTokens,
|
||||
inputTokensDetails: new ResponseUsageInputTokensDetails(cachedTokens),
|
||||
outputTokens: outputTokens,
|
||||
outputTokensDetails: new ResponseUsageOutputTokensDetails(reasoningTokens),
|
||||
totalTokens: totalTokens);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -35,7 +36,7 @@ public static class FoundryHostingExtensions
|
||||
/// <para>
|
||||
/// Example:
|
||||
/// <code>
|
||||
/// builder.Services.AddKeyedSingleton<AIAgent>("my-agent", myAgent);
|
||||
/// builder.AddAIAgent("my-agent", ...);
|
||||
/// builder.Services.AddFoundryResponses();
|
||||
///
|
||||
/// var app = builder.Build();
|
||||
@@ -49,7 +50,7 @@ public static class FoundryHostingExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddResponsesServer();
|
||||
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
|
||||
services.TryAddSingleton<AgentSessionStore, InMemoryAgentSessionStore>();
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
}
|
||||
@@ -76,7 +77,7 @@ public static class FoundryHostingExtensions
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="agent">The agent instance to register.</param>
|
||||
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, a file-system session store is used, rooted at <c>/.checkpoints</c> when running in a Foundry hosted environment and <c>{cwd}/.checkpoints</c> locally.</param>
|
||||
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, an in-memory session store will be used.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null)
|
||||
{
|
||||
@@ -84,7 +85,7 @@ public static class FoundryHostingExtensions
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
services.AddResponsesServer();
|
||||
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
|
||||
agentSessionStore ??= new InMemoryAgentSessionStore();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Name))
|
||||
{
|
||||
@@ -180,11 +181,20 @@ public static class FoundryHostingExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
endpoints.MapResponsesServer(prefix);
|
||||
|
||||
if (endpoints is IApplicationBuilder app)
|
||||
{
|
||||
// Ensure the middleware is added to the pipeline
|
||||
app.UseMiddleware<AgentFrameworkUserAgentMiddleware>();
|
||||
}
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ActivitySource name for the Responses hosting pipeline.
|
||||
/// Matches the value previously exposed by <c>AgentHostTelemetry.ResponsesSourceName</c>
|
||||
/// in <c>Azure.AI.AgentServer.Core</c>.
|
||||
/// </summary>
|
||||
private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses";
|
||||
|
||||
@@ -206,85 +216,46 @@ public static class FoundryHostingExtensions
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to wrap the agent's underlying <see cref="ResponsesClient"/>
|
||||
/// with a <see cref="UserAgentResponsesClient"/> so every outgoing Responses-API request
|
||||
/// carries the hosted-agent <c>User-Agent</c> segment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Best-effort and idempotent. The method is a no-op when:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><paramref name="agent"/> exposes no <see cref="IChatClient"/>;</description></item>
|
||||
/// <item><description>the chat client is not backed by MEAI's internal <c>OpenAIResponsesChatClient</c> (e.g., a non-OpenAI provider or a custom impl);</description></item>
|
||||
/// <item><description>the inner <see cref="ResponsesClient"/> is already a <see cref="UserAgentResponsesClient"/>.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Works for any <see cref="ResponsesClient"/>-derived inner client — both the Foundry-specific
|
||||
/// <see cref="Azure.AI.Extensions.OpenAI.ProjectResponsesClient"/> and the native OpenAI
|
||||
/// <see cref="ResponsesClient"/> obtained from <see cref="OpenAI.OpenAIClient"/>. The wrapper preserves
|
||||
/// the inner client's pipeline (Transport, RetryPolicy, NetworkTimeout, OrganizationId / ProjectId /
|
||||
/// UserAgentApplicationId, custom policies) because every override delegates to the inner instance.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Returns the same <paramref name="agent"/> instance unchanged. Mutation happens via
|
||||
/// reflection on MEAI's private <c>_responseClient</c> field; the agent itself is not wrapped.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static AIAgent TryApplyUserAgent(AIAgent agent)
|
||||
private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next)
|
||||
{
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
if (chatClient is null)
|
||||
private static readonly string s_userAgentValue = CreateUserAgentValue();
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
return agent;
|
||||
var headers = context.Request.Headers;
|
||||
var userAgent = headers.UserAgent.ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(userAgent))
|
||||
{
|
||||
headers.UserAgent = s_userAgentValue;
|
||||
}
|
||||
else if (!userAgent.Contains(s_userAgentValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
headers.UserAgent = $"{userAgent} {s_userAgentValue}";
|
||||
}
|
||||
|
||||
await next(context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var meaiType = s_meaiResponsesChatClientType;
|
||||
if (meaiType is null)
|
||||
private static string CreateUserAgentValue()
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
const string Name = "agent-framework-dotnet";
|
||||
|
||||
var meaiInstance = chatClient.GetService(meaiType);
|
||||
if (meaiInstance is null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
if (typeof(AgentFrameworkUserAgentMiddleware).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
var field = s_meaiResponseClientField;
|
||||
if (field is null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
var current = field.GetValue(meaiInstance) as ResponsesClient;
|
||||
if (current is null or UserAgentResponsesClient)
|
||||
{
|
||||
return agent;
|
||||
return Name;
|
||||
}
|
||||
|
||||
field.SetValue(meaiInstance, new UserAgentResponsesClient(current));
|
||||
return agent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MEAI's internal <c>OpenAIResponsesChatClient</c> type, resolved once via reflection.
|
||||
/// <see langword="null"/> if the type cannot be found (e.g., MEAI version drift).
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
|
||||
Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2073:RequiresUnreferencedCode",
|
||||
Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")]
|
||||
private static readonly Type? s_meaiResponsesChatClientType =
|
||||
typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
|
||||
|
||||
/// <summary>
|
||||
/// MEAI's internal <c>_responseClient</c> field on <c>OpenAIResponsesChatClient</c>,
|
||||
/// resolved once via reflection. <see langword="null"/> if the field cannot be found.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2080:RequiresDynamicallyAccessedMembers",
|
||||
Justification = "OpenAIResponsesChatClient and its private fields are preserved by the polyfill design; MEAI does the same reflection internally.")]
|
||||
private static readonly FieldInfo? s_meaiResponseClientField =
|
||||
s_meaiResponsesChatClientType?.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for translating between agent-framework tool-approval request ids and the
|
||||
/// strict-format wire ids required by the Responses Server SDK <c>mcp_approval_request</c>
|
||||
/// item type. The mapping is persisted in <see cref="AgentSessionStateBag"/> so an
|
||||
/// approval request emitted on one HTTP turn can be matched to the response posted
|
||||
/// back on the next turn.
|
||||
/// </summary>
|
||||
internal static class ToolApprovalIdMap
|
||||
{
|
||||
/// <summary>
|
||||
/// State-bag key used to store the wire-id ↔ AF-request-id mapping.
|
||||
/// </summary>
|
||||
public const string StateBagKey = "Microsoft.Agents.AI.Foundry.Hosting.ToolApprovalIdMap";
|
||||
|
||||
/// <summary>
|
||||
/// SDK item-id format constraints: <c>{prefix}_{50_or_48_chars}</c>. We use the
|
||||
/// canonical <c>mcpr_</c> prefix and a SHA-256 truncated to 50 hex chars (25 bytes)
|
||||
/// for deterministic, format-safe wire ids.
|
||||
/// </summary>
|
||||
public static string ComputeWireId(string afRequestId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(afRequestId);
|
||||
|
||||
#if NET10_0_OR_GREATER
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId), hash);
|
||||
#else
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId));
|
||||
#endif
|
||||
// 25 bytes = 50 hex chars (matches SDK body length 50).
|
||||
return "mcpr_" + Convert.ToHexString(hash).AsSpan(0, 50).ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the wire-id → AF-request-id mapping in the supplied state bag.
|
||||
/// </summary>
|
||||
public static void Record(AgentSessionStateBag? stateBag, string wireId, string afRequestId)
|
||||
{
|
||||
if (stateBag is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var map = stateBag.GetValue<Dictionary<string, string>>(StateBagKey)
|
||||
?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
map[wireId] = afRequestId;
|
||||
stateBag.SetValue(StateBagKey, map);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the AF request id for a given wire id. Returns the wire id verbatim
|
||||
/// when no mapping is present (best-effort fallback that keeps converters total).
|
||||
/// </summary>
|
||||
public static string Resolve(AgentSessionStateBag? stateBag, string wireId)
|
||||
{
|
||||
if (stateBag?.GetValue<Dictionary<string, string>>(StateBagKey) is { } map
|
||||
&& map.TryGetValue(wireId, out var afRequestId))
|
||||
{
|
||||
return afRequestId;
|
||||
}
|
||||
|
||||
return wireId;
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ResponsesClient"/> subclass that delegates every protocol-level request to a
|
||||
/// wrapped <see cref="ResponsesClient"/>. Before each call, a
|
||||
/// <see cref="HostedAgentUserAgentPolicy"/> is added to the per-call
|
||||
/// <see cref="RequestOptions"/> so the wrapped client's pipeline appends the hosted-agent
|
||||
/// <c>User-Agent</c> segment on the wire.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The streaming overloads MEAI binds via reflection (<c>internal CreateResponseStreamingAsync(CreateResponseOptions, RequestOptions)</c>
|
||||
/// and <c>internal GetResponseStreamingAsync(GetResponseOptions, RequestOptions)</c>) bottom out
|
||||
/// in calls to the public-virtual non-streaming protocol overloads on <see langword="this"/>. Overriding those
|
||||
/// non-streaming overloads is therefore sufficient to intercept both streaming and non-streaming traffic.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The base pipeline supplied to <see cref="ResponsesClient(ClientPipeline, OpenAIClientOptions)"/>
|
||||
/// is a dummy pipeline whose terminal transport throws if invoked. Every override on this class
|
||||
/// delegates to the inner client BEFORE any code path reaches <see cref="ResponsesClient.Pipeline"/>, so the dummy is
|
||||
/// never expected to run; the throwing transport surfaces any unexpected escape route loudly.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class UserAgentResponsesClient : ResponsesClient
|
||||
{
|
||||
private readonly ResponsesClient _inner;
|
||||
|
||||
public UserAgentResponsesClient(ResponsesClient inner)
|
||||
: base(BuildDummyPipeline(), new OpenAIClientOptions { Endpoint = inner?.Endpoint })
|
||||
{
|
||||
this._inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
public override async Task<ClientResult> CreateResponseAsync(BinaryContent content, RequestOptions? options = null)
|
||||
=> await this._inner.CreateResponseAsync(content, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult CreateResponse(BinaryContent content, RequestOptions? options = null)
|
||||
=> this._inner.CreateResponse(content, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> GetResponseAsync(string responseId, IEnumerable<IncludedResponseProperty>? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options)
|
||||
=> await this._inner.GetResponseAsync(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult GetResponse(string responseId, IEnumerable<IncludedResponseProperty>? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options)
|
||||
=> this._inner.GetResponse(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> DeleteResponseAsync(string responseId, RequestOptions options)
|
||||
=> await this._inner.DeleteResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult DeleteResponse(string responseId, RequestOptions options)
|
||||
=> this._inner.DeleteResponse(responseId, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> CancelResponseAsync(string responseId, RequestOptions options)
|
||||
=> await this._inner.CancelResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult CancelResponse(string responseId, RequestOptions options)
|
||||
=> this._inner.CancelResponse(responseId, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> GetInputTokenCountAsync(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> await this._inner.GetInputTokenCountAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult GetInputTokenCount(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> this._inner.GetInputTokenCount(contentType, content, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> CompactResponseAsync(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> await this._inner.CompactResponseAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult CompactResponse(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> this._inner.CompactResponse(contentType, content, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> GetResponseInputItemCollectionPageAsync(string responseId, int? limit, string order, string after, string before, RequestOptions options)
|
||||
=> await this._inner.GetResponseInputItemCollectionPageAsync(responseId, limit, order, after, before, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult GetResponseInputItemCollectionPage(string responseId, int? limit, string order, string after, string before, RequestOptions options)
|
||||
=> this._inner.GetResponseInputItemCollectionPage(responseId, limit, order, after, before, AddUserAgentPolicy(options));
|
||||
|
||||
private static RequestOptions AddUserAgentPolicy(RequestOptions? options)
|
||||
{
|
||||
options ??= new RequestOptions();
|
||||
options.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall);
|
||||
return options;
|
||||
}
|
||||
|
||||
private static ClientPipeline BuildDummyPipeline()
|
||||
{
|
||||
var options = new ClientPipelineOptions
|
||||
{
|
||||
Transport = new ThrowingTransport(),
|
||||
};
|
||||
return ClientPipeline.Create(options, default, default, default);
|
||||
}
|
||||
|
||||
private sealed class ThrowingTransport : PipelineTransport
|
||||
{
|
||||
private const string Message =
|
||||
"UserAgentResponsesClient transport invoked bypassed the override-and-delegate design. This exception should be unreachable and should never be thrown following the correct usage of UserAgentResponsesClient.";
|
||||
|
||||
protected override PipelineMessage CreateMessageCore() => throw new InvalidOperationException(Message);
|
||||
protected override void ProcessCore(PipelineMessage message) => throw new InvalidOperationException(Message);
|
||||
protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new InvalidOperationException(Message);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.Hosting.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -12,6 +13,20 @@ internal static class RequestOptionsExtensions
|
||||
/// <summary>Gets the singleton <see cref="PipelinePolicy"/> that adds a MEAI user-agent header.</summary>
|
||||
internal static PipelinePolicy UserAgentPolicy => MeaiUserAgentPolicy.Instance;
|
||||
|
||||
/// <summary>Creates a <see cref="RequestOptions"/> configured for use with Foundry Agents.</summary>
|
||||
public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming)
|
||||
{
|
||||
RequestOptions requestOptions = new()
|
||||
{
|
||||
CancellationToken = cancellationToken,
|
||||
BufferResponse = !streaming
|
||||
};
|
||||
|
||||
requestOptions.AddPolicy(MeaiUserAgentPolicy.Instance, PipelinePosition.PerCall);
|
||||
|
||||
return requestOptions;
|
||||
}
|
||||
|
||||
/// <summary>Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header.</summary>
|
||||
private sealed class MeaiUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
|
||||
@@ -42,19 +42,11 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
/// <inheritdoc/>
|
||||
public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
// Handle task updates
|
||||
if (context.IsContinuation)
|
||||
{
|
||||
return this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
// Handle messages received via streaming endpoint
|
||||
if (context.StreamingResponse)
|
||||
{
|
||||
return this.HandleNewMessageStreamingAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
// Handle new messages received via non-streaming endpoint
|
||||
return this.HandleNewMessageAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -88,19 +80,13 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
|
||||
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
|
||||
|
||||
AgentResponse response;
|
||||
try
|
||||
{
|
||||
response = await this._hostAgent.RunAsync(
|
||||
chatMessages,
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
var response = await this._hostAgent.RunAsync(
|
||||
chatMessages,
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
@@ -122,39 +108,6 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleNewMessageStreamingAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// AIAgent does not support resuming from arbitrary prior tasks.
|
||||
// Throw explicitly so the client gets a clear error rather than a response
|
||||
// that silently ignores the referenced task context.
|
||||
if (context.Message?.ReferenceTaskIds is { Count: > 0 })
|
||||
{
|
||||
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context.");
|
||||
}
|
||||
|
||||
List<ChatMessage> chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : [];
|
||||
|
||||
var options = context.Metadata is { Count: > 0 }
|
||||
? new AgentRunOptions { AdditionalProperties = context.Metadata.ToAdditionalProperties() }
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var update in this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var message = CreateMessageFromUpdate(contextId, update);
|
||||
await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
@@ -188,10 +141,8 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
await failUpdater.FailAsync(message: null, CancellationToken.None).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
@@ -223,16 +174,6 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
Metadata = response.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
private static Message CreateMessageFromUpdate(string contextId, AgentResponseUpdate update) =>
|
||||
new()
|
||||
{
|
||||
MessageId = update.ResponseId ?? Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = Role.Agent,
|
||||
Parts = update.ToParts(),
|
||||
Metadata = update.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask? agentTask)
|
||||
{
|
||||
if (agentTask?.History is not { Count: > 0 })
|
||||
|
||||
@@ -8,26 +8,6 @@ namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
|
||||
internal static class MessageConverter
|
||||
{
|
||||
public static List<Part> ToParts(this AgentResponseUpdate update)
|
||||
{
|
||||
if (update is null || update.Contents is not { Count: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var parts = new List<Part>();
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
var part = content.ToPart();
|
||||
if (part is not null)
|
||||
{
|
||||
parts.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
public static List<Part> ToParts(this IList<ChatMessage> chatMessages)
|
||||
{
|
||||
if (chatMessages is null || chatMessages.Count == 0)
|
||||
|
||||
@@ -21,8 +21,6 @@ internal static class BuiltInFunctions
|
||||
internal const string HttpPrefix = "http-";
|
||||
internal const string McpToolPrefix = "mcptool-";
|
||||
|
||||
private const string WaitForResponseHeaderName = "x-ms-wait-for-response";
|
||||
|
||||
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
|
||||
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
|
||||
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
|
||||
@@ -64,11 +62,6 @@ internal static class BuiltInFunctions
|
||||
StartOrchestrationOptions? options = instanceId is not null ? new StartOrchestrationOptions(instanceId) : null;
|
||||
string resolvedInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput, options);
|
||||
|
||||
if (ShouldWaitForResponse(req, defaultValue: false))
|
||||
{
|
||||
return await WaitForWorkflowCompletionAsync(req, client, context, resolvedInstanceId);
|
||||
}
|
||||
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
await response.WriteStringAsync($"Workflow orchestration started for {workflowName}. Orchestration runId: {resolvedInstanceId}");
|
||||
return response;
|
||||
@@ -311,7 +304,15 @@ internal static class BuiltInFunctions
|
||||
}
|
||||
|
||||
// Check if we should wait for response (default is true)
|
||||
bool waitForResponse = ShouldWaitForResponse(req, defaultValue: true);
|
||||
bool waitForResponse = true;
|
||||
if (req.Headers.TryGetValues("x-ms-wait-for-response", out IEnumerable<string>? waitForResponseValues))
|
||||
{
|
||||
string? waitForResponseValue = waitForResponseValues.FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(waitForResponseValue) && bool.TryParse(waitForResponseValue, out bool parsedValue))
|
||||
{
|
||||
waitForResponse = parsedValue;
|
||||
}
|
||||
}
|
||||
|
||||
AIAgent agentProxy = client.AsDurableAgentProxy(context, agentName);
|
||||
|
||||
@@ -427,95 +428,6 @@ internal static class BuiltInFunctions
|
||||
return metadata.ReadOutputAs<DurableWorkflowResult>()?.Result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a workflow orchestration to complete and returns an appropriate HTTP response.
|
||||
/// </summary>
|
||||
private static async Task<HttpResponseData> WaitForWorkflowCompletionAsync(
|
||||
HttpRequestData req,
|
||||
DurableTaskClient client,
|
||||
FunctionContext context,
|
||||
string instanceId)
|
||||
{
|
||||
bool acceptsJson = AcceptsJson(req);
|
||||
|
||||
OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync(
|
||||
instanceId,
|
||||
getInputsAndOutputs: true,
|
||||
cancellation: context.CancellationToken);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound,
|
||||
$"No workflow orchestration with ID '{instanceId}' was found.", acceptsJson);
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error";
|
||||
HttpResponseData failedResponse = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
await failedResponse.WriteAsJsonAsync(
|
||||
new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), Result: null, Error: errorMessage),
|
||||
context.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
failedResponse.Headers.Add("Content-Type", "text/plain");
|
||||
await failedResponse.WriteStringAsync(errorMessage, context.CancellationToken);
|
||||
}
|
||||
|
||||
return failedResponse;
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus is not OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.InternalServerError,
|
||||
$"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'.", acceptsJson);
|
||||
}
|
||||
|
||||
string? result = metadata.ReadOutputAs<DurableWorkflowResult>()?.Result;
|
||||
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
JsonElement? resultElement = null;
|
||||
if (!string.IsNullOrEmpty(result))
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(result);
|
||||
resultElement = doc.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Result is a plain string (not valid JSON) — serialize it as a JSON string element.
|
||||
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer))
|
||||
{
|
||||
writer.WriteStringValue(result);
|
||||
}
|
||||
|
||||
using JsonDocument fallbackDoc = JsonDocument.Parse(buffer.WrittenMemory);
|
||||
resultElement = fallbackDoc.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
await response.WriteAsJsonAsync(
|
||||
new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), resultElement),
|
||||
context.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.Headers.Add("Content-Type", "text/plain");
|
||||
await response.WriteStringAsync(result ?? string.Empty, context.CancellationToken);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error response with the specified status code and error message.
|
||||
/// </summary>
|
||||
@@ -523,18 +435,18 @@ internal static class BuiltInFunctions
|
||||
/// <param name="context">The function context.</param>
|
||||
/// <param name="statusCode">The HTTP status code.</param>
|
||||
/// <param name="errorMessage">The error message.</param>
|
||||
/// <param name="acceptsJson">Optional pre-computed value indicating whether the client accepts JSON. When <see langword="null"/>, the value is determined from the request's <c>Accept</c> header.</param>
|
||||
/// <returns>The HTTP response data containing the error.</returns>
|
||||
private static async Task<HttpResponseData> CreateErrorResponseAsync(
|
||||
HttpRequestData req,
|
||||
FunctionContext context,
|
||||
HttpStatusCode statusCode,
|
||||
string errorMessage,
|
||||
bool? acceptsJson = null)
|
||||
string errorMessage)
|
||||
{
|
||||
HttpResponseData response = req.CreateResponse(statusCode);
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson ?? AcceptsJson(req))
|
||||
if (acceptsJson)
|
||||
{
|
||||
ErrorResponse errorResponse = new((int)statusCode, errorMessage);
|
||||
await response.WriteAsJsonAsync(errorResponse, context.CancellationToken);
|
||||
@@ -567,7 +479,10 @@ internal static class BuiltInFunctions
|
||||
HttpResponseData response = req.CreateResponse(statusCode);
|
||||
response.Headers.Add("x-ms-thread-id", sessionId);
|
||||
|
||||
if (AcceptsJson(req))
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
AgentRunSuccessResponse successResponse = new((int)statusCode, sessionId, agentResponse);
|
||||
await response.WriteAsJsonAsync(successResponse, context.CancellationToken);
|
||||
@@ -596,7 +511,10 @@ internal static class BuiltInFunctions
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
response.Headers.Add("x-ms-thread-id", sessionId);
|
||||
|
||||
if (AcceptsJson(req))
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
AgentRunAcceptedResponse acceptedResponse = new((int)HttpStatusCode.Accepted, sessionId);
|
||||
await response.WriteAsJsonAsync(acceptedResponse, context.CancellationToken);
|
||||
@@ -610,34 +528,6 @@ internal static class BuiltInFunctions
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the caller has requested waiting for the workflow/agent to complete,
|
||||
/// as indicated by the <c>x-ms-wait-for-response</c> header. Falls back to <paramref name="defaultValue"/>
|
||||
/// when the header is absent or not a valid boolean.
|
||||
/// </summary>
|
||||
private static bool ShouldWaitForResponse(HttpRequestData req, bool defaultValue)
|
||||
{
|
||||
if (req.Headers.TryGetValues(WaitForResponseHeaderName, out IEnumerable<string>? values) &&
|
||||
bool.TryParse(values.FirstOrDefault(), out bool parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the request accepts the <c>application/json</c> media type.
|
||||
/// </summary>
|
||||
private static bool AcceptsJson(HttpRequestData req)
|
||||
{
|
||||
return req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues
|
||||
.SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(v => v.Split(';', 2)[0].Trim())
|
||||
.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GetAgentName(FunctionContext context)
|
||||
{
|
||||
// Check if the function name starts with the HttpPrefix
|
||||
@@ -701,19 +591,6 @@ internal static class BuiltInFunctions
|
||||
[property: JsonPropertyName("eventName")] string? EventName,
|
||||
[property: JsonPropertyName("response")] JsonElement Response);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a workflow run response when waiting for completion.
|
||||
/// </summary>
|
||||
/// <param name="RunId">The orchestration run ID.</param>
|
||||
/// <param name="WorkflowStatus">The orchestration runtime status (e.g., "Completed", "Failed").</param>
|
||||
/// <param name="Result">The workflow result as a JSON element so POCOs serialize as nested objects rather than escaped strings.</param>
|
||||
/// <param name="Error">An optional error message when the workflow has failed.</param>
|
||||
private sealed record WorkflowRunResponse(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("workflowStatus")] string WorkflowStatus,
|
||||
[property: JsonPropertyName("result")] JsonElement? Result,
|
||||
[property: JsonPropertyName("error"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// A service provider that combines the original service provider with an additional DurableTaskClient instance.
|
||||
/// </summary>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Support returning workflow results from HTTP trigger endpoint ([#5321](https://github.com/microsoft/agent-framework/pull/5321))
|
||||
- Added MCP tool trigger support for durable workflows ([#4768](https://github.com/microsoft/agent-framework/pull/4768))
|
||||
- Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single entry in the outbound network allow-list applied to the
|
||||
/// Hyperlight sandbox.
|
||||
/// </summary>
|
||||
public sealed class AllowedDomain
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AllowedDomain"/> class.
|
||||
/// </summary>
|
||||
/// <param name="target">URL or domain to allow, for example <c>"https://api.github.com"</c>.</param>
|
||||
/// <param name="methods">
|
||||
/// Optional list of HTTP methods to allow (for example <c>["GET", "POST"]</c>).
|
||||
/// When <see langword="null"/>, all methods supported by the backend are allowed.
|
||||
/// </param>
|
||||
public AllowedDomain(string target, IReadOnlyList<string>? methods = null)
|
||||
{
|
||||
this.Target = target;
|
||||
this.Methods = methods;
|
||||
}
|
||||
|
||||
/// <summary>Gets the URL or domain to allow.</summary>
|
||||
public string Target { get; }
|
||||
|
||||
/// <summary>Gets the optional list of HTTP methods to allow.</summary>
|
||||
public IReadOnlyList<string>? Methods { get; }
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight;
|
||||
|
||||
/// <summary>
|
||||
/// Controls the approval behavior for the <c>execute_code</c> tool exposed by
|
||||
/// <see cref="HyperlightCodeActProvider"/> and <see cref="HyperlightExecuteCodeFunction"/>.
|
||||
/// </summary>
|
||||
public enum CodeActApprovalMode
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>execute_code</c> always requires user approval before invocation.
|
||||
/// </summary>
|
||||
AlwaysRequire,
|
||||
|
||||
/// <summary>
|
||||
/// Approval is derived from the provider-owned CodeAct tool registry.
|
||||
/// If any configured tool is an
|
||||
/// <see cref="ApprovalRequiredAIFunction"/>,
|
||||
/// <c>execute_code</c> also requires approval. Otherwise it does not.
|
||||
/// </summary>
|
||||
NeverRequire,
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a host-to-sandbox file mount configuration used by
|
||||
/// <see cref="HyperlightCodeActProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class FileMount
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileMount"/> class.
|
||||
/// </summary>
|
||||
/// <param name="hostPath">Absolute or relative path on the host filesystem to mount into the sandbox.</param>
|
||||
/// <param name="mountPath">
|
||||
/// Path inside the sandbox the host path is exposed at (for example <c>"/input/data.csv"</c>).
|
||||
/// </param>
|
||||
public FileMount(string hostPath, string mountPath)
|
||||
{
|
||||
this.HostPath = hostPath;
|
||||
this.MountPath = mountPath;
|
||||
}
|
||||
|
||||
/// <summary>Gets the path on the host filesystem that is mounted into the sandbox.</summary>
|
||||
public string HostPath { get; }
|
||||
|
||||
/// <summary>Gets the path inside the sandbox at which the host path is exposed.</summary>
|
||||
public string MountPath { get; }
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIContextProvider"/> that enables CodeAct execution through a
|
||||
/// Hyperlight-backed sandbox.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The provider injects an <c>execute_code</c> tool into the model-facing tool
|
||||
/// surface and contributes a short CodeAct guidance block through
|
||||
/// <see cref="AIContext.Instructions"/>. Guest code executed via
|
||||
/// <c>execute_code</c> runs in an isolated Hyperlight sandbox with
|
||||
/// snapshot/restore for clean state per invocation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If no CodeAct-managed tools are configured the provider behaves as a code
|
||||
/// interpreter. If one or more tools are configured they are exposed to guest
|
||||
/// code via <c>call_tool(...)</c> but not to the model directly.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only a single <see cref="HyperlightCodeActProvider"/> may be attached to a
|
||||
/// given agent. <see cref="StateKeys"/> returns a fixed value so
|
||||
/// <c>ChatClientAgent</c>'s state-key uniqueness validation rejects duplicate
|
||||
/// registrations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> guest code runs with only the
|
||||
/// capabilities explicitly configured on this provider (file mounts, allowed
|
||||
/// outbound domains). Callers should configure the smallest capability set
|
||||
/// sufficient for the task and consider using
|
||||
/// <see cref="CodeActApprovalMode.AlwaysRequire"/> when guest code can reach
|
||||
/// sensitive resources.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HyperlightCodeActProvider : AIContextProvider, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Fixed state key used to enforce a single provider-per-agent.
|
||||
/// </summary>
|
||||
internal const string FixedStateKey = "HyperlightCodeActProvider";
|
||||
|
||||
private static readonly IReadOnlyList<string> s_stateKeys = [FixedStateKey];
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly HyperlightCodeActProviderOptions _options;
|
||||
private readonly SandboxExecutor _executor;
|
||||
|
||||
private readonly Dictionary<string, AIFunction> _tools = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, FileMount> _fileMounts = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, AllowedDomain> _allowedDomains = new(StringComparer.Ordinal);
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HyperlightCodeActProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">
|
||||
/// Optional configuration options for the provider. When <see langword="null"/> the provider
|
||||
/// uses the defaults of <see cref="HyperlightCodeActProviderOptions"/> (the
|
||||
/// <see cref="HyperlightSandbox.Api.SandboxBackend.JavaScript"/> backend with no tools, mounts, or allow-list entries).
|
||||
/// Use <see cref="HyperlightCodeActProviderOptions.CreateForWasm(string)"/> to target a Wasm
|
||||
/// guest module instead.
|
||||
/// </param>
|
||||
public HyperlightCodeActProvider(HyperlightCodeActProviderOptions? options = null)
|
||||
{
|
||||
this._options = options ?? new HyperlightCodeActProviderOptions();
|
||||
this._executor = new SandboxExecutor(this._options);
|
||||
|
||||
if (this._options.Tools is not null)
|
||||
{
|
||||
foreach (var tool in this._options.Tools.Where(t => t is not null))
|
||||
{
|
||||
this._tools[tool.Name] = tool;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._options.FileMounts is not null)
|
||||
{
|
||||
foreach (var mount in this._options.FileMounts.Where(m => m is not null))
|
||||
{
|
||||
this._fileMounts[mount.MountPath] = mount;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._options.AllowedDomains is not null)
|
||||
{
|
||||
foreach (var domain in this._options.AllowedDomains.Where(d => d is not null))
|
||||
{
|
||||
this._allowedDomains[domain.Target] = domain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> StateKeys => s_stateKeys;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Tool registry
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <summary>Adds tools to the provider-owned CodeAct tool registry. Tools with a duplicate name replace the existing registration.</summary>
|
||||
/// <param name="tools">The tools to add.</param>
|
||||
public void AddTools(params AIFunction[] tools)
|
||||
{
|
||||
_ = Throw.IfNull(tools);
|
||||
lock (this._gate)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
foreach (var tool in tools.Where(t => t is not null))
|
||||
{
|
||||
this._tools[tool.Name] = tool;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the current CodeAct-managed tools.</summary>
|
||||
public IReadOnlyList<AIFunction> GetTools()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
return this._tools.Values.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes tools by name from the CodeAct tool registry.</summary>
|
||||
/// <param name="names">The names of the tools to remove.</param>
|
||||
public void RemoveTools(params string[] names)
|
||||
{
|
||||
_ = Throw.IfNull(names);
|
||||
lock (this._gate)
|
||||
{
|
||||
foreach (var name in names.Where(n => n is not null))
|
||||
{
|
||||
_ = this._tools.Remove(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes all CodeAct-managed tools.</summary>
|
||||
public void ClearTools()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
this._tools.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// File mounts
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <summary>Adds file mount configurations. Mounts with a duplicate mount path replace the existing entry.</summary>
|
||||
/// <param name="mounts">The mount configurations to add.</param>
|
||||
public void AddFileMounts(params FileMount[] mounts)
|
||||
{
|
||||
_ = Throw.IfNull(mounts);
|
||||
lock (this._gate)
|
||||
{
|
||||
foreach (var mount in mounts.Where(m => m is not null))
|
||||
{
|
||||
this._fileMounts[mount.MountPath] = mount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the current file mount configurations.</summary>
|
||||
public IReadOnlyList<FileMount> GetFileMounts()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
return this._fileMounts.Values.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes file mounts by sandbox mount path.</summary>
|
||||
/// <param name="mountPaths">The mount paths to remove.</param>
|
||||
public void RemoveFileMounts(params string[] mountPaths)
|
||||
{
|
||||
_ = Throw.IfNull(mountPaths);
|
||||
lock (this._gate)
|
||||
{
|
||||
foreach (var path in mountPaths.Where(p => p is not null))
|
||||
{
|
||||
_ = this._fileMounts.Remove(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes all file mount configurations.</summary>
|
||||
public void ClearFileMounts()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
this._fileMounts.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Network allow-list
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <summary>Adds outbound network allow-list entries. Entries with a duplicate target replace the existing entry.</summary>
|
||||
/// <param name="domains">The allow-list entries to add.</param>
|
||||
public void AddAllowedDomains(params AllowedDomain[] domains)
|
||||
{
|
||||
_ = Throw.IfNull(domains);
|
||||
lock (this._gate)
|
||||
{
|
||||
foreach (var domain in domains.Where(d => d is not null))
|
||||
{
|
||||
this._allowedDomains[domain.Target] = domain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the current outbound allow-list entries.</summary>
|
||||
public IReadOnlyList<AllowedDomain> GetAllowedDomains()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
return this._allowedDomains.Values.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes allow-list entries by target.</summary>
|
||||
/// <param name="targets">The targets to remove.</param>
|
||||
public void RemoveAllowedDomains(params string[] targets)
|
||||
{
|
||||
_ = Throw.IfNull(targets);
|
||||
lock (this._gate)
|
||||
{
|
||||
foreach (var target in targets.Where(t => t is not null))
|
||||
{
|
||||
_ = this._allowedDomains.Remove(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes all outbound allow-list entries.</summary>
|
||||
public void ClearAllowedDomains()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
this._allowedDomains.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// AIContextProvider implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(context);
|
||||
|
||||
SandboxExecutor.RunSnapshot snapshot;
|
||||
lock (this._gate)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
snapshot = new SandboxExecutor.RunSnapshot(
|
||||
this._tools.Values.ToList(),
|
||||
this._fileMounts.Values.ToList(),
|
||||
this._allowedDomains.Values.ToList(),
|
||||
this._options.HostInputDirectory);
|
||||
}
|
||||
|
||||
var approvalRequired = ComputeApprovalRequired(this._options.ApprovalMode, snapshot.Tools);
|
||||
|
||||
var description = InstructionBuilder.BuildExecuteCodeDescription(
|
||||
snapshot.Tools,
|
||||
snapshot.FileMounts,
|
||||
snapshot.AllowedDomains,
|
||||
hasHostInputDirectory: !string.IsNullOrEmpty(snapshot.HostInputDirectory));
|
||||
|
||||
AIFunction executeCode = new ExecuteCodeFunction(this._executor, snapshot, description);
|
||||
if (approvalRequired)
|
||||
{
|
||||
executeCode = new ApprovalRequiredAIFunction(executeCode);
|
||||
}
|
||||
|
||||
var instructions = InstructionBuilder.BuildContextInstructions(toolsVisibleToModel: false);
|
||||
|
||||
var result = new AIContext
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools = [executeCode],
|
||||
};
|
||||
|
||||
return new ValueTask<AIContext>(result);
|
||||
}
|
||||
|
||||
internal static bool ComputeApprovalRequired(CodeActApprovalMode mode, IReadOnlyList<AIFunction> tools) =>
|
||||
mode == CodeActApprovalMode.AlwaysRequire
|
||||
|| tools.Any(t => t.GetService<ApprovalRequiredAIFunction>() is not null);
|
||||
|
||||
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this);
|
||||
|
||||
/// <summary>Releases the underlying sandbox and associated native resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
if (this._disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._disposed = true;
|
||||
}
|
||||
|
||||
this._executor.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using HyperlightSandbox.Api;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="HyperlightCodeActProvider"/> and
|
||||
/// <see cref="HyperlightExecuteCodeFunction"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use the <see cref="CreateForWasm(string)"/> and <see cref="CreateForJavaScript()"/>
|
||||
/// factory methods to construct an instance with the desired sandbox backend.
|
||||
/// The parameterless constructor is equivalent to <see cref="CreateForJavaScript()"/>.
|
||||
/// </remarks>
|
||||
public sealed class HyperlightCodeActProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance configured for the JavaScript backend.
|
||||
/// Equivalent to <see cref="CreateForJavaScript()"/>.
|
||||
/// </summary>
|
||||
public HyperlightCodeActProviderOptions()
|
||||
: this(SandboxBackend.JavaScript, modulePath: null)
|
||||
{
|
||||
}
|
||||
|
||||
private HyperlightCodeActProviderOptions(SandboxBackend backend, string? modulePath)
|
||||
{
|
||||
this.Backend = backend;
|
||||
this.ModulePath = modulePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates options targeting the <see cref="SandboxBackend.Wasm"/> backend.
|
||||
/// </summary>
|
||||
/// <param name="modulePath">Path to the guest module (<c>.wasm</c> or <c>.aot</c> file).</param>
|
||||
public static HyperlightCodeActProviderOptions CreateForWasm(string modulePath)
|
||||
=> new(SandboxBackend.Wasm, Throw.IfNullOrWhitespace(modulePath));
|
||||
|
||||
/// <summary>
|
||||
/// Creates options targeting the <see cref="SandboxBackend.JavaScript"/> backend.
|
||||
/// </summary>
|
||||
public static HyperlightCodeActProviderOptions CreateForJavaScript()
|
||||
=> new(SandboxBackend.JavaScript, modulePath: null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Hyperlight sandbox backend this options instance is configured for.
|
||||
/// </summary>
|
||||
public SandboxBackend Backend { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the guest module. Set when the options were created via
|
||||
/// <see cref="CreateForWasm(string)"/>; <see langword="null"/> otherwise.
|
||||
/// </summary>
|
||||
public string? ModulePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the guest heap size. Accepts human-readable strings such as
|
||||
/// <c>"50Mi"</c> or <c>"2Gi"</c>. When <see langword="null"/> the backend default is used.
|
||||
/// </summary>
|
||||
public string? HeapSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the guest stack size. Accepts human-readable strings such as
|
||||
/// <c>"35Mi"</c>. When <see langword="null"/> the backend default is used.
|
||||
/// </summary>
|
||||
public string? StackSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the initial set of provider-owned CodeAct tools made available
|
||||
/// inside the sandbox via <c>call_tool(...)</c>.
|
||||
/// </summary>
|
||||
public IEnumerable<AIFunction>? Tools { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default approval mode for <c>execute_code</c>.
|
||||
/// Defaults to <see cref="CodeActApprovalMode.NeverRequire"/>.
|
||||
/// </summary>
|
||||
public CodeActApprovalMode ApprovalMode { get; set; } = CodeActApprovalMode.NeverRequire;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional host directory exposed to the sandbox as its
|
||||
/// <c>/input</c> directory.
|
||||
/// </summary>
|
||||
public string? HostInputDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the initial set of file mount configurations.
|
||||
/// </summary>
|
||||
public IEnumerable<FileMount>? FileMounts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the initial outbound network allow-list entries.
|
||||
/// </summary>
|
||||
public IEnumerable<AllowedDomain>? AllowedDomains { get; set; }
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight;
|
||||
|
||||
/// <summary>
|
||||
/// Standalone <c>execute_code</c> <see cref="AIFunction"/> backed by a
|
||||
/// Hyperlight sandbox. Use this for manual/static wiring when an
|
||||
/// <see cref="AIContextProvider"/> lifecycle is not needed — for example
|
||||
/// when the tool registry and capability configuration are fixed for the
|
||||
/// lifetime of the agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike <see cref="HyperlightCodeActProvider"/>, this type does not hook
|
||||
/// into the <see cref="AIContextProvider"/> pipeline. It captures a single
|
||||
/// snapshot of the provided <see cref="HyperlightCodeActProviderOptions"/>
|
||||
/// at construction time and reuses it for the lifetime of the instance.
|
||||
/// The instance can be passed directly anywhere an <see cref="AIFunction"/>
|
||||
/// is accepted; when the configuration requires approval (per
|
||||
/// <see cref="HyperlightCodeActProviderOptions.ApprovalMode"/> or because a
|
||||
/// configured tool is itself an <see cref="ApprovalRequiredAIFunction"/>),
|
||||
/// the instance surfaces an <see cref="ApprovalRequiredAIFunction"/> via
|
||||
/// <see cref="AITool.GetService(Type, object?)"/>, which is how the rest of
|
||||
/// the framework discovers approval requirements.
|
||||
/// </remarks>
|
||||
public sealed class HyperlightExecuteCodeFunction : AIFunction, IDisposable
|
||||
{
|
||||
private const string ExecuteCodeName = "execute_code";
|
||||
|
||||
private static readonly JsonElement s_schema = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Code to execute using the provider's configured backend/runtime behavior."
|
||||
}
|
||||
},
|
||||
"required": ["code"]
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
private readonly SandboxExecutor _executor;
|
||||
private readonly SandboxExecutor.RunSnapshot _snapshot;
|
||||
private readonly string _description;
|
||||
private readonly bool _approvalRequired;
|
||||
private ApprovalRequiredAIFunction? _approvalProxy;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HyperlightExecuteCodeFunction"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">
|
||||
/// Optional configuration options. When <see langword="null"/> the defaults of
|
||||
/// <see cref="HyperlightCodeActProviderOptions"/> are used.
|
||||
/// </param>
|
||||
public HyperlightExecuteCodeFunction(HyperlightCodeActProviderOptions? options = null)
|
||||
{
|
||||
var effective = options ?? new HyperlightCodeActProviderOptions();
|
||||
this._executor = new SandboxExecutor(effective);
|
||||
|
||||
var tools = (effective.Tools?.Where(t => t is not null) ?? []).ToList();
|
||||
var fileMounts = (effective.FileMounts?.Where(m => m is not null) ?? []).ToList();
|
||||
var allowedDomains = (effective.AllowedDomains?.Where(d => d is not null) ?? []).ToList();
|
||||
|
||||
this._snapshot = new SandboxExecutor.RunSnapshot(tools, fileMounts, allowedDomains, effective.HostInputDirectory);
|
||||
|
||||
this._description = InstructionBuilder.BuildExecuteCodeDescription(
|
||||
this._snapshot.Tools,
|
||||
this._snapshot.FileMounts,
|
||||
this._snapshot.AllowedDomains,
|
||||
hasHostInputDirectory: !string.IsNullOrEmpty(this._snapshot.HostInputDirectory));
|
||||
|
||||
this._approvalRequired = HyperlightCodeActProvider.ComputeApprovalRequired(effective.ApprovalMode, this._snapshot.Tools);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => ExecuteCodeName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => this._description;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonElement JsonSchema => s_schema;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CodeAct instruction string describing the available tools and capabilities.
|
||||
/// </summary>
|
||||
/// <param name="toolsVisibleToModel">
|
||||
/// When <see langword="false"/>, the instructions assume tools are only accessible
|
||||
/// through CodeAct (via <c>call_tool</c>). When <see langword="true"/>, the instructions
|
||||
/// are abbreviated for cases where the same tools are already visible to the model as
|
||||
/// direct agent tools.
|
||||
/// </param>
|
||||
public string BuildInstructions(bool toolsVisibleToModel = false)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
return InstructionBuilder.BuildContextInstructions(toolsVisibleToModel);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
if (serviceKey is null
|
||||
&& this._approvalRequired
|
||||
&& serviceType == typeof(ApprovalRequiredAIFunction))
|
||||
{
|
||||
return this._approvalProxy ??= new ApprovalRequiredAIFunction(this);
|
||||
}
|
||||
|
||||
return base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<object?> InvokeCoreAsync(
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
|
||||
if (arguments is null || !arguments.TryGetValue("code", out var codeObj) || codeObj is null)
|
||||
{
|
||||
throw new ArgumentException("Missing required parameter 'code'.", nameof(arguments));
|
||||
}
|
||||
|
||||
var code = codeObj switch
|
||||
{
|
||||
string s => s,
|
||||
JsonElement { ValueKind: JsonValueKind.String } el => el.GetString() ?? string.Empty,
|
||||
_ => codeObj.ToString() ?? string.Empty,
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
throw new ArgumentException("Parameter 'code' must not be empty.", nameof(arguments));
|
||||
}
|
||||
|
||||
return await this._executor.ExecuteAsync(this._snapshot, code, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this);
|
||||
|
||||
/// <summary>Releases the underlying sandbox and associated native resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (this._disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._disposed = true;
|
||||
this._executor.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Run-scoped <see cref="AIFunction"/> that exposes <c>execute_code</c>
|
||||
/// to the model. The function closes over an immutable
|
||||
/// <see cref="SandboxExecutor.RunSnapshot"/> captured at the start of the
|
||||
/// agent invocation, so subsequent CRUD mutations on the provider do not
|
||||
/// affect an in-flight run.
|
||||
/// </summary>
|
||||
internal sealed class ExecuteCodeFunction : AIFunction
|
||||
{
|
||||
private const string ExecuteCodeName = "execute_code";
|
||||
|
||||
private static readonly JsonElement s_schema = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Code to execute using the provider's configured backend/runtime behavior."
|
||||
}
|
||||
},
|
||||
"required": ["code"]
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
private readonly SandboxExecutor _executor;
|
||||
private readonly SandboxExecutor.RunSnapshot _snapshot;
|
||||
private readonly string _description;
|
||||
|
||||
public ExecuteCodeFunction(
|
||||
SandboxExecutor executor,
|
||||
SandboxExecutor.RunSnapshot snapshot,
|
||||
string description)
|
||||
{
|
||||
this._executor = executor;
|
||||
this._snapshot = snapshot;
|
||||
this._description = description;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => ExecuteCodeName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => this._description;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonElement JsonSchema => s_schema;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<object?> InvokeCoreAsync(
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (arguments is null || !arguments.TryGetValue("code", out var codeObj) || codeObj is null)
|
||||
{
|
||||
throw new ArgumentException("Missing required parameter 'code'.", nameof(arguments));
|
||||
}
|
||||
|
||||
var code = codeObj switch
|
||||
{
|
||||
string s => s,
|
||||
JsonElement { ValueKind: JsonValueKind.String } el => el.GetString() ?? string.Empty,
|
||||
_ => codeObj.ToString() ?? string.Empty,
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
throw new ArgumentException("Parameter 'code' must not be empty.", nameof(arguments));
|
||||
}
|
||||
|
||||
return await this._executor.ExecuteAsync(this._snapshot, code, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON context for the well-known envelope shapes the Hyperlight
|
||||
/// integration serializes (the execute_code result payload and the tool error payload).
|
||||
/// User-supplied tool results are serialized via AIJsonUtilities.DefaultOptions instead
|
||||
/// because their types cannot be statically known at compile time.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.General)]
|
||||
[JsonSerializable(typeof(HyperlightExecutionResult))]
|
||||
[JsonSerializable(typeof(HyperlightToolError))]
|
||||
internal sealed partial class HyperlightJsonContext : JsonSerializerContext;
|
||||
|
||||
internal sealed record HyperlightExecutionResult(
|
||||
[property: JsonPropertyName("stdout")] string Stdout,
|
||||
[property: JsonPropertyName("stderr")] string Stderr,
|
||||
[property: JsonPropertyName("exit_code")] int ExitCode,
|
||||
[property: JsonPropertyName("success")] bool Success);
|
||||
|
||||
internal sealed record HyperlightToolError(
|
||||
[property: JsonPropertyName("error")] string Error);
|
||||
@@ -1,117 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the CodeAct guidance strings returned through
|
||||
/// <see cref="AIContext.Instructions"/> and the <c>execute_code</c>
|
||||
/// function description.
|
||||
/// </summary>
|
||||
internal static class InstructionBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the short CodeAct guidance block that is merged into the
|
||||
/// agent's instructions for the current invocation.
|
||||
/// </summary>
|
||||
public static string BuildContextInstructions(bool toolsVisibleToModel)
|
||||
{
|
||||
if (toolsVisibleToModel)
|
||||
{
|
||||
return
|
||||
"You can execute code in a secure sandbox by calling the `execute_code` tool. "
|
||||
+ "Use it for calculations, data analysis, and anything that benefits from running code. "
|
||||
+ "State does not persist between calls; pass any required values in the code you execute.";
|
||||
}
|
||||
|
||||
return
|
||||
"You can execute code in a secure sandbox by calling the `execute_code` tool. "
|
||||
+ "Any tools listed in the tool's description are only accessible from within the sandbox "
|
||||
+ "via `call_tool(\"<name>\", ...)` — they cannot be invoked directly. "
|
||||
+ "State does not persist between calls; pass any required values in the code you execute.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the detailed description attached to the run-scoped
|
||||
/// <c>execute_code</c> <see cref="AIFunction"/>. This includes the
|
||||
/// available <c>call_tool</c> signatures and a capability summary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Host-side filesystem paths are intentionally omitted from the
|
||||
/// description — only sandbox-visible mount paths are exposed to the
|
||||
/// model.
|
||||
/// </remarks>
|
||||
public static string BuildExecuteCodeDescription(
|
||||
IReadOnlyList<AIFunction> tools,
|
||||
IReadOnlyList<FileMount> fileMounts,
|
||||
IReadOnlyList<AllowedDomain> allowedDomains,
|
||||
bool hasHostInputDirectory)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("Executes code in a secure Hyperlight sandbox. ");
|
||||
sb.Append("Pass the full source to execute via the `code` parameter. ");
|
||||
sb.Append("Returns a JSON string with `stdout`, `stderr`, `exit_code`, and `success` fields.");
|
||||
|
||||
if (tools.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("The following host tools are available inside the sandbox via `call_tool(\"<name>\", **kwargs)`:");
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
sb.Append("- `");
|
||||
sb.Append(tool.Name);
|
||||
sb.Append('`');
|
||||
if (!string.IsNullOrWhiteSpace(tool.Description))
|
||||
{
|
||||
sb.Append(": ");
|
||||
sb.Append(tool.Description);
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (hasHostInputDirectory || fileMounts.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Filesystem access:");
|
||||
if (hasHostInputDirectory)
|
||||
{
|
||||
sb.AppendLine("- Host input directory mounted read-only at `/input`.");
|
||||
}
|
||||
|
||||
foreach (var mount in fileMounts)
|
||||
{
|
||||
sb.Append("- `");
|
||||
sb.Append(mount.MountPath);
|
||||
sb.AppendLine("`");
|
||||
}
|
||||
}
|
||||
|
||||
if (allowedDomains.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Outbound network access is restricted to the following targets:");
|
||||
foreach (var domain in allowedDomains)
|
||||
{
|
||||
sb.Append("- `");
|
||||
sb.Append(domain.Target);
|
||||
sb.Append('`');
|
||||
if (domain.Methods is { Count: > 0 })
|
||||
{
|
||||
sb.Append(" [");
|
||||
sb.Append(string.Join(", ", domain.Methods));
|
||||
sb.Append(']');
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using HyperlightSandbox.Api;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Captures a per-run snapshot of the provider state and owns the
|
||||
/// lifecycle of the underlying <see cref="Sandbox"/>. A single
|
||||
/// <see cref="SandboxExecutor"/> is shared across runs and serializes
|
||||
/// execution via snapshot/restore.
|
||||
/// </summary>
|
||||
internal sealed class SandboxExecutor : IDisposable
|
||||
{
|
||||
private readonly HyperlightCodeActProviderOptions _options;
|
||||
private readonly SemaphoreSlim _executionLock = new(1, 1);
|
||||
|
||||
private Sandbox? _sandbox;
|
||||
private SandboxSnapshot? _warmSnapshot;
|
||||
private string? _lastConfigFingerprint;
|
||||
private bool _disposed;
|
||||
|
||||
public SandboxExecutor(HyperlightCodeActProviderOptions options)
|
||||
{
|
||||
this._options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot of provider state at the start of a run.
|
||||
/// Used to build a run-scoped <c>execute_code</c> function that is
|
||||
/// independent of subsequent CRUD mutations.
|
||||
/// </summary>
|
||||
internal sealed class RunSnapshot
|
||||
{
|
||||
public RunSnapshot(
|
||||
IReadOnlyList<AIFunction> tools,
|
||||
IReadOnlyList<FileMount> fileMounts,
|
||||
IReadOnlyList<AllowedDomain> allowedDomains,
|
||||
string? hostInputDirectory)
|
||||
{
|
||||
this.Tools = tools;
|
||||
this.FileMounts = fileMounts;
|
||||
this.AllowedDomains = allowedDomains;
|
||||
this.HostInputDirectory = hostInputDirectory;
|
||||
this.ConfigFingerprint = ComputeFingerprint(tools, fileMounts, allowedDomains, hostInputDirectory);
|
||||
}
|
||||
|
||||
public IReadOnlyList<AIFunction> Tools { get; }
|
||||
|
||||
public IReadOnlyList<FileMount> FileMounts { get; }
|
||||
|
||||
public IReadOnlyList<AllowedDomain> AllowedDomains { get; }
|
||||
|
||||
public string? HostInputDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Stable fingerprint of the configuration that materially affects how
|
||||
/// the sandbox must be built. Used by <see cref="SandboxExecutor"/> to
|
||||
/// decide whether a previously-built sandbox can be reused or must be
|
||||
/// rebuilt because tools / mounts / allow-list entries have changed.
|
||||
/// </summary>
|
||||
public string ConfigFingerprint { get; }
|
||||
|
||||
internal static string ComputeFingerprint(
|
||||
IReadOnlyList<AIFunction> tools,
|
||||
IReadOnlyList<FileMount> fileMounts,
|
||||
IReadOnlyList<AllowedDomain> allowedDomains,
|
||||
string? hostInputDirectory)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("tools=");
|
||||
foreach (var name in tools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append(name).Append('|');
|
||||
}
|
||||
|
||||
sb.Append(";mounts=");
|
||||
foreach (var m in fileMounts
|
||||
.Select(m => m.MountPath + "->" + m.HostPath)
|
||||
.OrderBy(s => s, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append(m).Append('|');
|
||||
}
|
||||
|
||||
sb.Append(";allow=");
|
||||
foreach (var d in allowedDomains
|
||||
.Select(d => d.Target + "/" + (d.Methods is null ? "*" : string.Join(",", d.Methods)))
|
||||
.OrderBy(s => s, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append(d).Append('|');
|
||||
}
|
||||
|
||||
sb.Append(";input=").Append(hostInputDirectory ?? string.Empty);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes <paramref name="code"/> inside the sandbox using the
|
||||
/// captured <paramref name="snapshot"/>. Builds (or rebuilds) the
|
||||
/// sandbox lazily when the snapshot's configuration fingerprint
|
||||
/// differs from the previously-used one.
|
||||
/// </summary>
|
||||
public async Task<string> ExecuteAsync(RunSnapshot snapshot, string code, CancellationToken cancellationToken)
|
||||
{
|
||||
await this._executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
this.EnsureInitialized(snapshot);
|
||||
|
||||
if (this._warmSnapshot is not null)
|
||||
{
|
||||
this._sandbox!.Restore(this._warmSnapshot);
|
||||
}
|
||||
|
||||
ExecutionResult result;
|
||||
try
|
||||
{
|
||||
result = this._sandbox!.Run(code);
|
||||
}
|
||||
#pragma warning disable CA1031 // Surface sandbox execution failures as structured JSON rather than propagating.
|
||||
catch (Exception ex)
|
||||
#pragma warning restore CA1031
|
||||
{
|
||||
return BuildErrorResult(ex.Message);
|
||||
}
|
||||
|
||||
return BuildResult(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._executionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureInitialized(RunSnapshot snapshot)
|
||||
{
|
||||
if (this._sandbox is not null && string.Equals(this._lastConfigFingerprint, snapshot.ConfigFingerprint, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Configuration changed (or first run) — dispose the previous sandbox
|
||||
// so the new one picks up the new tool/mount/allow-list set.
|
||||
this._warmSnapshot?.Dispose();
|
||||
this._sandbox?.Dispose();
|
||||
this._warmSnapshot = null;
|
||||
this._sandbox = null;
|
||||
|
||||
this.BuildAndWarmUp(snapshot);
|
||||
}
|
||||
|
||||
private void BuildAndWarmUp(RunSnapshot snapshot)
|
||||
{
|
||||
var builder = new SandboxBuilder()
|
||||
.WithBackend(this._options.Backend);
|
||||
|
||||
if (!string.IsNullOrEmpty(this._options.ModulePath))
|
||||
{
|
||||
builder = builder.WithModulePath(this._options.ModulePath!);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(this._options.HeapSize))
|
||||
{
|
||||
builder = builder.WithHeapSize(this._options.HeapSize!);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(this._options.StackSize))
|
||||
{
|
||||
builder = builder.WithStackSize(this._options.StackSize!);
|
||||
}
|
||||
|
||||
var hostInput = snapshot.HostInputDirectory;
|
||||
if (!string.IsNullOrEmpty(hostInput))
|
||||
{
|
||||
builder = builder.WithInputDir(hostInput!);
|
||||
}
|
||||
|
||||
// The Hyperlight .NET SDK currently exposes only a single input + output + temp-output
|
||||
// surface; per-mount configuration (`FileMount`) is captured in the execute_code
|
||||
// description so the model is aware of the layout, and will be wired to a richer
|
||||
// mount API once the SDK exposes one.
|
||||
if (snapshot.FileMounts.Count > 0 || !string.IsNullOrEmpty(hostInput))
|
||||
{
|
||||
builder = builder.WithTempOutput();
|
||||
}
|
||||
|
||||
var sandbox = builder.Build();
|
||||
|
||||
// Tools must be registered before the first Run() call.
|
||||
ToolBridge.RegisterAll(sandbox, snapshot.Tools);
|
||||
|
||||
foreach (var allowedDomain in snapshot.AllowedDomains)
|
||||
{
|
||||
sandbox.AllowDomain(allowedDomain.Target, allowedDomain.Methods);
|
||||
}
|
||||
|
||||
// Warm-up run to trigger lazy initialization, then capture a clean snapshot
|
||||
// that is restored before every subsequent user invocation.
|
||||
// Backend-specific no-op used to trigger lazy guest runtime initialization
|
||||
// before the warm snapshot is captured. Matches the values used by the
|
||||
// upstream HyperlightSandbox.Extensions.AI CodeExecutionTool reference.
|
||||
_ = sandbox.Run(this._options.Backend == SandboxBackend.JavaScript ? "void 0;" : "None");
|
||||
this._warmSnapshot = sandbox.Snapshot();
|
||||
this._sandbox = sandbox;
|
||||
this._lastConfigFingerprint = snapshot.ConfigFingerprint;
|
||||
}
|
||||
|
||||
private static string BuildResult(ExecutionResult result) =>
|
||||
JsonSerializer.Serialize(
|
||||
new HyperlightExecutionResult(
|
||||
result.Stdout ?? string.Empty,
|
||||
result.Stderr ?? string.Empty,
|
||||
result.ExitCode,
|
||||
result.ExitCode == 0),
|
||||
HyperlightJsonContext.Default.HyperlightExecutionResult);
|
||||
|
||||
private static string BuildErrorResult(string message) =>
|
||||
JsonSerializer.Serialize(
|
||||
new HyperlightExecutionResult(string.Empty, message, -1, false),
|
||||
HyperlightJsonContext.Default.HyperlightExecutionResult);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (this._disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._disposed = true;
|
||||
this._warmSnapshot?.Dispose();
|
||||
this._sandbox?.Dispose();
|
||||
this._executionLock.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Tasks;
|
||||
using HyperlightSandbox.Api;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges an <see cref="AIFunction"/> to the
|
||||
/// <see cref="Sandbox.RegisterToolAsync(string, Func{string, Task{string}})"/>
|
||||
/// overload so the guest can invoke .NET tools via <c>call_tool(...)</c>.
|
||||
/// </summary>
|
||||
internal static class ToolBridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers every <paramref name="tools"/> entry against the provided
|
||||
/// <paramref name="sandbox"/> as a raw JSON-in / JSON-out async tool.
|
||||
/// </summary>
|
||||
public static void RegisterAll(Sandbox sandbox, IReadOnlyList<AIFunction> tools)
|
||||
{
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
RegisterOne(sandbox, tool);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterOne(Sandbox sandbox, AIFunction tool)
|
||||
=> sandbox.RegisterToolAsync(
|
||||
tool.Name,
|
||||
async (string argsJson) => await InvokeAsync(tool, argsJson).ConfigureAwait(false));
|
||||
|
||||
internal static async Task<string> InvokeAsync(AIFunction tool, string argsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = ParseArguments(argsJson);
|
||||
var result = await tool.InvokeAsync(new AIFunctionArguments(arguments)).ConfigureAwait(false);
|
||||
return SerializeResult(result);
|
||||
}
|
||||
#pragma warning disable CA1031 // Catch all: we must surface every failure as a JSON error to the guest rather than crash the FFI boundary.
|
||||
catch (Exception ex)
|
||||
#pragma warning restore CA1031
|
||||
{
|
||||
return JsonSerializer.Serialize(new HyperlightToolError(ex.Message), HyperlightJsonContext.Default.HyperlightToolError);
|
||||
}
|
||||
}
|
||||
|
||||
internal static IDictionary<string, object?> ParseArguments(string argsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(argsJson))
|
||||
{
|
||||
return new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// Use JsonNode.Parse instead of JsonSerializer.Deserialize<Dictionary<...>>
|
||||
// so the bridge stays NativeAOT-compatible (the typed Deserialize overload
|
||||
// requires reflection-based metadata for object-typed values).
|
||||
var node = JsonNode.Parse(argsJson);
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Tool arguments must be a JSON object.",
|
||||
nameof(argsJson));
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
foreach (var kvp in obj)
|
||||
{
|
||||
result[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string SerializeResult(object? result)
|
||||
{
|
||||
if (result is null)
|
||||
{
|
||||
return "null";
|
||||
}
|
||||
|
||||
// Tool results are arbitrary user types — defer to AIJsonUtilities so that
|
||||
// the same trim/AOT-friendly serializer chain used elsewhere in the framework
|
||||
// is applied here. The inputs are produced by user-supplied AIFunctions and
|
||||
// therefore cannot be modeled in our own JsonSerializerContext.
|
||||
var typeInfo = AIJsonUtilities.DefaultOptions.GetTypeInfo(result.GetType());
|
||||
return JsonSerializer.Serialize(result, typeInfo);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<TargetFrameworks>net10.0;net9.0;net8.0</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Hyperlight.HyperlightSandbox.Api" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework - Hyperlight CodeAct integration</Title>
|
||||
<Description>Provides Hyperlight-backed CodeAct (sandboxed code execution) integration for Microsoft Agent Framework.</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="/" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hyperlight.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,41 +0,0 @@
|
||||
# Microsoft.Agents.AI.Hyperlight
|
||||
|
||||
First-class [CodeAct](../../../docs/decisions/0024-codeact-integration.md)
|
||||
support for the Microsoft Agent Framework, backed by the
|
||||
[Hyperlight](https://github.com/hyperlight-dev/hyperlight) VM-isolated sandbox.
|
||||
|
||||
The package exposes two entry points:
|
||||
|
||||
* **`HyperlightCodeActProvider`** — an `AIContextProvider` that injects an
|
||||
`execute_code` tool and CodeAct guidance into every agent invocation. Only
|
||||
one `HyperlightCodeActProvider` may be attached to a given agent; it
|
||||
enforces this through a fixed `StateKeys` value so `ChatClientAgent`'s
|
||||
state-key uniqueness validation rejects duplicate registrations.
|
||||
* **`HyperlightExecuteCodeFunction`** — a standalone `AIFunction` for
|
||||
static/manual wiring when the sandbox configuration is fixed for the
|
||||
agent's lifetime.
|
||||
|
||||
Both surfaces support:
|
||||
|
||||
* Provider-owned tools exposed inside the sandbox via `call_tool(...)`
|
||||
(multiple allowed).
|
||||
* Opt-in filesystem mounts and outbound network allow-list.
|
||||
* `CodeActApprovalMode` control: `NeverRequire` (default; approval propagates
|
||||
from tools wrapped in `ApprovalRequiredAIFunction`) and `AlwaysRequire`.
|
||||
* Snapshot/restore per run so the guest starts from a known clean state
|
||||
every invocation.
|
||||
|
||||
## Requirements
|
||||
|
||||
* The `Hyperlight.HyperlightSandbox.Api` NuGet package, published from the
|
||||
`src/sdk/dotnet` SDK in [hyperlight-dev/hyperlight-sandbox](https://github.com/hyperlight-dev/hyperlight-sandbox)
|
||||
(the .NET API was added in [PR #46](https://github.com/hyperlight-dev/hyperlight-sandbox/pull/46),
|
||||
now merged). Until the package is published to nuget.org the project
|
||||
restore will fail; this project is intentionally `IsPackable=false` in
|
||||
the meantime.
|
||||
* A Hyperlight Python guest module when using `SandboxBackend.Wasm`.
|
||||
|
||||
## Status
|
||||
|
||||
Preview. API may change until the underlying Hyperlight SDK reaches a stable
|
||||
release.
|
||||
@@ -56,13 +56,6 @@ public static class DeclarativeWorkflowBuilder
|
||||
/// <param name="options">Configuration options for workflow execution.</param>
|
||||
/// <param name="inputTransform">An optional function to transform the input message into a <see cref="ChatMessage"/>.</param>
|
||||
/// <returns>The <see cref="Workflow"/> that corresponds with the YAML object model.</returns>
|
||||
/// <remarks>
|
||||
/// The returned workflow's root executor accepts <typeparamref name="TInput"/>,
|
||||
/// <see cref="ChatMessage"/>, <see cref="System.Collections.Generic.IEnumerable{T}"/> of
|
||||
/// <see cref="ChatMessage"/>, <see cref="string"/>, and <see cref="TurnToken"/>. This
|
||||
/// makes the workflow usable both for direct invocation and for hosting via
|
||||
/// <see cref="WorkflowHostingExtensions.AsAIAgent(Workflow, string?, string?, string?, IWorkflowExecutionEnvironment?, bool, bool)"/>.
|
||||
/// </remarks>
|
||||
public static Workflow Build<TInput>(
|
||||
TextReader yamlReader,
|
||||
DeclarativeWorkflowOptions options,
|
||||
|
||||
@@ -26,12 +26,6 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid
|
||||
/// </summary>
|
||||
public IMcpToolHandler? McpToolHandler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the HTTP request handler for executing <c>HttpRequestAction</c> actions within workflows.
|
||||
/// If not set, HTTP request actions will fail with an appropriate error message.
|
||||
/// </summary>
|
||||
public IHttpRequestHandler? HttpRequestHandler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Defines the configuration settings for the workflow.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IHttpRequestHandler"/> built on <see cref="HttpClient"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This handler supports per-request authentication via an optional <c>httpClientProvider</c> callback that
|
||||
/// returns a pre-configured <see cref="HttpClient"/> for a given request (e.g. authenticated, custom handler).
|
||||
/// When the provider returns <see langword="null"/>, or no provider is supplied, a shared internal <see cref="HttpClient"/>
|
||||
/// is used.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The handler applies the per-request <see cref="HttpRequestInfo.Timeout"/> using a linked <see cref="CancellationTokenSource"/>
|
||||
/// so it does not mutate <see cref="HttpClient.Timeout"/> on shared instances.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable
|
||||
{
|
||||
private readonly Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
|
||||
private readonly Lazy<HttpClient> _ownedHttpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that uses an
|
||||
/// internally owned <see cref="HttpClient"/> for all requests. The internal client is disposed
|
||||
/// when <see cref="DisposeAsync"/> is called.
|
||||
/// </summary>
|
||||
public DefaultHttpRequestHandler()
|
||||
: this(httpClientProvider: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that uses the
|
||||
/// supplied <see cref="HttpClient"/> for all requests.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">
|
||||
/// The <see cref="HttpClient"/> to use for all requests. The caller retains ownership of this
|
||||
/// instance; it is not disposed by <see cref="DisposeAsync"/>.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="httpClient"/> is <see langword="null"/>.</exception>
|
||||
public DefaultHttpRequestHandler(HttpClient httpClient)
|
||||
: this(CreateSingleClientProvider(httpClient))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that selects
|
||||
/// an <see cref="HttpClient"/> per request via a caller-supplied callback — for example, to route
|
||||
/// different URLs through differently authenticated clients.
|
||||
/// </summary>
|
||||
/// <param name="httpClientProvider">
|
||||
/// An optional callback invoked for each request. The callback receives the <see cref="HttpRequestInfo"/>
|
||||
/// and should return a pre-configured <see cref="HttpClient"/> (e.g. with authentication or a custom
|
||||
/// transport). Return <see langword="null"/> to fall back to the handler's shared internal
|
||||
/// <see cref="HttpClient"/>.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Ownership</b>: the caller is solely responsible for the lifetime of clients returned by this
|
||||
/// callback. <see cref="DefaultHttpRequestHandler"/> will <b>not</b> dispose provider-returned
|
||||
/// clients; only the handler's internally owned fallback client is disposed by <see cref="DisposeAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reuse</b>: callers are expected to cache and reuse clients (for example, keyed by base URL or
|
||||
/// auth scope) across requests. Returning a newly allocated <see cref="HttpClient"/> on every
|
||||
/// invocation will leak sockets and handler resources.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public DefaultHttpRequestHandler(Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>>? httpClientProvider)
|
||||
{
|
||||
this._httpClientProvider = httpClientProvider;
|
||||
this._ownedHttpClient = new Lazy<HttpClient>(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
private static Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>> CreateSingleClientProvider(HttpClient httpClient)
|
||||
{
|
||||
if (httpClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
return (_, _) => Task.FromResult<HttpClient?>(httpClient);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<HttpRequestResult> SendAsync(HttpRequestInfo request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Url))
|
||||
{
|
||||
throw new ArgumentException("Request URL must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Method))
|
||||
{
|
||||
throw new ArgumentException("Request method must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
HttpClient? providedClient = null;
|
||||
if (this._httpClientProvider is not null)
|
||||
{
|
||||
providedClient = await this._httpClientProvider(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
HttpClient client = providedClient ?? this._ownedHttpClient.Value;
|
||||
|
||||
using HttpRequestMessage httpRequest = BuildHttpRequestMessage(request);
|
||||
|
||||
using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero
|
||||
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
|
||||
: null;
|
||||
|
||||
timeoutCts?.CancelAfter(request.Timeout!.Value);
|
||||
|
||||
CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken;
|
||||
|
||||
using HttpResponseMessage httpResponse = await client
|
||||
.SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
string? body = httpResponse.Content is null
|
||||
? null
|
||||
#if NET
|
||||
: await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false);
|
||||
#else
|
||||
: await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
#endif
|
||||
|
||||
Dictionary<string, IReadOnlyList<string>> headers = new(StringComparer.OrdinalIgnoreCase);
|
||||
AppendHeaders(headers, httpResponse.Headers);
|
||||
if (httpResponse.Content is not null)
|
||||
{
|
||||
AppendHeaders(headers, httpResponse.Content.Headers);
|
||||
}
|
||||
|
||||
return new HttpRequestResult
|
||||
{
|
||||
StatusCode = (int)httpResponse.StatusCode,
|
||||
IsSuccessStatusCode = httpResponse.IsSuccessStatusCode,
|
||||
Body = body,
|
||||
Headers = headers,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (this._ownedHttpClient.IsValueCreated)
|
||||
{
|
||||
this._ownedHttpClient.Value.Dispose();
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo request)
|
||||
{
|
||||
HttpMethod method = ResolveMethod(request.Method);
|
||||
string requestUri = ResolveRequestUri(request);
|
||||
HttpRequestMessage httpRequest = new(method, requestUri);
|
||||
|
||||
if (request.Body is not null)
|
||||
{
|
||||
string contentType = string.IsNullOrWhiteSpace(request.BodyContentType)
|
||||
? "text/plain"
|
||||
: request.BodyContentType!;
|
||||
|
||||
httpRequest.Content = new StringContent(request.Body, Encoding.UTF8);
|
||||
// Replace the default content-type header (including charset) with the declared type.
|
||||
httpRequest.Content.Headers.Remove("Content-Type");
|
||||
httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", contentType);
|
||||
}
|
||||
|
||||
if (request.Headers is not null)
|
||||
{
|
||||
foreach (KeyValuePair<string, string> header in request.Headers)
|
||||
{
|
||||
if (string.IsNullOrEmpty(header.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Content-* headers belong on HttpContent; all others belong on the request.
|
||||
if (header.Key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && httpRequest.Content is not null)
|
||||
{
|
||||
httpRequest.Content.Headers.Remove(header.Key);
|
||||
httpRequest.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value))
|
||||
{
|
||||
httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return httpRequest;
|
||||
}
|
||||
|
||||
private static HttpMethod ResolveMethod(string method)
|
||||
{
|
||||
string normalized = method.Trim().ToUpperInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"GET" => HttpMethod.Get,
|
||||
"POST" => HttpMethod.Post,
|
||||
"PUT" => HttpMethod.Put,
|
||||
"DELETE" => HttpMethod.Delete,
|
||||
#if NET
|
||||
"PATCH" => HttpMethod.Patch,
|
||||
#else
|
||||
"PATCH" => new HttpMethod("PATCH"),
|
||||
#endif
|
||||
_ => new HttpMethod(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveRequestUri(HttpRequestInfo request)
|
||||
{
|
||||
string baseUrl = request.Url;
|
||||
if (request.QueryParameters is null || request.QueryParameters.Count == 0)
|
||||
{
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
StringBuilder queryBuilder = new();
|
||||
foreach (KeyValuePair<string, string> parameter in request.QueryParameters)
|
||||
{
|
||||
if (string.IsNullOrEmpty(parameter.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (queryBuilder.Length > 0)
|
||||
{
|
||||
queryBuilder.Append('&');
|
||||
}
|
||||
|
||||
queryBuilder.Append(Uri.EscapeDataString(parameter.Key))
|
||||
.Append('=')
|
||||
.Append(Uri.EscapeDataString(parameter.Value ?? string.Empty));
|
||||
}
|
||||
|
||||
if (queryBuilder.Length == 0)
|
||||
{
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
char separator = baseUrl.Contains('?') ? '&' : '?';
|
||||
return string.Concat(baseUrl, separator.ToString(), queryBuilder.ToString());
|
||||
}
|
||||
|
||||
private static void AppendHeaders(
|
||||
Dictionary<string, IReadOnlyList<string>> target,
|
||||
System.Net.Http.Headers.HttpHeaders source)
|
||||
{
|
||||
foreach (KeyValuePair<string, IEnumerable<string>> header in source)
|
||||
{
|
||||
string[] values = header.Value.ToArray();
|
||||
|
||||
if (target.TryGetValue(header.Key, out IReadOnlyList<string>? existing))
|
||||
{
|
||||
List<string> combined = new(existing);
|
||||
combined.AddRange(values);
|
||||
target[header.Key] = combined;
|
||||
}
|
||||
else
|
||||
{
|
||||
target[header.Key] = values;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-45
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -9,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
/// <summary>
|
||||
/// Represents a request for external input.
|
||||
/// </summary>
|
||||
public sealed class ExternalInputRequest : IExternalRequestEnvelope
|
||||
public sealed class ExternalInputRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The source message that triggered the request for external input.
|
||||
@@ -31,47 +30,4 @@ public sealed class ExternalInputRequest : IExternalRequestEnvelope
|
||||
{
|
||||
this.AgentResponse = new AgentResponse(new ChatMessage(ChatRole.User, text));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Prefers <see cref="ToolApprovalRequestContent"/> (when the workflow declared
|
||||
/// <c>requireApproval: true</c>) over <see cref="FunctionCallContent"/> so that
|
||||
/// hosts which speak the approval protocol see the approval-bearing content.
|
||||
/// </remarks>
|
||||
AIContent? IExternalRequestEnvelope.GetInnerRequestContent()
|
||||
{
|
||||
IList<ChatMessage>? messages = this.AgentResponse?.Messages;
|
||||
if (messages is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is ToolApprovalRequestContent toolApprovalRequest)
|
||||
{
|
||||
return toolApprovalRequest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent functionCall)
|
||||
{
|
||||
return functionCall;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
object IExternalRequestEnvelope.CreateResponse(IList<ChatMessage> messages)
|
||||
=> new ExternalInputResponse(messages);
|
||||
}
|
||||
|
||||
-54
@@ -16,60 +16,6 @@ internal static class ChatMessageExtensions
|
||||
public static RecordValue ToRecord(this ChatMessage message) =>
|
||||
FormulaValue.NewRecordFromFields(message.GetMessageFields());
|
||||
|
||||
/// <summary>
|
||||
/// Merges the user-authored <paramref name="input"/> with the round-tripped
|
||||
/// <paramref name="inputMessage"/> returned by <c>AgentProvider.CreateMessageAsync</c>
|
||||
/// to produce the value stored in <c>System.LastMessage</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The agent service often strips or alters <see cref="TextContent"/> on round-trip,
|
||||
/// while replacing inline media (<see cref="DataContent"/>, <see cref="UriContent"/>)
|
||||
/// with server-side references (typically <see cref="HostedFileContent"/>).
|
||||
/// We want both: the original text (so <c>=System.LastMessage.Text</c> works) and
|
||||
/// the server's media references (so subsequent actions don't re-upload large blobs).
|
||||
/// <para>
|
||||
/// Strategy: keep <paramref name="inputMessage"/> as the base — it has the server-generated
|
||||
/// <see cref="ChatMessage.MessageId"/> and any provider-augmented metadata, and is forward-
|
||||
/// compatible with new properties added on <see cref="ChatMessage"/> in the abstractions
|
||||
/// layer. Only the <see cref="ChatMessage.Contents"/> list is mutated to substitute
|
||||
/// original <see cref="TextContent"/> items in place (and append any extras the round-trip
|
||||
/// dropped). Non-text content items returned by the service are left untouched so
|
||||
/// server-side references survive.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static ChatMessage MergeForLastMessage(this ChatMessage input, ChatMessage? inputMessage)
|
||||
{
|
||||
if (inputMessage is null)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
// Build a queue of the original text items, in order. Fall back to ChatMessage.Text
|
||||
// if the input has no explicit TextContent entries.
|
||||
Queue<TextContent> originalTexts = new(input.Contents.OfType<TextContent>());
|
||||
if (originalTexts.Count == 0 && !string.IsNullOrEmpty(input.Text))
|
||||
{
|
||||
originalTexts.Enqueue(new TextContent(input.Text));
|
||||
}
|
||||
|
||||
// Replace TextContent items in inputMessage.Contents with the originals, in order.
|
||||
for (int i = 0; i < inputMessage.Contents.Count && originalTexts.Count > 0; i++)
|
||||
{
|
||||
if (inputMessage.Contents[i] is TextContent)
|
||||
{
|
||||
inputMessage.Contents[i] = originalTexts.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
// Append any remaining original text items that the round-trip dropped entirely.
|
||||
while (originalTexts.Count > 0)
|
||||
{
|
||||
inputMessage.Contents.Add(originalTexts.Dequeue());
|
||||
}
|
||||
|
||||
return inputMessage;
|
||||
}
|
||||
|
||||
public static TableValue ToTable(this IEnumerable<ChatMessage> messages) =>
|
||||
FormulaValue.NewTable(TypeSchema.Message.RecordType, messages.Select(message => message.ToRecord()));
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for executing HTTP requests emitted by <c>HttpRequestAction</c> within declarative workflows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface allows the HTTP request dispatch to be abstracted, enabling different implementations
|
||||
/// for local development, hosted workflows, authenticated scenarios, and testing.
|
||||
/// </remarks>
|
||||
public interface IHttpRequestHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends an HTTP request and returns the response.
|
||||
/// </summary>
|
||||
/// <param name="request">The HTTP request to send.</param>
|
||||
/// <param name="cancellationToken">A token to observe cancellation.</param>
|
||||
/// <returns>The <see cref="HttpRequestResult"/> describing the HTTP response.</returns>
|
||||
Task<HttpRequestResult> SendAsync(
|
||||
HttpRequestInfo request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes an HTTP request to be sent by an <see cref="IHttpRequestHandler"/>.
|
||||
/// </summary>
|
||||
[SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "URL is carried as a string to preserve the declarative expression result and to avoid forcing handler implementations to construct a Uri eagerly.")]
|
||||
public sealed class HttpRequestInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the HTTP method to use (GET, POST, PUT, PATCH, DELETE).
|
||||
/// </summary>
|
||||
public string Method { get; init; } = "GET";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the absolute URL to send the request to.
|
||||
/// </summary>
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the headers to include on the request, excluding the <c>Content-Type</c> header (which is supplied via <see cref="BodyContentType"/>).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <c>Content-Type</c> of the request body, or <see langword="null"/> if no body is sent.
|
||||
/// </summary>
|
||||
public string? BodyContentType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the serialized request body, or <see langword="null"/> if no body is sent.
|
||||
/// </summary>
|
||||
public string? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum amount of time to wait for the request to complete, or <see langword="null"/> to use the handler default.
|
||||
/// </summary>
|
||||
public TimeSpan? Timeout { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the query parameters to append to the request URL, with values already formatted as strings.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? QueryParameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the declared remote connection, or <see langword="null"/> if no connection is declared.
|
||||
/// This maps to the Foundry project connection Id and is only used when running in foundry service.
|
||||
/// </summary>
|
||||
public string? ConnectionName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of an HTTP request executed by an <see cref="IHttpRequestHandler"/>.
|
||||
/// </summary>
|
||||
public sealed class HttpRequestResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the HTTP status code returned by the server.
|
||||
/// </summary>
|
||||
public int StatusCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the status code is in the range 200-299.
|
||||
/// </summary>
|
||||
public bool IsSuccessStatusCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the response body, or <see langword="null"/> if no body was returned.
|
||||
/// </summary>
|
||||
public string? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the response headers keyed by header name. Each header may have multiple values.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, IReadOnlyList<string>>? Headers { get; init; }
|
||||
}
|
||||
+7
-144
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
@@ -14,24 +13,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
|
||||
/// <summary>
|
||||
/// The root executor for a declarative workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In addition to the strongly-typed <typeparamref name="TInput"/> route inherited from
|
||||
/// <see cref="Executor{TInput}"/>, this executor also accepts <see cref="string"/>,
|
||||
/// <see cref="ChatMessage"/>, <see cref="IEnumerable{T}"/> of <see cref="ChatMessage"/>,
|
||||
/// <see cref="ChatMessage"/><c>[]</c>, and <see cref="TurnToken"/> so that the workflow
|
||||
/// satisfies <see cref="ChatProtocolExtensions.IsChatProtocol"/>. This makes the workflow
|
||||
/// usable both for direct <c>Run.SendMessageAsync(input)</c> invocations and for hosting
|
||||
/// via <see cref="WorkflowHostingExtensions.AsAIAgent(Workflow, string?, string?, string?, IWorkflowExecutionEnvironment?, bool, bool)"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Each non-<see cref="TurnToken"/> input drives the declarative graph forward
|
||||
/// immediately. The host's <see cref="TurnToken"/> arrives after the message batch and
|
||||
/// is treated as a no-op because the inbound message has already been processed.
|
||||
/// External responses (HITL function results) bypass the start executor entirely
|
||||
/// (they are routed via <c>WorkflowSession.SendResponseAsync</c> to request-info
|
||||
/// executors), so the start executor only ever sees a single inbound batch per turn.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class DeclarativeWorkflowExecutor<TInput>(
|
||||
string workflowId,
|
||||
DeclarativeWorkflowOptions options,
|
||||
@@ -45,143 +26,25 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[SendsMessage(typeof(ActionExecutorResult))]
|
||||
public override ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ChatMessage input = inputTransform.Invoke(message);
|
||||
return this.AdvanceAsync(input, context, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
// Inherit the TInput route + method/class attributes (e.g. SendsMessage on HandleAsync).
|
||||
ProtocolBuilder result = base.ConfigureProtocol(protocolBuilder);
|
||||
|
||||
// Add the chat-protocol input shapes so the workflow satisfies IsChatProtocol
|
||||
// and can be hosted via AsAIAgent. Skip any shape that already matches TInput
|
||||
// (the inherited route handles that case via inputTransform).
|
||||
return result.ConfigureRoutes(this.ConfigureChatProtocolRoutes)
|
||||
.SendsMessage<ActionExecutorResult>();
|
||||
}
|
||||
|
||||
private void ConfigureChatProtocolRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
Type tInput = typeof(TInput);
|
||||
|
||||
// Skip an exact-type match because RouteBuilder.AddHandler throws on duplicate
|
||||
// registrations for the same message type. Equality (not IsAssignableFrom) is
|
||||
// also what ChatProtocolExtensions.IsChatProtocol checks, so always registering
|
||||
// IEnumerable<ChatMessage> when TInput is broader (e.g. object) keeps the
|
||||
// workflow chat-protocol-compliant.
|
||||
if (tInput != typeof(string))
|
||||
{
|
||||
routeBuilder.AddHandler<string>(this.HandleStringAsync);
|
||||
}
|
||||
|
||||
if (tInput != typeof(ChatMessage))
|
||||
{
|
||||
routeBuilder.AddHandler<ChatMessage>(this.HandleChatMessageAsync);
|
||||
}
|
||||
|
||||
if (tInput != typeof(IEnumerable<ChatMessage>))
|
||||
{
|
||||
routeBuilder.AddHandler<IEnumerable<ChatMessage>>(this.HandleChatMessagesAsync);
|
||||
}
|
||||
|
||||
if (tInput != typeof(ChatMessage[]))
|
||||
{
|
||||
routeBuilder.AddHandler<ChatMessage[]>(this.HandleChatMessageArrayAsync);
|
||||
}
|
||||
|
||||
if (tInput != typeof(TurnToken))
|
||||
{
|
||||
routeBuilder.AddHandler<TurnToken>(this.HandleTurnTokenAsync);
|
||||
}
|
||||
}
|
||||
|
||||
private ValueTask HandleStringAsync(string message, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.AdvanceAsync(new ChatMessage(ChatRole.User, message), context, cancellationToken);
|
||||
}
|
||||
|
||||
private ValueTask HandleChatMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.AdvanceAsync(message, context, cancellationToken);
|
||||
}
|
||||
private async ValueTask HandleChatMessagesAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var list = messages as IList<ChatMessage> ?? new List<ChatMessage>(messages);
|
||||
if (list.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
await this.AdvanceAsync(list[i], context, cancellationToken, finalizeTurn: i == list.Count - 1).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask HandleChatMessageArrayAsync(ChatMessage[] messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (messages.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < messages.Length; i++)
|
||||
{
|
||||
await this.AdvanceAsync(messages[i], context, cancellationToken, finalizeTurn: i == messages.Length - 1).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The host sends a TurnToken after the message batch; the message has already
|
||||
// driven the graph forward, so we treat the token as a no-op here.
|
||||
private ValueTask HandleTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
private async ValueTask AdvanceAsync(ChatMessage input, IWorkflowContext context, CancellationToken cancellationToken, bool finalizeTurn = true)
|
||||
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// No state to restore if we're starting from the beginning.
|
||||
state.SetInitialized();
|
||||
|
||||
DeclarativeWorkflowContext declarativeContext = new(context, state);
|
||||
ChatMessage input = inputTransform.Invoke(message);
|
||||
|
||||
// Conversation id resolution prefers state already persisted by a prior turn,
|
||||
// so multi-turn invocations reuse the same backend conversation rather than
|
||||
// creating a fresh one each turn.
|
||||
string? conversationId = declarativeContext.GetWorkflowConversation();
|
||||
if (string.IsNullOrWhiteSpace(conversationId))
|
||||
{
|
||||
conversationId = options.ConversationId;
|
||||
}
|
||||
|
||||
bool conversationCreated = false;
|
||||
string? conversationId = options.ConversationId;
|
||||
if (string.IsNullOrWhiteSpace(conversationId))
|
||||
{
|
||||
conversationId = await options.AgentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false);
|
||||
conversationCreated = true;
|
||||
}
|
||||
await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (conversationCreated || !string.Equals(declarativeContext.GetWorkflowConversation(), conversationId, StringComparison.Ordinal))
|
||||
{
|
||||
await declarativeContext.QueueConversationUpdateAsync(conversationId!, isExternal: true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken).ConfigureAwait(false);
|
||||
await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
|
||||
|
||||
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId!, input, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Use the original input for System.LastMessage to ensure Text is preserved (the
|
||||
// service may strip text on round-trip), but substitute server-side media references
|
||||
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
|
||||
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
|
||||
|
||||
if (finalizeTurn)
|
||||
{
|
||||
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-12
@@ -529,18 +529,6 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
|
||||
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);
|
||||
}
|
||||
|
||||
protected override void Visit(HttpRequestAction item)
|
||||
{
|
||||
this.Trace(item);
|
||||
|
||||
if (this._workflowOptions.HttpRequestHandler is null)
|
||||
{
|
||||
throw new DeclarativeModelException("HTTP request handler not configured. Set HttpRequestHandler in DeclarativeWorkflowOptions to use HttpRequestAction actions.");
|
||||
}
|
||||
|
||||
this.ContinueWith(new HttpRequestExecutor(item, this._workflowOptions.HttpRequestHandler, this._workflowOptions.AgentProvider, this._workflowState));
|
||||
}
|
||||
|
||||
#region Not supported
|
||||
|
||||
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
|
||||
@@ -585,6 +573,8 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
|
||||
|
||||
protected override void Visit(GetConversationMembers item) => this.NotSupported(item);
|
||||
|
||||
protected override void Visit(HttpRequestAction item) => this.NotSupported(item);
|
||||
|
||||
protected override void Visit(RecognizeIntent item) => this.NotSupported(item);
|
||||
|
||||
protected override void Visit(TransferConversation item) => this.NotSupported(item);
|
||||
|
||||
@@ -58,6 +58,7 @@ public abstract class RootExecutor<TInput> : Executor<TInput>, IResettableExecut
|
||||
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
DeclarativeWorkflowContext declarativeContext = new(context, this._state);
|
||||
await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage input = (this._inputTransform ?? DefaultInputTransform).Invoke(message);
|
||||
|
||||
@@ -68,13 +69,7 @@ public abstract class RootExecutor<TInput> : Executor<TInput>, IResettableExecut
|
||||
await declarativeContext.QueueConversationUpdateAsync(this._conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage inputMessage = await this._agentProvider.CreateMessageAsync(this._conversationId, input, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Use the original input for System.LastMessage to ensure Text is preserved (the
|
||||
// service may strip text on round-trip), but substitute server-side media references
|
||||
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
|
||||
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
|
||||
|
||||
await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
|
||||
await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
|
||||
|
||||
await declarativeContext.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
-346
@@ -1,346 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.PowerFx.Types;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
|
||||
/// <summary>
|
||||
/// Executor for the <see cref="HttpRequestAction"/> action.
|
||||
/// Dispatches the request through the configured <see cref="IHttpRequestHandler"/> and assigns
|
||||
/// the response body and headers to the declared property paths.
|
||||
/// </summary>
|
||||
internal sealed class HttpRequestExecutor(
|
||||
HttpRequestAction model,
|
||||
IHttpRequestHandler httpRequestHandler,
|
||||
ResponseAgentProvider agentProvider,
|
||||
WorkflowFormulaState state) :
|
||||
DeclarativeActionExecutor<HttpRequestAction>(model, state)
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string method = this.GetMethod();
|
||||
string url = this.GetUrl();
|
||||
Dictionary<string, string>? headers = this.GetHeaders();
|
||||
Dictionary<string, string>? queryParameters = this.GetQueryParameters();
|
||||
(string? body, string? contentType) = this.GetBody();
|
||||
TimeSpan? timeout = this.GetTimeout();
|
||||
string? conversationId = this.GetConversationId();
|
||||
string? connectionName = this.GetConnectionName();
|
||||
|
||||
HttpRequestInfo requestInfo = new()
|
||||
{
|
||||
Method = method,
|
||||
Url = url,
|
||||
Headers = headers,
|
||||
QueryParameters = queryParameters,
|
||||
Body = body,
|
||||
BodyContentType = contentType,
|
||||
Timeout = timeout,
|
||||
ConnectionName = connectionName,
|
||||
};
|
||||
|
||||
HttpRequestResult result;
|
||||
try
|
||||
{
|
||||
result = await httpRequestHandler.SendAsync(requestInfo, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw this.Exception($"HTTP request to '{url}' timed out.");
|
||||
}
|
||||
catch (Exception exception) when (exception is not DeclarativeActionException)
|
||||
{
|
||||
throw this.Exception($"HTTP request to '{url}' failed: {exception.Message}", exception);
|
||||
}
|
||||
|
||||
if (result.IsSuccessStatusCode)
|
||||
{
|
||||
await this.AssignResponseAsync(context, result.Body).ConfigureAwait(false);
|
||||
await this.AssignResponseHeadersAsync(context, result.Headers).ConfigureAwait(false);
|
||||
await this.AddResponseToConversationAsync(conversationId, result.Body, cancellationToken).ConfigureAwait(false);
|
||||
return default;
|
||||
}
|
||||
|
||||
// Non-success status code - throw.
|
||||
// Also publish response headers for diagnostic purposes.
|
||||
await this.AssignResponseHeadersAsync(context, result.Headers).ConfigureAwait(false);
|
||||
|
||||
string bodyPreview = FormatBodyForDiagnostics(result.Body);
|
||||
string message = bodyPreview.Length == 0
|
||||
? $"HTTP request to '{url}' failed with status code {result.StatusCode}."
|
||||
: $"HTTP request to '{url}' failed with status code {result.StatusCode}. Body: '{bodyPreview}'";
|
||||
|
||||
throw this.Exception(message);
|
||||
}
|
||||
|
||||
// Response bodies can echo secrets (tokens, PII) and may be very large (multi-MB HTML error pages).
|
||||
// Exception messages are often logged and persisted, so we clip the body to bound both exposure
|
||||
// and message size. Full bodies are still available via the success path (assigned to Response).
|
||||
private const int MaxBodyDiagnosticLength = 256;
|
||||
private const string BodyTruncationSuffix = " \u2026 [truncated]";
|
||||
|
||||
private static string FormatBodyForDiagnostics(string? body)
|
||||
{
|
||||
if (string.IsNullOrEmpty(body))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
int sourceLen = body!.Length;
|
||||
bool truncated = sourceLen > MaxBodyDiagnosticLength;
|
||||
int copyLen = truncated ? MaxBodyDiagnosticLength : sourceLen;
|
||||
int finalLen = copyLen + (truncated ? BodyTruncationSuffix.Length : 0);
|
||||
|
||||
// Size the buffer for the final string so we only allocate once for the chars
|
||||
// and once for the string itself. For a 10 KB error body we touch 256 chars instead of 10,000.
|
||||
char[] buffer = new char[finalLen];
|
||||
for (int i = 0; i < copyLen; i++)
|
||||
{
|
||||
char c = body[i];
|
||||
buffer[i] = c is '\r' or '\n' or '\t' ? ' ' : c;
|
||||
}
|
||||
|
||||
if (truncated)
|
||||
{
|
||||
BodyTruncationSuffix.CopyTo(0, buffer, copyLen, BodyTruncationSuffix.Length);
|
||||
}
|
||||
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
private async ValueTask AddResponseToConversationAsync(string? conversationId, string? responseBody, CancellationToken cancellationToken)
|
||||
{
|
||||
if (conversationId is null || string.IsNullOrEmpty(responseBody))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ChatMessage message = new(ChatRole.Assistant, responseBody);
|
||||
await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask AssignResponseAsync(IWorkflowContext context, string? responseBody)
|
||||
{
|
||||
if (this.Model.Response is not { Path: { } responsePath })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await this.AssignAsync(responsePath, ParseResponseBody(responseBody), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask AssignResponseHeadersAsync(IWorkflowContext context, IReadOnlyDictionary<string, IReadOnlyList<string>>? responseHeaders)
|
||||
{
|
||||
if (this.Model.ResponseHeaders is not { Path: { } headersPath })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseHeaders is null || responseHeaders.Count == 0)
|
||||
{
|
||||
await this.AssignAsync(headersPath, FormulaValue.NewBlank(), context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Flatten multi-value headers by joining with commas (standard HTTP header folding).
|
||||
Dictionary<string, object?> flattened = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (KeyValuePair<string, IReadOnlyList<string>> header in responseHeaders)
|
||||
{
|
||||
flattened[header.Key] = string.Join(",", header.Value);
|
||||
}
|
||||
|
||||
await this.AssignAsync(headersPath, flattened.ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static FormulaValue ParseResponseBody(string? responseBody)
|
||||
{
|
||||
if (string.IsNullOrEmpty(responseBody))
|
||||
{
|
||||
return FormulaValue.NewBlank();
|
||||
}
|
||||
|
||||
// Attempt to parse as JSON so records/tables are exposed naturally to the workflow.
|
||||
try
|
||||
{
|
||||
using JsonDocument jsonDocument = JsonDocument.Parse(responseBody);
|
||||
|
||||
object? parsedValue = jsonDocument.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
|
||||
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
|
||||
JsonValueKind.String => jsonDocument.RootElement.GetString(),
|
||||
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l)
|
||||
? l
|
||||
: jsonDocument.RootElement.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => responseBody,
|
||||
};
|
||||
|
||||
return parsedValue.ToFormula();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not valid JSON — return the raw string.
|
||||
return FormulaValue.New(responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetMethod()
|
||||
{
|
||||
EnumExpression<HttpMethodTypeWrapper>? methodExpression = this.Model.Method;
|
||||
if (methodExpression is null)
|
||||
{
|
||||
return "GET";
|
||||
}
|
||||
|
||||
HttpMethodTypeWrapper wrapper = this.Evaluator.GetValue(methodExpression).Value;
|
||||
return !string.IsNullOrEmpty(wrapper.UnknownValue) ? wrapper.UnknownValue! : wrapper.Value.ToString().ToUpperInvariant();
|
||||
}
|
||||
|
||||
private string GetUrl() =>
|
||||
this.Evaluator.GetValue(
|
||||
Throw.IfNull(
|
||||
this.Model.Url,
|
||||
$"{nameof(this.Model)}.{nameof(this.Model.Url)}")).Value;
|
||||
|
||||
private Dictionary<string, string>? GetHeaders()
|
||||
{
|
||||
if (this.Model.Headers is null || this.Model.Headers.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, string> result = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (KeyValuePair<string, StringExpression> header in this.Model.Headers)
|
||||
{
|
||||
string value = this.Evaluator.GetValue(header.Value).Value;
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
result[header.Key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
|
||||
private (string? Body, string? ContentType) GetBody()
|
||||
{
|
||||
switch (this.Model.Body)
|
||||
{
|
||||
case null:
|
||||
case NoRequestContent:
|
||||
return (null, null);
|
||||
|
||||
case JsonRequestContent jsonContent when jsonContent.Content is not null:
|
||||
{
|
||||
FormulaValue formula = this.Evaluator.GetValue(jsonContent.Content).Value.ToFormula();
|
||||
string json = formula.ToJson().ToJsonString();
|
||||
return (json, "application/json");
|
||||
}
|
||||
|
||||
case RawRequestContent rawContent:
|
||||
{
|
||||
string? content = rawContent.Content is null
|
||||
? null
|
||||
: this.Evaluator.GetValue(rawContent.Content).Value;
|
||||
|
||||
string? contentType = rawContent.ContentType is null
|
||||
? null
|
||||
: this.Evaluator.GetValue(rawContent.ContentType).Value;
|
||||
|
||||
return (content, string.IsNullOrEmpty(contentType) ? null : contentType);
|
||||
}
|
||||
|
||||
default:
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan? GetTimeout()
|
||||
{
|
||||
if (this.Model.RequestTimeoutInMilliseconds is null || this.Model.RequestTimeoutInMillisecondsIsDefaultValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long value = this.Evaluator.GetValue(this.Model.RequestTimeoutInMilliseconds).Value;
|
||||
return value > 0 ? TimeSpan.FromMilliseconds(value) : null;
|
||||
}
|
||||
|
||||
private Dictionary<string, string>? GetQueryParameters()
|
||||
{
|
||||
if (this.Model.QueryParameters is null || this.Model.QueryParameters.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, string> result = new(StringComparer.Ordinal);
|
||||
foreach (KeyValuePair<string, ValueExpression> parameter in this.Model.QueryParameters)
|
||||
{
|
||||
if (string.IsNullOrEmpty(parameter.Key) || parameter.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
object? rawValue = this.Evaluator.GetValue(parameter.Value).Value.ToObject();
|
||||
string? formatted = FormatQueryValue(rawValue);
|
||||
if (formatted is not null)
|
||||
{
|
||||
result[parameter.Key] = formatted;
|
||||
}
|
||||
}
|
||||
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
|
||||
private static string? FormatQueryValue(object? value) =>
|
||||
value switch
|
||||
{
|
||||
null => null,
|
||||
string s => s,
|
||||
bool b => b ? "true" : "false",
|
||||
IFormattable formattable => formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture),
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
private string? GetConversationId()
|
||||
{
|
||||
if (this.Model.ConversationId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string value = this.Evaluator.GetValue(this.Model.ConversationId).Value;
|
||||
return value.Length == 0 ? null : value;
|
||||
}
|
||||
|
||||
private string? GetConnectionName()
|
||||
{
|
||||
RemoteConnection? connection = this.Model.Connection;
|
||||
if (connection is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? name = connection.Name is null
|
||||
? null
|
||||
: this.Evaluator.GetValue(connection.Name).Value;
|
||||
|
||||
return string.IsNullOrEmpty(name) ? null : name;
|
||||
}
|
||||
}
|
||||
-9
@@ -6,7 +6,6 @@ using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
|
||||
@@ -20,14 +19,6 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt
|
||||
string activityText = this.Engine.Format(messageActivity.Text).Trim();
|
||||
|
||||
await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Route through YieldOutputAsync so the activity participates in the workflow's
|
||||
// output-filter pipeline. The runner currently special-cases AgentResponse to
|
||||
// produce an AgentResponseEvent identical to the one we'd build by hand, so this
|
||||
// is behavior-preserving today and forward-compatible if filtering is ever
|
||||
// applied to agent responses.
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
|
||||
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return default;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user