mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0295b4c4c7 | ||
|
|
a9dafd53c3 | ||
|
|
97228e49b6 | ||
|
|
e3f76618c5 | ||
|
|
899394a58c | ||
|
|
f747d8a6d4 | ||
|
|
08aeb67a9a | ||
|
|
9004282168 | ||
|
|
e4595be0c2 | ||
|
|
025655b573 | ||
|
|
53274fde85 | ||
|
|
7f661e8524 | ||
|
|
8dca006edd | ||
|
|
99627e41d2 | ||
|
|
d0ac1d83bc | ||
|
|
9d89353818 | ||
|
|
b4c853ec1b | ||
|
|
673f3d9214 |
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -6,12 +6,8 @@
|
||||
[](https://learn.microsoft.com/en-us/agent-framework/)
|
||||
[](https://pypi.org/project/agent-framework/)
|
||||
[](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
|
||||
[](https://github.com/microsoft/agent-framework/stargazers)
|
||||
|
||||
|
||||
Microsoft Agent Framework (MAF) is an open, multi-language framework for building **production-grade AI agents and multi-agent workflows** in **.NET and Python**.
|
||||
|
||||
Microsoft Agent Framework is built for teams taking agents from prototype to production. It provides a consistent foundation for building, orchestrating, and operating agent systems across Python and .NET, while keeping architecture choices open as requirements evolve, and supports a broad ecosystem including Microsoft Foundry, Azure OpenAI, OpenAI, and the GitHub Copilot SDK, with samples and hosting patterns for both local development and cloud deployment.
|
||||
Welcome to Microsoft's comprehensive multi-language framework for building, orchestrating, and deploying AI agents with support for both .NET and Python implementations. This framework provides everything from simple chat agents to complex multi-agent workflows with graph-based orchestration.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=AAgdMhftj8w" title="Watch the full Agent Framework introduction (30 min)">
|
||||
@@ -25,54 +21,10 @@ Microsoft Agent Framework is built for teams taking agents from prototype to pro
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Is this the right framework for you?
|
||||
## đź“‹ Getting Started
|
||||
|
||||
MAF is a strong fit if you:
|
||||
- are building agents and workflows you expect to run in production,
|
||||
- need orchestration beyond a single prompt or stateless chat loop,
|
||||
- want graph-based patterns such as sequential, concurrent, handoff, and group collaboration,
|
||||
- care about durability, restartability, observability, governance, or human-in-the-loop control,
|
||||
- need provider flexibility so your architecture can evolve without major rewrites.
|
||||
### 📦 Installation
|
||||
|
||||
## Key Features
|
||||
Explore new MAF capabilities and real implementation patterns on the [official blog](https://devblogs.microsoft.com/agent-framework/).
|
||||
|
||||
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
|
||||
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
|
||||
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
|
||||
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
|
||||
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
|
||||
- [Python middleware](./python/samples/02-agents/middleware/) | [.NET middleware](./dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/)
|
||||
- **Orchestration Patterns & Workflows**: Build multi-agent systems with graph-based workflows supporting sequential, concurrent, handoff, and group collaboration patterns; includes checkpointing, streaming, human-in-the-loop, and time-travel
|
||||
- [Python workflows](./python/samples/03-workflows/) | [.NET workflows](./dotnet/samples/03-workflows/)
|
||||
- **Foundry Hosted Agents (new)**: Deploy and host your agents to Foundry-hosted infrastructure with just 2 additional lines of code
|
||||
- [Python samples](./python/samples/04-hosting/foundry-hosted-agents/) | [.NET samples](./dotnet/samples/04-hosting/FoundryHostedAgents/)
|
||||
- **Observability**: Built-in OpenTelemetry integration for distributed tracing, monitoring, and debugging
|
||||
- [Python observability](./python/samples/02-agents/observability/) | [.NET telemetry](./dotnet/samples/02-agents/AgentOpenTelemetry/)
|
||||
- **Declarative Agents**: Define agents using YAML for faster setup and versioning
|
||||
- [Declarative agent samples](./declarative-agents/)
|
||||
- **Agent Skills**: Build domain-specific knowledge bases from multiple sources—files, inline code, class libraries—for agents to discover and use
|
||||
- [Skills design](./docs/decisions/0021-agent-skills-design.md)
|
||||
- **AF Labs**: Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives
|
||||
- [Labs directory](./python/packages/lab/)
|
||||
- **DevUI**: Interactive developer UI for agent development, testing, and debugging workflows
|
||||
- [See the DevUI in action](https://www.youtube.com/watch?v=mOAaGY4WPvc)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Getting Started](#getting-started)
|
||||
- [Installation](#installation)
|
||||
- [Learning Resources](#learning-resources)
|
||||
- [Quickstart](#quickstart)
|
||||
- [Basic Agent - Python](#basic-agent---python)
|
||||
- [Basic Agent - .NET](#basic-agent---net)
|
||||
- [More Examples & Samples](#more-examples--samples)
|
||||
- [Community & Feedback](#community--feedback)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Contributor Resources](#contributor-resources)
|
||||
|
||||
## Getting Started
|
||||
### Installation
|
||||
Python
|
||||
|
||||
```bash
|
||||
@@ -85,13 +37,9 @@ pip install agent-framework
|
||||
|
||||
```bash
|
||||
dotnet add package Microsoft.Agents.AI
|
||||
# For Foundry integration (used in the .NET quickstart below):
|
||||
dotnet add package Microsoft.Agents.AI.Foundry
|
||||
dotnet add package Azure.AI.Projects
|
||||
dotnet add package Azure.Identity
|
||||
```
|
||||
|
||||
### Learning Resources
|
||||
### 📚 Documentation
|
||||
|
||||
- **[Overview](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)** - High level overview of the framework
|
||||
- **[Quick Start](https://learn.microsoft.com/agent-framework/tutorials/quick-start)** - Get started with a simple agent
|
||||
@@ -100,9 +48,44 @@ dotnet add package Azure.Identity
|
||||
- **[Migration from Semantic Kernel](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel)** - Guide to migrate from Semantic Kernel
|
||||
- **[Migration from AutoGen](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-autogen)** - Guide to migrate from AutoGen
|
||||
|
||||
### Quickstart
|
||||
Still have questions? Join our [weekly office hours](./COMMUNITY.md#public-community-office-hours) or ask questions in our [Discord channel](https://discord.gg/b5zjErwbQM) to get help from the team and other users.
|
||||
|
||||
#### Basic Agent - Python
|
||||
### ✨ **Highlights**
|
||||
|
||||
- **Graph-based Workflows**: Connect agents and deterministic functions using data flows with streaming, checkpointing, human-in-the-loop, and time-travel capabilities
|
||||
- [Python workflows](./python/samples/03-workflows/) | [.NET workflows](./dotnet/samples/03-workflows/)
|
||||
- **AF Labs**: Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives
|
||||
- [Labs directory](./python/packages/lab/)
|
||||
- **DevUI**: Interactive developer UI for agent development, testing, and debugging workflows
|
||||
- [DevUI package](./python/packages/devui/)
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=mOAaGY4WPvc">
|
||||
<img src="https://img.youtube.com/vi/mOAaGY4WPvc/hqdefault.jpg" alt="See the DevUI in action" width="480">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=mOAaGY4WPvc">
|
||||
See the DevUI in action (1 min)
|
||||
</a>
|
||||
</p>
|
||||
|
||||
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
|
||||
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
|
||||
- **Observability**: Built-in OpenTelemetry integration for distributed tracing, monitoring, and debugging
|
||||
- [Python observability](./python/samples/02-agents/observability/) | [.NET telemetry](./dotnet/samples/02-agents/AgentOpenTelemetry/)
|
||||
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
|
||||
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
|
||||
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
|
||||
- [Python middleware](./python/samples/02-agents/middleware/) | [.NET middleware](./dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/)
|
||||
|
||||
### đź’¬ **We want your feedback!**
|
||||
|
||||
- For bugs, please file a [GitHub issue](https://github.com/microsoft/agent-framework/issues).
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Basic Agent - Python
|
||||
|
||||
Create a simple Azure Responses Agent that writes a haiku about the Microsoft Agent Framework
|
||||
|
||||
@@ -126,7 +109,7 @@ async def main():
|
||||
# project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
# model=os.environ["FOUNDRY_MODEL_DEPLOYMENT_NAME"],
|
||||
),
|
||||
name="HaikuAgent",
|
||||
name="HaikuBot",
|
||||
instructions="You are an upbeat assistant that writes beautifully.",
|
||||
)
|
||||
|
||||
@@ -136,24 +119,40 @@ if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
#### Basic Agent - .NET
|
||||
Create a simple Agent, using Microsoft Foundry that writes a haiku about the Microsoft Agent Framework
|
||||
### Basic Agent - .NET
|
||||
Create a simple Agent, using Microsoft Foundry with token-based auth, that writes a haiku about the Microsoft Agent Framework
|
||||
|
||||
```c#
|
||||
// This sample shows how to create and run a basic agent with AIProjectClient.AsAIAgent(...).
|
||||
|
||||
// dotnet add package Microsoft.Agents.AI.Foundry
|
||||
// Use `az login` to authenticate with Azure CLI
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using System;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
|
||||
AIAgent agent =
|
||||
new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.AsAIAgent(model: deploymentName, instructions: "You are an upbeat assistant that writes beautifully.", name: "HaikuAgent");
|
||||
var agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.AsAIAgent(model: deploymentName, name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
|
||||
```
|
||||
|
||||
Create a simple Agent, using OpenAI Responses, that writes a haiku about the Microsoft Agent Framework
|
||||
|
||||
```c#
|
||||
// dotnet add package Microsoft.Agents.AI.OpenAI
|
||||
using System;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
// Replace the <apikey> with your OpenAI API key.
|
||||
var agent = new OpenAIClient("<apikey>")
|
||||
.GetResponsesClient()
|
||||
.AsAIAgent(model: "gpt-5.4-mini", name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
|
||||
|
||||
// Once you have the agent, you can invoke it like any other AIAgent.
|
||||
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
|
||||
```
|
||||
|
||||
@@ -176,12 +175,6 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
|
||||
- [Hosting](./dotnet/samples/04-hosting): A2A, Durable Agents, Durable Workflows
|
||||
- [End-to-End](./dotnet/samples/05-end-to-end): full applications and demos
|
||||
|
||||
## Community & Feedback
|
||||
|
||||
- **Found a bug?** File a [GitHub issue](https://github.com/microsoft/agent-framework/issues) to help us improve.
|
||||
- **Enjoying MAF?** [](https://github.com/microsoft/agent-framework) to show your support and help others discover the project.
|
||||
- **Have questions?** Join our [Discord](https://discord.gg/b5zjErwbQM) or visit [weekly office hours](./COMMUNITY.md#public-community-office-hours).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication
|
||||
@@ -194,7 +187,16 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
|
||||
> **Tip:** `DefaultAzureCredential` is convenient for development but in production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
|
||||
### Environment Variables
|
||||
For environment variable configuration specific to each sample, refer to the README in the sample directory ([Python samples](./python/samples/) | [.NET samples](./dotnet/samples/)).
|
||||
|
||||
The samples typically read configuration from environment variables. Common required variables:
|
||||
|
||||
| Variable | Used by | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI samples | Your Azure OpenAI resource URL |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI samples | Model deployment name (e.g. `gpt-4o-mini`) |
|
||||
| `AZURE_AI_PROJECT_ENDPOINT` | Microsoft Foundry samples | Your Microsoft Foundry project endpoint |
|
||||
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Microsoft Foundry samples | Model deployment name |
|
||||
| `OPENAI_API_KEY` | OpenAI (non-Azure) samples | Your OpenAI platform API key |
|
||||
|
||||
## Contributor Resources
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use HyperlightCodeActProvider as a sandboxed Python
|
||||
// code interpreter: the model can write and execute arbitrary Python code to
|
||||
// answer quantitative questions without calling any additional tools.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using OpenAI.Chat;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
|
||||
|
||||
using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(guestPath));
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "You are a helpful assistant. When the user asks something quantitative, write Python and call `execute_code` instead of guessing." },
|
||||
AIContextProviders = [codeAct],
|
||||
});
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What is the 20th Fibonacci number?"));
|
||||
Console.WriteLine(await agent.RunAsync("Compute the mean and standard deviation of [1, 4, 9, 16, 25, 36]."));
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
# AgentWithCodeAct_Step01_Interpreter
|
||||
|
||||
A minimal CodeAct sample. The agent uses `HyperlightCodeActProvider` as a
|
||||
sandboxed Python interpreter: when the user asks something quantitative, the
|
||||
model writes Python and invokes the `execute_code` tool rather than answering
|
||||
from memory.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Description |
|
||||
|--------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
|
||||
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
|
||||
|
||||
Authentication uses `DefaultAzureCredential`.
|
||||
|
||||
## Getting the guest module
|
||||
|
||||
The Python guest module is built from the
|
||||
[hyperlight-dev/hyperlight-sandbox](https://github.com/hyperlight-dev/hyperlight-sandbox)
|
||||
repository — see its README for the exact `cargo`/`just` invocations and
|
||||
the location of the resulting `.wasm` / `.aot` file. Set
|
||||
`HYPERLIGHT_PYTHON_GUEST_PATH` to the absolute path of that artifact
|
||||
before running the sample.
|
||||
|
||||
Hyperlight requires a hardware virtualization back end on the host:
|
||||
KVM on Linux or WHP (Windows Hypervisor Platform) on Windows.
|
||||
|
||||
## Run
|
||||
|
||||
```shell
|
||||
cd AgentWithCodeAct_Step01_Interpreter
|
||||
dotnet run
|
||||
```
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use HyperlightCodeActProvider with provider-owned
|
||||
// tools (exposed inside the sandbox via `call_tool(...)`). The model can
|
||||
// orchestrate those tools in a single Python block, reducing round-trips. A
|
||||
// sensitive tool (`send_email`) is additionally wrapped in
|
||||
// ApprovalRequiredAIFunction so any code that reaches it requires user approval
|
||||
// for the entire execute_code invocation.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
|
||||
|
||||
AIFunction fetchDocs = AIFunctionFactory.Create(
|
||||
(string topic) => $"Docs for {topic}: (...)",
|
||||
name: "fetch_docs",
|
||||
description: "Fetch documentation for a given topic.");
|
||||
|
||||
AIFunction queryData = AIFunctionFactory.Create(
|
||||
(string query) => $"Rows for `{query}`: []",
|
||||
name: "query_data",
|
||||
description: "Run a read-only SQL-like query against the sample store.");
|
||||
|
||||
AIFunction sendEmail = new ApprovalRequiredAIFunction(
|
||||
AIFunctionFactory.Create(
|
||||
(string to, string subject) => $"Sent '{subject}' to {to}.",
|
||||
name: "send_email",
|
||||
description: "Send an email on behalf of the user."));
|
||||
|
||||
var options = HyperlightCodeActProviderOptions.CreateForWasm(guestPath);
|
||||
options.Tools = [fetchDocs, queryData, sendEmail];
|
||||
|
||||
using var codeAct = new HyperlightCodeActProvider(options);
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "You are a helpful assistant. Prefer orchestrating your work in a single `execute_code` block using `call_tool(...)` over issuing many direct tool calls." },
|
||||
AIContextProviders = [codeAct],
|
||||
});
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("Look up docs on 'retries' and query the 'orders' table, then summarize."));
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
# AgentWithCodeAct_Step02_ToolEnabled
|
||||
|
||||
Demonstrates adding provider-owned tools to `HyperlightCodeActProvider`. Those
|
||||
tools are **only** available to code running inside the sandbox via
|
||||
`call_tool("<name>", ...)` — they are never exposed to the model as direct
|
||||
tools. This lets the model orchestrate multiple tool calls in a single Python
|
||||
block.
|
||||
|
||||
One tool (`send_email`) is wrapped in `ApprovalRequiredAIFunction`, which causes
|
||||
the entire `execute_code` invocation to require user approval when that tool
|
||||
is configured.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Description |
|
||||
|--------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
|
||||
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
|
||||
|
||||
## Run
|
||||
|
||||
```shell
|
||||
cd AgentWithCodeAct_Step02_ToolEnabled
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Planned follow-up
|
||||
|
||||
A more realistic "upload a file (e.g. an Excel workbook), have the agent
|
||||
analyze it with code" sample is planned as a separate step that will use
|
||||
`HostInputDirectory` together with a guest tool capable of reading the
|
||||
uploaded file. It will be added in a follow-up PR once the corresponding
|
||||
guest module support is in place.
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to wire up CodeAct manually using
|
||||
// HyperlightExecuteCodeFunction rather than the AIContextProvider. Use this
|
||||
// when you want a fixed tool surface for the agent's lifetime and don't need
|
||||
// the per-run snapshot/registry semantics of HyperlightCodeActProvider.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
|
||||
|
||||
AIFunction calculate = AIFunctionFactory.Create(
|
||||
(double a, double b) => a * b,
|
||||
name: "multiply",
|
||||
description: "Multiply two numbers.");
|
||||
|
||||
var options = HyperlightCodeActProviderOptions.CreateForWasm(guestPath);
|
||||
options.Tools = [calculate];
|
||||
|
||||
using var executeCode = new HyperlightExecuteCodeFunction(options);
|
||||
|
||||
var instructions =
|
||||
"You are a helpful assistant. When math is involved, solve it by writing Python "
|
||||
+ "and calling `execute_code` instead of computing values yourself.\n\n"
|
||||
+ executeCode.BuildInstructions(toolsVisibleToModel: false);
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(instructions: instructions, tools: [executeCode]);
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What is 12.3 * 4.5? Use the multiply tool from within `execute_code`."));
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
# AgentWithCodeAct_Step03_ManualWiring
|
||||
|
||||
Shows how to wire CodeAct manually using `HyperlightExecuteCodeFunction` as a
|
||||
direct agent tool instead of via an `AIContextProvider`. This is useful when
|
||||
the sandbox's tool surface and capabilities are fixed for the agent's
|
||||
lifetime, avoiding per-run snapshot/restore of the provider registry.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Description |
|
||||
|--------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
|
||||
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
|
||||
|
||||
## Run
|
||||
|
||||
```shell
|
||||
cd AgentWithCodeAct_Step03_ManualWiring
|
||||
dotnet run
|
||||
```
|
||||
@@ -1,16 +0,0 @@
|
||||
# Agent Framework CodeAct (Hyperlight) Samples
|
||||
|
||||
These samples show how to enable an agent to write and execute code in a
|
||||
Hyperlight-backed sandbox via the CodeAct pattern. Guest code can be pure
|
||||
Python (interpreter mode) or orchestrate host-provided tools through
|
||||
`call_tool(...)` — all inside a secure sandbox with opt-in filesystem and
|
||||
network access.
|
||||
|
||||
|Sample|Description|
|
||||
|---|---|
|
||||
|[Code interpreter](./AgentWithCodeAct_Step01_Interpreter/)|Uses `HyperlightCodeActProvider` as a sandboxed Python interpreter with no host tools.|
|
||||
|[Tool-enabled CodeAct](./AgentWithCodeAct_Step02_ToolEnabled/)|Registers provider-owned tools that guest code can orchestrate via `call_tool(...)`, with an approval-required tool for sensitive actions.|
|
||||
|[Manual wiring](./AgentWithCodeAct_Step03_ManualWiring/)|Uses `HyperlightExecuteCodeFunction` directly as an agent tool when the sandbox configuration is fixed.|
|
||||
|
||||
All samples require a Hyperlight Python guest module. Set
|
||||
`HYPERLIGHT_PYTHON_GUEST_PATH` to its absolute path before running.
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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<AIAgent>("my-agent", myAgent);
|
||||
/// builder.AddAIAgent("my-agent", ...);
|
||||
/// builder.Services.AddFoundryResponses();
|
||||
///
|
||||
/// var app = builder.Build();
|
||||
@@ -49,7 +50,7 @@ public static class FoundryHostingExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddResponsesServer();
|
||||
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
|
||||
services.TryAddSingleton<AgentSessionStore, InMemoryAgentSessionStore>();
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
}
|
||||
@@ -76,7 +77,7 @@ public static class FoundryHostingExtensions
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="agent">The agent instance to register.</param>
|
||||
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, a file-system session store is used, rooted at <c>/.checkpoints</c> when running in a Foundry hosted environment and <c>{cwd}/.checkpoints</c> locally.</param>
|
||||
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, an in-memory session store will be used.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null)
|
||||
{
|
||||
@@ -84,7 +85,7 @@ public static class FoundryHostingExtensions
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
services.AddResponsesServer();
|
||||
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
|
||||
agentSessionStore ??= new InMemoryAgentSessionStore();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Name))
|
||||
{
|
||||
@@ -180,11 +181,20 @@ public static class FoundryHostingExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
endpoints.MapResponsesServer(prefix);
|
||||
|
||||
if (endpoints is IApplicationBuilder app)
|
||||
{
|
||||
// Ensure the middleware is added to the pipeline
|
||||
app.UseMiddleware<AgentFrameworkUserAgentMiddleware>();
|
||||
}
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ActivitySource name for the Responses hosting pipeline.
|
||||
/// Matches the value previously exposed by <c>AgentHostTelemetry.ResponsesSourceName</c>
|
||||
/// in <c>Azure.AI.AgentServer.Core</c>.
|
||||
/// </summary>
|
||||
private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses";
|
||||
|
||||
@@ -206,85 +216,46 @@ public static class FoundryHostingExtensions
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to wrap the agent's underlying <see cref="ResponsesClient"/>
|
||||
/// with a <see cref="UserAgentResponsesClient"/> so every outgoing Responses-API request
|
||||
/// carries the hosted-agent <c>User-Agent</c> segment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Best-effort and idempotent. The method is a no-op when:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><paramref name="agent"/> exposes no <see cref="IChatClient"/>;</description></item>
|
||||
/// <item><description>the chat client is not backed by MEAI's internal <c>OpenAIResponsesChatClient</c> (e.g., a non-OpenAI provider or a custom impl);</description></item>
|
||||
/// <item><description>the inner <see cref="ResponsesClient"/> is already a <see cref="UserAgentResponsesClient"/>.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Works for any <see cref="ResponsesClient"/>-derived inner client — both the Foundry-specific
|
||||
/// <see cref="Azure.AI.Extensions.OpenAI.ProjectResponsesClient"/> and the native OpenAI
|
||||
/// <see cref="ResponsesClient"/> obtained from <see cref="OpenAI.OpenAIClient"/>. The wrapper preserves
|
||||
/// the inner client's pipeline (Transport, RetryPolicy, NetworkTimeout, OrganizationId / ProjectId /
|
||||
/// UserAgentApplicationId, custom policies) because every override delegates to the inner instance.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Returns the same <paramref name="agent"/> instance unchanged. Mutation happens via
|
||||
/// reflection on MEAI's private <c>_responseClient</c> field; the agent itself is not wrapped.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static AIAgent TryApplyUserAgent(AIAgent agent)
|
||||
private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next)
|
||||
{
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
if (chatClient is null)
|
||||
private static readonly string s_userAgentValue = CreateUserAgentValue();
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
return agent;
|
||||
var headers = context.Request.Headers;
|
||||
var userAgent = headers.UserAgent.ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(userAgent))
|
||||
{
|
||||
headers.UserAgent = s_userAgentValue;
|
||||
}
|
||||
else if (!userAgent.Contains(s_userAgentValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
headers.UserAgent = $"{userAgent} {s_userAgentValue}";
|
||||
}
|
||||
|
||||
await next(context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var meaiType = s_meaiResponsesChatClientType;
|
||||
if (meaiType is null)
|
||||
private static string CreateUserAgentValue()
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
const string Name = "agent-framework-dotnet";
|
||||
|
||||
var meaiInstance = chatClient.GetService(meaiType);
|
||||
if (meaiInstance is null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
if (typeof(AgentFrameworkUserAgentMiddleware).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
var field = s_meaiResponseClientField;
|
||||
if (field is null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
var current = field.GetValue(meaiInstance) as ResponsesClient;
|
||||
if (current is null or UserAgentResponsesClient)
|
||||
{
|
||||
return agent;
|
||||
return Name;
|
||||
}
|
||||
|
||||
field.SetValue(meaiInstance, new UserAgentResponsesClient(current));
|
||||
return agent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MEAI's internal <c>OpenAIResponsesChatClient</c> type, resolved once via reflection.
|
||||
/// <see langword="null"/> if the type cannot be found (e.g., MEAI version drift).
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
|
||||
Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2073:RequiresUnreferencedCode",
|
||||
Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")]
|
||||
private static readonly Type? s_meaiResponsesChatClientType =
|
||||
typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
|
||||
|
||||
/// <summary>
|
||||
/// MEAI's internal <c>_responseClient</c> field on <c>OpenAIResponsesChatClient</c>,
|
||||
/// resolved once via reflection. <see langword="null"/> if the field cannot be found.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2080:RequiresDynamicallyAccessedMembers",
|
||||
Justification = "OpenAIResponsesChatClient and its private fields are preserved by the polyfill design; MEAI does the same reflection internally.")]
|
||||
private static readonly FieldInfo? s_meaiResponseClientField =
|
||||
s_meaiResponsesChatClientType?.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for translating between agent-framework tool-approval request ids and the
|
||||
/// strict-format wire ids required by the Responses Server SDK <c>mcp_approval_request</c>
|
||||
/// item type. The mapping is persisted in <see cref="AgentSessionStateBag"/> so an
|
||||
/// approval request emitted on one HTTP turn can be matched to the response posted
|
||||
/// back on the next turn.
|
||||
/// </summary>
|
||||
internal static class ToolApprovalIdMap
|
||||
{
|
||||
/// <summary>
|
||||
/// State-bag key used to store the wire-id ↔ AF-request-id mapping.
|
||||
/// </summary>
|
||||
public const string StateBagKey = "Microsoft.Agents.AI.Foundry.Hosting.ToolApprovalIdMap";
|
||||
|
||||
/// <summary>
|
||||
/// SDK item-id format constraints: <c>{prefix}_{50_or_48_chars}</c>. We use the
|
||||
/// canonical <c>mcpr_</c> prefix and a SHA-256 truncated to 50 hex chars (25 bytes)
|
||||
/// for deterministic, format-safe wire ids.
|
||||
/// </summary>
|
||||
public static string ComputeWireId(string afRequestId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(afRequestId);
|
||||
|
||||
#if NET10_0_OR_GREATER
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId), hash);
|
||||
#else
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId));
|
||||
#endif
|
||||
// 25 bytes = 50 hex chars (matches SDK body length 50).
|
||||
return "mcpr_" + Convert.ToHexString(hash).AsSpan(0, 50).ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the wire-id → AF-request-id mapping in the supplied state bag.
|
||||
/// </summary>
|
||||
public static void Record(AgentSessionStateBag? stateBag, string wireId, string afRequestId)
|
||||
{
|
||||
if (stateBag is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var map = stateBag.GetValue<Dictionary<string, string>>(StateBagKey)
|
||||
?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
map[wireId] = afRequestId;
|
||||
stateBag.SetValue(StateBagKey, map);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the AF request id for a given wire id. Returns the wire id verbatim
|
||||
/// when no mapping is present (best-effort fallback that keeps converters total).
|
||||
/// </summary>
|
||||
public static string Resolve(AgentSessionStateBag? stateBag, string wireId)
|
||||
{
|
||||
if (stateBag?.GetValue<Dictionary<string, string>>(StateBagKey) is { } map
|
||||
&& map.TryGetValue(wireId, out var afRequestId))
|
||||
{
|
||||
return afRequestId;
|
||||
}
|
||||
|
||||
return wireId;
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ResponsesClient"/> subclass that delegates every protocol-level request to a
|
||||
/// wrapped <see cref="ResponsesClient"/>. Before each call, a
|
||||
/// <see cref="HostedAgentUserAgentPolicy"/> is added to the per-call
|
||||
/// <see cref="RequestOptions"/> so the wrapped client's pipeline appends the hosted-agent
|
||||
/// <c>User-Agent</c> segment on the wire.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The streaming overloads MEAI binds via reflection (<c>internal CreateResponseStreamingAsync(CreateResponseOptions, RequestOptions)</c>
|
||||
/// and <c>internal GetResponseStreamingAsync(GetResponseOptions, RequestOptions)</c>) bottom out
|
||||
/// in calls to the public-virtual non-streaming protocol overloads on <see langword="this"/>. Overriding those
|
||||
/// non-streaming overloads is therefore sufficient to intercept both streaming and non-streaming traffic.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The base pipeline supplied to <see cref="ResponsesClient(ClientPipeline, OpenAIClientOptions)"/>
|
||||
/// is a dummy pipeline whose terminal transport throws if invoked. Every override on this class
|
||||
/// delegates to the inner client BEFORE any code path reaches <see cref="ResponsesClient.Pipeline"/>, so the dummy is
|
||||
/// never expected to run; the throwing transport surfaces any unexpected escape route loudly.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class UserAgentResponsesClient : ResponsesClient
|
||||
{
|
||||
private readonly ResponsesClient _inner;
|
||||
|
||||
public UserAgentResponsesClient(ResponsesClient inner)
|
||||
: base(BuildDummyPipeline(), new OpenAIClientOptions { Endpoint = inner?.Endpoint })
|
||||
{
|
||||
this._inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
public override async Task<ClientResult> CreateResponseAsync(BinaryContent content, RequestOptions? options = null)
|
||||
=> await this._inner.CreateResponseAsync(content, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult CreateResponse(BinaryContent content, RequestOptions? options = null)
|
||||
=> this._inner.CreateResponse(content, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> GetResponseAsync(string responseId, IEnumerable<IncludedResponseProperty>? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options)
|
||||
=> await this._inner.GetResponseAsync(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult GetResponse(string responseId, IEnumerable<IncludedResponseProperty>? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options)
|
||||
=> this._inner.GetResponse(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> DeleteResponseAsync(string responseId, RequestOptions options)
|
||||
=> await this._inner.DeleteResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult DeleteResponse(string responseId, RequestOptions options)
|
||||
=> this._inner.DeleteResponse(responseId, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> CancelResponseAsync(string responseId, RequestOptions options)
|
||||
=> await this._inner.CancelResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult CancelResponse(string responseId, RequestOptions options)
|
||||
=> this._inner.CancelResponse(responseId, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> GetInputTokenCountAsync(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> await this._inner.GetInputTokenCountAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult GetInputTokenCount(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> this._inner.GetInputTokenCount(contentType, content, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> CompactResponseAsync(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> await this._inner.CompactResponseAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult CompactResponse(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> this._inner.CompactResponse(contentType, content, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> GetResponseInputItemCollectionPageAsync(string responseId, int? limit, string order, string after, string before, RequestOptions options)
|
||||
=> await this._inner.GetResponseInputItemCollectionPageAsync(responseId, limit, order, after, before, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult GetResponseInputItemCollectionPage(string responseId, int? limit, string order, string after, string before, RequestOptions options)
|
||||
=> this._inner.GetResponseInputItemCollectionPage(responseId, limit, order, after, before, AddUserAgentPolicy(options));
|
||||
|
||||
private static RequestOptions AddUserAgentPolicy(RequestOptions? options)
|
||||
{
|
||||
options ??= new RequestOptions();
|
||||
options.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall);
|
||||
return options;
|
||||
}
|
||||
|
||||
private static ClientPipeline BuildDummyPipeline()
|
||||
{
|
||||
var options = new ClientPipelineOptions
|
||||
{
|
||||
Transport = new ThrowingTransport(),
|
||||
};
|
||||
return ClientPipeline.Create(options, default, default, default);
|
||||
}
|
||||
|
||||
private sealed class ThrowingTransport : PipelineTransport
|
||||
{
|
||||
private const string Message =
|
||||
"UserAgentResponsesClient transport invoked bypassed the override-and-delegate design. This exception should be unreachable and should never be thrown following the correct usage of UserAgentResponsesClient.";
|
||||
|
||||
protected override PipelineMessage CreateMessageCore() => throw new InvalidOperationException(Message);
|
||||
protected override void ProcessCore(PipelineMessage message) => throw new InvalidOperationException(Message);
|
||||
protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new InvalidOperationException(Message);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.Hosting.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -12,6 +13,20 @@ internal static class RequestOptionsExtensions
|
||||
/// <summary>Gets the singleton <see cref="PipelinePolicy"/> that adds a MEAI user-agent header.</summary>
|
||||
internal static PipelinePolicy UserAgentPolicy => MeaiUserAgentPolicy.Instance;
|
||||
|
||||
/// <summary>Creates a <see cref="RequestOptions"/> configured for use with Foundry Agents.</summary>
|
||||
public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming)
|
||||
{
|
||||
RequestOptions requestOptions = new()
|
||||
{
|
||||
CancellationToken = cancellationToken,
|
||||
BufferResponse = !streaming
|
||||
};
|
||||
|
||||
requestOptions.AddPolicy(MeaiUserAgentPolicy.Instance, PipelinePosition.PerCall);
|
||||
|
||||
return requestOptions;
|
||||
}
|
||||
|
||||
/// <summary>Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header.</summary>
|
||||
private sealed class MeaiUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
|
||||
@@ -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
-45
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -9,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
/// <summary>
|
||||
/// Represents a request for external input.
|
||||
/// </summary>
|
||||
public sealed class ExternalInputRequest : IExternalRequestEnvelope
|
||||
public sealed class ExternalInputRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The source message that triggered the request for external input.
|
||||
@@ -31,47 +30,4 @@ public sealed class ExternalInputRequest : IExternalRequestEnvelope
|
||||
{
|
||||
this.AgentResponse = new AgentResponse(new ChatMessage(ChatRole.User, text));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Prefers <see cref="ToolApprovalRequestContent"/> (when the workflow declared
|
||||
/// <c>requireApproval: true</c>) over <see cref="FunctionCallContent"/> so that
|
||||
/// hosts which speak the approval protocol see the approval-bearing content.
|
||||
/// </remarks>
|
||||
AIContent? IExternalRequestEnvelope.GetInnerRequestContent()
|
||||
{
|
||||
IList<ChatMessage>? messages = this.AgentResponse?.Messages;
|
||||
if (messages is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is ToolApprovalRequestContent toolApprovalRequest)
|
||||
{
|
||||
return toolApprovalRequest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent functionCall)
|
||||
{
|
||||
return functionCall;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
object IExternalRequestEnvelope.CreateResponse(IList<ChatMessage> messages)
|
||||
=> new ExternalInputResponse(messages);
|
||||
}
|
||||
|
||||
+6
-139
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -6,7 +6,6 @@ using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
|
||||
@@ -20,14 +19,6 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt
|
||||
string activityText = this.Engine.Format(messageActivity.Text).Trim();
|
||||
|
||||
await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Route through YieldOutputAsync so the activity participates in the workflow's
|
||||
// output-filter pipeline. The runner currently special-cases AgentResponse to
|
||||
// produce an AgentResponseEvent identical to the one we'd build by hand, so this
|
||||
// is behavior-preserving today and forward-compatible if filtering is ever
|
||||
// applied to agent responses.
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
|
||||
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return default;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
-212
@@ -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;
|
||||
}
|
||||
}
|
||||
-28
@@ -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));
|
||||
}
|
||||
}
|
||||
-302
@@ -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();
|
||||
}
|
||||
}
|
||||
-164
@@ -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
|
||||
}
|
||||
-35
@@ -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>
|
||||
-213
@@ -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;
|
||||
}
|
||||
-452
@@ -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();
|
||||
}
|
||||
+2
-1
@@ -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
|
||||
+2
-1
@@ -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
|
||||
{
|
||||
+2
-1
@@ -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
|
||||
{
|
||||
+2
-1
@@ -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
|
||||
{
|
||||
+2
-1
@@ -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
|
||||
{
|
||||
+2
-1
@@ -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.
|
||||
+2
-464
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-129
@@ -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)
|
||||
+2
-44
@@ -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.");
|
||||
}
|
||||
}
|
||||
+134
@@ -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();
|
||||
}
|
||||
+508
@@ -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();
|
||||
}
|
||||
}
|
||||
+29
@@ -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());
|
||||
}
|
||||
}
|
||||
-11
@@ -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]));
|
||||
}
|
||||
}
|
||||
-173
@@ -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);
|
||||
}
|
||||
}
|
||||
-16
@@ -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 _));
|
||||
}
|
||||
}
|
||||
-1
@@ -11,7 +11,6 @@
|
||||
"min_action_count": 8,
|
||||
"min_message_count": 1,
|
||||
"min_response_count": 1,
|
||||
"max_response_count": 4,
|
||||
"actions": {
|
||||
"start": [
|
||||
"conversation_create1",
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-4
@@ -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",
|
||||
|
||||
-10
@@ -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)
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+5
-3
@@ -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
Reference in New Issue
Block a user