Compare commits

..
Author SHA1 Message Date
westeyandGitHub 0295b4c4c7 .NET: Add FileAccessProvdider and concurrency fix for FileMemoryProvider (#5583)
* Add FileAccessProvdider and concurrency fix for FileMemoryProvider

* Address PR comments
2026-04-30 15:04:56 +01:00
westeyandGitHub a9dafd53c3 Merge branch 'main' into feature-harness 2026-04-30 14:05:25 +01:00
westeyandGitHub 97228e49b6 .NET: Refactor harness console to be more extensible and easy to understand with better UX (#5573)
* Refactor harness console to be more extensible and easy to understand with better UX.

* Fix formatting issues.

* Allow multiple clarifications in one response

* Address PR comments
2026-04-30 12:07:11 +01:00
e3f76618c5 .NET: Harness filememory index plus instructions consistency (#5540)
* Add FileMemoryProvider index and improve instruction consistency

* Address PR comments.

* Address PR comments

* Address PR comments.

* Apply suggestion from @rogerbarreto

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2026-04-29 17:20:43 +01:00
westeyandGitHub 899394a58c .NET: Add subagents provider and sample (#5518)
* Add subagents provider and sample

* Addressing PR comments.
2026-04-28 11:22:55 +01:00
westeyandGitHub f747d8a6d4 .NET: Make Todo, Mode and FileMemory providers more configurable (#5477)
* Make Todo, Mode and FileMemory providers more configurable

* Address PR comments.
2026-04-27 10:43:26 +01:00
westeyandGitHub 08aeb67a9a Merge branch 'main' into feature-harness 2026-04-27 09:18:21 +01:00
westeyandGitHub 9004282168 Merge branch 'main' into feature-harness 2026-04-24 17:37:47 +01:00
westeyandGitHub e4595be0c2 .NET: Add always approve helpers, improve sample and fix bug (#5451)
* Add always approve helpers, improve sample and fix bug

* Address PR comments
2026-04-24 10:59:09 +01:00
westeyandGitHub 025655b573 Merge branch 'main' into feature-harness 2026-04-22 10:32:48 +01:00
westeyandGitHub 53274fde85 .NET: Harness: Improve path validation (#5404)
* Harness: Improve path validation

* Address PR comments
2026-04-22 10:28:37 +01:00
westeyandGitHub 7f661e8524 .NET: Harness: Improve prompts and add FileSystem store (#5365)
* Harness: Improve prompts and add FileSystem store

* Address PR comments
2026-04-21 10:39:17 +01:00
westeyandGitHub 8dca006edd .NET: Add a file memory provider (#5315)
* Add a file memory provider

* Address PR comments

* Fix review comments.

* Add additional unit tests

* Addressing PR comments.
2026-04-20 14:34:31 +01:00
westeyandGitHub 99627e41d2 Merge branch 'main' into feature-harness 2026-04-16 16:46:17 +01:00
d0ac1d83bc .NET: Add context window size compaction strategy for harness (#5304)
* Add context window size compaction strategy for harness

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Address PR comments

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-16 16:44:45 +01:00
westeyandGitHub 9d89353818 .NET: Add sample to show how to build a harness (#5268)
* Add sample to show how to build a harness

* Improve sample

* Sample max output tokens and model

* Fix encoding

* Fix model name in readme

* Address PR comments
2026-04-15 14:58:28 +01:00
westeyandGitHub b4c853ec1b .NET: Add a ModeProvider for managing agent modes (#5247)
* Add a ModeProvider for managing agent modes

* Fix typo

* Fix typo

* Fix typo

* Address PR comments
2026-04-15 09:17:59 +01:00
westeyandGitHub 673f3d9214 .NET: Add a TODO AIContextProvider (#5233)
* Add a TODO AIContextProvider

* Add unit tests

* Address PR comments

* Address PR comments

* Fix test after removing one tool
2026-04-14 12:07:55 +01:00
214 changed files with 1550 additions and 15299 deletions
+18 -57
View File
@@ -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
@@ -474,9 +435,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') ||
@@ -510,36 +471,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()
+18 -57
View File
@@ -278,8 +278,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 +289,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 +403,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
@@ -658,9 +619,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') ||
@@ -691,36 +652,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()
+79 -77
View File
@@ -6,12 +6,8 @@
[![MS Learn Documentation](https://img.shields.io/badge/MS%20Learn-Documentation-blue)](https://learn.microsoft.com/en-us/agent-framework/)
[![PyPI](https://img.shields.io/pypi/v/agent-framework)](https://pypi.org/project/agent-framework/)
[![NuGet](https://img.shields.io/nuget/v/Microsoft.Agents.AI)](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
[![GitHub stars](https://img.shields.io/github/stars/microsoft/agent-framework?style=social)](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?** [![GitHub stars](https://img.shields.io/badge/Star-us%20on%20GitHub-yellow)](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
-2
View File
@@ -109,8 +109,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" />
-20
View File
@@ -175,12 +175,6 @@
<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" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
@@ -541,16 +535,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" />
@@ -576,7 +560,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 +581,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 +599,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" />
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.4.0</VersionPrefix>
<VersionPrefix>1.3.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260505</DateSuffix>
<DateSuffix>260423</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.3.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
</ItemGroup>
</Project>
@@ -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]."));
@@ -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
```
@@ -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>
@@ -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."));
@@ -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.
@@ -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>
@@ -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`."));
@@ -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.
-1
View File
@@ -11,7 +11,6 @@ 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 |
@@ -19,7 +19,8 @@ namespace Azure.AI.Projects;
/// Foundry toolbox definitions as server-side tools.
/// </summary>
/// <remarks>
/// Provides a single call on the project client to retrieve tools ready for use
/// These extensions mirror Python's <c>FoundryChatClient.get_toolbox()</c> pattern,
/// allowing 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)]
@@ -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);
}
@@ -32,6 +32,9 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// 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>
/// <para>
/// This is the dotnet equivalent of Python's <c>FoundryChatClient.get_toolbox()</c> pattern.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryToolbox
@@ -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),
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)
@@ -44,7 +44,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);
@@ -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&lt;AIAgent&gt;("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
{
@@ -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,
@@ -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);
}
@@ -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,29 @@ 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);
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);
}
}
@@ -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;
@@ -1,47 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Optional interface implemented by request payload types that wrap underlying
/// AI content (such as <see cref="FunctionCallContent"/> or
/// <see cref="ToolApprovalRequestContent"/>) and define a paired response envelope.
/// </summary>
/// <remarks>
/// <para>
/// This abstraction allows higher-level layers (e.g., declarative workflows) to define
/// their own request/response envelope types while still allowing
/// <c>WorkflowSession</c> to surface the inner content to hosts on the request side
/// and to wrap incoming responses back into the envelope on the response side -
/// without the runtime taking a reference back to the higher-level layer.
/// </para>
/// <para>
/// When an <c>ExternalRequest.Data</c> payload implements this interface, the
/// runtime uses <see cref="GetInnerRequestContent"/> to drive wire serialization
/// for hosts (so a host receives a normal <see cref="FunctionCallContent"/> or
/// <see cref="ToolApprovalRequestContent"/>), and uses <see cref="CreateResponse"/>
/// to wrap the host's response payload back into the envelope expected by the
/// workflow's request port.
/// </para>
/// </remarks>
public interface IExternalRequestEnvelope
{
/// <summary>
/// Returns the inner AI content that should be delivered to the host on the wire.
/// Typically a <see cref="FunctionCallContent"/> or <see cref="ToolApprovalRequestContent"/>.
/// </summary>
/// <returns>The inner content, or <c>null</c> if no suitable inner content is available.</returns>
AIContent? GetInnerRequestContent();
/// <summary>
/// Wraps the supplied response messages into the envelope's matching response type
/// for delivery to the workflow's request port.
/// </summary>
/// <param name="messages">The response messages, typically containing a
/// <see cref="FunctionResultContent"/> and/or <see cref="ToolApprovalResponseContent"/>.</param>
/// <returns>An instance of the envelope's response type wrapping <paramref name="messages"/>.</returns>
object CreateResponse(IList<ChatMessage> messages);
}
@@ -287,93 +287,24 @@ internal sealed class WorkflowSession : AgentSession
hasMatchedResponseForStartExecutor);
}
/// <summary>
/// Resolves the concrete request payload type from <see cref="RequestPortInfo.RequestType"/>
/// and returns it as an <see cref="IExternalRequestEnvelope"/> if the type implements that
/// abstraction. Resolving via the concrete <see cref="TypeId"/> (rather than asking the
/// PortableValue to deserialize directly to <see cref="IExternalRequestEnvelope"/>) is
/// required because checkpointed payloads round-trip as JSON which cannot be deserialized
/// to an interface; the concrete type populates the deserialization cache so subsequent
/// interface assignment succeeds.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
private static bool TryGetRequestEnvelope(ExternalRequest request, [NotNullWhen(true)] out IExternalRequestEnvelope? envelope)
{
envelope = null;
TypeId requestType = request.PortInfo.RequestType;
Type? concreteType = Type.GetType($"{requestType.TypeName}, {requestType.AssemblyName}", throwOnError: false);
if (concreteType is null || !typeof(IExternalRequestEnvelope).IsAssignableFrom(concreteType))
{
return false;
}
if (!request.TryGetDataAs(concreteType, out object? data) || data is not IExternalRequestEnvelope env)
{
return false;
}
envelope = env;
return true;
}
/// <summary>
/// Creates the workflow-facing request content surfaced in response updates.
/// </summary>
private static AIContent CreateRequestContentForDelivery(ExternalRequest request)
private static AIContent CreateRequestContentForDelivery(ExternalRequest request) => request switch
{
// If the request payload is a higher-layer envelope (e.g., a declarative
// ExternalInputRequest), surface its inner FCC/TARC to the host on the wire.
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
{
AIContent? inner = envelope.GetInnerRequestContent();
if (inner is ToolApprovalRequestContent toolApprovalRequest)
{
return CloneToolApprovalRequestContent(toolApprovalRequest, request.RequestId);
}
if (inner is FunctionCallContent functionCall)
{
return CloneFunctionCallContent(functionCall, request.RequestId);
}
}
return request switch
{
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent)
=> CloneFunctionCallContent(functionCallContent, externalRequest.RequestId),
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
=> CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId),
ExternalRequest externalRequest
=> externalRequest.ToFunctionCall(),
};
}
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent)
=> CloneFunctionCallContent(functionCallContent, externalRequest.RequestId),
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
=> CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId),
ExternalRequest externalRequest
=> externalRequest.ToFunctionCall(),
};
/// <summary>
/// Rewrites workflow-facing response content back to the original agent-owned content ID.
/// </summary>
private static object NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request)
{
// If the request payload is a higher-layer envelope, recover the original
// CallId/RequestId from the inner content and ask the envelope to wrap the
// response back into its paired response type for delivery to the request port.
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
{
AIContent? inner = envelope.GetInnerRequestContent();
AIContent payload = (content, inner) switch
{
(FunctionResultContent functionResult, FunctionCallContent functionCall)
=> CloneFunctionResultContent(functionResult, functionCall.CallId),
(FunctionResultContent functionResult, ToolApprovalRequestContent toolApprovalRequest)
=> CloneFunctionResultContent(functionResult, toolApprovalRequest.ToolCall.CallId),
(ToolApprovalResponseContent toolApprovalResponse, ToolApprovalRequestContent toolApprovalRequest)
=> CloneToolApprovalResponseContent(toolApprovalResponse, toolApprovalRequest.RequestId),
_ => content,
};
ChatMessage message = new(ChatRole.Tool, [payload]);
return envelope.CreateResponse([message]);
}
switch (content)
{
// If we got a FRC, and were expecting a FRC (because the request started out as a FCC, rather than getting converted to
@@ -496,41 +427,10 @@ internal sealed class WorkflowSession : AgentSession
break;
case ExecutorFailedEvent executorFailed:
// Mirror WorkflowErrorEvent: never expose internal workflow graph
// identifiers (executor ID) to the client. Surface the exception
// message only when the host opts in via _includeExceptionDetails.
Exception? executorException = executorFailed.Data;
while (executorException is { InnerException: not null }
&& (executorException is TargetInvocationException
|| executorException.GetType().Name == "DeclarativeActionException"))
{
executorException = executorException.InnerException;
}
string executorMessage = this._includeExceptionDetails && executorException != null
? executorException.Message
: "An error occurred while executing the workflow.";
yield return this.CreateUpdate(this.LastResponseId, evt, new ErrorContent(executorMessage));
break;
case SuperStepCompletedEvent stepCompleted:
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
goto default;
case AgentResponseEvent agentResponse:
if (!this._includeWorkflowOutputsInResponse)
{
goto default;
}
foreach (ChatMessage message in agentResponse.Response.Messages)
{
yield return this.CreateUpdate(this.LastResponseId, evt, message);
}
break;
case WorkflowOutputEvent output:
IEnumerable<ChatMessage>? updateMessages = output.Data switch
{
@@ -1,212 +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 Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// Unit tests for <see cref="AgentFrameworkResponseHandler"/> that verify behavior
/// when the registered agent is a workflow-backed <see cref="AIAgent"/>. These exercise
/// real workflow builders and the in-process execution environment to drive the handler
/// through realistic streaming event patterns.
/// </summary>
public class AgentFrameworkResponseHandlerWorkflowTests
{
[Fact]
public async Task SequentialWorkflow_SingleAgent_ProducesTextOutputAsync()
{
// Arrange: single-agent sequential workflow
var echoAgent = new StreamingTextAgent("echo", "Hello from the workflow!");
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential", echoAgent);
var workflowAgent = workflow.AsAIAgent(
id: "workflow-agent",
name: "Test Workflow",
executionEnvironment: InProcessExecution.OffThread,
includeExceptionDetails: true);
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
// Act
var events = await CollectEventsAsync(handler, request, context);
// Assert: should have lifecycle events + at least one text output + terminal
Assert.IsType<ResponseCreatedEvent>(events[0]);
Assert.IsType<ResponseInProgressEvent>(events[1]);
Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}");
var lastEvent = events[^1];
Assert.True(
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
$"Expected terminal event, got {lastEvent.GetType().Name}");
}
[Fact]
public async Task SequentialWorkflow_TwoAgents_ProducesOutputFromBothAsync()
{
// Arrange: two agents in sequence
var agent1 = new StreamingTextAgent("agent1", "First agent says hello");
var agent2 = new StreamingTextAgent("agent2", "Second agent says goodbye");
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential-2", agent1, agent2);
var workflowAgent = workflow.AsAIAgent(
id: "seq-workflow",
name: "Sequential Workflow",
executionEnvironment: InProcessExecution.OffThread,
includeExceptionDetails: true);
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Process this");
// Act
var events = await CollectEventsAsync(handler, request, context);
// Assert: should have workflow action events for executor lifecycle
var lastEvent = events[^1];
Assert.True(
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
$"Expected terminal event, got {lastEvent.GetType().Name}");
// Should have output item events (either text messages or workflow actions)
Assert.True(events.OfType<ResponseOutputItemAddedEvent>().Any(),
"Expected at least one output item from the workflow");
}
[Fact]
public async Task Workflow_AgentThrowsException_ProducesErrorOutputAsync()
{
// Arrange: workflow with an agent that throws
var throwingAgent = new ThrowingStreamingAgent("thrower", new InvalidOperationException("Agent crashed"));
var workflow = AgentWorkflowBuilder.BuildSequential("test-error", throwingAgent);
var workflowAgent = workflow.AsAIAgent(
id: "error-workflow",
name: "Error Workflow",
executionEnvironment: InProcessExecution.OffThread,
includeExceptionDetails: true);
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Trigger error");
// Act
var events = await CollectEventsAsync(handler, request, context);
// Assert: should have lifecycle events + error/failure indicator
Assert.IsType<ResponseCreatedEvent>(events[0]);
Assert.IsType<ResponseInProgressEvent>(events[1]);
var lastEvent = events[^1];
// Workflow errors surface as either Failed or Completed (depending on error handling)
Assert.True(
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
$"Expected terminal event, got {lastEvent.GetType().Name}");
}
[Fact]
public async Task Workflow_ExecutorEvents_ProduceWorkflowActionItemsAsync()
{
// Arrange
var agent = new StreamingTextAgent("test-agent", "Result");
var workflow = AgentWorkflowBuilder.BuildSequential("test-actions", agent);
var workflowAgent = workflow.AsAIAgent(
id: "actions-workflow",
name: "Actions Workflow",
executionEnvironment: InProcessExecution.OffThread);
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
// Act
var events = await CollectEventsAsync(handler, request, context);
// Assert: workflow should produce OutputItemAdded events for executor lifecycle
var addedEvents = events.OfType<ResponseOutputItemAddedEvent>().ToList();
Assert.True(addedEvents.Count >= 1,
$"Expected at least 1 output item added event, got {addedEvents.Count}");
}
[Fact]
public async Task WorkflowAgent_RegisteredWithKey_ResolvesCorrectlyAsync()
{
// Arrange: workflow agent registered with a keyed service name
var agent = new StreamingTextAgent("inner", "Keyed workflow response");
var workflow = AgentWorkflowBuilder.BuildSequential("keyed-wf", agent);
var workflowAgent = workflow.AsAIAgent(
id: "keyed-workflow",
name: "Keyed Workflow",
executionEnvironment: InProcessExecution.OffThread);
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton("my-workflow", workflowAgent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-workflow") };
request.Input = CreateUserInput("Test keyed workflow");
var mockContext = CreateMockContext();
// Act
var events = await CollectEventsAsync(handler, request, mockContext.Object);
// Assert
Assert.IsType<ResponseCreatedEvent>(events[0]);
Assert.True(events.Count >= 3, $"Expected at least 3 events, got {events.Count}");
}
private static (AgentFrameworkResponseHandler handler, CreateResponse request, ResponseContext context)
CreateHandlerWithAgent(AIAgent agent, string userMessage)
{
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton(agent);
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
request.Input = CreateUserInput(userMessage);
var mockContext = CreateMockContext();
return (handler, request, mockContext.Object);
}
private static BinaryData CreateUserInput(string text)
{
return BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_in_1", status = "completed", role = "user",
content = new[] { new { type = "input_text", text } }
}
});
}
private static Mock<ResponseContext> CreateMockContext()
{
var mock = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mock.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mock.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
return mock;
}
private static async Task<List<ResponseStreamEvent>> CollectEventsAsync(
AgentFrameworkResponseHandler handler,
CreateResponse request,
ResponseContext context)
{
var events = new List<ResponseStreamEvent>();
await foreach (var evt in handler.CreateAsync(request, context, CancellationToken.None))
{
events.Add(evt);
}
return events;
}
}
@@ -1,28 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
internal sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider
{
public override GetTokenOptions? CreateTokenOptions(IReadOnlyDictionary<string, object> properties)
{
return new GetTokenOptions(new Dictionary<string, object>());
}
public override AuthenticationToken GetToken(GetTokenOptions options, CancellationToken cancellationToken)
{
return new AuthenticationToken("token-value", "token-type", DateTimeOffset.UtcNow.AddHours(1));
}
public override ValueTask<AuthenticationToken> GetTokenAsync(GetTokenOptions options, CancellationToken cancellationToken)
{
return new ValueTask<AuthenticationToken>(this.GetToken(options, cancellationToken));
}
}
@@ -1,302 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Foundry.Hosting;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public sealed class FileSystemAgentSessionStoreTests : IDisposable
{
private readonly string _root;
public FileSystemAgentSessionStoreTests()
{
this._root = Path.Combine(Path.GetTempPath(), "fs-session-store-tests-" + Guid.NewGuid().ToString("N"));
}
public void Dispose()
{
try
{
if (Directory.Exists(this._root))
{
Directory.Delete(this._root, recursive: true);
}
}
catch
{
// best-effort cleanup
}
}
[Fact]
public void Constructor_ResolvesRootDirectoryToFullPath()
{
var store = new FileSystemAgentSessionStore(this._root);
Assert.Equal(Path.GetFullPath(this._root), store.RootDirectory);
}
[Fact]
public void Constructor_NullOrWhitespaceRoot_Throws()
{
Assert.Throws<ArgumentNullException>(() => new FileSystemAgentSessionStore(null!));
Assert.Throws<ArgumentException>(() => new FileSystemAgentSessionStore(""));
Assert.Throws<ArgumentException>(() => new FileSystemAgentSessionStore(" "));
}
[Fact]
public async Task GetSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAgentAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent();
var session = await store.GetSessionAsync(agent, "conv-1");
Assert.NotNull(session);
Assert.Equal(1, agent.CreateCalls);
Assert.Equal(0, agent.DeserializeCalls);
}
[Fact]
public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsFreshSessionAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
Directory.CreateDirectory(store.RootDirectory);
File.WriteAllText(Path.Combine(store.RootDirectory, "conv-empty.json"), string.Empty);
var agent = new TestAgent();
var session = await store.GetSessionAsync(agent, "conv-empty");
Assert.NotNull(session);
Assert.Equal(1, agent.CreateCalls);
Assert.Equal(0, agent.DeserializeCalls);
}
[Fact]
public async Task SaveSessionAsync_CreatesRootDirectoryIfMissingAsync()
{
var nested = Path.Combine(this._root, "nested", "deeper");
var store = new FileSystemAgentSessionStore(nested);
Assert.False(Directory.Exists(nested));
var agent = new TestAgent("{\"workflow\":\"x\"}");
await store.SaveSessionAsync(agent, "conv-2", NewSession());
Assert.True(Directory.Exists(nested));
Assert.True(File.Exists(Path.Combine(nested, "conv-2.json")));
}
[Fact]
public async Task SaveSessionAsync_ThenGetSessionAsync_RoundTripsViaAgentSerializerAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent("{\"foo\":42}");
await store.SaveSessionAsync(agent, "round-trip", NewSession());
await store.GetSessionAsync(agent, "round-trip");
Assert.Equal(1, agent.SerializeCalls);
Assert.Equal(1, agent.DeserializeCalls);
Assert.NotNull(agent.LastDeserialized);
Assert.Equal(JsonValueKind.Object, agent.LastDeserialized!.Value.ValueKind);
Assert.Equal(42, agent.LastDeserialized!.Value.GetProperty("foo").GetInt32());
}
[Fact]
public async Task SaveSessionAsync_TwoAgentsSameConversationId_DoNotCollideAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agentA = new TestAgent("{\"who\":\"a\"}", name: "AgentA");
var agentB = new TestAgent("{\"who\":\"b\"}", name: "AgentB");
await store.SaveSessionAsync(agentA, "shared-conv", NewSession());
await store.SaveSessionAsync(agentB, "shared-conv", NewSession());
// Agents with distinct Names get distinct subdirectories so neither overwrites the other.
var pathA = Path.Combine(store.RootDirectory, "AgentA", "shared-conv.json");
var pathB = Path.Combine(store.RootDirectory, "AgentB", "shared-conv.json");
Assert.True(File.Exists(pathA));
Assert.True(File.Exists(pathB));
Assert.Contains("\"a\"", File.ReadAllText(pathA), StringComparison.Ordinal);
Assert.Contains("\"b\"", File.ReadAllText(pathB), StringComparison.Ordinal);
}
[Fact]
public async Task SaveSessionAsync_LongConversationId_DoesNotStackOverflowAsync()
{
// Keep the value < typical OS file-name limits (~255 chars) so the file write
// succeeds, but long enough to force Sanitize past its small-input fast path.
var store = new FileSystemAgentSessionStore(this._root);
var conversationId = new string('a', 200);
var agent = new TestAgent();
await store.SaveSessionAsync(agent, conversationId, NewSession());
var files = Directory.GetFiles(store.RootDirectory, "*.json");
Assert.Single(files);
}
[Fact]
public async Task SaveSessionAsync_SanitizesInvalidPathCharactersAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent();
// Pick an invalid filename char for the current OS. The set differs by platform
// (e.g. '?' is invalid on Windows but not on Linux), so we must select dynamically.
var invalidChars = Path.GetInvalidFileNameChars();
Assert.NotEmpty(invalidChars);
char invalid = invalidChars[0];
// Avoid NUL specifically because some shells/loggers handle it oddly; prefer
// the next character if available.
if (invalid == '\0' && invalidChars.Length > 1)
{
invalid = invalidChars[1];
}
var conversationId = $"id-with{invalid}invalid-chars";
await store.SaveSessionAsync(agent, conversationId, NewSession());
var files = Directory.GetFiles(store.RootDirectory, "*.json");
Assert.Single(files);
var fileName = Path.GetFileName(files[0]);
Assert.DoesNotContain(invalid.ToString(), fileName, StringComparison.Ordinal);
Assert.Contains("id-with", fileName, StringComparison.Ordinal);
Assert.Contains("invalid-chars", fileName, StringComparison.Ordinal);
}
[Fact]
public async Task SaveSessionAsync_ConcurrentSavesOnSameConversation_DoNotCollideOnTempFileAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent("{\"x\":1}");
// Fan out N concurrent saves; with a fixed temp filename ("path.tmp") this would
// race on FileMode.Create / Move. Verify they all complete successfully.
var tasks = new List<Task>();
for (int i = 0; i < 16; i++)
{
tasks.Add(store.SaveSessionAsync(agent, "concurrent", NewSession()).AsTask());
}
await Task.WhenAll(tasks);
Assert.True(File.Exists(Path.Combine(store.RootDirectory, "concurrent.json")));
var leftoverTempFiles = Directory.GetFiles(store.RootDirectory, "*.tmp");
Assert.Empty(leftoverTempFiles);
}
[Theory]
[InlineData(".")]
[InlineData("..")]
[InlineData("...")]
public async Task SaveSessionAsync_AgentNameIsDotSegment_DoesNotEscapeRootAsync(string agentName)
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent(name: agentName);
await store.SaveSessionAsync(agent, "conv-dots", NewSession());
// The session file must land inside RootDirectory, not in (or above) it as a sibling.
var allFiles = Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories);
Assert.Single(allFiles);
var fullPath = Path.GetFullPath(allFiles[0]);
Assert.StartsWith(Path.GetFullPath(this._root) + Path.DirectorySeparatorChar, fullPath, StringComparison.Ordinal);
// The bucket directory name must not be a navigable dot-segment. After
// percent-encoding every dot in an all-dot segment, names like ".", "..", and
// "..." become "%2E", "%2E%2E", "%2E%2E%2E" — distinct, OS-neutral filenames.
var bucketName = Path.GetFileName(Path.GetDirectoryName(fullPath)!);
Assert.NotEmpty(bucketName);
Assert.NotEqual(".", bucketName);
Assert.NotEqual("..", bucketName);
Assert.DoesNotContain(bucketName, c => c == '.');
}
[Fact]
public async Task SaveSessionAsync_DistinctNamesWithInvalidChars_ProduceDistinctFilesAsync()
{
// Percent-encoding must keep otherwise-colliding inputs distinct: under the
// earlier underscore-substitution scheme, "foo/bar" and "foo_bar" both sanitized
// to "foo_bar" and would have shared a session bucket on disk.
var store = new FileSystemAgentSessionStore(this._root);
var agentSlash = new TestAgent(name: "foo/bar");
var agentUnderscore = new TestAgent(name: "foo_bar");
await store.SaveSessionAsync(agentSlash, "conv-1", NewSession());
await store.SaveSessionAsync(agentUnderscore, "conv-1", NewSession());
var bucketDirs = Directory.GetDirectories(store.RootDirectory);
Assert.Equal(2, bucketDirs.Length);
}
[Fact]
public async Task GetSessionAsync_NoExistingFile_DoesNotCreateAgentDirectoryAsync()
{
// Read operations must not have side effects on the file system.
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent(name: "agent-with-bucket");
var session = await store.GetSessionAsync(agent, "missing-id");
Assert.NotNull(session);
Assert.False(Directory.Exists(this._root), "Read miss must not create the root directory.");
}
private static TestSession NewSession() => new();
private sealed class TestSession : AgentSession
{
}
private sealed class TestAgent : AIAgent
{
private readonly string _serializedJson;
private readonly string? _name;
public TestAgent(string serializedJson = "{}", string? name = null)
{
this._serializedJson = serializedJson;
this._name = name;
}
public override string? Name => this._name;
public int CreateCalls { get; private set; }
public int SerializeCalls { get; private set; }
public int DeserializeCalls { get; private set; }
public JsonElement? LastDeserialized { get; private set; }
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
{
this.CreateCalls++;
return new ValueTask<AgentSession>(NewSession());
}
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
this.SerializeCalls++;
using var doc = JsonDocument.Parse(this._serializedJson);
return new ValueTask<JsonElement>(doc.RootElement.Clone());
}
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
this.DeserializeCalls++;
this.LastDeserialized = serializedState.Clone();
return new ValueTask<AgentSession>(NewSession());
}
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<Extensions.AI.ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<Extensions.AI.ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
@@ -1,164 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Extensions.OpenAI;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// End-to-end tests that exercise the FULL hosted ASP.NET Core pipeline:
/// inbound HTTP → MapFoundryResponses → AgentFrameworkResponseHandler → TryApplyUserAgent →
/// agent invocation → outbound HTTP from inside the hosted environment.
/// Verifies that the hosted-agent <c>User-Agent</c> supplement reaches the outbound wire,
/// not just the inbound request.
/// </summary>
public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
{
private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj";
private const string Deployment = "fake-deployment";
private WebApplication? _app;
private HttpClient? _inboundClient;
private RecordingHandler? _outboundHandler;
public async ValueTask DisposeAsync()
{
this._inboundClient?.Dispose();
this._outboundHandler?.Dispose();
if (this._app is not null)
{
await this._app.DisposeAsync();
}
}
[Fact]
public async Task Hosted_InboundResponsesRequest_TriggersOutboundCall_WithFoundryHostingSupplementAsync()
{
// Arrange: spin up a real ASP.NET Core TestServer that hosts an AIAgent backed by MEAI's
// OpenAIResponsesChatClient → ProjectResponsesClient → fake HTTP transport. This is the
// exact production stack minus the network: the only thing not real is the wire transport.
await this.StartHostedServerAsync();
// Act: send an inbound /openai/v1/responses request as the Foundry runtime would.
using var inboundRequest = new HttpRequestMessage(HttpMethod.Post, "/responses")
{
Content = new StringContent(InboundResponsesRequestJson(), Encoding.UTF8, "application/json"),
};
using var inboundResponse = await this._inboundClient!.SendAsync(inboundRequest);
var inboundBody = await inboundResponse.Content.ReadAsStringAsync();
// Assert: at least one OUTBOUND request reached the fake transport, AND it carries the
// foundry-hosting/agent-framework-dotnet/{version} supplement on its User-Agent.
// (We don't care about the inbound response shape — only that the agent's call to MEAI
// triggered an outbound request whose UA reaches the sandbox boundary correctly.)
Assert.True(this._outboundHandler!.Requests.Count > 0,
$"Expected at least one outbound request. Inbound status: {(int)inboundResponse.StatusCode}, body: {inboundBody}");
var outbound = this._outboundHandler.Requests[0];
Assert.StartsWith(TestEndpoint, outbound.Uri);
Assert.Contains("MEAI/", outbound.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", outbound.UserAgent);
}
private async Task StartHostedServerAsync()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
// Build a real ChatClientAgent whose IChatClient is MEAI's OpenAIResponsesChatClient
// wrapping a ProjectResponsesClient backed by a fake HTTP handler. After AgentFrameworkResponseHandler
// resolves this agent, TryApplyUserAgent will swap the inner _responseClient with our wrapper.
this._outboundHandler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
var outboundHttpClient = new HttpClient(this._outboundHandler);
#pragma warning restore CA5399
var projectOptions = new ProjectResponsesClientOptions
{
Transport = new HttpClientPipelineTransport(outboundHttpClient),
};
var projectResponsesClient = new ProjectResponsesClient(
new Uri(TestEndpoint),
new FakeAuthenticationTokenProvider(),
projectOptions);
IChatClient chatClient = projectResponsesClient.AsIChatClient(Deployment);
AIAgent agent = new ChatClientAgent(chatClient);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddLogging();
this._app = builder.Build();
this._app.MapFoundryResponses();
await this._app.StartAsync();
var testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
this._inboundClient = testServer.CreateClient();
}
private static string InboundResponsesRequestJson() => """
{
"model": "fake-deployment",
"input": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "user",
"content": [{ "type": "input_text", "text": "Hello" }]
}
]
}
""";
private static string MinimalResponseJson() => """
{
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
}
""";
private sealed class RecordingHandler : HttpClientHandler
{
private readonly string _body;
public List<RecordedRequest> Requests { get; } = [];
public RecordingHandler(string body)
{
this._body = body;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string ua = request.Headers.TryGetValues("User-Agent", out var values)
? string.Join(",", values)
: "(none)";
this.Requests.Add(new RecordedRequest(request.RequestUri?.ToString() ?? "?", ua));
var resp = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
RequestMessage = request,
};
return Task.FromResult(resp);
}
}
private readonly record struct RecordedRequest(string Uri, string UserAgent);
}
@@ -1,40 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
internal sealed class HttpHandlerAssert : HttpClientHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage>? _assertion;
private readonly Func<HttpRequestMessage, Task<HttpResponseMessage>>? _assertionAsync;
public HttpHandlerAssert(Func<HttpRequestMessage, HttpResponseMessage> assertion)
{
this._assertion = assertion;
}
public HttpHandlerAssert(Func<HttpRequestMessage, Task<HttpResponseMessage>> assertionAsync)
{
this._assertionAsync = assertionAsync;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (this._assertionAsync is not null)
{
return await this._assertionAsync.Invoke(request);
}
return this._assertion!.Invoke(request);
}
#if NET
protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
{
return this._assertion!(request);
}
#endif
}
@@ -1,35 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<NoWarn>$(NoWarn);NU1605;NU1903</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Azure.AI.AgentServer.Responses" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="TestData\ToolboxRecordResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxVersionResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxVersionWithDecorationFields.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,213 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using Moq;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// Unit tests for <see cref="OutputConverter"/> driven directly by hand-crafted update
/// sequences that mirror the patterns produced by real workflow executions
/// (sequential, group chat, code executor, sub-workflow, mixed content types).
/// </summary>
public class OutputConverterWorkflowTests
{
[Fact]
public async Task SequentialWorkflowPattern_ProducesCorrectEventsAsync()
{
// Simulate what WorkflowSession produces for a 2-agent sequential workflow
var (stream, _) = CreateTestStream();
var updates = new[]
{
// Superstep 1: Agent 1
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
new AgentResponseUpdate { MessageId = "msg_a1", Contents = [new MeaiTextContent("Agent 1 output")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
// Superstep 2: Agent 2
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") },
new AgentResponseUpdate { MessageId = "msg_a2", Contents = [new MeaiTextContent("Agent 2 output")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 4 workflow action items + 2 text messages = 6 output items
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task GroupChatPattern_ProducesCorrectEventsAsync()
{
// Simulate round-robin group chat: agent1 → agent2 → agent1 → terminate
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
new AgentResponseUpdate { MessageId = "msg_gc_1", Contents = [new MeaiTextContent("Agent 1 turn 1")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_2", "turn") },
new AgentResponseUpdate { MessageId = "msg_gc_2", Contents = [new MeaiTextContent("Agent 2 turn 1")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_2", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(3) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
new AgentResponseUpdate { MessageId = "msg_gc_3", Contents = [new MeaiTextContent("Agent 1 turn 2")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(3) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 6 workflow actions + 3 text messages = 9 output items
Assert.Equal(9, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Equal(3, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task CodeExecutorPattern_ProducesCorrectEventsAsync()
{
// Simulate a code-based FunctionExecutor: invoked → completed, no text content
// (code executors don't produce AgentResponseUpdateEvent, just executor lifecycle)
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("uppercase_fn", "hello") },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("uppercase_fn", "HELLO") },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
// Second executor uses the output
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("format_agent", "start") },
new AgentResponseUpdate { MessageId = "msg_fmt", Contents = [new MeaiTextContent("Formatted: HELLO")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("format_agent", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 4 workflow actions + 1 text message = 5 output items
Assert.Equal(5, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task SubworkflowPattern_ProducesCorrectEventsAsync()
{
// Simulate a parent workflow that invokes a sub-workflow executor
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("parent") },
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
// Sub-workflow executor invoked
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("sub_workflow_host", "start") },
// Inner agent within sub-workflow produces text (unwrapped by WorkflowSession)
new AgentResponseUpdate { MessageId = "msg_sub_1", Contents = [new MeaiTextContent("Sub-workflow agent output")] },
// Sub-workflow executor completed
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("sub_workflow_host", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 2 workflow actions + 1 text message = 3 output items
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task WorkflowWithMultipleContentTypes_HandlesAllCorrectlyAsync()
{
// Simulate a workflow producing reasoning, text, function calls, and usage
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("planner", "start") },
// Reasoning
new AgentResponseUpdate { Contents = [new TextReasoningContent("Let me think about this...")] },
// Function call (tool use)
new AgentResponseUpdate
{
Contents = [new FunctionCallContent("call_search", "web_search",
new Dictionary<string, object?> { ["query"] = "latest news" })]
},
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("planner", null) },
// Next executor uses tool result
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("writer", "start") },
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("Based on my research, ")] },
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("here are the findings.")] },
new AgentResponseUpdate
{
Contents = [new UsageContent(new UsageDetails { InputTokenCount = 500, OutputTokenCount = 200, TotalTokenCount = 700 })]
},
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("writer", null) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// Workflow actions: 4 (2 invoked + 2 completed)
// Content: 1 reasoning + 1 function call + 1 text message = 3
// Total: 7 output items
Assert.Equal(7, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
{
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
var request = new CreateResponse { Model = "test-model" };
var stream = new ResponseEventStream(mockContext.Object, request);
return (stream, mockContext);
}
private static async IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> source)
{
foreach (var item in source)
{
yield return item;
}
await Task.CompletedTask;
}
}
@@ -1,30 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.IO;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// Utility class for loading toolbox-related test data files.
/// </summary>
internal static class TestDataUtil
{
private static readonly string s_toolboxRecordResponseJson = File.ReadAllText("TestData/ToolboxRecordResponse.json");
private static readonly string s_toolboxVersionResponseJson = File.ReadAllText("TestData/ToolboxVersionResponse.json");
private static readonly string s_toolboxVersionWithDecorationFieldsJson = File.ReadAllText("TestData/ToolboxVersionWithDecorationFields.json");
/// <summary>
/// Gets the toolbox record response JSON.
/// </summary>
public static string GetToolboxRecordResponseJson() => s_toolboxRecordResponseJson;
/// <summary>
/// Gets the toolbox version response JSON.
/// </summary>
public static string GetToolboxVersionResponseJson() => s_toolboxVersionResponseJson;
/// <summary>
/// Gets the toolbox version response JSON with decoration fields on tools.
/// </summary>
public static string GetToolboxVersionWithDecorationFieldsJson() => s_toolboxVersionWithDecorationFieldsJson;
}
@@ -1,452 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Extensions.OpenAI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// Verifies that <see cref="UserAgentResponsesClient"/> preserves user-supplied client options
/// (Transport, RetryPolicy, UserAgentApplicationId, OrganizationId, ProjectId) and adds the
/// hosted-agent User-Agent supplement on every outgoing request, including streaming.
/// Covers both the Azure-flavored <see cref="ProjectResponsesClient"/> and the native OpenAI
/// <see cref="ResponsesClient"/>.
/// </summary>
public sealed partial class UserAgentResponsesClientTests
{
private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj";
private const string OpenAIEndpoint = "https://fake-openai.example.com/v1";
private const string Deployment = "fake-deployment";
[System.Text.RegularExpressions.GeneratedRegex("foundry-hosting/agent-framework-dotnet")]
private static partial System.Text.RegularExpressions.Regex SupplementRegex();
[Fact]
public async Task Polyfill_NonStreaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
var chat = MakeWithDelegating(inner);
// Act
_ = await chat.GetResponseAsync("hello");
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("MEAI/", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
Assert.StartsWith(TestEndpoint, req.Uri);
}
[Fact]
public async Task Polyfill_Streaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalSseResponse());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
var chat = MakeWithDelegating(inner);
// Act
await foreach (var _ in chat.GetStreamingResponseAsync("hello"))
{
}
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("MEAI/", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
Assert.StartsWith(TestEndpoint, req.Uri);
}
[Fact]
public async Task Polyfill_PreservesOrganizationAndProjectHeadersAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient,
userAgentApplicationId: "MY_APP_ID",
organizationId: "org_xyz",
projectId: "proj_abc");
var chat = MakeWithDelegating(inner);
// Act
_ = await chat.GetResponseAsync("hello");
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
}
[Fact]
public async Task Polyfill_HonorsUserSuppliedRetryPolicy_ByCountingRetriesAsync()
{
// Arrange
var retryPolicy = new CountingRetryPolicy(extraAttempts: 2);
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy);
var chat = MakeWithDelegating(inner);
// Act
_ = await chat.GetResponseAsync("hello");
// Assert: retry policy ran (1 + 2 extras = 3 attempts).
Assert.Equal(3, handler.Requests.Count);
Assert.Equal(3, retryPolicy.InvocationCount);
foreach (var req in handler.Requests)
{
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("MEAI/", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
}
}
[Fact]
public async Task Baseline_NonStreaming_DoesNotInjectSupplementAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
var chat = inner.AsIChatClient(Deployment);
// Act
_ = await chat.GetResponseAsync("hello");
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("MEAI/", req.UserAgent);
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", req.UserAgent);
}
[Fact]
public async Task Polyfill_NativeOpenAIResponsesClient_NonStreaming_AddsSupplementAsync()
{
// Arrange: use the NATIVE OpenAI SDK ResponsesClient (no Foundry / Azure project involved).
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
var chat = MakeWithDelegating(inner);
// Act
_ = await chat.GetResponseAsync("hello");
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("MEAI/", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
Assert.StartsWith(OpenAIEndpoint, req.Uri);
}
[Fact]
public async Task Polyfill_NativeOpenAIResponsesClient_Streaming_AddsSupplementAsync()
{
// Arrange
using var handler = new RecordingHandler(MinimalSseResponse());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
var chat = MakeWithDelegating(inner);
// Act
await foreach (var _ in chat.GetStreamingResponseAsync("hello"))
{
}
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("MEAI/", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
Assert.StartsWith(OpenAIEndpoint, req.Uri);
}
[Theory]
[InlineData("DeleteResponseAsync")]
[InlineData("CancelResponseAsync")]
[InlineData("GetInputTokenCountAsync")]
[InlineData("CompactResponseAsync")]
[InlineData("GetResponseInputItemCollectionPageAsync")]
public async Task Polyfill_AncillaryProtocolMethod_AddsSupplementAsync(string method)
{
// Arrange: hit the wrapper DIRECTLY (no MEAI in the chain) to simulate user code that
// grabs the underlying ResponsesClient via chat.GetService<ResponsesClient>() and invokes
// a non-Create/Get protocol method. This is the regression path: without overriding these,
// the wrapper's dummy throwing pipeline would fire.
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
var wrapper = new UserAgentResponsesClient(inner);
// Act
switch (method)
{
case "DeleteResponseAsync":
_ = await wrapper.DeleteResponseAsync("resp_1", options: null!);
break;
case "CancelResponseAsync":
_ = await wrapper.CancelResponseAsync("resp_1", options: null!);
break;
case "GetInputTokenCountAsync":
_ = await wrapper.GetInputTokenCountAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}")));
break;
case "CompactResponseAsync":
_ = await wrapper.CompactResponseAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}")));
break;
case "GetResponseInputItemCollectionPageAsync":
_ = await wrapper.GetResponseInputItemCollectionPageAsync("resp_1", limit: null, order: "asc", after: "a", before: "b", options: null!);
break;
default:
Assert.Fail($"Unhandled method: {method}");
break;
}
// Assert
var req = Assert.Single(handler.Requests);
Assert.Contains("MY_APP_ID", req.UserAgent);
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
}
[Fact]
public async Task Polyfill_RetryWithinCall_DoesNotDuplicateSupplementInUserAgentAsync()
{
// Arrange: a custom retry policy that re-runs the inner pipeline on the SAME message,
// so the per-call HostedAgentUserAgentPolicy fires multiple times against the same headers.
// The policy's Contains-guard must prevent the supplement from appearing twice.
var retryPolicy = new CountingRetryPolicy(extraAttempts: 2);
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy);
var chat = MakeWithDelegating(inner);
// Act
_ = await chat.GetResponseAsync("hello");
// Assert: each retry attempt must have exactly ONE foundry-hosting segment, never two.
Assert.Equal(3, handler.Requests.Count);
foreach (var req in handler.Requests)
{
int matches = SupplementRegex().Matches(req.UserAgent).Count;
Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment per retry attempt, got {matches}. UA: {req.UserAgent}");
}
}
[Fact]
public async Task TryApplyUserAgent_CalledTwiceOnSameAgent_DoesNotDoubleWrapAsync()
{
// Arrange: build a real ChatClientAgent whose IChatClient resolves to MEAI's
// OpenAIResponsesChatClient → ProjectResponsesClient (with a fake transport).
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
IChatClient chatClient = inner.AsIChatClient(Deployment);
AIAgent agent = new ChatClientAgent(chatClient);
// Act: apply twice.
FoundryHostingExtensions.TryApplyUserAgent(agent);
FoundryHostingExtensions.TryApplyUserAgent(agent);
// Assert: invoking the agent produces exactly ONE outbound request whose UA contains
// the supplement EXACTLY ONCE (would be twice if the wrapper were nested).
_ = await chatClient.GetResponseAsync("hello");
var req = Assert.Single(handler.Requests);
int matches = SupplementRegex().Matches(req.UserAgent).Count;
Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment, got {matches}. UA: {req.UserAgent}");
}
[Fact]
public void OpenAIResponsesChatClient_ResponseClientField_ReflectionGuard()
{
// Guards the polyfill's reflection target. Failure here means MEAI internals
// changed and the polyfill needs updating.
var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly
.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
Assert.NotNull(meaiType);
var field = meaiType!.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(field);
Assert.True(typeof(ResponsesClient).IsAssignableFrom(field!.FieldType),
$"Expected _responseClient to be assignable to ResponsesClient but was {field.FieldType}.");
}
[Fact]
public void ResponsesClient_PipelineProperty_ReflectionGuard()
{
// The polyfill design assumes ResponsesClient.Pipeline remains accessible.
var pipelineProp = typeof(ResponsesClient).GetProperty("Pipeline", BindingFlags.Public | BindingFlags.Instance);
Assert.NotNull(pipelineProp);
Assert.Equal(typeof(ClientPipeline), pipelineProp!.PropertyType);
}
private static IChatClient MakeWithDelegating(ResponsesClient inner)
{
IChatClient meai = inner.AsIChatClient(Deployment);
var meaiType = meai.GetType();
var field = meaiType.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance)!;
field.SetValue(meai, new UserAgentResponsesClient(inner));
return meai;
}
private static ProjectResponsesClient BuildInner(
HttpClient httpClient,
string? userAgentApplicationId = null,
string? organizationId = null,
string? projectId = null,
PipelinePolicy? retryPolicy = null)
{
var options = new ProjectResponsesClientOptions
{
Transport = new HttpClientPipelineTransport(httpClient),
};
if (userAgentApplicationId is not null)
{
options.UserAgentApplicationId = userAgentApplicationId;
}
if (organizationId is not null)
{
options.OrganizationId = organizationId;
}
if (projectId is not null)
{
options.ProjectId = projectId;
}
if (retryPolicy is not null)
{
options.RetryPolicy = retryPolicy;
}
return new ProjectResponsesClient(new Uri(TestEndpoint), new FakeAuthenticationTokenProvider(), options);
}
private static ResponsesClient BuildOpenAIInner(
HttpClient httpClient,
string? userAgentApplicationId = null)
{
var options = new OpenAIClientOptions
{
Transport = new HttpClientPipelineTransport(httpClient),
Endpoint = new Uri(OpenAIEndpoint),
};
if (userAgentApplicationId is not null)
{
options.UserAgentApplicationId = userAgentApplicationId;
}
return new ResponsesClient(new ApiKeyCredential("test-key"), options);
}
private static string MinimalResponseJson() => """
{
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
}
""";
private static string MinimalSseResponse()
{
var sb = new StringBuilder();
sb.Append("event: response.completed\n");
sb.Append("data: ").Append("""{"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":1700000000,"status":"completed","model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}""").Append("\n\n");
sb.Append("data: [DONE]\n\n");
return sb.ToString();
}
private sealed class RecordingHandler : HttpClientHandler
{
private readonly string _body;
public List<RecordedRequest> Requests { get; } = [];
public RecordingHandler(string body)
{
this._body = body;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string ua = request.Headers.TryGetValues("User-Agent", out var values)
? string.Join(",", values)
: "(none)";
this.Requests.Add(new RecordedRequest(request.Method.Method, request.RequestUri?.ToString() ?? "?", ua));
var resp = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
RequestMessage = request,
};
return Task.FromResult(resp);
}
}
private readonly record struct RecordedRequest(string Method, string Uri, string UserAgent);
private sealed class CountingRetryPolicy : PipelinePolicy
{
private readonly int _extraAttempts;
public int InvocationCount { get; private set; }
public CountingRetryPolicy(int extraAttempts)
{
this._extraAttempts = extraAttempts;
}
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
for (int i = 0; i <= this._extraAttempts; i++)
{
this.InvocationCount++;
ProcessNext(message, pipeline, currentIndex);
}
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
for (int i = 0; i <= this._extraAttempts; i++)
{
this.InvocationCount++;
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
}
}
}
}
@@ -1,96 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
/// <summary>
/// A test agent that streams a single text update.
/// </summary>
internal sealed class StreamingTextAgent(string id, string responseText) : AIAgent
{
public new string Id => id;
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
yield return new AgentResponseUpdate
{
MessageId = $"msg_{id}",
Contents = [new MeaiTextContent(responseText)]
};
await Task.CompletedTask;
}
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
/// <summary>
/// A test agent that always throws an exception during streaming.
/// </summary>
internal sealed class ThrowingStreamingAgent(string id, Exception exception) : AIAgent
{
public new string Id => id;
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw exception;
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
@@ -10,6 +10,7 @@ using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
@@ -18,7 +19,7 @@ using OpenTelemetry;
using OpenTelemetry.Trace;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
/// <summary>
/// Tests that verify OTel spans are actually emitted and captured through the
@@ -9,6 +9,7 @@ using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -16,7 +17,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public class AgentFrameworkResponseHandlerTests
{
@@ -1,8 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Foundry.Hosting;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public class FoundryAIToolExtensionsTests
{
@@ -6,9 +6,10 @@ using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Agents.AI.Foundry.Hosting;
using Moq;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public class FoundryToolboxBearerTokenHandlerTests
{
@@ -4,10 +4,11 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.Options;
using Moq;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public class FoundryToolboxServiceTests
{
@@ -9,12 +9,13 @@ using System.Text;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Unit tests for the <see cref="FoundryToolbox"/> class.
@@ -3,10 +3,11 @@
using System;
using System.Linq;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public class InputConverterTests
{
@@ -757,467 +758,4 @@ public class InputConverterTests
Assert.Equal("box-b", markers[1].Name);
Assert.Equal("2025-01", markers[1].Version);
}
// === Tool-approval (HITL) wire-format coverage ===
[Fact]
public void ConvertItemsToMessages_McpApprovalRequest_ProducesToolApprovalRequest()
{
var item = new ItemMcpApprovalRequest(
id: "mcpr_" + new string('a', 50),
serverLabel: "agent_framework",
name: "get_weather",
arguments: "{\"city\":\"Seattle\"}");
var messages = InputConverter.ConvertItemsToMessages([item]);
var content = Assert.IsType<ToolApprovalRequestContent>(Assert.Single(messages[0].Contents));
Assert.Equal(item.Id, content.RequestId);
var fc = Assert.IsType<FunctionCallContent>(content.ToolCall);
Assert.Equal("get_weather", fc.Name);
Assert.NotNull(fc.Arguments);
Assert.Equal("Seattle", fc.Arguments!["city"]?.ToString());
}
[Fact]
public void ConvertItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse_FallsBackToWireIdWhenNoMapping()
{
var wireId = "mcpr_" + new string('a', 50);
var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: true);
var messages = InputConverter.ConvertItemsToMessages([item]);
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(wireId, content.RequestId);
Assert.True(content.Approved);
}
[Fact]
public void ConvertItemsToMessages_McpApprovalResponse_ResolvesAfRequestIdFromStateBag()
{
const string AfRequestId = "af_request_xyz";
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
var stateBag = new AgentSessionStateBag();
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: false);
var messages = InputConverter.ConvertItemsToMessages([item], stateBag);
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(AfRequestId, content.RequestId);
Assert.False(content.Approved);
}
[Fact]
public void ConvertOutputItemsToMessages_McpApprovalRequest_ProducesToolApprovalRequest()
{
var item = new OutputItemMcpApprovalRequest(
id: "mcpr_" + new string('b', 50),
serverLabel: "agent_framework",
name: "delete_file",
arguments: "{}");
var messages = InputConverter.ConvertOutputItemsToMessages([item]);
var content = Assert.IsType<ToolApprovalRequestContent>(Assert.Single(messages[0].Contents));
Assert.Equal(item.Id, content.RequestId);
Assert.Equal("delete_file", Assert.IsType<FunctionCallContent>(content.ToolCall).Name);
}
[Fact]
public void ConvertOutputItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse()
{
const string AfRequestId = "af_request_history";
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
var stateBag = new AgentSessionStateBag();
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
var item = new OutputItemMcpApprovalResponseResource(
id: "ar_history_id",
approvalRequestId: wireId,
approve: true);
var messages = InputConverter.ConvertOutputItemsToMessages([item], stateBag);
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(AfRequestId, content.RequestId);
Assert.True(content.Approved);
}
[Fact]
public void ConvertItemsToMessages_McpApprovalRequest_MalformedArguments_PreservesRaw()
{
var item = new ItemMcpApprovalRequest(
id: "mcpr_" + new string('c', 50),
serverLabel: "agent_framework",
name: "noisy",
arguments: "not valid json");
var messages = InputConverter.ConvertItemsToMessages([item]);
var content = Assert.IsType<ToolApprovalRequestContent>(Assert.Single(messages[0].Contents));
var fc = Assert.IsType<FunctionCallContent>(content.ToolCall);
Assert.NotNull(fc.Arguments);
Assert.Equal("not valid json", fc.Arguments!["_raw"]?.ToString());
}
// ── input_file data-URI decoding (TryDecodeTextDataUri) ──
[Fact]
public void ConvertInputToMessages_FileContentWithTextDataUri_DecodesToTextContent()
{
var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("hello world"));
var input = new[]
{
new
{
type = "message",
id = "msg_text_uri",
status = "completed",
role = "user",
content = new[] { new { type = "input_file", file_data = $"data:text/plain;base64,{encoded}" } }
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("hello world", text.Text);
}
[Fact]
public void ConvertInputToMessages_FileContentWithTextDataUriAndFilename_PrefixesFilenameInDecodedText()
{
var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("body"));
var input = new[]
{
new
{
type = "message",
id = "msg_text_uri_name",
status = "completed",
role = "user",
content = new[]
{
new
{
type = "input_file",
filename = "notes.txt",
file_data = $"data:text/plain;base64,{encoded}"
}
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.StartsWith("[File: notes.txt]", text.Text, StringComparison.Ordinal);
Assert.Contains("body", text.Text, StringComparison.Ordinal);
}
[Fact]
public void ConvertInputToMessages_FileContentWithNonTextDataUri_RemainsDataContent()
{
// image/png data URIs must NOT be decoded as text — only text/* is decoded inline.
var input = new[]
{
new
{
type = "message",
id = "msg_image_uri",
status = "completed",
role = "user",
content = new[]
{
new { type = "input_file", file_data = "data:image/png;base64,iVBORw0KGgo=" }
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
Assert.IsType<DataContent>(Assert.Single(messages[0].Contents));
}
[Fact]
public void ConvertInputToMessages_FileContentWithMalformedDataUri_FallsBackToDataContent()
{
// Missing ;base64, marker — TryDecodeTextDataUri should return false and the
// original payload survives as DataContent.
var input = new[]
{
new
{
type = "message",
id = "msg_bad_uri",
status = "completed",
role = "user",
content = new[]
{
new { type = "input_file", file_data = "data:text/plain,not-base64-payload" }
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
Assert.IsType<DataContent>(Assert.Single(messages[0].Contents));
}
[Fact]
public void ConvertInputToMessages_FileContentWithFileUrlAndFilename_PropagatesFilename()
{
var input = new[]
{
new
{
type = "message",
id = "msg_url_name",
status = "completed",
role = "user",
content = new[]
{
new
{
type = "input_file",
file_url = "https://example.com/doc.pdf",
filename = "doc.pdf"
}
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
var uri = Assert.IsType<UriContent>(Assert.Single(messages[0].Contents));
Assert.NotNull(uri.AdditionalProperties);
Assert.Equal("doc.pdf", uri.AdditionalProperties!["filename"]);
}
[Fact]
public void ConvertInputToMessages_FileContentWithFileIdAndFilename_PropagatesFilename()
{
var input = new[]
{
new
{
type = "message",
id = "msg_id_name",
status = "completed",
role = "user",
content = new[]
{
new
{
type = "input_file",
file_id = "file_abc123",
filename = "doc.pdf"
}
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
var hosted = Assert.IsType<HostedFileContent>(Assert.Single(messages[0].Contents));
Assert.NotNull(hosted.AdditionalProperties);
Assert.Equal("doc.pdf", hosted.AdditionalProperties!["filename"]);
}
// ── C2: SDK content types passing through ItemMessage / OutputItemMessage ──
[Fact]
public void ConvertItemsToMessages_SdkTextContent_ProducesTextContent()
{
var msg = new ItemMessage(
MessageRole.User,
new MessageContent[] { new Azure.AI.AgentServer.Responses.Models.TextContent("plain text") });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("plain text", text.Text);
}
[Fact]
public void ConvertItemsToMessages_SummaryTextContent_ProducesTextContent()
{
var msg = new ItemMessage(
MessageRole.Assistant,
new MessageContent[] { new SummaryTextContent("a summary") });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("a summary", text.Text);
}
[Fact]
public void ConvertItemsToMessages_ReasoningTextContent_ProducesTextReasoningContent()
{
var msg = new ItemMessage(
MessageRole.Assistant,
new MessageContent[] { new MessageContentReasoningTextContent("internal reasoning") });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var reasoning = Assert.IsType<TextReasoningContent>(Assert.Single(messages[0].Contents));
Assert.Equal("internal reasoning", reasoning.Text);
}
[Fact]
public void ConvertItemsToMessages_ComputerScreenshotContent_HttpUrl_ProducesUriContent()
{
var screenshot = new ComputerScreenshotContent(
imageUrl: new Uri("https://example.com/screen.png"),
fileId: null!,
detail: default);
var msg = new ItemMessage(MessageRole.User, new MessageContent[] { screenshot });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var uri = Assert.IsType<UriContent>(Assert.Single(messages[0].Contents));
Assert.Equal("https://example.com/screen.png", uri.Uri.ToString());
}
[Fact]
public void ConvertItemsToMessages_ComputerScreenshotContent_DataUri_ProducesDataContent()
{
var screenshot = new ComputerScreenshotContent(
imageUrl: new Uri("data:image/png;base64,iVBORw0KGgo="),
fileId: null!,
detail: default);
var msg = new ItemMessage(MessageRole.User, new MessageContent[] { screenshot });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var data = Assert.IsType<DataContent>(Assert.Single(messages[0].Contents));
Assert.StartsWith("data:image", data.Uri);
}
[Fact]
public void ConvertOutputItemsToMessages_SummaryTextContent_ProducesTextContent()
{
var outputMsg = new OutputItemMessage(
id: "out_summary",
role: MessageRole.Assistant,
content: new MessageContent[] { new SummaryTextContent("output summary") },
status: MessageStatus.Completed);
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("output summary", text.Text);
}
[Fact]
public void ConvertOutputItemsToMessages_ReasoningTextContent_ProducesTextReasoningContent()
{
var outputMsg = new OutputItemMessage(
id: "out_reasoning",
role: MessageRole.Assistant,
content: new MessageContent[] { new MessageContentReasoningTextContent("output reasoning") },
status: MessageStatus.Completed);
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
var reasoning = Assert.IsType<TextReasoningContent>(Assert.Single(messages[0].Contents));
Assert.Equal("output reasoning", reasoning.Text);
}
[Fact]
public void ConvertOutputItemsToMessages_ComputerScreenshotContent_ProducesUriContent()
{
var screenshot = new ComputerScreenshotContent(
imageUrl: new Uri("https://example.com/output-screen.png"),
fileId: null!,
detail: default);
var outputMsg = new OutputItemMessage(
id: "out_screenshot",
role: MessageRole.Assistant,
content: new MessageContent[] { screenshot },
status: MessageStatus.Completed);
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
var uri = Assert.IsType<UriContent>(Assert.Single(messages[0].Contents));
Assert.Equal("https://example.com/output-screen.png", uri.Uri.ToString());
}
[Fact]
public void ConvertOutputItemsToMessages_SdkTextContent_ProducesTextContent()
{
var outputMsg = new OutputItemMessage(
id: "out_text",
role: MessageRole.Assistant,
content: new MessageContent[] { new Azure.AI.AgentServer.Responses.Models.TextContent("sdk text") },
status: MessageStatus.Completed);
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("sdk text", text.Text);
}
[Fact]
public void ConvertInputToMessages_OversizedTextDataUri_FallsBackToDataContent()
{
// The decoder must reject oversized base64 payloads so a malicious or
// misconfigured client cannot trigger a multi-megabyte allocation.
// We construct a base64 payload whose encoded length exceeds the 16 MiB cap
// (using a tiny but valid base64 unit repeated to keep the test fast).
const int OverLimit = (16 * 1024 * 1024) + 4;
var encoded = new string('A', OverLimit);
var dataUri = "data:text/plain;base64," + encoded;
var input = new[]
{
new
{
type = "message",
id = "msg_oversize",
status = "completed",
role = "user",
content = new[]
{
new
{
type = "input_file",
file_data = dataUri,
filename = "huge.txt",
}
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
// Should NOT have decoded into a TextContent (which would have allocated).
Assert.DoesNotContain(messages[0].Contents, c => c is MeaiTextContent t && t.Text.Length > 1024);
// Should have fallen back to DataContent (carrying the original opaque blob).
Assert.Contains(messages[0].Contents, c => c is DataContent);
}
}
@@ -7,12 +7,13 @@ using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using Moq;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public class OutputConverterTests
{
@@ -204,7 +205,7 @@ public class OutputConverterTests
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream, cancellationToken: cts.Token))
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream, cts.Token))
{
// Should throw before yielding
}
@@ -1068,133 +1069,6 @@ public class OutputConverterTests
Assert.IsType<ResponseCompletedEvent>(events[0]);
}
// === Tool-approval (HITL) wire-format coverage ===
[Fact]
public async Task ConvertUpdatesToEventsAsync_ToolApprovalRequest_EmitsMcpApprovalRequestAsync()
{
var (stream, _) = CreateTestStream();
var stateBag = new AgentSessionStateBag();
const string AfRequestId = "af_request_abc";
var functionCall = new FunctionCallContent("call_1", "delete_resource",
new Dictionary<string, object?> { ["target"] = "db" });
var approval = new ToolApprovalRequestContent(AfRequestId, functionCall);
var update = new AgentResponseUpdate { Contents = [approval] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream, stateBag))
{
events.Add(evt);
}
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
var item = Assert.IsType<OutputItemMcpApprovalRequest>(added.Item);
Assert.Equal("agent_framework", item.ServerLabel);
Assert.Equal("delete_resource", item.Name);
Assert.Contains("\"target\":\"db\"", item.Arguments);
Assert.StartsWith("mcpr_", item.Id);
// Mapping persisted to state bag.
Assert.Equal(AfRequestId, ToolApprovalIdMap.Resolve(stateBag, item.Id));
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_ToolApprovalRequest_NonFunctionToolCall_SkippedAsync()
{
// ToolCall implementations that aren't FunctionCallContent (e.g. raw MCP calls)
// are intentionally NOT emitted — mirrors the OpenAI Hosting layer's behavior.
var (stream, _) = CreateTestStream();
var unknownTool = new RawToolCallContent("call_x");
var approval = new ToolApprovalRequestContent("af_x", unknownTool);
var update = new AgentResponseUpdate { Contents = [approval] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.DoesNotContain(events.OfType<ResponseOutputItemAddedEvent>(),
e => e.Item is OutputItemMcpApprovalRequest);
// Defense in depth: only the terminal ResponseCompletedEvent should be emitted.
// No spurious output-item-added/output-item-done events should leak for the
// unsupported tool-call shape.
Assert.Single(events);
Assert.IsType<ResponseCompletedEvent>(events[0]);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_ToolApprovalResponse_NotReEmittedAsync()
{
var (stream, _) = CreateTestStream();
var fc = new FunctionCallContent("call_1", "noop");
var response = new ToolApprovalResponseContent("af_x", true, fc);
var update = new AgentResponseUpdate { Contents = [response] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
// Approval responses are inbound-only; output side should silently drop them
// and emit only the terminal completed event.
Assert.Single(events);
Assert.IsType<ResponseCompletedEvent>(events[0]);
}
// D1: WorkflowEvent in RawRepresentation but Contents is non-empty → fall through to content path.
[Fact]
public async Task ConvertUpdatesToEventsAsync_WorkflowEventWithTextContent_FlowsThroughContentPathAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
MessageId = "msg_workflow_text",
RawRepresentation = new ExecutorInvokedEvent("exec_x", "invoked"),
Contents = [new MeaiTextContent("payload from workflow event")],
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
// Content path must have been taken: a text-delta event must be emitted from the payload.
Assert.Contains(events, e => e is ResponseTextDeltaEvent);
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_WorkflowEventWithErrorContent_EmitsFailedAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
RawRepresentation = new ExecutorFailedEvent("exec_y", new InvalidOperationException("boom")),
Contents = [new ErrorContent("boom")],
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
// ErrorContent should drive a failed event rather than being swallowed by the workflow branch.
Assert.Contains(events, e => e is ResponseFailedEvent);
}
private sealed class RawToolCallContent : ToolCallContent
{
public RawToolCallContent(string callId) : base(callId) { }
}
private static async IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> source)
{
foreach (var item in source)
@@ -3,12 +3,11 @@
using System;
using System.Linq;
using Azure.AI.AgentServer.Responses;
using Microsoft.Extensions.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public class ServiceCollectionExtensionsTests
{
@@ -94,45 +93,4 @@ public class ServiceCollectionExtensionsTests
Assert.Same(instrumented, result);
}
[Fact]
public void TryApplyUserAgent_AgentWithoutChatClient_NoOp()
{
// Arrange: agent.GetService<IChatClient>() returns null.
var mockAgent = new Mock<AIAgent>();
// Act
var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object);
// Assert
Assert.Same(mockAgent.Object, result);
}
[Fact]
public void TryApplyUserAgent_AgentWithNonMeaiChatClient_NoOp()
{
// Arrange: chat client that does not return MEAI's OpenAIResponsesChatClient via GetService.
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(c => c.GetService(It.IsAny<Type>(), It.IsAny<object?>())).Returns(null!);
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.GetService(typeof(IChatClient), It.IsAny<object?>())).Returns(mockChatClient.Object);
// Act
var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object);
// Assert
Assert.Same(mockAgent.Object, result);
}
[Fact]
public void MeaiOpenAIResponsesChatClient_TypeFullName_ReflectionGuard()
{
// Guards the polyfill's reflection target type-name.
var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly
.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
Assert.NotNull(meaiType);
Assert.True(typeof(IChatClient).IsAssignableFrom(meaiType!),
$"Expected MEAI {meaiType!.FullName} to implement IChatClient.");
}
}
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
/// <summary>
/// Tests for the <c>AgentFrameworkUserAgentMiddleware</c> registered by
/// <see cref="FoundryHostingExtensions.MapFoundryResponses"/>.
/// </summary>
public sealed partial class UserAgentMiddlewareTests : IAsyncDisposable
{
private const string VersionedUserAgentPattern = @"agent-framework-dotnet/\d+\.\d+\.\d+(-[\w.]+)?";
private WebApplication? _app;
private HttpClient? _httpClient;
public async ValueTask DisposeAsync()
{
this._httpClient?.Dispose();
if (this._app != null)
{
await this._app.DisposeAsync();
}
}
[Fact]
public async Task MapFoundryResponses_NoUserAgentHeader_SetsAgentFrameworkUserAgentAsync()
{
// Arrange
await this.CreateTestServerAsync();
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
// Act
var response = await this._httpClient!.SendAsync(request);
var userAgent = await response.Content.ReadAsStringAsync();
// Assert
Assert.Matches(VersionedUserAgentPattern, userAgent);
}
[Fact]
public async Task MapFoundryResponses_WithExistingUserAgent_AppendsAgentFrameworkUserAgentAsync()
{
// Arrange
await this.CreateTestServerAsync();
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
request.Headers.TryAddWithoutValidation("User-Agent", "MyApp/1.0");
// Act
var response = await this._httpClient!.SendAsync(request);
var userAgent = await response.Content.ReadAsStringAsync();
// Assert
Assert.StartsWith("MyApp/1.0", userAgent);
Assert.Matches(VersionedUserAgentPattern, userAgent);
}
[Fact]
public async Task MapFoundryResponses_AlreadyContainsUserAgent_DoesNotDuplicateAsync()
{
// Arrange
await this.CreateTestServerAsync();
// First request to capture the actual middleware-generated value
using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
var firstResponse = await this._httpClient!.SendAsync(firstRequest);
var middlewareValue = await firstResponse.Content.ReadAsStringAsync();
// Act: send a second request that already contains the middleware value
using var secondRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
secondRequest.Headers.TryAddWithoutValidation("User-Agent", $"MyApp/2.0 {middlewareValue}");
var secondResponse = await this._httpClient!.SendAsync(secondRequest);
var userAgent = await secondResponse.Content.ReadAsStringAsync();
// Assert: should remain unchanged (no duplication)
Assert.Equal($"MyApp/2.0 {middlewareValue}", userAgent);
Assert.Single(VersionedUserAgentRegex().Matches(userAgent));
}
[Fact]
public async Task MapFoundryResponses_UserAgentValue_ContainsVersionAsync()
{
// Arrange
await this.CreateTestServerAsync();
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
// Act
var response = await this._httpClient!.SendAsync(request);
var userAgent = await response.Content.ReadAsStringAsync();
// Assert: should match "agent-framework-dotnet/x.y.z" pattern
Assert.Matches(VersionedUserAgentPattern, userAgent);
}
private async Task CreateTestServerAsync()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var mockAgent = new Mock<AIAgent>();
builder.Services.AddFoundryResponses(mockAgent.Object);
this._app = builder.Build();
this._app.MapFoundryResponses();
// Test endpoint that echoes the User-Agent header after middleware processing
this._app.MapGet("/test-ua", (HttpContext ctx) =>
Results.Text(ctx.Request.Headers.UserAgent.ToString()));
await this._app.StartAsync();
var testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
this._httpClient = testServer.CreateClient();
}
[GeneratedRegex(VersionedUserAgentPattern)]
private static partial Regex VersionedUserAgentRegex();
}
@@ -0,0 +1,508 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
/// <summary>
/// Integration tests that verify workflow execution through the
/// <see cref="AgentFrameworkResponseHandler"/> → <see cref="OutputConverter"/> pipeline.
/// These use real workflow builders and the InProcessExecution environment
/// to produce authentic streaming event patterns.
/// </summary>
public class WorkflowIntegrationTests
{
// ===== Sequential Workflow Tests =====
[Fact]
public async Task SequentialWorkflow_SingleAgent_ProducesTextOutputAsync()
{
// Arrange: single-agent sequential workflow
var echoAgent = new StreamingTextAgent("echo", "Hello from the workflow!");
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential", echoAgent);
var workflowAgent = workflow.AsAIAgent(
id: "workflow-agent",
name: "Test Workflow",
executionEnvironment: InProcessExecution.OffThread,
includeExceptionDetails: true);
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
// Act
var events = await CollectEventsAsync(handler, request, context);
// Assert: should have lifecycle events + at least one text output + terminal
Assert.IsType<ResponseCreatedEvent>(events[0]);
Assert.IsType<ResponseInProgressEvent>(events[1]);
Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}");
var lastEvent = events[^1];
Assert.True(
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
$"Expected terminal event, got {lastEvent.GetType().Name}");
}
[Fact]
public async Task SequentialWorkflow_TwoAgents_ProducesOutputFromBothAsync()
{
// Arrange: two agents in sequence
var agent1 = new StreamingTextAgent("agent1", "First agent says hello");
var agent2 = new StreamingTextAgent("agent2", "Second agent says goodbye");
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential-2", agent1, agent2);
var workflowAgent = workflow.AsAIAgent(
id: "seq-workflow",
name: "Sequential Workflow",
executionEnvironment: InProcessExecution.OffThread,
includeExceptionDetails: true);
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Process this");
// Act
var events = await CollectEventsAsync(handler, request, context);
// Assert: should have workflow action events for executor lifecycle
var lastEvent = events[^1];
Assert.True(
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
$"Expected terminal event, got {lastEvent.GetType().Name}");
// Should have output item events (either text messages or workflow actions)
Assert.True(events.OfType<ResponseOutputItemAddedEvent>().Any(),
"Expected at least one output item from the workflow");
}
// ===== Workflow Error Propagation =====
[Fact]
public async Task Workflow_AgentThrowsException_ProducesErrorOutputAsync()
{
// Arrange: workflow with an agent that throws
var throwingAgent = new ThrowingStreamingAgent("thrower", new InvalidOperationException("Agent crashed"));
var workflow = AgentWorkflowBuilder.BuildSequential("test-error", throwingAgent);
var workflowAgent = workflow.AsAIAgent(
id: "error-workflow",
name: "Error Workflow",
executionEnvironment: InProcessExecution.OffThread,
includeExceptionDetails: true);
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Trigger error");
// Act
var events = await CollectEventsAsync(handler, request, context);
// Assert: should have lifecycle events + error/failure indicator
Assert.IsType<ResponseCreatedEvent>(events[0]);
Assert.IsType<ResponseInProgressEvent>(events[1]);
var lastEvent = events[^1];
// Workflow errors surface as either Failed or Completed (depending on error handling)
Assert.True(
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
$"Expected terminal event, got {lastEvent.GetType().Name}");
}
// ===== Workflow Action Lifecycle Events =====
[Fact]
public async Task Workflow_ExecutorEvents_ProduceWorkflowActionItemsAsync()
{
// Arrange
var agent = new StreamingTextAgent("test-agent", "Result");
var workflow = AgentWorkflowBuilder.BuildSequential("test-actions", agent);
var workflowAgent = workflow.AsAIAgent(
id: "actions-workflow",
name: "Actions Workflow",
executionEnvironment: InProcessExecution.OffThread);
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
// Act
var events = await CollectEventsAsync(handler, request, context);
// Assert: workflow should produce OutputItemAdded events for executor lifecycle
var addedEvents = events.OfType<ResponseOutputItemAddedEvent>().ToList();
Assert.True(addedEvents.Count >= 1,
$"Expected at least 1 output item added event, got {addedEvents.Count}");
}
// ===== Keyed Workflow Registration =====
[Fact]
public async Task WorkflowAgent_RegisteredWithKey_ResolvesCorrectlyAsync()
{
// Arrange: workflow agent registered with a keyed service name
var agent = new StreamingTextAgent("inner", "Keyed workflow response");
var workflow = AgentWorkflowBuilder.BuildSequential("keyed-wf", agent);
var workflowAgent = workflow.AsAIAgent(
id: "keyed-workflow",
name: "Keyed Workflow",
executionEnvironment: InProcessExecution.OffThread);
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddKeyedSingleton("my-workflow", workflowAgent);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-workflow") };
request.Input = CreateUserInput("Test keyed workflow");
var mockContext = CreateMockContext();
// Act
var events = await CollectEventsAsync(handler, request, mockContext.Object);
// Assert
Assert.IsType<ResponseCreatedEvent>(events[0]);
Assert.True(events.Count >= 3, $"Expected at least 3 events, got {events.Count}");
}
// ===== OutputConverter Direct Workflow Pattern Tests =====
// These test the OutputConverter directly with update patterns that mirror real workflows.
[Fact]
public async Task OutputConverter_SequentialWorkflowPattern_ProducesCorrectEventsAsync()
{
// Simulate what WorkflowSession produces for a 2-agent sequential workflow
var (stream, _) = CreateTestStream();
var updates = new[]
{
// Superstep 1: Agent 1
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
new AgentResponseUpdate { MessageId = "msg_a1", Contents = [new MeaiTextContent("Agent 1 output")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
// Superstep 2: Agent 2
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") },
new AgentResponseUpdate { MessageId = "msg_a2", Contents = [new MeaiTextContent("Agent 2 output")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 4 workflow action items + 2 text messages = 6 output items
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task OutputConverter_GroupChatPattern_ProducesCorrectEventsAsync()
{
// Simulate round-robin group chat: agent1 → agent2 → agent1 → terminate
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
new AgentResponseUpdate { MessageId = "msg_gc_1", Contents = [new MeaiTextContent("Agent 1 turn 1")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_2", "turn") },
new AgentResponseUpdate { MessageId = "msg_gc_2", Contents = [new MeaiTextContent("Agent 2 turn 1")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_2", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(3) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
new AgentResponseUpdate { MessageId = "msg_gc_3", Contents = [new MeaiTextContent("Agent 1 turn 2")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(3) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 6 workflow actions + 3 text messages = 9 output items
Assert.Equal(9, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Equal(3, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task OutputConverter_CodeExecutorPattern_ProducesCorrectEventsAsync()
{
// Simulate a code-based FunctionExecutor: invoked → completed, no text content
// (code executors don't produce AgentResponseUpdateEvent, just executor lifecycle)
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("uppercase_fn", "hello") },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("uppercase_fn", "HELLO") },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
// Second executor uses the output
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("format_agent", "start") },
new AgentResponseUpdate { MessageId = "msg_fmt", Contents = [new MeaiTextContent("Formatted: HELLO")] },
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("format_agent", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 4 workflow actions + 1 text message = 5 output items
Assert.Equal(5, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task OutputConverter_SubworkflowPattern_ProducesCorrectEventsAsync()
{
// Simulate a parent workflow that invokes a sub-workflow executor
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("parent") },
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
// Sub-workflow executor invoked
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("sub_workflow_host", "start") },
// Inner agent within sub-workflow produces text (unwrapped by WorkflowSession)
new AgentResponseUpdate { MessageId = "msg_sub_1", Contents = [new MeaiTextContent("Sub-workflow agent output")] },
// Sub-workflow executor completed
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("sub_workflow_host", null) },
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// 2 workflow actions + 1 text message = 3 output items
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task OutputConverter_WorkflowWithMultipleContentTypes_HandlesAllCorrectlyAsync()
{
// Simulate a workflow producing reasoning, text, function calls, and usage
var (stream, _) = CreateTestStream();
var updates = new[]
{
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("planner", "start") },
// Reasoning
new AgentResponseUpdate { Contents = [new TextReasoningContent("Let me think about this...")] },
// Function call (tool use)
new AgentResponseUpdate
{
Contents = [new FunctionCallContent("call_search", "web_search",
new Dictionary<string, object?> { ["query"] = "latest news" })]
},
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("planner", null) },
// Next executor uses tool result
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("writer", "start") },
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("Based on my research, ")] },
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("here are the findings.")] },
new AgentResponseUpdate
{
Contents = [new UsageContent(new UsageDetails { InputTokenCount = 500, OutputTokenCount = 200, TotalTokenCount = 700 })]
},
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("writer", null) },
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
{
events.Add(evt);
}
// Workflow actions: 4 (2 invoked + 2 completed)
// Content: 1 reasoning + 1 function call + 1 text message = 3
// Total: 7 output items
Assert.Equal(7, events.OfType<ResponseOutputItemAddedEvent>().Count());
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// ===== Helpers =====
private static (AgentFrameworkResponseHandler handler, CreateResponse request, ResponseContext context)
CreateHandlerWithAgent(AIAgent agent, string userMessage)
{
var services = new ServiceCollection();
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
services.AddSingleton(agent);
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
request.Input = CreateUserInput(userMessage);
var mockContext = CreateMockContext();
return (handler, request, mockContext.Object);
}
private static BinaryData CreateUserInput(string text)
{
return BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_in_1", status = "completed", role = "user",
content = new[] { new { type = "input_text", text } }
}
});
}
private static Mock<ResponseContext> CreateMockContext()
{
var mock = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
mock.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<OutputItem>());
mock.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Item>());
return mock;
}
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
{
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
var request = new CreateResponse { Model = "test-model" };
var stream = new ResponseEventStream(mockContext.Object, request);
return (stream, mockContext);
}
private static async Task<List<ResponseStreamEvent>> CollectEventsAsync(
AgentFrameworkResponseHandler handler,
CreateResponse request,
ResponseContext context)
{
var events = new List<ResponseStreamEvent>();
await foreach (var evt in handler.CreateAsync(request, context, CancellationToken.None))
{
events.Add(evt);
}
return events;
}
private static async IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> source)
{
foreach (var item in source)
{
yield return item;
}
await Task.CompletedTask;
}
// ===== Test Agent Types =====
/// <summary>
/// A test agent that streams a single text update.
/// </summary>
private sealed class StreamingTextAgent(string id, string responseText) : AIAgent
{
public new string Id => id;
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
yield return new AgentResponseUpdate
{
MessageId = $"msg_{id}",
Contents = [new MeaiTextContent(responseText)]
};
await Task.CompletedTask;
}
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
/// <summary>
/// A test agent that always throws an exception during streaming.
/// </summary>
private sealed class ThrowingStreamingAgent(string id, Exception exception) : AIAgent
{
public new string Id => id;
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw exception;
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
}
@@ -7,13 +7,33 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
<PackageReference Include="Azure.AI.AgentServer.Responses" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
</ItemGroup>
<!-- Hosting tests only compile on .NET Core TFMs -->
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<Compile Remove="Hosting\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="FoundryEvalConverterTests.cs" />
@@ -30,6 +50,15 @@
<None Update="TestData\OpenAIDefaultResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxRecordResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxVersionResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxVersionWithDecorationFields.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,115 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel.Primitives;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Verifies the per-call <c>MeaiUserAgentPolicy</c> exposed via
/// <see cref="RequestOptionsExtensions.UserAgentPolicy"/>. The policy is reachable through the
/// public <see cref="FoundryAgent"/> constructors (which add it to the internally-built
/// <see cref="Azure.AI.Projects.AIProjectClient"/>'s pipeline), so its behavior is part of the
/// public API surface.
/// </summary>
public sealed class RequestOptionsExtensionsTests
{
[Fact]
public async Task MeaiUserAgentPolicy_AddsMeaiSegment_ToOutgoingRequestAsync()
{
// Arrange
using var handler = new RecordingHandler();
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var pipeline = ClientPipeline.Create(
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy],
perTryPolicies: default,
beforeTransportPolicies: default);
// Act
var message = pipeline.CreateMessage();
message.Request.Method = "POST";
message.Request.Uri = new System.Uri("https://example.test/anything");
await pipeline.SendAsync(message);
// Assert
Assert.Equal(1, handler.Count);
Assert.NotNull(handler.LastUserAgent);
Assert.Contains("MEAI/", handler.LastUserAgent);
}
[Fact]
public async Task MeaiUserAgentPolicy_DoesNotAddFoundryHostingSegmentAsync()
{
// Arrange
using var handler = new RecordingHandler();
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var pipeline = ClientPipeline.Create(
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy],
perTryPolicies: default,
beforeTransportPolicies: default);
// Act
var message = pipeline.CreateMessage();
message.Request.Method = "POST";
message.Request.Uri = new System.Uri("https://example.test/anything");
await pipeline.SendAsync(message);
// Assert: the policy is MEAI-only; the foundry-hosting supplement is added elsewhere
// (by the polyfill UserAgentResponsesClient → HostedAgentUserAgentPolicy).
Assert.NotNull(handler.LastUserAgent);
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", handler.LastUserAgent);
}
[Fact]
public void UserAgentPolicy_ExposesSingletonInstance()
{
// Two reads of the static property must return the same instance — the policy is stateless and shared.
var first = RequestOptionsExtensions.UserAgentPolicy;
var second = RequestOptionsExtensions.UserAgentPolicy;
Assert.Same(first, second);
}
[Fact]
public void MeaiUserAgentPolicy_ValueIncludesAFFoundryAssemblyVersion_ReflectionGuard()
{
// The policy emits "MEAI/{Microsoft.Agents.AI.Foundry assembly InformationalVersion}".
// If the assembly metadata stops being readable, the policy falls back to "MEAI" without a version,
// which is a measurable telemetry regression.
var attr = typeof(RequestOptionsExtensions).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
Assert.NotNull(attr);
Assert.False(string.IsNullOrEmpty(attr!.InformationalVersion));
}
private sealed class RecordingHandler : HttpClientHandler
{
public int Count { get; private set; }
public string? LastUserAgent { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.Count++;
this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values)
? string.Join(",", values)
: null;
var resp = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
RequestMessage = request,
};
return Task.FromResult(resp);
}
}
}
@@ -14,6 +14,9 @@ internal static class TestDataUtil
private static readonly string s_agentResponseJson = File.ReadAllText("TestData/AgentResponse.json");
private static readonly string s_agentVersionResponseJson = File.ReadAllText("TestData/AgentVersionResponse.json");
private static readonly string s_openAIDefaultResponseJson = File.ReadAllText("TestData/OpenAIDefaultResponse.json");
private static readonly string s_toolboxRecordResponseJson = File.ReadAllText("TestData/ToolboxRecordResponse.json");
private static readonly string s_toolboxVersionResponseJson = File.ReadAllText("TestData/ToolboxVersionResponse.json");
private static readonly string s_toolboxVersionWithDecorationFieldsJson = File.ReadAllText("TestData/ToolboxVersionWithDecorationFields.json");
private const string AgentDefinitionPlaceholder = "\"agent-definition-placeholder\"";
@@ -162,4 +165,19 @@ internal static class TestDataUtil
}
return json;
}
/// <summary>
/// Gets the toolbox record response JSON.
/// </summary>
public static string GetToolboxRecordResponseJson() => s_toolboxRecordResponseJson;
/// <summary>
/// Gets the toolbox version response JSON.
/// </summary>
public static string GetToolboxVersionResponseJson() => s_toolboxVersionResponseJson;
/// <summary>
/// Gets the toolbox version response JSON with decoration fields on tools.
/// </summary>
public static string GetToolboxVersionWithDecorationFieldsJson() => s_toolboxVersionWithDecorationFieldsJson;
}
@@ -1,59 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Hyperlight.IntegrationTests;
/// <summary>
/// Integration tests that exercise a real Hyperlight sandbox. Gated by the
/// <c>HYPERLIGHT_PYTHON_GUEST_PATH</c> environment variable: when not set these
/// tests are skipped.
/// </summary>
public sealed class CodeActEndToEndTests
{
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
private static string? GuestPath => Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH");
private static string SkipReason => "HYPERLIGHT_PYTHON_GUEST_PATH is not set; skipping hyperlight integration test.";
[Fact]
public async Task ExecuteCode_PythonPrint_ReturnsStdoutAsync()
{
// Skip if no guest available.
if (string.IsNullOrWhiteSpace(GuestPath))
{
Assert.Skip(SkipReason);
return;
}
// Arrange
using var provider = new HyperlightCodeActProvider(
HyperlightCodeActProviderOptions.CreateForWasm(GuestPath!));
var context = await provider.InvokingAsync(
new AIContextProvider.InvokingContext(s_mockAgent, session: null, new AIContext()));
var executeCode = Assert.IsAssignableFrom<AIFunction>(context.Tools!.First());
// Act
var rawResult = await executeCode.InvokeAsync(
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?>
{
["code"] = "print(\"hi\")",
}));
// Assert
var json = rawResult?.ToString();
Assert.False(string.IsNullOrWhiteSpace(json));
using var doc = JsonDocument.Parse(json!);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("hi", doc.RootElement.GetProperty("stdout").GetString()!);
Assert.Equal(0, doc.RootElement.GetProperty("exit_code").GetInt32());
}
}
@@ -1,11 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
</ItemGroup>
</Project>
@@ -1,62 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hyperlight.UnitTests;
public sealed class ApprovalComputationTests
{
[Fact]
public void AlwaysRequire_ReturnsTrueWithNoTools()
{
// Act / Assert
Assert.True(HyperlightCodeActProvider.ComputeApprovalRequired(
CodeActApprovalMode.AlwaysRequire,
tools: []));
}
[Fact]
public void AlwaysRequire_ReturnsTrueEvenWithoutApprovalTool()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "ok", name: "t");
// Act / Assert
Assert.True(HyperlightCodeActProvider.ComputeApprovalRequired(
CodeActApprovalMode.AlwaysRequire,
tools: [tool]));
}
[Fact]
public void NeverRequire_NoTools_ReturnsFalse()
{
Assert.False(HyperlightCodeActProvider.ComputeApprovalRequired(
CodeActApprovalMode.NeverRequire,
tools: []));
}
[Fact]
public void NeverRequire_NoApprovalRequiredTool_ReturnsFalse()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "ok", name: "t");
// Act / Assert
Assert.False(HyperlightCodeActProvider.ComputeApprovalRequired(
CodeActApprovalMode.NeverRequire,
tools: [tool]));
}
[Fact]
public void NeverRequire_WithApprovalRequiredTool_ReturnsTrue()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "ok", name: "t");
var wrapped = new ApprovalRequiredAIFunction(tool);
// Act / Assert
Assert.True(HyperlightCodeActProvider.ComputeApprovalRequired(
CodeActApprovalMode.NeverRequire,
tools: [wrapped]));
}
}
@@ -1,173 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hyperlight.UnitTests;
public sealed class HyperlightCodeActProviderTests
{
[Fact]
public void Ctor_NullOptions_UsesDefaults()
{
// Act
using var provider = new HyperlightCodeActProvider();
// Assert
Assert.Empty(provider.GetTools());
Assert.Empty(provider.GetFileMounts());
Assert.Empty(provider.GetAllowedDomains());
Assert.Equal([HyperlightCodeActProvider.FixedStateKey], provider.StateKeys);
}
[Fact]
public void StateKeys_IsFixedSingleKey()
{
// Arrange
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
// Act / Assert
Assert.Equal([HyperlightCodeActProvider.FixedStateKey], provider.StateKeys);
}
[Fact]
public void Tools_Crud_AddReplacesByName()
{
// Arrange
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
var first = AIFunctionFactory.Create(() => "a", name: "t");
var replacement = AIFunctionFactory.Create(() => "b", name: "t");
// Act
provider.AddTools(first);
provider.AddTools(replacement);
// Assert
var tools = provider.GetTools();
Assert.Single(tools);
Assert.Same(replacement, tools[0]);
}
[Fact]
public void Tools_RemoveAndClear_Work()
{
// Arrange
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
provider.AddTools(
AIFunctionFactory.Create(() => "a", name: "a"),
AIFunctionFactory.Create(() => "b", name: "b"));
// Act
provider.RemoveTools("a");
// Assert
Assert.Single(provider.GetTools());
Assert.Equal("b", provider.GetTools()[0].Name);
// Act
provider.ClearTools();
// Assert
Assert.Empty(provider.GetTools());
}
[Fact]
public void FileMounts_Crud_ReplaceByMountPath()
{
// Arrange
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
var m1 = new FileMount("/host/a", "/input/a");
var m2 = new FileMount("/host/a-new", "/input/a");
var m3 = new FileMount("/host/b", "/input/b");
// Act
provider.AddFileMounts(m1, m3);
provider.AddFileMounts(m2);
// Assert
var mounts = provider.GetFileMounts().OrderBy(m => m.MountPath).ToArray();
Assert.Equal(2, mounts.Length);
Assert.Same(m2, mounts[0]);
Assert.Same(m3, mounts[1]);
// Act
provider.RemoveFileMounts("/input/a");
// Assert
Assert.Single(provider.GetFileMounts());
// Act
provider.ClearFileMounts();
// Assert
Assert.Empty(provider.GetFileMounts());
}
[Fact]
public void AllowedDomains_Crud_ReplaceByTarget()
{
// Arrange
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
var d1 = new AllowedDomain("https://a", ["GET"]);
var d2 = new AllowedDomain("https://a", ["POST"]);
var d3 = new AllowedDomain("https://b");
// Act
provider.AddAllowedDomains(d1, d3);
provider.AddAllowedDomains(d2);
// Assert
var domains = provider.GetAllowedDomains().OrderBy(d => d.Target).ToArray();
Assert.Equal(2, domains.Length);
Assert.Same(d2, domains[0]);
Assert.Same(d3, domains[1]);
// Act
provider.RemoveAllowedDomains("https://a");
// Assert
Assert.Single(provider.GetAllowedDomains());
// Act
provider.ClearAllowedDomains();
// Assert
Assert.Empty(provider.GetAllowedDomains());
}
[Fact]
public void Ctor_SeedsFromOptions()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "x", name: "x");
var options = new HyperlightCodeActProviderOptions
{
Tools = new[] { tool },
FileMounts = new[] { new FileMount("/h", "/m") },
AllowedDomains = new[] { new AllowedDomain("https://a") },
};
// Act
using var provider = new HyperlightCodeActProvider(options);
// Assert
Assert.Single(provider.GetTools());
Assert.Single(provider.GetFileMounts());
Assert.Single(provider.GetAllowedDomains());
}
[Fact]
public void Dispose_IsIdempotentAndBlocksFurtherAddTools()
{
// Arrange
var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
var tool = AIFunctionFactory.Create(() => "x", name: "x");
// Act
provider.Dispose();
provider.Dispose();
// Assert
Assert.Throws<System.ObjectDisposedException>(() => provider.AddTools(tool));
}
}
@@ -1,108 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Hyperlight.Internal;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hyperlight.UnitTests;
public sealed class InstructionBuilderTests
{
[Fact]
public void BuildContextInstructions_HiddenTools_MentionsCallTool()
{
// Act
var text = InstructionBuilder.BuildContextInstructions(toolsVisibleToModel: false);
// Assert
Assert.Contains("execute_code", text);
Assert.Contains("call_tool", text);
// Backend-agnostic: don't mention a specific language.
Assert.DoesNotContain("Python", text);
}
[Fact]
public void BuildContextInstructions_VisibleTools_OmitsCallTool()
{
// Act
var text = InstructionBuilder.BuildContextInstructions(toolsVisibleToModel: true);
// Assert
Assert.Contains("execute_code", text);
Assert.DoesNotContain("call_tool", text);
Assert.DoesNotContain("Python", text);
}
[Fact]
public void BuildExecuteCodeDescription_WithNoExtras_ReturnsBaseBlurbOnly()
{
// Act
var text = InstructionBuilder.BuildExecuteCodeDescription(
tools: [],
fileMounts: [],
allowedDomains: [],
hasHostInputDirectory: false);
// Assert
Assert.Contains("Executes code", text);
Assert.DoesNotContain("call_tool", text);
Assert.DoesNotContain("Filesystem access", text);
Assert.DoesNotContain("Outbound network access", text);
}
[Fact]
public void BuildExecuteCodeDescription_WithTools_IncludesToolNames()
{
// Arrange
var tool = AIFunctionFactory.Create(() => "ok", name: "fetch_docs", description: "fetch docs");
// Act
var text = InstructionBuilder.BuildExecuteCodeDescription(
tools: [tool],
fileMounts: [],
allowedDomains: [],
hasHostInputDirectory: false);
// Assert
Assert.Contains("call_tool", text);
Assert.Contains("fetch_docs", text);
Assert.Contains("fetch docs", text);
}
[Fact]
public void BuildExecuteCodeDescription_WithFilesystem_IncludesSandboxPathsOnly()
{
// Act
var text = InstructionBuilder.BuildExecuteCodeDescription(
tools: [],
fileMounts: [new FileMount("/host/data.csv", "/input/data.csv")],
allowedDomains: [],
hasHostInputDirectory: true);
// Assert
Assert.Contains("Filesystem access", text);
Assert.Contains("/input", text);
Assert.Contains("/input/data.csv", text);
// Host paths must not leak to the model.
Assert.DoesNotContain("/host/workspace", text);
Assert.DoesNotContain("/host/data.csv", text);
}
[Fact]
public void BuildExecuteCodeDescription_WithAllowedDomains_IncludesNetworkSection()
{
// Act
var text = InstructionBuilder.BuildExecuteCodeDescription(
tools: [],
fileMounts: [],
allowedDomains: [new AllowedDomain("https://api.github.com", new List<string> { "GET", "POST" })],
hasHostInputDirectory: false);
// Assert
Assert.Contains("Outbound network access", text);
Assert.Contains("api.github.com", text);
Assert.Contains("GET", text);
Assert.Contains("POST", text);
}
}
@@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
</PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
</Project>
@@ -1,85 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Hyperlight.UnitTests;
public sealed class ProvideAIContextTests
{
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
private static AIContextProvider.InvokingContext NewInvokingContext() => new(s_mockAgent, session: null, new AIContext());
[Fact]
public async Task ProvideAIContextAsync_ReturnsExecuteCodeToolAndInstructionsAsync()
{
// Arrange
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
// Act
var context = await provider.InvokingAsync(NewInvokingContext());
// Assert
Assert.NotNull(context);
Assert.NotNull(context!.Tools);
var tools = context.Tools!.ToList();
Assert.Single(tools);
var function = Assert.IsAssignableFrom<AIFunction>(tools[0]);
Assert.Equal("execute_code", function.Name);
Assert.False(string.IsNullOrWhiteSpace(context.Instructions));
}
[Fact]
public async Task ProvideAIContextAsync_AlwaysRequire_WrapsInApprovalRequiredAsync()
{
// Arrange
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions
{
ApprovalMode = CodeActApprovalMode.AlwaysRequire,
});
// Act
var context = await provider.InvokingAsync(NewInvokingContext());
// Assert
_ = Assert.IsType<ApprovalRequiredAIFunction>(context!.Tools!.First());
}
[Fact]
public async Task ProvideAIContextAsync_NeverRequireWithApprovalTool_WrapsInApprovalRequiredAsync()
{
// Arrange
var inner = AIFunctionFactory.Create(() => "ok", name: "t");
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions
{
ApprovalMode = CodeActApprovalMode.NeverRequire,
Tools = [new ApprovalRequiredAIFunction(inner)],
});
// Act
var context = await provider.InvokingAsync(NewInvokingContext());
// Assert
_ = Assert.IsType<ApprovalRequiredAIFunction>(context!.Tools!.First());
}
[Fact]
public async Task ProvideAIContextAsync_CapturesSnapshot_MutationsAfterDoNotAffectDescriptionAsync()
{
// Arrange
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
provider.AddTools(AIFunctionFactory.Create(() => "one", name: "first_tool"));
// Act
var context = await provider.InvokingAsync(NewInvokingContext());
provider.AddTools(AIFunctionFactory.Create(() => "two", name: "second_tool"));
// Assert — the returned execute_code description must reflect the first snapshot only.
var function = Assert.IsAssignableFrom<AIFunction>(context!.Tools!.First());
Assert.Contains("first_tool", function.Description);
Assert.DoesNotContain("second_tool", function.Description);
}
}
@@ -1,84 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Hyperlight.Internal;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hyperlight.UnitTests;
public sealed class SandboxExecutorTests
{
[Fact]
public void Fingerprint_DifferentToolSets_DifferentFingerprints()
{
// Arrange
var t1 = AIFunctionFactory.Create(() => "a", name: "a");
var t2 = AIFunctionFactory.Create(() => "b", name: "b");
// Act
var fpA = SandboxExecutor.RunSnapshot.ComputeFingerprint([t1], [], [], hostInputDirectory: null);
var fpAB = SandboxExecutor.RunSnapshot.ComputeFingerprint([t1, t2], [], [], hostInputDirectory: null);
// Assert
Assert.NotEqual(fpA, fpAB);
}
[Fact]
public void Fingerprint_OrderInsensitive_OnTools()
{
// Arrange
var t1 = AIFunctionFactory.Create(() => "a", name: "a");
var t2 = AIFunctionFactory.Create(() => "b", name: "b");
// Act
var fp1 = SandboxExecutor.RunSnapshot.ComputeFingerprint([t1, t2], [], [], hostInputDirectory: null);
var fp2 = SandboxExecutor.RunSnapshot.ComputeFingerprint([t2, t1], [], [], hostInputDirectory: null);
// Assert
Assert.Equal(fp1, fp2);
}
[Fact]
public void Fingerprint_DifferentMounts_DifferentFingerprints()
{
// Act
var fpEmpty = SandboxExecutor.RunSnapshot.ComputeFingerprint([], [], [], hostInputDirectory: null);
var fpMount = SandboxExecutor.RunSnapshot.ComputeFingerprint(
[],
[new FileMount("/host/a", "/input/a")],
[],
hostInputDirectory: null);
// Assert
Assert.NotEqual(fpEmpty, fpMount);
}
[Fact]
public void Fingerprint_DifferentAllowedDomains_DifferentFingerprints()
{
// Act
var fp1 = SandboxExecutor.RunSnapshot.ComputeFingerprint(
[],
[],
[new AllowedDomain("https://a")],
hostInputDirectory: null);
var fp2 = SandboxExecutor.RunSnapshot.ComputeFingerprint(
[],
[],
[new AllowedDomain("https://b")],
hostInputDirectory: null);
// Assert
Assert.NotEqual(fp1, fp2);
}
[Fact]
public void Fingerprint_DifferentHostInputDirectory_DifferentFingerprints()
{
// Act
var fpNone = SandboxExecutor.RunSnapshot.ComputeFingerprint([], [], [], hostInputDirectory: null);
var fpDir = SandboxExecutor.RunSnapshot.ComputeFingerprint([], [], [], hostInputDirectory: "/tmp/work");
// Assert
Assert.NotEqual(fpNone, fpDir);
}
}
@@ -1,71 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hyperlight.Internal;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hyperlight.UnitTests;
public sealed class ToolBridgeTests
{
[Fact]
public async Task InvokeAsync_PassesArgumentsAndReturnsSerializedResultAsync()
{
// Arrange
static string Echo(string value) => $"echo:{value}";
var tool = AIFunctionFactory.Create(Echo, name: "echo");
// Act
var result = await ToolBridge.InvokeAsync(tool, """{"value":"hello"}""");
// Assert — AIFunction.InvokeAsync returns the string; ToolBridge JSON-encodes it.
Assert.Equal("\"echo:hello\"", result);
}
[Fact]
public async Task InvokeAsync_ReturnsErrorJsonOnExceptionAsync()
{
// Arrange
static int Boom() => throw new InvalidOperationException("nope");
var tool = AIFunctionFactory.Create(Boom, name: "boom");
// Act
var result = await ToolBridge.InvokeAsync(tool, "{}");
// Assert
using var doc = JsonDocument.Parse(result);
Assert.True(doc.RootElement.TryGetProperty("error", out var err));
Assert.Contains("nope", err.GetString()!);
}
[Fact]
public async Task InvokeAsync_EmptyArguments_InvokesToolWithNoArgsAsync()
{
// Arrange
static string Hi() => "hi";
var tool = AIFunctionFactory.Create(Hi, name: "hi");
// Act
var result = await ToolBridge.InvokeAsync(tool, string.Empty);
// Assert
Assert.Equal("\"hi\"", result);
}
[Fact]
public async Task InvokeAsync_NonObjectJson_ReturnsErrorAsync()
{
// Arrange
static string Hi() => "hi";
var tool = AIFunctionFactory.Create(Hi, name: "hi");
// Act
var result = await ToolBridge.InvokeAsync(tool, "[1, 2, 3]");
// Assert
using var doc = JsonDocument.Parse(result);
Assert.True(doc.RootElement.TryGetProperty("error", out _));
}
}
@@ -11,7 +11,6 @@
"min_action_count": 8,
"min_message_count": 1,
"min_response_count": 1,
"max_response_count": 4,
"actions": {
"start": [
"conversation_create1",
@@ -11,7 +11,7 @@
"min_action_count": 6,
"max_action_count": -1,
"min_response_count": 2,
"max_response_count": 9,
"max_response_count": 8,
"min_message_count": 4,
"max_message_count": -1,
"actions": {
@@ -9,10 +9,7 @@
"validation": {
"conversation_count": 1,
"min_action_count": 3,
"min_message_count": 0,
"max_message_count": 0,
"min_response_count": 1,
"max_response_count": 1,
"min_response_count": 0,
"actions": {
"start": [
"set_user_input",
@@ -1,10 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
@@ -29,14 +27,6 @@ public sealed class SendActivityExecutorTest(ITestOutputHelper output) : Workflo
// Assert
VerifyModel(model, action);
Assert.Contains(events, e => e is MessageActivityEvent);
// The executor must also emit an AgentResponseEvent carrying the activity text
// so workflow consumers (hosting runtime, UIs) can surface it as an agent turn.
AgentResponseEvent agentEvent = Assert.Single(events.OfType<AgentResponseEvent>());
Assert.Equal(action.Id, agentEvent.ExecutorId);
ChatMessage message = Assert.Single(agentEvent.Response.Messages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Test activity message", message.Text);
}
private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null)
+1 -1
View File
@@ -34,7 +34,7 @@ Status is grouped into these buckets:
| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` |
| `agent-framework-gemini` | `python/packages/gemini` | `alpha` |
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` |
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `alpha` |
| `agent-framework-lab` | `python/packages/lab` | `beta` |
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
| `agent-framework-ollama` | `python/packages/ollama` | `beta` |
@@ -26,6 +26,7 @@ pytestmark = [
pytest.mark.integration,
pytest.mark.sample("03_reliable_streaming"),
pytest.mark.usefixtures("function_app_for_test"),
pytest.mark.skip(reason="Temp disabled to fix test instability - needs investigation into root cause"),
]
@@ -55,11 +56,12 @@ class TestSampleReliableStreaming:
# Wait a moment for the agent to start writing to Redis
time.sleep(2)
# Stream response from Redis with longer timeout to account for LLM latency
# Stream response from Redis with shorter timeout
# Note: We use text/plain to avoid SSE parsing complexity
stream_response = requests.get(
f"{self.stream_url}/{thread_id}",
headers={"Accept": "text/plain"},
timeout=60,
timeout=30, # Shorter timeout for test
)
assert stream_response.status_code == 200
@@ -81,7 +83,7 @@ class TestSampleReliableStreaming:
stream_response = requests.get(
f"{self.stream_url}/{thread_id}",
headers={"Accept": "text/event-stream"},
timeout=60,
timeout=30, # Shorter timeout
)
assert stream_response.status_code == 200
content_type = stream_response.headers.get("content-type", "")
@@ -42,7 +42,7 @@ class TestWorkflowParallel:
self.base_url = base_url
self.helper = sample_helper
@pytest.mark.skip(reason="xdist distributes module tests across workers, each spawning a func process")
@pytest.mark.skip(reason="Causes timeouts.")
def test_parallel_workflow_document_analysis(self) -> None:
"""Test parallel workflow with a standard document."""
payload = {
@@ -71,7 +71,7 @@ class TestWorkflowParallel:
assert status["runtimeStatus"] == "Completed"
assert "output" in status
@pytest.mark.skip(reason="xdist distributes module tests across workers, each spawning a func process")
@pytest.mark.skip(reason="Causes timeouts.")
def test_parallel_workflow_short_document(self) -> None:
"""Test parallel workflow with a short document."""
payload = {
@@ -91,7 +91,7 @@ class TestWorkflowParallel:
assert status["runtimeStatus"] == "Completed"
assert "output" in status
@pytest.mark.skip(reason="xdist distributes module tests across workers, each spawning a func process")
@pytest.mark.skip(reason="Causes timeouts.")
def test_parallel_workflow_technical_document(self) -> None:
"""Test parallel workflow with a technical document."""
payload = {
@@ -115,7 +115,7 @@ class TestWorkflowParallel:
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"], max_wait=300)
assert status["runtimeStatus"] == "Completed"
@pytest.mark.skip(reason="xdist distributes module tests across workers, each spawning a func process")
@pytest.mark.skip(reason="Causes timeouts.")
def test_workflow_status_endpoint(self) -> None:
"""Test that the workflow status endpoint works correctly."""
payload = {
@@ -3,10 +3,9 @@
from __future__ import annotations
import contextlib
import inspect
import logging
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload
@@ -74,54 +73,6 @@ logger = logging.getLogger("agent_framework.claude")
TOOLS_MCP_SERVER_NAME = "_agent_framework_tools"
FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"]
"""Callback invoked by the agent before executing a FunctionTool that requires approval.
The callback receives a ``FunctionCallContent`` describing the pending call
(``name``, ``arguments``, and a synthetic ``call_id``) and must return ``True``
to allow execution or ``False`` to deny it. Both synchronous and ``await``-able
return values are supported.
The Claude Agent SDK manages its own tool-calling loop, so the framework cannot
round-trip a ``FunctionApprovalRequestContent`` / ``FunctionApprovalResponseContent``
pair the way the standard chat-client pipeline does. This callback is the
agent-level enforcement point for tools declared with
``approval_mode="always_require"``: when no callback is configured the agent
denies these calls by default.
"""
async def _resolve_function_approval(
callback: FunctionApprovalCallback | None,
func_tool: FunctionTool,
arguments: Mapping[str, Any] | None,
) -> bool:
"""Run the agent-level approval callback for a pending tool call.
Returns ``True`` only when ``callback`` is configured and explicitly returns
a truthy value. A missing callback or any callback failure is treated as a
denial so the secure-by-default policy holds even if the user code raises.
"""
if callback is None:
return False
request = Content.from_function_call(
call_id=f"af-claude-approval::{func_tool.name}",
name=func_tool.name,
arguments=None if arguments is None else dict(arguments),
)
try:
outcome = callback(request)
if inspect.isawaitable(outcome):
outcome = await outcome
except Exception:
logger.exception(
"on_function_approval callback raised for tool '%s'; denying execution.",
func_tool.name,
)
return False
return bool(outcome)
class ClaudeAgentSettings(TypedDict, total=False):
"""Claude Agent settings.
@@ -224,13 +175,6 @@ class ClaudeAgentOptions(TypedDict, total=False):
effort: Literal["low", "medium", "high", "max"]
"""Effort level for thinking depth."""
on_function_approval: FunctionApprovalCallback
"""Approval callback for ``FunctionTool`` instances declared with
``approval_mode="always_require"``. The callback is awaited (sync or async)
inside the SDK tool-handler before the tool is executed; a falsy return
value denies the call. If omitted, calls to such tools are denied with an
explanatory message returned to the model."""
OptionsT = TypeVar(
"OptionsT",
@@ -331,7 +275,6 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
max_turns = opts.pop("max_turns", None)
max_budget_usd = opts.pop("max_budget_usd", None)
self._mcp_servers: dict[str, Any] = opts.pop("mcp_servers", None) or {}
self._function_approval_handler: FunctionApprovalCallback | None = opts.pop("on_function_approval", None)
# Load settings from environment and options
self._settings = load_settings(
@@ -544,29 +487,10 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
Returns:
An SdkMcpTool instance.
"""
approval_handler = self._function_approval_handler
requires_approval = func_tool.approval_mode == "always_require"
async def handler(args: dict[str, Any]) -> dict[str, Any]:
"""Handler that invokes the FunctionTool."""
try:
if requires_approval and not await _resolve_function_approval(approval_handler, func_tool, args):
deny_text = (
f"Tool '{func_tool.name}' requires human approval "
"(approval_mode='always_require') and the request was denied."
if approval_handler is not None
else (
f"Tool '{func_tool.name}' requires human approval "
"(approval_mode='always_require') but no on_function_approval "
"callback is configured on the agent; the request was denied."
)
)
logger.warning(
"Denying execution of tool '%s' (approval_mode='always_require', %s)",
func_tool.name,
"callback denied" if approval_handler is not None else "no callback configured",
)
return {"content": [{"type": "text", "text": deny_text}]}
if func_tool.input_model:
args_instance = func_tool.input_model(**args)
result = await func_tool.invoke(arguments=args_instance)
@@ -614,13 +538,6 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
if not options or not self._client:
return
if "on_function_approval" in options:
raise ValueError(
"on_function_approval is a security-sensitive option and must be set "
"via default_options at agent construction time. It cannot be overridden "
"per run."
)
if "model" in options:
await self._client.set_model(options["model"])
@@ -602,141 +602,6 @@ class TestClaudeAgentToolConversion:
assert "Something went wrong" in result["content"][0]["text"]
# region Test ClaudeAgent Function Approval Enforcement
class TestClaudeAgentFunctionApproval:
"""Tests that ``approval_mode='always_require'`` is enforced at the agent boundary."""
async def test_handler_denies_when_no_callback_configured(self) -> None:
"""Approval-required tool must be denied without executing when no callback is set."""
invocations: list[Any] = []
@tool(approval_mode="always_require")
def dangerous(path: str) -> str:
"""A tool that requires human approval."""
invocations.append(path)
return f"deleted {path}"
agent = ClaudeAgent()
sdk_tool = agent._function_tool_to_sdk_mcp_tool(dangerous) # type: ignore[reportPrivateUsage]
result = await sdk_tool.handler({"path": "/critical"})
assert invocations == []
text = result["content"][0]["text"]
assert "requires human approval" in text
assert "no on_function_approval callback is configured" in text
async def test_handler_denies_when_callback_returns_false(self) -> None:
"""Falsy callback return value must deny the call and skip execution."""
invocations: list[Any] = []
seen: list[Content] = []
def deny(call: Content) -> bool:
seen.append(call)
return False
@tool(approval_mode="always_require")
def dangerous(path: str) -> str:
"""A tool that requires human approval."""
invocations.append(path)
return f"deleted {path}"
agent = ClaudeAgent(default_options={"on_function_approval": deny})
sdk_tool = agent._function_tool_to_sdk_mcp_tool(dangerous) # type: ignore[reportPrivateUsage]
result = await sdk_tool.handler({"path": "/critical"})
assert invocations == []
assert len(seen) == 1
assert seen[0].type == "function_call"
assert seen[0].name == "dangerous" # type: ignore[attr-defined]
assert seen[0].arguments == {"path": "/critical"} # type: ignore[attr-defined]
assert "denied" in result["content"][0]["text"].lower()
async def test_handler_executes_when_callback_returns_true(self) -> None:
"""Truthy callback return value must allow the tool to execute normally."""
def approve(call: Content) -> bool:
return True
@tool(approval_mode="always_require")
def guarded(x: int) -> str:
"""A tool that requires human approval."""
return f"result={x}"
agent = ClaudeAgent(default_options={"on_function_approval": approve})
sdk_tool = agent._function_tool_to_sdk_mcp_tool(guarded) # type: ignore[reportPrivateUsage]
result = await sdk_tool.handler({"x": 42})
assert result["content"][0]["text"] == "result=42"
async def test_handler_supports_async_callback(self) -> None:
"""Async callback must be awaited and respected."""
async def approve(call: Content) -> bool:
return True
@tool(approval_mode="always_require")
def guarded(x: int) -> str:
"""A tool that requires human approval."""
return f"async={x}"
agent = ClaudeAgent(default_options={"on_function_approval": approve})
sdk_tool = agent._function_tool_to_sdk_mcp_tool(guarded) # type: ignore[reportPrivateUsage]
result = await sdk_tool.handler({"x": 7})
assert result["content"][0]["text"] == "async=7"
async def test_callback_failure_denies_safely(self) -> None:
"""A callback that raises must result in denial, not in tool execution."""
invocations: list[Any] = []
def boom(call: Content) -> bool:
raise RuntimeError("nope")
@tool(approval_mode="always_require")
def dangerous(x: int) -> str:
"""A tool that requires human approval."""
invocations.append(x)
return f"x={x}"
agent = ClaudeAgent(default_options={"on_function_approval": boom})
sdk_tool = agent._function_tool_to_sdk_mcp_tool(dangerous) # type: ignore[reportPrivateUsage]
result = await sdk_tool.handler({"x": 1})
assert invocations == []
assert "denied" in result["content"][0]["text"].lower()
async def test_handler_does_not_invoke_callback_for_never_require(self) -> None:
"""Tools without approval_mode='always_require' must not trigger the callback."""
callback_calls: list[Any] = []
def approve(call: Content) -> bool:
callback_calls.append(call)
return True
@tool
def safe(x: int) -> str:
"""A tool that does not require approval."""
return f"safe={x}"
agent = ClaudeAgent(default_options={"on_function_approval": approve})
sdk_tool = agent._function_tool_to_sdk_mcp_tool(safe) # type: ignore[reportPrivateUsage]
result = await sdk_tool.handler({"x": 5})
assert callback_calls == []
assert result["content"][0]["text"] == "safe=5"
# endregion
# region Test ClaudeAgent Permissions
@@ -921,20 +786,6 @@ class TestApplyRuntimeOptions:
mock_client.set_model.assert_not_called()
mock_client.set_permission_mode.assert_not_called()
async def test_apply_runtime_on_function_approval_rejected(self) -> None:
"""on_function_approval cannot be overridden per run."""
mock_client = MagicMock()
mock_client.set_model = AsyncMock()
mock_client.set_permission_mode = AsyncMock()
agent = ClaudeAgent()
agent._client = mock_client # type: ignore[reportPrivateUsage]
with pytest.raises(ValueError, match="on_function_approval"):
await agent._apply_runtime_options({"on_function_approval": lambda _c: True}) # type: ignore[reportPrivateUsage]
mock_client.set_model.assert_not_called()
mock_client.set_permission_mode.assert_not_called()
# region Test ClaudeAgent Structured Output
@@ -79,29 +79,6 @@ from ._evaluation import (
tool_calls_present,
)
from ._feature_stage import ExperimentalFeature, ReleaseCandidateFeature
from ._harness._memory import (
DEFAULT_MEMORY_SOURCE_ID,
MemoryContextProvider,
MemoryFileStore,
MemoryIndexEntry,
MemoryStore,
MemoryTopicRecord,
)
from ._harness._mode import (
DEFAULT_MODE_SOURCE_ID,
AgentModeProvider,
get_agent_mode,
set_agent_mode,
)
from ._harness._todo import (
DEFAULT_TODO_SOURCE_ID,
TodoFileStore,
TodoInput,
TodoItem,
TodoProvider,
TodoSessionStore,
TodoStore,
)
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool
from ._middleware import (
AgentContext,
@@ -284,9 +261,6 @@ __all__ = [
"APP_INFO",
"COMPACTION_STATE_KEY",
"DEFAULT_MAX_ITERATIONS",
"DEFAULT_MEMORY_SOURCE_ID",
"DEFAULT_MODE_SOURCE_ID",
"DEFAULT_TODO_SOURCE_ID",
"EXCLUDED_KEY",
"EXCLUDE_REASON_KEY",
"GROUP_ANNOTATION_KEY",
@@ -311,7 +285,6 @@ __all__ = [
"AgentMiddleware",
"AgentMiddlewareLayer",
"AgentMiddlewareTypes",
"AgentModeProvider",
"AgentResponse",
"AgentResponseUpdate",
"AgentRunInputs",
@@ -382,11 +355,6 @@ __all__ = [
"MCPStdioTool",
"MCPStreamableHTTPTool",
"MCPWebsocketTool",
"MemoryContextProvider",
"MemoryFileStore",
"MemoryIndexEntry",
"MemoryStore",
"MemoryTopicRecord",
"Message",
"MiddlewareException",
"MiddlewareTermination",
@@ -428,12 +396,6 @@ __all__ = [
"SwitchCaseEdgeGroupCase",
"SwitchCaseEdgeGroupDefault",
"TextSpanRegion",
"TodoFileStore",
"TodoInput",
"TodoItem",
"TodoProvider",
"TodoSessionStore",
"TodoStore",
"TokenBudgetComposedStrategy",
"TokenizerProtocol",
"ToolMode",
@@ -477,7 +439,6 @@ __all__ = [
"evaluator",
"executor",
"function_middleware",
"get_agent_mode",
"get_run_context",
"handler",
"included_messages",
@@ -494,7 +455,6 @@ __all__ = [
"register_state_type",
"resolve_agent_id",
"response_handler",
"set_agent_mode",
"step",
"tool",
"tool_call_args_match",
@@ -49,7 +49,6 @@ class ExperimentalFeature(str, Enum):
EVALS = "EVALS"
FILE_HISTORY = "FILE_HISTORY"
FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS"
HARNESS = "HARNESS"
SKILLS = "SKILLS"
TOOLBOXES = "TOOLBOXES"
File diff suppressed because it is too large Load Diff
@@ -1,262 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from typing import Any, cast
from .._feature_stage import ExperimentalFeature, experimental
from .._sessions import AgentSession, ContextProvider, SessionContext
from .._tools import tool
DEFAULT_MODE_SOURCE_ID = "agent_mode"
DEFAULT_MODE_INSTRUCTIONS = (
"## Agent Mode\n\n"
"You can operate in different modes. Depending on the mode you are in, "
"you will be required to follow different processes.\n\n"
"Use the get_mode tool to check your current operating mode.\n"
"Use the set_mode tool to switch between modes as your work progresses. "
"Only use set_mode if the user explicitly instructs/allows you to change modes.\n\n"
"{available_modes}\n"
"\n"
"You are currently operating in the {current_mode} mode.\n"
)
DEFAULT_MODE_DESCRIPTIONS: dict[str, str] = {
"plan": (
"Use this mode when analyzing requirements, breaking down tasks, and creating plans. "
"This is the interactive mode — ask clarifying questions, discuss options, and get user approval before "
"proceeding."
),
"execute": (
"Use this mode when carrying out approved plans. Work autonomously using your best judgement — do not ask "
"the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, "
"useful result when the user returns. If you encounter ambiguity, choose the most reasonable option and note "
"your choice."
),
}
def _get_mode_state(session: AgentSession, *, source_id: str) -> dict[str, Any]:
"""Return the mutable session state used by the mode provider."""
provider_state = session.state.get(source_id)
if isinstance(provider_state, dict):
return cast(dict[str, Any], provider_state)
if provider_state is not None:
raise TypeError(
f"Session state for source_id {source_id!r} must be a dict, got {type(provider_state).__name__}."
)
state: dict[str, Any] = {}
session.state[source_id] = state
return state
def _normalize_available_modes(available_modes: Sequence[str]) -> dict[str, str]:
"""Return normalized mode names mapped to display names."""
normalized_modes: dict[str, str] = {}
for mode in available_modes:
display_mode = mode.strip()
normalized_mode = display_mode.lower()
if normalized_mode in normalized_modes:
raise ValueError(f"Duplicate mode configured: {mode}.")
normalized_modes[normalized_mode] = display_mode
return normalized_modes
def _normalize_mode(mode: str, *, available_modes: Mapping[str, str]) -> str:
"""Validate and normalize a mode string."""
normalized = mode.strip().lower()
if normalized not in available_modes:
supported_modes = ", ".join(repr(item) for item in available_modes.values())
raise ValueError(f"Invalid mode: {mode}. Supported modes are {supported_modes}.")
return normalized
def _resolve_default_mode(default_mode: str | None, *, available_modes: Mapping[str, str]) -> str:
"""Resolve the default mode, falling back to the first configured mode when omitted."""
if default_mode is None:
return next(iter(available_modes))
return _normalize_mode(default_mode, available_modes=available_modes)
@experimental(feature_id=ExperimentalFeature.HARNESS)
def get_agent_mode(
session: AgentSession,
*,
source_id: str = DEFAULT_MODE_SOURCE_ID,
default_mode: str | None = None,
available_modes: Sequence[str] | None = None,
) -> str:
"""Get the current operating mode from session state.
Args:
session: The agent session to read the mode from.
Keyword Args:
source_id: Unique source ID for the provider state.
default_mode: Initial mode used when no mode is stored yet. When omitted, the first entry of
``available_modes`` is used.
available_modes: Supported modes to validate against. Defaults to the built-in modes.
Returns:
The current mode string.
"""
normalized_modes = _normalize_available_modes(tuple(available_modes or DEFAULT_MODE_DESCRIPTIONS))
normalized_default_mode = _resolve_default_mode(default_mode, available_modes=normalized_modes)
provider_state = _get_mode_state(session, source_id=source_id)
current_mode = provider_state.get("current_mode")
if isinstance(current_mode, str):
try:
return _normalize_mode(current_mode, available_modes=normalized_modes)
except ValueError:
# Stored mode is no longer in the configured set (e.g. available_modes was reconfigured).
# Fall through and reset to the default mode.
pass
provider_state["current_mode"] = normalized_default_mode
return normalized_default_mode
@experimental(feature_id=ExperimentalFeature.HARNESS)
def set_agent_mode(
session: AgentSession,
mode: str,
*,
source_id: str = DEFAULT_MODE_SOURCE_ID,
available_modes: Sequence[str] | None = None,
) -> str:
"""Set the current operating mode in session state.
Args:
session: The agent session to update the mode in.
mode: The new mode to set.
Keyword Args:
source_id: Unique source ID for the provider state.
available_modes: Supported modes to validate against. Defaults to the built-in modes.
Returns:
The normalized mode string that was stored.
Raises:
ValueError: The requested mode is not configured.
"""
normalized_modes = _normalize_available_modes(tuple(available_modes or DEFAULT_MODE_DESCRIPTIONS))
normalized_mode = _normalize_mode(mode, available_modes=normalized_modes)
provider_state = _get_mode_state(session, source_id=source_id)
provider_state["current_mode"] = normalized_mode
return normalized_mode
@experimental(feature_id=ExperimentalFeature.HARNESS)
class AgentModeProvider(ContextProvider):
"""Track the agent's operating mode in session state and provide mode tools.
The ``AgentModeProvider`` enables agents to operate in distinct modes during long-running complex tasks.
The current mode is persisted in the ``AgentSession`` state and is included in the instructions provided to the
agent on each invocation.
The set of available modes is configurable with ``mode_descriptions``. By default, two modes are provided:
``"plan"`` (interactive planning) and ``"execute"`` (autonomous execution).
This provider exposes the following tools to the agent:
- ``set_mode``: Switch the agent's operating mode.
- ``get_mode``: Retrieve the agent's current operating mode.
Public helper functions ``get_agent_mode`` and ``set_agent_mode`` allow external code to programmatically read
and change the mode.
"""
def __init__(
self,
source_id: str = DEFAULT_MODE_SOURCE_ID,
*,
default_mode: str | None = None,
mode_descriptions: Mapping[str, str] | None = None,
instructions: str | None = None,
) -> None:
"""Initialize a new agent mode provider.
Args:
source_id: Unique source ID for the provider.
Keyword Args:
default_mode: Initial mode used when no mode is stored yet. When omitted, the first entry of
``mode_descriptions`` is used.
mode_descriptions: Mapping of supported modes to descriptions of when and how to use each mode.
instructions: Custom instructions for using the mode tools. The instructions can contain an
``{available_modes}`` placeholder for the configured list of modes and a ``{current_mode}`` placeholder
for the currently active mode. When omitted, the provider uses a default set of instructions.
Raises:
ValueError: No modes are configured, or the default mode is not configured.
"""
super().__init__(source_id)
mode_descriptions = dict(DEFAULT_MODE_DESCRIPTIONS if mode_descriptions is None else mode_descriptions)
self._mode_display_names = _normalize_available_modes(tuple(mode_descriptions))
if not self._mode_display_names:
raise ValueError("mode_descriptions must contain at least one mode.")
self.mode_descriptions = {mode.strip().lower(): description for mode, description in mode_descriptions.items()}
self.available_modes = tuple(self._mode_display_names)
self.default_mode = _resolve_default_mode(default_mode, available_modes=self._mode_display_names)
self.instructions = instructions
def _build_instructions(self, current_mode: str) -> str:
"""Build the mode guidance injected for the current session."""
mode_lines = "".join(
f'- "{self._mode_display_names[mode]}": {description}\n'
for mode, description in self.mode_descriptions.items()
)
instructions = self.instructions or DEFAULT_MODE_INSTRUCTIONS
return instructions.replace("{available_modes}", mode_lines).replace("{current_mode}", current_mode)
async def before_run(
self,
*,
agent: Any,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Inject mode tools and instructions before the model runs.
Args:
agent: The agent being invoked.
session: The agent session whose state stores the current mode.
context: The session context to receive instructions and tools.
state: Per-provider invocation state.
"""
del agent, state
current_mode = get_agent_mode(
session,
source_id=self.source_id,
default_mode=self.default_mode,
available_modes=self.available_modes,
)
@tool(name="set_mode", approval_mode="never_require")
def set_mode(mode: str) -> str:
"""Switch the agent's operating mode."""
normalized_mode = set_agent_mode(
session,
mode,
source_id=self.source_id,
available_modes=self.available_modes,
)
return json.dumps({"mode": normalized_mode, "message": f"Mode changed to '{normalized_mode}'."})
@tool(name="get_mode", approval_mode="never_require")
def get_mode() -> str:
"""Get the agent's current operating mode."""
current_mode_value = get_agent_mode(
session,
source_id=self.source_id,
default_mode=self.default_mode,
available_modes=self.available_modes,
)
return json.dumps({"mode": current_mode_value})
context.extend_instructions(
self.source_id,
[self._build_instructions(current_mode)],
)
context.extend_tools(self.source_id, [set_mode, get_mode])
@@ -1,549 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import json
import os
import weakref
from abc import ABC, abstractmethod
from base64 import urlsafe_b64encode
from collections.abc import Mapping, MutableMapping
from pathlib import Path
from typing import Any, ClassVar, cast
from .._feature_stage import ExperimentalFeature, experimental
from .._serialization import SerializationMixin
from .._sessions import AgentSession, ContextProvider, SessionContext
from .._tools import tool
from .._types import Message
DEFAULT_TODO_SOURCE_ID = "todo"
DEFAULT_TODO_INSTRUCTIONS = (
"## Todo Items\n\n"
"You have access to a todo list for tracking work items.\n"
"While planning, make sure that you break down complex tasks into manageable todo items "
"and add them to the list.\n"
"Ask questions from the user where clarification is needed to create effective todos.\n"
"If the user provides feedback on your plan, adjust your todos accordingly by adding new items "
"or removing irrelevant ones.\n"
"During execution, use the todo list to keep track of what needs to be done, "
"mark items as complete when finished, and remove any items that are no longer needed.\n"
"When a user changes the topic or changes their mind, ensure that you update the todo list accordingly "
"by removing irrelevant items or adding new ones as needed.\n\n"
"Use these tools to manage your tasks:\n"
"- Use add_todos to break down complex work into trackable items (supports adding one or many at once).\n"
"- Use complete_todos to mark items as done when finished (supports one or many at once).\n"
"- Use get_remaining_todos to check what work is still pending.\n"
"- Use get_all_todos to review the full list including completed items.\n"
"- Use remove_todos to remove items that are no longer needed (supports one or many at once)."
)
@experimental(feature_id=ExperimentalFeature.HARNESS)
class TodoItem(SerializationMixin):
"""Represent one todo item tracked for the current session."""
id: int
title: str
description: str | None
is_complete: bool
__slots__ = ("description", "id", "is_complete", "title")
def __init__(self, id: int, title: str, description: str | None = None, is_complete: bool = False) -> None:
"""Initialize one todo item."""
self.id = id
self.title = title
self.description = description
self.is_complete = is_complete
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
"""Serialize the todo item for persistence."""
del exclude
payload = {
"id": self.id,
"title": self.title,
"description": self.description,
"is_complete": self.is_complete,
}
return {key: value for key, value in payload.items() if value is not None or not exclude_none}
@classmethod
def from_dict(
cls, raw_item: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None
) -> TodoItem:
"""Parse one todo item loaded from storage."""
del dependencies
item_id = raw_item.get("id")
title = raw_item.get("title")
description = raw_item.get("description")
is_complete = raw_item.get("is_complete", False)
if not isinstance(item_id, int):
raise ValueError("Todo item id must be an integer.")
if not isinstance(title, str) or not title.strip():
raise ValueError("Todo item title must be a non-empty string.")
if description is not None and not isinstance(description, str):
raise ValueError("Todo item description must be a string or null.")
if not isinstance(is_complete, bool):
raise ValueError("Todo item is_complete must be a boolean.")
return cls(id=item_id, title=title, description=description, is_complete=is_complete)
def __eq__(self, other: object) -> bool:
"""Return whether two todo items have the same values."""
return isinstance(other, TodoItem) and self.to_dict() == other.to_dict()
def __repr__(self) -> str:
"""Return a helpful debug representation."""
return (
"TodoItem("
f"id={self.id!r}, title={self.title!r}, description={self.description!r}, is_complete={self.is_complete!r})"
)
@experimental(feature_id=ExperimentalFeature.HARNESS)
class TodoInput(SerializationMixin):
"""Describe one todo item to create."""
title: str
description: str | None
__slots__ = ("description", "title")
def __init__(self, title: str, description: str | None = None) -> None:
"""Initialize one todo input."""
normalized_title = title.strip()
if not normalized_title:
raise ValueError("Todo input title must be a non-empty string.")
if description is not None and not isinstance(description, str):
raise ValueError("Todo input description must be a string or null.")
self.title = normalized_title
self.description = description
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
"""Serialize the todo input."""
del exclude
payload = {"title": self.title, "description": self.description}
return {key: value for key, value in payload.items() if value is not None or not exclude_none}
@classmethod
def from_dict(
cls, raw_todo: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None
) -> TodoInput:
"""Parse one todo input loaded from tool arguments."""
del dependencies
title = raw_todo.get("title")
description = raw_todo.get("description")
if not isinstance(title, str):
raise ValueError("Todo input title must be a string.")
return cls(title=title, description=description)
def _parse_todo_items(items_payload: list[Any], *, source_description: str) -> list[TodoItem]:
"""Parse persisted todo item payloads with clear corruption errors."""
items: list[TodoItem] = []
for index, item in enumerate(items_payload):
if not isinstance(item, Mapping):
raise ValueError(
f"Todo item at index {index} in {source_description} must be a mapping; got {type(item).__name__}."
)
items.append(TodoItem.from_dict(dict(cast(Mapping[str, Any], item))))
return items
def _coerce_todo_input(todo: TodoInput | dict[str, Any] | Any) -> TodoInput:
"""Normalize tool-provided todo input into a TodoInput model."""
if isinstance(todo, TodoInput):
return todo
if isinstance(todo, MutableMapping):
return TodoInput.from_dict(cast(MutableMapping[str, Any], todo))
raise ValueError("Todo input must be a TodoInput instance or JSON object.")
def _safe_next_id(items: list[TodoItem], next_id: int) -> int:
"""Clamp ``next_id`` so it cannot collide with any persisted item id."""
return max(next_id, max((item.id for item in items), default=0) + 1)
@experimental(feature_id=ExperimentalFeature.HARNESS)
class TodoStore(ABC):
"""Abstract backing store for session todo items."""
@abstractmethod
async def load_state(self, session: AgentSession, *, source_id: str) -> tuple[list[TodoItem], int]:
"""Load persisted todo items and the next available ID."""
@abstractmethod
async def save_state(self, session: AgentSession, items: list[TodoItem], *, next_id: int, source_id: str) -> None:
"""Persist todo items and the next available ID."""
async def load_items(self, session: AgentSession, *, source_id: str) -> list[TodoItem]:
"""Load todo items for one session."""
items, _ = await self.load_state(session, source_id=source_id)
return items
@experimental(feature_id=ExperimentalFeature.HARNESS)
class TodoSessionStore(TodoStore):
"""Store todo state inside ``AgentSession.state``."""
async def load_state(self, session: AgentSession, *, source_id: str) -> tuple[list[TodoItem], int]:
"""Load todo state from session state."""
provider_state_value = session.state.get(source_id)
if provider_state_value is None:
provider_state: dict[str, Any] = {}
session.state[source_id] = provider_state
elif isinstance(provider_state_value, dict):
provider_state = cast(dict[str, Any], provider_state_value)
else:
raise ValueError(
f"Session state for source_id {source_id!r} must be a dict; got {type(provider_state_value).__name__}."
)
raw_items = provider_state.get("items", [])
if not isinstance(raw_items, list):
raise ValueError(
f"Session state for source_id {source_id!r} has a non-list 'items' field; "
f"got {type(raw_items).__name__}."
)
raw_next_id = provider_state.get("next_id", 1)
if not isinstance(raw_next_id, int):
raise ValueError(
f"Session state for source_id {source_id!r} has a non-integer 'next_id' field; "
f"got {type(raw_next_id).__name__}."
)
items_payload: list[Any] = cast(Any, raw_items)
items = _parse_todo_items(items_payload, source_description="session todo state")
return items, _safe_next_id(items, raw_next_id)
async def save_state(self, session: AgentSession, items: list[TodoItem], *, next_id: int, source_id: str) -> None:
"""Persist todo state back into session state."""
provider_state_value = session.state.get(source_id)
provider_state = cast(dict[str, Any], provider_state_value) if isinstance(provider_state_value, dict) else {}
if not isinstance(provider_state_value, dict):
session.state[source_id] = provider_state
provider_state["items"] = [item.to_dict(exclude_none=False) for item in items]
provider_state["next_id"] = _safe_next_id(items, next_id)
@experimental(feature_id=ExperimentalFeature.HARNESS)
class TodoFileStore(TodoStore):
"""Store todo state in one JSON file per session and source ID."""
def __init__(
self,
base_path: str | Path,
*,
kind: str = "todos",
owner_prefix: str = "",
owner_state_key: str | None = None,
state_filename: str = "todos.json",
) -> None:
"""Initialize the file-backed todo store.
Args:
base_path: Root storage directory.
Keyword Args:
kind: Storage bucket name under each owner directory.
owner_prefix: Optional prefix applied to the resolved owner ID.
owner_state_key: Session-state key holding the logical owner ID.
state_filename: File name used for the persisted todo state.
"""
self.base_path = Path(base_path)
self.kind = kind
self.owner_prefix = owner_prefix
self.owner_state_key = owner_state_key
self.state_filename = state_filename
self._base_root = self.base_path.resolve()
_ENCODED_SEGMENT_PREFIX: ClassVar[str] = "~todo-"
_WINDOWS_RESERVED_FILE_STEMS: ClassVar[frozenset[str]] = frozenset({
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
})
def _get_state_path(self, session: AgentSession, *, source_id: str) -> Path:
"""Return the JSON file path for one session and source ID."""
session_directory = self.base_path
if self.owner_state_key is not None:
owner_value = session.state.get(self.owner_state_key)
if owner_value is None:
raise RuntimeError(
f"TodoFileStore requires session.state[{self.owner_state_key!r}] to be set for file-backed storage."
)
owner_segment = self._path_segment(owner_value, label="owner")
session_directory = session_directory / f"{self.owner_prefix}{owner_segment}" / self.kind
session_directory = session_directory / self._path_segment(
session.session_id, label="session_id", reject_path_separators=True
)
state_path = (session_directory / self._state_filename(source_id)).resolve()
if not state_path.is_relative_to(self._base_root):
raise ValueError(f"Todo file path escaped base directory for session_id {session.session_id!r}.")
return state_path
@classmethod
def _path_segment(cls, value: object, *, label: str, reject_path_separators: bool = False) -> str:
"""Return a filesystem-safe path segment for user-controlled state values."""
raw_value = str(value)
if reject_path_separators and ("/" in raw_value or "\\" in raw_value):
raise ValueError(f"TodoFileStore {label} must not contain path separators: {raw_value!r}")
if cls._is_literal_path_segment_safe(raw_value):
return raw_value
encoded_value = urlsafe_b64encode(raw_value.encode("utf-8")).decode("ascii").rstrip("=")
return f"{cls._ENCODED_SEGMENT_PREFIX}{encoded_value or label}"
@classmethod
def _is_literal_path_segment_safe(cls, value: str) -> bool:
"""Return whether a value can be used directly as one path segment."""
if (
not value
or value.startswith(".")
or value.endswith((" ", "."))
or value.upper() in cls._WINDOWS_RESERVED_FILE_STEMS
):
return False
if any(ord(character) < 32 for character in value):
return False
return all(character.isalnum() or character in "._-" for character in value)
def _state_filename(self, source_id: str) -> str:
"""Return a source-specific JSON state filename."""
state_path = Path(self.state_filename)
source_segment = self._path_segment(source_id, label="source_id")
if state_path.suffix:
return f"{state_path.stem}.{source_segment}{state_path.suffix}"
return f"{state_path.name}.{source_segment}.json"
async def load_state(self, session: AgentSession, *, source_id: str) -> tuple[list[TodoItem], int]:
"""Load todo state from disk."""
state_path = self._get_state_path(session, source_id=source_id)
return await asyncio.to_thread(self._load_state_sync, state_path)
@staticmethod
def _load_state_sync(state_path: Path) -> tuple[list[TodoItem], int]:
"""Synchronous helper that performs the disk I/O for ``load_state``."""
if not state_path.exists():
return [], 1
payload = cast(dict[str, Any], json.loads(state_path.read_text(encoding="utf-8")))
if not isinstance(payload, dict):
raise ValueError(f"Todo file {state_path} must contain a JSON object.")
raw_items = payload.get("items", [])
raw_next_id = payload.get("next_id", 1)
if not isinstance(raw_items, list):
raise ValueError(f"Todo file {state_path} has a non-list 'items' field.")
if not isinstance(raw_next_id, int):
raise ValueError(f"Todo file {state_path} has a non-integer 'next_id' field.")
items_payload: list[Any] = cast(Any, raw_items)
items = _parse_todo_items(items_payload, source_description=f"todo file {state_path}")
return items, _safe_next_id(items, raw_next_id)
async def save_state(self, session: AgentSession, items: list[TodoItem], *, next_id: int, source_id: str) -> None:
"""Persist todo state to disk."""
state_path = self._get_state_path(session, source_id=source_id)
payload = (
json.dumps({
"items": [item.to_dict(exclude_none=False) for item in items],
"next_id": _safe_next_id(items, next_id),
})
+ "\n"
)
await asyncio.to_thread(self._save_state_sync, state_path, payload)
@staticmethod
def _save_state_sync(state_path: Path, payload: str) -> None:
"""Synchronous helper that atomically writes the JSON state file."""
state_path.parent.mkdir(parents=True, exist_ok=True)
# Write to a sibling temp file then atomically replace, so a crash mid-write cannot leave
# a truncated state file that breaks every subsequent tool call.
temp_path = state_path.with_name(f"{state_path.name}.tmp.{os.getpid()}")
try:
temp_path.write_text(payload, encoding="utf-8")
os.replace(temp_path, state_path)
finally:
if temp_path.exists():
temp_path.unlink(missing_ok=True)
@experimental(feature_id=ExperimentalFeature.HARNESS)
class TodoProvider(ContextProvider):
"""Provide todo management tools and instructions to an agent.
The ``TodoProvider`` enables agents to create, complete, remove, and query todo items as part of their planning
and execution workflow. Todo state is stored in the configured ``TodoStore`` and persists across agent invocations
within the same session. By default, state is stored in ``AgentSession.state`` with ``TodoSessionStore``; callers
can provide ``TodoFileStore`` or another store implementation for file-backed or custom persistence.
This provider exposes the following tools to the agent:
- ``add_todos``: Add one or more todo items, each with a title and optional description.
- ``complete_todos``: Mark one or more todo items as complete by their IDs.
- ``remove_todos``: Remove one or more todo items by their IDs.
- ``get_remaining_todos``: Retrieve only incomplete todo items.
- ``get_all_todos``: Retrieve all todo items, complete and incomplete.
"""
def __init__(
self,
source_id: str = DEFAULT_TODO_SOURCE_ID,
*,
instructions: str | None = None,
store: TodoStore | None = None,
) -> None:
"""Initialize the todo provider.
Args:
source_id: Unique source ID for the provider.
Keyword Args:
instructions: Optional instruction override.
store: Optional todo store override.
"""
super().__init__(source_id)
self.instructions = instructions or DEFAULT_TODO_INSTRUCTIONS
self.store = store or TodoSessionStore()
# WeakKeyDictionary so per-session locks are evicted automatically when the session is GC'd
# rather than accumulating in long-running services that create many sessions.
self._mutation_locks: weakref.WeakKeyDictionary[AgentSession, asyncio.Lock] = weakref.WeakKeyDictionary()
def _mutation_lock(self, session: AgentSession) -> asyncio.Lock:
"""Return the per-session lock for read-modify-write todo operations."""
lock = self._mutation_locks.get(session)
if lock is None:
lock = asyncio.Lock()
self._mutation_locks[session] = lock
return lock
async def before_run(
self,
*,
agent: Any,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Inject todo tools and instructions before the model runs."""
del agent, state
@tool(name="add_todos", approval_mode="never_require")
async def add_todos(todos: list[dict[str, Any]]) -> str:
"""Add one or more todo items for the current session."""
if not todos:
raise ValueError("todos must contain at least one item.")
async with self._mutation_lock(session):
existing_items, next_id = await self.store.load_state(session, source_id=self.source_id)
created_items: list[TodoItem] = []
for raw_todo in todos:
todo = _coerce_todo_input(raw_todo)
created_item = TodoItem(
id=next_id,
title=todo.title,
description=todo.description.strip() if todo.description is not None else None,
)
existing_items.append(created_item)
created_items.append(created_item)
next_id += 1
await self.store.save_state(session, existing_items, next_id=next_id, source_id=self.source_id)
return json.dumps([item.to_dict(exclude_none=False) for item in created_items])
@tool(name="complete_todos", approval_mode="never_require")
async def complete_todos(ids: list[int]) -> str:
"""Mark one or more todo items as complete by ID."""
if not ids:
raise ValueError("ids must contain at least one todo ID.")
async with self._mutation_lock(session):
items, next_id = await self.store.load_state(session, source_id=self.source_id)
id_set = set(ids)
completed_count = 0
updated_items: list[TodoItem] = []
for item in items:
if not item.is_complete and item.id in id_set:
updated_items.append(
TodoItem(
id=item.id,
title=item.title,
description=item.description,
is_complete=True,
)
)
completed_count += 1
else:
updated_items.append(item)
if completed_count:
await self.store.save_state(session, updated_items, next_id=next_id, source_id=self.source_id)
return json.dumps({"completed": completed_count})
@tool(name="remove_todos", approval_mode="never_require")
async def remove_todos(ids: list[int]) -> str:
"""Remove one or more todo items by ID."""
if not ids:
raise ValueError("ids must contain at least one todo ID.")
async with self._mutation_lock(session):
items, next_id = await self.store.load_state(session, source_id=self.source_id)
remaining_items = [item for item in items if item.id not in set(ids)]
removed_count = len(items) - len(remaining_items)
if removed_count:
await self.store.save_state(session, remaining_items, next_id=next_id, source_id=self.source_id)
return json.dumps({"removed": removed_count})
@tool(name="get_remaining_todos", approval_mode="never_require")
async def get_remaining_todos() -> str:
"""Retrieve only incomplete todo items for the current session."""
items = [
item for item in await self.store.load_items(session, source_id=self.source_id) if not item.is_complete
]
return json.dumps([item.to_dict(exclude_none=False) for item in items])
@tool(name="get_all_todos", approval_mode="never_require")
async def get_all_todos() -> str:
"""Retrieve all todo items for the current session."""
items = await self.store.load_items(session, source_id=self.source_id)
return json.dumps([item.to_dict(exclude_none=False) for item in items])
context.extend_instructions(self.source_id, [self.instructions])
context.extend_tools(
self.source_id,
[add_todos, complete_todos, remove_todos, get_remaining_todos, get_all_todos],
)
current_items = await self.store.load_items(session, source_id=self.source_id)
context.extend_messages(
self.source_id,
[
Message(
role="user",
contents=[
"### Current todo list\n"
+ (
"\n".join(
f"- {item.id} [{'done' if item.is_complete else 'open'}] {item.title}"
+ (f": {item.description}" if item.description else "")
for item in current_items
)
or "- none yet"
)
],
)
],
)

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