mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d4c3723a7 | ||
|
|
9711562c9e | ||
|
|
14d779c0fb | ||
|
|
2607ba1b36 | ||
|
|
912961b10c | ||
|
|
8a08776a32 |
@@ -37,7 +37,6 @@ jobs:
|
||||
outputs:
|
||||
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
|
||||
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
|
||||
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
@@ -48,21 +47,6 @@ jobs:
|
||||
- 'dotnet/**'
|
||||
cosmosdb:
|
||||
- 'dotnet/src/Microsoft.Agents.AI.CosmosNoSql/**'
|
||||
# The Foundry hosted-agent IT is costly (builds a container, pushes to ACR,
|
||||
# provisions live agents). Only run it when the project under test, its
|
||||
# dependency chain, the test container, the test fixture, or their tooling
|
||||
# changed. Keep this list in sync with $hashedDirs in scripts/it-build-image.ps1.
|
||||
foundryHosting:
|
||||
- 'dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/**'
|
||||
- 'dotnet/src/Microsoft.Agents.AI.Foundry/**'
|
||||
- 'dotnet/src/Microsoft.Agents.AI/**'
|
||||
- 'dotnet/src/Microsoft.Agents.AI.Abstractions/**'
|
||||
- 'dotnet/src/Microsoft.Agents.AI.Workflows/**'
|
||||
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/**'
|
||||
- 'dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/**'
|
||||
- 'dotnet/Directory.Packages.props'
|
||||
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1'
|
||||
- '.github/workflows/dotnet-build-and-test.yml'
|
||||
# run only if 'dotnet' files were changed
|
||||
- name: dotnet tests
|
||||
if: steps.filter.outputs.dotnet == 'true'
|
||||
@@ -273,11 +257,8 @@ jobs:
|
||||
-c ${{ matrix.configuration }} `
|
||||
--no-build -v Normal `
|
||||
--report-xunit-trx `
|
||||
--report-junit `
|
||||
--results-directory ../IntegrationTestResults/ `
|
||||
--ignore-exit-code 8 `
|
||||
--filter-not-trait "Category=IntegrationDisabled" `
|
||||
--filter-not-trait "Category=FoundryHostedAgents" `
|
||||
--parallel-algorithm aggressive `
|
||||
--max-threads 2.0x
|
||||
env:
|
||||
@@ -296,10 +277,6 @@ jobs:
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
|
||||
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
|
||||
# Anthropic Models
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL_NAME: ${{ vars.ANTHROPIC_CHAT_MODEL_NAME }}
|
||||
ANTHROPIC_REASONING_MODEL_NAME: ${{ vars.ANTHROPIC_REASONING_MODEL_NAME }}
|
||||
|
||||
# Generate test reports and check coverage
|
||||
- name: Generate test reports
|
||||
@@ -322,117 +299,11 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./dotnet/eng/scripts/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
|
||||
|
||||
- name: Upload integration test results
|
||||
if: always() && github.event_name != 'pull_request' && matrix.integration-tests
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dotnet-test-results-${{ matrix.targetFramework }}-${{ matrix.os }}
|
||||
path: IntegrationTestResults/**/*.junit
|
||||
if-no-files-found: ignore
|
||||
|
||||
# The Foundry hosted-agent IT is costly (it builds a container, pushes to ACR, and provisions
|
||||
# live agents on a separate Foundry project). Running it in its own job keeps the overall
|
||||
# workflow time roughly flat: it executes in parallel to dotnet-build and dotnet-test and is
|
||||
# gated on paths-filter.outputs.foundryHostingChanges so unrelated edits skip the work.
|
||||
dotnet-foundry-hosted-it:
|
||||
needs: paths-filter
|
||||
if: github.event_name != 'pull_request' && needs.paths-filter.outputs.foundryHostingChanges == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
targetFramework: net10.0
|
||||
configuration: Release
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.
|
||||
.github
|
||||
dotnet
|
||||
python
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
- name: Generate test solution (no samples)
|
||||
shell: pwsh
|
||||
run: |
|
||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
|
||||
-Solution dotnet/agent-framework-dotnet.slnx `
|
||||
-TargetFramework $env:targetFramework `
|
||||
-Configuration $env:configuration `
|
||||
-ExcludeSamples `
|
||||
-OutputPath dotnet/filtered.slnx `
|
||||
-Verbose
|
||||
|
||||
- name: Generate Foundry hosted IT filtered solution
|
||||
shell: pwsh
|
||||
run: |
|
||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
|
||||
-Solution dotnet/filtered.slnx `
|
||||
-TargetFramework $env:targetFramework `
|
||||
-Configuration $env:configuration `
|
||||
-TestProjectNameFilter "Foundry.Hosting.IntegrationTests*" `
|
||||
-OutputPath dotnet/filtered-foundry-hosted.slnx `
|
||||
-Verbose
|
||||
|
||||
- name: Build Foundry hosted IT (and its deps)
|
||||
shell: bash
|
||||
run: dotnet build dotnet/filtered-foundry-hosted.slnx -c "$configuration" -f "$targetFramework" --warnaserror
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# We rebuild and push the test container image on every IT run so framework code changes
|
||||
# are picked up; the image tag is content-hashed across the test container source AND its
|
||||
# framework project references, so identical content is a no-op push.
|
||||
#
|
||||
# `-UsePrebuiltProjectReferences` opts into the no-rebuild fast path: publish skips
|
||||
# rebuilding ProjectReferences and consumes the DLLs the prior "Build Foundry hosted IT
|
||||
# (and its deps)" step already produced. This avoids MSB3026 ("file is being used by
|
||||
# another process") collisions caused by the previous build's shared-compilation server
|
||||
# still holding file handles to those DLLs. Safe in CI because the prebuild step ran in
|
||||
# the same job against the same source. Do not remove the prebuild step (the subsequent
|
||||
# `dotnet test --no-build` step depends on it too).
|
||||
- name: Build and push Foundry Hosted Agents test container
|
||||
id: build-foundry-hosted-image
|
||||
shell: pwsh
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
$registry = "${{ vars.IT_HOSTED_AGENT_REGISTRY }}"
|
||||
if ([string]::IsNullOrWhiteSpace($registry)) {
|
||||
throw "IT_HOSTED_AGENT_REGISTRY not set in the integration environment."
|
||||
}
|
||||
& "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry -UsePrebuiltProjectReferences | Tee-Object -FilePath $env:GITHUB_ENV -Append
|
||||
|
||||
- name: Run Foundry Hosted Agents Integration Tests
|
||||
shell: pwsh
|
||||
working-directory: dotnet
|
||||
run: |
|
||||
dotnet test --solution ./filtered-foundry-hosted.slnx `
|
||||
-f $env:targetFramework `
|
||||
-c $env:configuration `
|
||||
--no-build -v Normal `
|
||||
--report-xunit-trx `
|
||||
--ignore-exit-code 8 `
|
||||
--filter-trait "Category=FoundryHostedAgents"
|
||||
env:
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.IT_HOSTED_AGENT_PROJECT_ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME }}
|
||||
# IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step.
|
||||
|
||||
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
|
||||
dotnet-build-and-test-check:
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it]
|
||||
needs: [dotnet-build, dotnet-test]
|
||||
steps:
|
||||
- name: Get Date
|
||||
shell: bash
|
||||
@@ -470,64 +341,3 @@ jobs:
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
# Integration test trend report (aggregates JUnit XML results from dotnet test jobs)
|
||||
dotnet-integration-test-report:
|
||||
name: Integration Test Report
|
||||
if: >
|
||||
always() &&
|
||||
github.event_name != 'pull_request' &&
|
||||
(contains(join(needs.*.result, ','), 'success') ||
|
||||
contains(join(needs.*.result, ','), 'failure'))
|
||||
needs: [dotnet-test]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github/actions/python-setup
|
||||
python
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: "3.13"
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: dotnet-test-results-*
|
||||
path: dotnet-test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
dotnet-integration-report-history-
|
||||
- name: Generate trend report
|
||||
run: >
|
||||
uv run python scripts/integration_test_report/aggregate.py
|
||||
../dotnet-test-results/
|
||||
dotnet-integration-report-history.json
|
||||
dotnet-integration-test-report.md
|
||||
- name: Post to Job Summary
|
||||
if: always()
|
||||
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
- name: Upload trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dotnet-integration-test-report
|
||||
path: |
|
||||
python/dotnet-integration-test-report.md
|
||||
python/dotnet-integration-report-history.json
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -71,12 +71,12 @@
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.5.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.5.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.3.0-preview.1.26109.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.1" />
|
||||
@@ -98,7 +98,7 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0-beta.2" />
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="0.1.29" />
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
|
||||
<!-- M365 Agents SDK -->
|
||||
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
|
||||
@@ -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" />
|
||||
|
||||
@@ -33,4 +33,3 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
|
||||
- [Design Documents](../docs/design)
|
||||
- [Architectural Decision Records](../docs/decisions)
|
||||
- [MSFT Learn Docs](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Solution>
|
||||
<Solution>
|
||||
<Configurations>
|
||||
<BuildType Name="Debug" />
|
||||
<BuildType Name="Publish" />
|
||||
@@ -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" />
|
||||
@@ -319,9 +313,6 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
||||
</Folder>
|
||||
@@ -544,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" />
|
||||
@@ -579,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" />
|
||||
@@ -596,14 +576,11 @@
|
||||
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
|
||||
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
|
||||
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj" />
|
||||
<Project Path="tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.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" />
|
||||
@@ -629,7 +606,6 @@
|
||||
<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>
|
||||
|
||||
@@ -50,12 +50,12 @@ Console.WriteLine(await agent.RunAsync("My name is RuaidhrĂ", session));
|
||||
Console.WriteLine(await agent.RunAsync("I am 20 years old", session));
|
||||
|
||||
// We can serialize the session. The serialized state will include the state of the memory component.
|
||||
JsonElement sessionElement = await agent.SerializeSessionAsync(session);
|
||||
JsonElement sesionElement = await agent.SerializeSessionAsync(session);
|
||||
|
||||
Console.WriteLine("\n>> Use deserialized session with previously created memories\n");
|
||||
|
||||
// Later we can deserialize the session and continue the conversation with the previous memory component state.
|
||||
var deserializedSession = await agent.DeserializeSessionAsync(sessionElement);
|
||||
var deserializedSession = await agent.DeserializeSessionAsync(sesionElement);
|
||||
Console.WriteLine(await agent.RunAsync("What is my name and age?", deserializedSession));
|
||||
|
||||
Console.WriteLine("\n>> Read memories using memory component\n");
|
||||
|
||||
@@ -12,9 +12,7 @@ static Task<PermissionRequestResult> PromptPermission(PermissionRequest request,
|
||||
Console.Write("Approve? (y/n): ");
|
||||
|
||||
string? input = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||
PermissionRequestResultKind kind = input is "Y" or "YES"
|
||||
? PermissionRequestResultKind.Approved
|
||||
: PermissionRequestResultKind.Rejected;
|
||||
string kind = input is "Y" or "YES" ? "approved" : "denied-interactively-by-user";
|
||||
|
||||
return Task.FromResult(new PermissionRequestResult { Kind = kind });
|
||||
}
|
||||
|
||||
-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.
|
||||
@@ -24,5 +24,5 @@ public interface ICommandHandler
|
||||
/// <param name="input">The raw user input string.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
/// <returns><see langword="true"/> if this handler handled the input; <see langword="false"/> otherwise.</returns>
|
||||
ValueTask<bool> TryHandleAsync(string input, AgentSession session);
|
||||
bool TryHandle(string input, AgentSession session);
|
||||
}
|
||||
|
||||
+5
-5
@@ -27,17 +27,17 @@ internal sealed class ModeCommandHandler : ICommandHandler
|
||||
public string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<bool> TryHandleAsync(string input, AgentSession session)
|
||||
public bool TryHandle(string input, AgentSession session)
|
||||
{
|
||||
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._modeProvider is null)
|
||||
{
|
||||
System.Console.WriteLine("AgentModeProvider is not available.");
|
||||
return ValueTask.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
@@ -45,7 +45,7 @@ internal sealed class ModeCommandHandler : ICommandHandler
|
||||
{
|
||||
string current = this._modeProvider.GetMode(session);
|
||||
System.Console.WriteLine($"\n Current mode: {current}\n");
|
||||
return ValueTask.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
string newMode = parts[1];
|
||||
@@ -64,6 +64,6 @@ internal sealed class ModeCommandHandler : ICommandHandler
|
||||
System.Console.ResetColor();
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ internal sealed class TodoCommandHandler : ICommandHandler
|
||||
public string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask<bool> TryHandleAsync(string input, AgentSession session)
|
||||
public bool TryHandle(string input, AgentSession session)
|
||||
{
|
||||
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -37,7 +37,7 @@ internal sealed class TodoCommandHandler : ICommandHandler
|
||||
return true;
|
||||
}
|
||||
|
||||
var todos = await this._todoProvider.GetAllTodosAsync(session).ConfigureAwait(false);
|
||||
var todos = this._todoProvider.GetAllTodos(session);
|
||||
if (todos.Count == 0)
|
||||
{
|
||||
System.Console.WriteLine("\n No todos yet.\n");
|
||||
|
||||
@@ -69,7 +69,7 @@ public static class HarnessConsole
|
||||
bool handled = false;
|
||||
foreach (var handler in commandHandlers)
|
||||
{
|
||||
if (await handler.TryHandleAsync(userInput, session).ConfigureAwait(false))
|
||||
if (handler.TryHandle(userInput, session))
|
||||
{
|
||||
handled = true;
|
||||
break;
|
||||
|
||||
@@ -165,8 +165,7 @@ AIAgent agent =
|
||||
Tools =
|
||||
[
|
||||
ResponseTool.CreateWebSearchTool().AsAITool(), // Add the foundry hosted web search tool that runs in the service.
|
||||
new WebBrowsingTool( // Add a local web browsing tool that converts html to markdown.
|
||||
new WebBrowsingToolOptions { AllowPublicNetworks = true }),
|
||||
new WebBrowsingTool(), // Add a local web browsing tool that converts html to markdown.
|
||||
],
|
||||
MaxOutputTokens = MaxOutputTokens, // Set a high token limit for long research tasks with many tool calls and long outputs.
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -11,23 +10,11 @@ namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// An AI function that downloads HTML pages and converts them to markdown.
|
||||
/// Access is controlled by <see cref="WebBrowsingToolOptions"/> — by default, no hosts are accessible.
|
||||
/// </summary>
|
||||
internal sealed partial class WebBrowsingTool : AIFunction
|
||||
{
|
||||
private static readonly HttpClient s_httpClient = new();
|
||||
private readonly AIFunction _inner;
|
||||
private readonly WebBrowsingToolOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="WebBrowsingTool"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">Options controlling which URLs are permitted. By default, no hosts are accessible.</param>
|
||||
public WebBrowsingTool(WebBrowsingToolOptions options)
|
||||
{
|
||||
this._options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this._inner = AIFunctionFactory.Create(this.DownloadUriAsync);
|
||||
}
|
||||
private readonly AIFunction _inner = AIFunctionFactory.Create(DownloadUriAsync);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Name => this._inner.Name;
|
||||
@@ -45,7 +32,7 @@ internal sealed partial class WebBrowsingTool : AIFunction
|
||||
this._inner.InvokeAsync(arguments, cancellationToken);
|
||||
|
||||
[Description("Fetch the html from the given url as markdown")]
|
||||
private async Task<string> DownloadUriAsync(
|
||||
private static async Task<string> DownloadUriAsync(
|
||||
[Description("The URL to download")] string uri,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -59,12 +46,9 @@ internal sealed partial class WebBrowsingTool : AIFunction
|
||||
return $"Error: Only HTTP and HTTPS URLs are supported. Got: '{parsedUri.Scheme}'.";
|
||||
}
|
||||
|
||||
// Check access policy.
|
||||
string? accessError = await this.CheckAccessAsync(parsedUri, cancellationToken);
|
||||
if (accessError is not null)
|
||||
{
|
||||
return accessError;
|
||||
}
|
||||
// NOTE: In production scenarios, consider also blocking requests to private/internal IP
|
||||
// ranges (e.g., 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.0.0.1, 169.254.169.254)
|
||||
// to prevent SSRF attacks via prompt injection in web content.
|
||||
|
||||
try
|
||||
{
|
||||
@@ -77,142 +61,6 @@ internal sealed partial class WebBrowsingTool : AIFunction
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the given URI is permitted by the configured access policy.
|
||||
/// Returns null if allowed, or an error message string if blocked.
|
||||
/// </summary>
|
||||
private async Task<string?> CheckAccessAsync(Uri uri, CancellationToken cancellationToken)
|
||||
{
|
||||
string host = uri.Host;
|
||||
|
||||
// 1. Check AllowedHosts.
|
||||
if (this._options.AllowedHosts is { Count: > 0 } allowedHosts)
|
||||
{
|
||||
foreach (string pattern in allowedHosts)
|
||||
{
|
||||
if (HostMatchesPattern(host, pattern))
|
||||
{
|
||||
return null; // Allowed by explicit host list.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Short-circuit when the policy is guaranteed to block.
|
||||
if (!this._options.AllowPublicNetworks &&
|
||||
!this._options.AllowPrivateNetworks &&
|
||||
!this._options.AllowAllHosts)
|
||||
{
|
||||
return $"Error: Access to '{host}' is blocked by the current access policy. Configure WebBrowsingToolOptions to allow access.";
|
||||
}
|
||||
|
||||
// 3. Resolve DNS to determine if the host is public or private.
|
||||
IPAddress[] addresses;
|
||||
try
|
||||
{
|
||||
addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return $"Error: Could not resolve host '{host}'.";
|
||||
}
|
||||
|
||||
if (addresses.Length == 0)
|
||||
{
|
||||
return $"Error: Could not resolve host '{host}'.";
|
||||
}
|
||||
|
||||
bool isPrivate = Array.Exists(addresses, IsPrivateAddress);
|
||||
|
||||
// 4. If public and AllowPublicNetworks is true → allow.
|
||||
if (!isPrivate && this._options.AllowPublicNetworks)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5. If private and AllowPrivateNetworks is true → allow.
|
||||
if (isPrivate && this._options.AllowPrivateNetworks)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 6. If AllowAllHosts is true → allow.
|
||||
if (this._options.AllowAllHosts)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 7. Block.
|
||||
string networkType = isPrivate ? "private/internal network" : "public network";
|
||||
return $"Error: Access to '{host}' is blocked. The host resolves to a {networkType} address and the current access policy does not permit this. " +
|
||||
"Configure WebBrowsingToolOptions to allow access.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a host matches a pattern. Supports exact match and wildcard prefix (e.g., "*.example.com").
|
||||
/// </summary>
|
||||
private static bool HostMatchesPattern(string host, string pattern)
|
||||
{
|
||||
if (string.Equals(host, pattern, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard prefix: "*.example.com" matches "sub.example.com" and "a.b.example.com".
|
||||
if (pattern.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
string suffix = pattern[1..]; // ".example.com"
|
||||
return host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an IP address is private, loopback, or link-local.
|
||||
/// </summary>
|
||||
private static bool IsPrivateAddress(IPAddress address)
|
||||
{
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
{
|
||||
address = address.MapToIPv4();
|
||||
}
|
||||
|
||||
if (IPAddress.IsLoopback(address))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
return bytes[0] switch
|
||||
{
|
||||
10 => true, // 10.0.0.0/8
|
||||
172 => bytes[1] >= 16 && bytes[1] <= 31, // 172.16.0.0/12
|
||||
192 => bytes[1] == 168, // 192.168.0.0/16
|
||||
169 => bytes[1] == 254, // 169.254.0.0/16 (link-local + metadata)
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
{
|
||||
// fe80::/10 (link-local) or fc00::/7 (unique local).
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
if (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80)
|
||||
{
|
||||
return true; // Link-local
|
||||
}
|
||||
|
||||
if ((bytes[0] & 0xfe) == 0xfc)
|
||||
{
|
||||
return true; // Unique local
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple HTML to Markdown converter using regex-based transformations.
|
||||
/// Handles the most common HTML elements without requiring external dependencies.
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// Options that control which URLs the <see cref="WebBrowsingTool"/> is permitted to access.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// By default, <b>no hosts are accessible</b>. You must explicitly opt in to one or more
|
||||
/// of the access modes below. The validation order is:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description>If the host matches an entry in <see cref="AllowedHosts"/>, the request is allowed.</description></item>
|
||||
/// <item><description>If the resolved IP is a public address and <see cref="AllowPublicNetworks"/> is <see langword="true"/>, the request is allowed.</description></item>
|
||||
/// <item><description>If the resolved IP is a private/loopback/link-local address and <see cref="AllowPrivateNetworks"/> is <see langword="true"/>, the request is allowed.</description></item>
|
||||
/// <item><description>If <see cref="AllowAllHosts"/> is <see langword="true"/>, the request is allowed.</description></item>
|
||||
/// <item><description>Otherwise, the request is blocked.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal sealed class WebBrowsingToolOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a list of host patterns that are always permitted, regardless of other settings.
|
||||
/// Patterns support wildcard prefix matching (e.g., <c>"*.example.com"</c> matches <c>"docs.example.com"</c>).
|
||||
/// Exact host names (e.g., <c>"docs.microsoft.com"</c>) are also supported.
|
||||
/// </summary>
|
||||
/// <remarks>This has the highest priority — if a host matches, it is allowed immediately.</remarks>
|
||||
public IReadOnlyList<string>? AllowedHosts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether public internet hosts (non-private, non-loopback, non-link-local IPs) are permitted.
|
||||
/// Default is <see langword="false"/>.
|
||||
/// </summary>
|
||||
public bool AllowPublicNetworks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether private network hosts are permitted.
|
||||
/// This includes RFC 1918 addresses (10.x.x.x, 172.16-31.x.x, 192.168.x.x),
|
||||
/// loopback (127.x.x.x, ::1), link-local (169.254.x.x, fe80::),
|
||||
/// and cloud metadata endpoints (169.254.169.254).
|
||||
/// Default is <see langword="false"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Warning:</b> Enabling this allows the agent to make requests to internal services,
|
||||
/// localhost, and cloud metadata endpoints. Only enable this if you understand the SSRF risks.
|
||||
/// </remarks>
|
||||
public bool AllowPrivateNetworks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether all hosts are permitted without any restriction.
|
||||
/// Default is <see langword="false"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>⚠️ UNSAFE:</b> Enabling this disables all network boundary checks and allows the agent
|
||||
/// to access any URL, including internal services, cloud metadata endpoints, and localhost.
|
||||
/// Only use this for trusted, isolated environments where SSRF is not a concern.
|
||||
/// </remarks>
|
||||
public bool AllowAllHosts { get; set; }
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
.env
|
||||
bin/
|
||||
obj/
|
||||
out/
|
||||
.vs/
|
||||
.vscode/
|
||||
*.user
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
|
||||
# Capture prompt / completion / tool argument content on GenAI spans.
|
||||
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
|
||||
|
||||
# Uncomment and set to send local-run telemetry to Application Insights.
|
||||
# When the agent runs inside Foundry this value is injected automatically.
|
||||
#APPLICATIONINSIGHTS_CONNECTION_STRING=<your-app-insights-connection-string>
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedObservability.dll"]
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
|
||||
# which means a standard multi-stage Docker build cannot resolve dependencies outside
|
||||
# this folder. Instead, pre-publish the app targeting the container runtime and copy
|
||||
# the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-observability .
|
||||
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-observability -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-observability
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedObservability.dll"]
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedObservability</RootNamespace>
|
||||
<AssemblyName>HostedObservability</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<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>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Hosted Observability Agent - demonstrates that the Foundry hosting pipeline
|
||||
// emits OpenTelemetry traces, metrics and logs with no extra wiring required.
|
||||
// Two small tools are included so a request produces a span tree covering
|
||||
// agent invocation, the chat call, and tool execution.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
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-4o";
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// ── Tools ────────────────────────────────────────────────────────────────────
|
||||
|
||||
string[] locations = ["New York", "London", "Paris", "Tokyo"];
|
||||
string[] conditions = ["sunny", "cloudy", "rainy", "stormy"];
|
||||
|
||||
[Description("Get the current location of the user.")]
|
||||
string GetCurrentLocation() => locations[Random.Shared.Next(locations.Length)];
|
||||
|
||||
[Description("Get the weather for a given location.")]
|
||||
string GetWeather(
|
||||
[Description("The location to get the weather for.")] string location)
|
||||
=> $"The weather in {location} is {conditions[Random.Shared.Next(conditions.Length)]} with a high of {Random.Shared.Next(10, 31)}°C.";
|
||||
|
||||
// ── Create and host the agent ────────────────────────────────────────────────
|
||||
//
|
||||
// AddFoundryResponses automatically wraps `agent` with OpenTelemetryAgent
|
||||
// (see Microsoft.Agents.AI.Foundry.Hosting.ServiceCollectionExtensions.ApplyOpenTelemetry)
|
||||
// and the OTLP exporter is registered by Azure.AI.AgentServer.Core's
|
||||
// AddAgentHostTelemetry(). No additional observability wiring is required.
|
||||
|
||||
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: "You are a friendly assistant. Keep your answers brief.",
|
||||
name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-observability",
|
||||
description: "A hosted agent that demonstrates Foundry observability.",
|
||||
tools: [
|
||||
AIFunctionFactory.Create(GetCurrentLocation),
|
||||
AIFunctionFactory.Create(GetWeather),
|
||||
]);
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
# Hosted-Observability
|
||||
|
||||
A hosted [Agent Framework](https://github.com/microsoft/agent-framework) agent that demonstrates how the Foundry hosting pipeline emits OpenTelemetry traces, metrics and logs with no extra wiring.
|
||||
|
||||
The agent has two small tools, `GetCurrentLocation` and `GetWeather`, so an end-to-end run produces a span tree covering agent invocation, the underlying chat call, and tool execution.
|
||||
|
||||
## How it works
|
||||
|
||||
### Instrumentation is on by default
|
||||
|
||||
Unlike the Python SDK, the .NET hosting library is instrumented by default. `AddFoundryResponses(agent)` automatically wraps the agent with `OpenTelemetryAgent` (see `Microsoft.Agents.AI.Foundry.Hosting.ServiceCollectionExtensions.ApplyOpenTelemetry`) and the OTLP exporter pipeline is registered by `Azure.AI.AgentServer.Core`'s `AddAgentHostTelemetry()`. There is no `ENABLE_INSTRUMENTATION` flag to set.
|
||||
|
||||
### Sensitive content
|
||||
|
||||
Prompt, completion and tool argument content are omitted from spans by default. Set the OpenTelemetry standard environment variable to capture them:
|
||||
|
||||
```env
|
||||
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
|
||||
```
|
||||
|
||||
This is the .NET equivalent of the Python sample's `ENABLE_SENSITIVE_DATA`. It is read by `OpenTelemetryAgent.EnableSensitiveData`.
|
||||
|
||||
### Where the telemetry goes
|
||||
|
||||
Foundry injects `APPLICATIONINSIGHTS_CONNECTION_STRING` when the agent runs in the hosted environment, so traces, metrics and logs flow to Application Insights with no code change. To send telemetry from a local run, set the connection string yourself in `.env`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your Azure AI Foundry project endpoint:
|
||||
|
||||
```env
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
|
||||
```
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability
|
||||
AGENT_NAME=hosted-observability dotnet run
|
||||
```
|
||||
|
||||
The agent starts on `http://localhost:8088`.
|
||||
|
||||
### Test it
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "What is the current weather where I am?"
|
||||
```
|
||||
|
||||
Or with curl:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "What is the current weather where I am?", "model": "hosted-observability"}'
|
||||
```
|
||||
|
||||
## Expected span tree
|
||||
|
||||
A single request produces approximately the following spans:
|
||||
|
||||
| Span | Source |
|
||||
|------|--------|
|
||||
| `invoke_agent` | Outer span emitted by the Azure AI AgentServer hosting SDK |
|
||||
| `agent_invoke <name>` | Emitted by `OpenTelemetryAgent` for each agent invocation |
|
||||
| `chat <model>` | Emitted by the underlying `IChatClient` for each model call |
|
||||
| `execute_tool <tool>` | Emitted for each invocation of `GetCurrentLocation` / `GetWeather` |
|
||||
|
||||
See the [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) for the attributes captured on each span.
|
||||
|
||||
## Running with Docker
|
||||
|
||||
This project uses `ProjectReference` to the local Agent Framework source, so use `Dockerfile.contributor` with a pre-published output:
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
docker build -f Dockerfile.contributor -t hosted-observability .
|
||||
|
||||
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=hosted-observability \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
--env-file .env \
|
||||
hosted-observability
|
||||
```
|
||||
|
||||
## Deploying to Foundry and viewing traces
|
||||
|
||||
Once deployed, telemetry flows to the Application Insights instance attached to your Foundry project. In the Foundry UI, the **Traces** tab next to **Playground** lists conversations and lets you drill into the span tree for any request.
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedObservability.csproj` for the `PackageReference` alternative.
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-observability
|
||||
displayName: "Hosted Observability Agent"
|
||||
|
||||
description: >
|
||||
A hosted Agent Framework agent that demonstrates how the Foundry hosting
|
||||
pipeline emits OpenTelemetry traces, metrics and logs to Application Insights
|
||||
with no extra wiring required.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Observability
|
||||
- OpenTelemetry
|
||||
- Agent Framework
|
||||
|
||||
template:
|
||||
name: hosted-observability
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
# Capture prompt / completion / tool argument content on GenAI spans.
|
||||
- name: OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT
|
||||
value: "true"
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-observability
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
# Capture prompt / completion / tool argument content on GenAI spans.
|
||||
# See https://opentelemetry.io/docs/specs/semconv/gen-ai/ for the standard env var.
|
||||
- name: OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT
|
||||
value: "true"
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -56,32 +55,6 @@ internal static class AGUIChatMessageExtensions
|
||||
break;
|
||||
}
|
||||
|
||||
case AGUIReasoningMessage reasoningMessage:
|
||||
{
|
||||
var contents = new List<AIContent>();
|
||||
|
||||
if (!string.IsNullOrEmpty(reasoningMessage.Content))
|
||||
{
|
||||
contents.Add(new TextReasoningContent(reasoningMessage.Content)
|
||||
{
|
||||
ProtectedData = reasoningMessage.EncryptedValue
|
||||
});
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(reasoningMessage.EncryptedValue))
|
||||
{
|
||||
contents.Add(new TextReasoningContent("")
|
||||
{
|
||||
ProtectedData = reasoningMessage.EncryptedValue
|
||||
});
|
||||
}
|
||||
|
||||
yield return new ChatMessage(role, contents)
|
||||
{
|
||||
MessageId = message.Id
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case AGUIAssistantMessage assistantMessage when assistantMessage.ToolCalls is { Length: > 0 }:
|
||||
{
|
||||
var contents = new List<AIContent>();
|
||||
@@ -152,12 +125,6 @@ internal static class AGUIChatMessageExtensions
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant)
|
||||
{
|
||||
var reasoningMessage = MapReasoningMessage(message);
|
||||
if (reasoningMessage != null)
|
||||
{
|
||||
yield return reasoningMessage;
|
||||
}
|
||||
|
||||
var assistantMessage = MapAssistantMessage(jsonSerializerOptions, message);
|
||||
if (assistantMessage != null)
|
||||
{
|
||||
@@ -177,32 +144,6 @@ internal static class AGUIChatMessageExtensions
|
||||
}
|
||||
}
|
||||
|
||||
private static AGUIReasoningMessage? MapReasoningMessage(ChatMessage message)
|
||||
{
|
||||
var reasoning = message.Contents.OfType<TextReasoningContent>().FirstOrDefault();
|
||||
if (reasoning is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var text = string.Join(
|
||||
string.Empty,
|
||||
message.Contents.OfType<TextReasoningContent>()
|
||||
.Where(r => !string.IsNullOrEmpty(r.Text))
|
||||
.Select(r => r.Text));
|
||||
|
||||
var protectedData = message.Contents.OfType<TextReasoningContent>()
|
||||
.Select(r => r.ProtectedData)
|
||||
.LastOrDefault(p => !string.IsNullOrEmpty(p));
|
||||
|
||||
return new AGUIReasoningMessage
|
||||
{
|
||||
Id = message.MessageId,
|
||||
Content = text,
|
||||
EncryptedValue = protectedData,
|
||||
};
|
||||
}
|
||||
|
||||
private static AGUIAssistantMessage? MapAssistantMessage(JsonSerializerOptions jsonSerializerOptions, ChatMessage message)
|
||||
{
|
||||
List<AGUIToolCall>? toolCalls = null;
|
||||
@@ -271,6 +212,5 @@ internal static class AGUIChatMessageExtensions
|
||||
string.Equals(role, AGUIRoles.Assistant, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant :
|
||||
string.Equals(role, AGUIRoles.Developer, StringComparison.OrdinalIgnoreCase) ? s_developerChatRole :
|
||||
string.Equals(role, AGUIRoles.Tool, StringComparison.OrdinalIgnoreCase) ? ChatRole.Tool :
|
||||
string.Equals(role, AGUIRoles.Reasoning, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant :
|
||||
throw new InvalidOperationException($"Unknown chat role: {role}");
|
||||
}
|
||||
|
||||
@@ -31,18 +31,4 @@ internal static class AGUIEventTypes
|
||||
public const string StateSnapshot = "STATE_SNAPSHOT";
|
||||
|
||||
public const string StateDelta = "STATE_DELTA";
|
||||
|
||||
public const string ReasoningStart = "REASONING_START";
|
||||
|
||||
public const string ReasoningMessageStart = "REASONING_MESSAGE_START";
|
||||
|
||||
public const string ReasoningMessageContent = "REASONING_MESSAGE_CONTENT";
|
||||
|
||||
public const string ReasoningMessageEnd = "REASONING_MESSAGE_END";
|
||||
|
||||
public const string ReasoningEnd = "REASONING_END";
|
||||
|
||||
public const string ReasoningMessageChunk = "REASONING_MESSAGE_CHUNK";
|
||||
|
||||
public const string ReasoningEncryptedValue = "REASONING_ENCRYPTED_VALUE";
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ namespace Microsoft.Agents.AI.AGUI;
|
||||
[JsonSerializable(typeof(AGUIUserMessage))]
|
||||
[JsonSerializable(typeof(AGUIAssistantMessage))]
|
||||
[JsonSerializable(typeof(AGUIToolMessage))]
|
||||
[JsonSerializable(typeof(AGUIReasoningMessage))]
|
||||
[JsonSerializable(typeof(AGUITool))]
|
||||
[JsonSerializable(typeof(AGUIToolCall))]
|
||||
[JsonSerializable(typeof(AGUIToolCall[]))]
|
||||
@@ -47,13 +46,6 @@ namespace Microsoft.Agents.AI.AGUI;
|
||||
[JsonSerializable(typeof(ToolCallResultEvent))]
|
||||
[JsonSerializable(typeof(StateSnapshotEvent))]
|
||||
[JsonSerializable(typeof(StateDeltaEvent))]
|
||||
[JsonSerializable(typeof(ReasoningStartEvent))]
|
||||
[JsonSerializable(typeof(ReasoningMessageStartEvent))]
|
||||
[JsonSerializable(typeof(ReasoningMessageContentEvent))]
|
||||
[JsonSerializable(typeof(ReasoningMessageEndEvent))]
|
||||
[JsonSerializable(typeof(ReasoningEndEvent))]
|
||||
[JsonSerializable(typeof(ReasoningMessageChunkEvent))]
|
||||
[JsonSerializable(typeof(ReasoningEncryptedValueEvent))]
|
||||
[JsonSerializable(typeof(IDictionary<string, object?>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
[JsonSerializable(typeof(IDictionary<string, System.Text.Json.JsonElement?>))]
|
||||
|
||||
@@ -41,7 +41,6 @@ internal sealed class AGUIMessageJsonConverter : JsonConverter<AGUIMessage>
|
||||
AGUIRoles.User => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIUserMessage))) as AGUIUserMessage,
|
||||
AGUIRoles.Assistant => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIAssistantMessage))) as AGUIAssistantMessage,
|
||||
AGUIRoles.Tool => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIToolMessage))) as AGUIToolMessage,
|
||||
AGUIRoles.Reasoning => jsonElement.Deserialize(options.GetTypeInfo(typeof(AGUIReasoningMessage))) as AGUIReasoningMessage,
|
||||
_ => throw new JsonException($"Unknown AGUIMessage role discriminator: '{discriminator}'")
|
||||
};
|
||||
|
||||
@@ -76,9 +75,6 @@ internal sealed class AGUIMessageJsonConverter : JsonConverter<AGUIMessage>
|
||||
case AGUIToolMessage tool:
|
||||
JsonSerializer.Serialize(writer, tool, options.GetTypeInfo(typeof(AGUIToolMessage)));
|
||||
break;
|
||||
case AGUIReasoningMessage reasoning:
|
||||
JsonSerializer.Serialize(writer, reasoning, options.GetTypeInfo(typeof(AGUIReasoningMessage)));
|
||||
break;
|
||||
default:
|
||||
throw new JsonException($"Unknown AGUIMessage type: {value.GetType().Name}");
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class AGUIReasoningMessage : AGUIMessage
|
||||
{
|
||||
public AGUIReasoningMessage()
|
||||
{
|
||||
this.Role = AGUIRoles.Reasoning;
|
||||
}
|
||||
|
||||
[JsonPropertyName("encryptedValue")]
|
||||
public string? EncryptedValue { get; set; }
|
||||
}
|
||||
@@ -17,6 +17,4 @@ internal static class AGUIRoles
|
||||
public const string Developer = "developer";
|
||||
|
||||
public const string Tool = "tool";
|
||||
|
||||
public const string Reasoning = "reasoning";
|
||||
}
|
||||
|
||||
@@ -47,13 +47,6 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
|
||||
AGUIEventTypes.ToolCallEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallEndEvent))) as ToolCallEndEvent,
|
||||
AGUIEventTypes.ToolCallResult => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallResultEvent))) as ToolCallResultEvent,
|
||||
AGUIEventTypes.StateSnapshot => jsonElement.Deserialize(options.GetTypeInfo(typeof(StateSnapshotEvent))) as StateSnapshotEvent,
|
||||
AGUIEventTypes.ReasoningStart => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningStartEvent))) as ReasoningStartEvent,
|
||||
AGUIEventTypes.ReasoningMessageStart => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageStartEvent))) as ReasoningMessageStartEvent,
|
||||
AGUIEventTypes.ReasoningMessageContent => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageContentEvent))) as ReasoningMessageContentEvent,
|
||||
AGUIEventTypes.ReasoningMessageEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageEndEvent))) as ReasoningMessageEndEvent,
|
||||
AGUIEventTypes.ReasoningEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningEndEvent))) as ReasoningEndEvent,
|
||||
AGUIEventTypes.ReasoningMessageChunk => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningMessageChunkEvent))) as ReasoningMessageChunkEvent,
|
||||
AGUIEventTypes.ReasoningEncryptedValue => jsonElement.Deserialize(options.GetTypeInfo(typeof(ReasoningEncryptedValueEvent))) as ReasoningEncryptedValueEvent,
|
||||
_ => throw new JsonException($"Unknown BaseEvent type discriminator: '{discriminator}'")
|
||||
};
|
||||
|
||||
@@ -109,27 +102,6 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
|
||||
case StateDeltaEvent stateDelta:
|
||||
JsonSerializer.Serialize(writer, stateDelta, options.GetTypeInfo(typeof(StateDeltaEvent)));
|
||||
break;
|
||||
case ReasoningStartEvent reasoningStart:
|
||||
JsonSerializer.Serialize(writer, reasoningStart, options.GetTypeInfo(typeof(ReasoningStartEvent)));
|
||||
break;
|
||||
case ReasoningMessageStartEvent reasoningMessageStart:
|
||||
JsonSerializer.Serialize(writer, reasoningMessageStart, options.GetTypeInfo(typeof(ReasoningMessageStartEvent)));
|
||||
break;
|
||||
case ReasoningMessageContentEvent reasoningMessageContent:
|
||||
JsonSerializer.Serialize(writer, reasoningMessageContent, options.GetTypeInfo(typeof(ReasoningMessageContentEvent)));
|
||||
break;
|
||||
case ReasoningMessageEndEvent reasoningMessageEnd:
|
||||
JsonSerializer.Serialize(writer, reasoningMessageEnd, options.GetTypeInfo(typeof(ReasoningMessageEndEvent)));
|
||||
break;
|
||||
case ReasoningEndEvent reasoningEnd:
|
||||
JsonSerializer.Serialize(writer, reasoningEnd, options.GetTypeInfo(typeof(ReasoningEndEvent)));
|
||||
break;
|
||||
case ReasoningMessageChunkEvent reasoningMessageChunk:
|
||||
JsonSerializer.Serialize(writer, reasoningMessageChunk, options.GetTypeInfo(typeof(ReasoningMessageChunkEvent)));
|
||||
break;
|
||||
case ReasoningEncryptedValueEvent reasoningEncryptedValue:
|
||||
JsonSerializer.Serialize(writer, reasoningEncryptedValue, options.GetTypeInfo(typeof(ReasoningEncryptedValueEvent)));
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown event type: {value.GetType().Name}");
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
string? responseId = null;
|
||||
var textMessageBuilder = new TextMessageBuilder();
|
||||
var toolCallAccumulator = new ToolCallBuilder();
|
||||
var reasoningBuilder = new ReasoningMessageBuilder();
|
||||
await foreach (var evt in events.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
switch (evt)
|
||||
@@ -42,7 +41,6 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
responseId = runStarted.RunId;
|
||||
toolCallAccumulator.SetConversationAndResponseIds(conversationId, responseId);
|
||||
textMessageBuilder.SetConversationAndResponseIds(conversationId, responseId);
|
||||
reasoningBuilder.SetConversationAndResponseIds(conversationId, responseId);
|
||||
yield return ValidateAndEmitRunStart(runStarted);
|
||||
break;
|
||||
case RunFinishedEvent runFinished:
|
||||
@@ -90,36 +88,6 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
yield return CreateStateDeltaUpdate(stateDelta, conversationId, responseId, jsonSerializerOptions);
|
||||
}
|
||||
break;
|
||||
|
||||
// Reasoning events (explicit lifecycle form)
|
||||
case ReasoningMessageStartEvent reasoningStart:
|
||||
reasoningBuilder.AddReasoningStart(reasoningStart);
|
||||
break;
|
||||
case ReasoningMessageContentEvent reasoningContent:
|
||||
yield return reasoningBuilder.EmitReasoningContent(reasoningContent);
|
||||
break;
|
||||
case ReasoningMessageEndEvent reasoningEnd:
|
||||
reasoningBuilder.EndCurrentMessage(reasoningEnd);
|
||||
break;
|
||||
|
||||
// Reasoning events (chunk shorthand form)
|
||||
case ReasoningMessageChunkEvent reasoningChunk:
|
||||
var chunkUpdate = reasoningBuilder.EmitReasoningChunk(reasoningChunk);
|
||||
if (chunkUpdate is not null)
|
||||
{
|
||||
yield return chunkUpdate;
|
||||
}
|
||||
break;
|
||||
|
||||
// Encrypted reasoning value (emitted by either form)
|
||||
case ReasoningEncryptedValueEvent encryptedValue:
|
||||
yield return reasoningBuilder.EmitEncryptedValue(encryptedValue);
|
||||
break;
|
||||
|
||||
// ReasoningStartEvent and ReasoningEndEvent are bracket markers only — no content to emit
|
||||
case ReasoningStartEvent:
|
||||
case ReasoningEndEvent:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,81 +305,6 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ReasoningMessageBuilder()
|
||||
{
|
||||
private string? _currentMessageId;
|
||||
private string? _conversationId;
|
||||
private string? _responseId;
|
||||
|
||||
public void SetConversationAndResponseIds(string? conversationId, string? responseId)
|
||||
{
|
||||
this._conversationId = conversationId;
|
||||
this._responseId = responseId;
|
||||
}
|
||||
|
||||
public void AddReasoningStart(ReasoningMessageStartEvent reasoningStart)
|
||||
{
|
||||
if (this._currentMessageId != null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Received ReasoningMessageStartEvent while another message is being processed.");
|
||||
}
|
||||
|
||||
this._currentMessageId = reasoningStart.MessageId;
|
||||
}
|
||||
|
||||
public ChatResponseUpdate EmitReasoningContent(ReasoningMessageContentEvent contentEvent)
|
||||
{
|
||||
return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent(contentEvent.Delta)])
|
||||
{
|
||||
ConversationId = this._conversationId,
|
||||
ResponseId = this._responseId,
|
||||
MessageId = contentEvent.MessageId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public ChatResponseUpdate? EmitReasoningChunk(ReasoningMessageChunkEvent chunkEvent)
|
||||
{
|
||||
if (string.IsNullOrEmpty(chunkEvent.Delta))
|
||||
{
|
||||
// Empty delta is the implicit close signal for chunk-based streaming
|
||||
this._currentMessageId = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
this._currentMessageId ??= chunkEvent.MessageId;
|
||||
return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent(chunkEvent.Delta)])
|
||||
{
|
||||
ConversationId = this._conversationId,
|
||||
ResponseId = this._responseId,
|
||||
MessageId = chunkEvent.MessageId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public ChatResponseUpdate EmitEncryptedValue(ReasoningEncryptedValueEvent encryptedEvent)
|
||||
{
|
||||
return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("") { ProtectedData = encryptedEvent.EncryptedValue }])
|
||||
{
|
||||
ConversationId = this._conversationId,
|
||||
ResponseId = this._responseId,
|
||||
MessageId = encryptedEvent.EntityId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public void EndCurrentMessage(ReasoningMessageEndEvent reasoningEnd)
|
||||
{
|
||||
if (!string.Equals(this._currentMessageId, reasoningEnd.MessageId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Received ReasoningMessageEndEvent for a different message than the current one.");
|
||||
}
|
||||
this._currentMessageId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?>? DeserializeArgumentsIfAvailable(string argsJson, JsonSerializerOptions options)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(argsJson))
|
||||
@@ -449,9 +342,6 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
|
||||
string? currentMessageId = null;
|
||||
string? streamingMessageId = null;
|
||||
string? currentReasoningBaseId = null;
|
||||
string? currentReasoningId = null;
|
||||
string? currentReasoningMessageId = null;
|
||||
await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Generate a fallback MessageId when the provider doesn't supply one.
|
||||
@@ -466,25 +356,6 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
chatResponse.Contents[0] is TextContent &&
|
||||
!string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal))
|
||||
{
|
||||
// Close any open reasoning block before opening a text message, so AG-UI
|
||||
// events are properly bracketed. MEAI providers share one MessageId across
|
||||
// reasoning and text content, so the reasoning-block state alone wouldn't
|
||||
// detect the transition.
|
||||
if (currentReasoningMessageId is not null)
|
||||
{
|
||||
yield return new ReasoningMessageEndEvent
|
||||
{
|
||||
MessageId = currentReasoningMessageId
|
||||
};
|
||||
yield return new ReasoningEndEvent
|
||||
{
|
||||
MessageId = currentReasoningId!
|
||||
};
|
||||
currentReasoningBaseId = null;
|
||||
currentReasoningId = null;
|
||||
currentReasoningMessageId = null;
|
||||
}
|
||||
|
||||
// End the previous message if there was one
|
||||
if (currentMessageId is not null)
|
||||
{
|
||||
@@ -510,7 +381,7 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
{
|
||||
yield return new TextMessageContentEvent
|
||||
{
|
||||
MessageId = currentMessageId!,
|
||||
MessageId = chatResponse.MessageId!,
|
||||
Delta = textContent.Text
|
||||
};
|
||||
}
|
||||
@@ -522,22 +393,6 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
{
|
||||
if (content is FunctionCallContent functionCallContent)
|
||||
{
|
||||
// Close any open reasoning block before emitting tool events.
|
||||
if (currentReasoningMessageId is not null)
|
||||
{
|
||||
yield return new ReasoningMessageEndEvent
|
||||
{
|
||||
MessageId = currentReasoningMessageId
|
||||
};
|
||||
yield return new ReasoningEndEvent
|
||||
{
|
||||
MessageId = currentReasoningId!
|
||||
};
|
||||
currentReasoningBaseId = null;
|
||||
currentReasoningId = null;
|
||||
currentReasoningMessageId = null;
|
||||
}
|
||||
|
||||
yield return new ToolCallStartEvent
|
||||
{
|
||||
ToolCallId = functionCallContent.CallId,
|
||||
@@ -560,22 +415,6 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
}
|
||||
else if (content is FunctionResultContent functionResultContent)
|
||||
{
|
||||
// Close any open reasoning block before emitting tool result events.
|
||||
if (currentReasoningMessageId is not null)
|
||||
{
|
||||
yield return new ReasoningMessageEndEvent
|
||||
{
|
||||
MessageId = currentReasoningMessageId
|
||||
};
|
||||
yield return new ReasoningEndEvent
|
||||
{
|
||||
MessageId = currentReasoningId!
|
||||
};
|
||||
currentReasoningBaseId = null;
|
||||
currentReasoningId = null;
|
||||
currentReasoningMessageId = null;
|
||||
}
|
||||
|
||||
yield return new ToolCallResultEvent
|
||||
{
|
||||
MessageId = chatResponse.MessageId,
|
||||
@@ -584,55 +423,6 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
Role = AGUIRoles.Tool
|
||||
};
|
||||
}
|
||||
else if (content is TextReasoningContent reasoningContent
|
||||
&& (!string.IsNullOrEmpty(reasoningContent.Text) || !string.IsNullOrEmpty(reasoningContent.ProtectedData)))
|
||||
{
|
||||
if (!string.Equals(currentReasoningBaseId, chatResponse.MessageId, StringComparison.Ordinal))
|
||||
{
|
||||
if (currentReasoningMessageId is not null)
|
||||
{
|
||||
yield return new ReasoningMessageEndEvent
|
||||
{
|
||||
MessageId = currentReasoningMessageId
|
||||
};
|
||||
yield return new ReasoningEndEvent
|
||||
{
|
||||
MessageId = currentReasoningId!
|
||||
};
|
||||
}
|
||||
|
||||
currentReasoningBaseId = chatResponse.MessageId;
|
||||
currentReasoningId = Guid.NewGuid().ToString("N");
|
||||
currentReasoningMessageId = Guid.NewGuid().ToString("N");
|
||||
|
||||
yield return new ReasoningStartEvent
|
||||
{
|
||||
MessageId = currentReasoningId
|
||||
};
|
||||
yield return new ReasoningMessageStartEvent
|
||||
{
|
||||
MessageId = currentReasoningMessageId
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(reasoningContent.Text))
|
||||
{
|
||||
yield return new ReasoningMessageContentEvent
|
||||
{
|
||||
MessageId = currentReasoningMessageId!,
|
||||
Delta = reasoningContent.Text
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(reasoningContent.ProtectedData))
|
||||
{
|
||||
yield return new ReasoningEncryptedValueEvent
|
||||
{
|
||||
EntityId = currentReasoningMessageId!,
|
||||
EncryptedValue = reasoningContent.ProtectedData
|
||||
};
|
||||
}
|
||||
}
|
||||
else if (content is DataContent dataContent)
|
||||
{
|
||||
if (MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) && mediaType.Equals(s_json))
|
||||
@@ -686,19 +476,6 @@ internal static class ChatResponseUpdateAGUIExtensions
|
||||
}
|
||||
}
|
||||
|
||||
// End the last reasoning block if there was one
|
||||
if (currentReasoningMessageId is not null)
|
||||
{
|
||||
yield return new ReasoningMessageEndEvent
|
||||
{
|
||||
MessageId = currentReasoningMessageId
|
||||
};
|
||||
yield return new ReasoningEndEvent
|
||||
{
|
||||
MessageId = currentReasoningId!
|
||||
};
|
||||
}
|
||||
|
||||
// End the last message if there was one
|
||||
if (currentMessageId is not null)
|
||||
{
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class ReasoningEncryptedValueEvent : BaseEvent
|
||||
{
|
||||
public ReasoningEncryptedValueEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.ReasoningEncryptedValue;
|
||||
}
|
||||
|
||||
[JsonPropertyName("subtype")]
|
||||
public string Subtype { get; set; } = "message";
|
||||
|
||||
[JsonPropertyName("entityId")]
|
||||
public string EntityId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("encryptedValue")]
|
||||
public string EncryptedValue { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class ReasoningEndEvent : BaseEvent
|
||||
{
|
||||
public ReasoningEndEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.ReasoningEnd;
|
||||
}
|
||||
|
||||
[JsonPropertyName("messageId")]
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class ReasoningMessageChunkEvent : BaseEvent
|
||||
{
|
||||
public ReasoningMessageChunkEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.ReasoningMessageChunk;
|
||||
}
|
||||
|
||||
[JsonPropertyName("messageId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? MessageId { get; set; }
|
||||
|
||||
[JsonPropertyName("delta")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Delta { get; set; }
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class ReasoningMessageContentEvent : BaseEvent
|
||||
{
|
||||
public ReasoningMessageContentEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.ReasoningMessageContent;
|
||||
}
|
||||
|
||||
[JsonPropertyName("messageId")]
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("delta")]
|
||||
public string Delta { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class ReasoningMessageEndEvent : BaseEvent
|
||||
{
|
||||
public ReasoningMessageEndEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.ReasoningMessageEnd;
|
||||
}
|
||||
|
||||
[JsonPropertyName("messageId")]
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class ReasoningMessageStartEvent : BaseEvent
|
||||
{
|
||||
public ReasoningMessageStartEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.ReasoningMessageStart;
|
||||
}
|
||||
|
||||
[JsonPropertyName("messageId")]
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; set; } = AGUIRoles.Reasoning;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
#if ASPNETCORE
|
||||
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
|
||||
#else
|
||||
namespace Microsoft.Agents.AI.AGUI.Shared;
|
||||
#endif
|
||||
|
||||
internal sealed class ReasoningStartEvent : BaseEvent
|
||||
{
|
||||
public ReasoningStartEvent()
|
||||
{
|
||||
this.Type = AGUIEventTypes.ReasoningStart;
|
||||
}
|
||||
|
||||
[JsonPropertyName("messageId")]
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,6 @@ using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -19,9 +18,10 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
/// is already present in the <c>User-Agent</c> header, the policy does not append it again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This policy is added at hosted-agent resolution time via the MEAI 10.5.1
|
||||
/// <see cref="OpenAIRequestPolicies"/> hook on the agent's underlying chat client. It is only
|
||||
/// registered when an agent is resolved by the Foundry hosting layer.
|
||||
/// 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -190,7 +203,7 @@ internal static class InputConverter
|
||||
|
||||
private static ChatMessage ConvertFunctionCallOutput(FunctionCallOutputItemParam funcOutput)
|
||||
{
|
||||
var output = DecodeFunctionResultPayload(funcOutput.Output);
|
||||
var output = funcOutput.Output?.ToString() ?? string.Empty;
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
[new FunctionResultContent(funcOutput.CallId, output)]);
|
||||
@@ -218,73 +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
|
||||
/// <see cref="FunctionCallContent"/> via <see cref="ToolApprovalIdMap"/> so the
|
||||
/// reconstructed response carries the original tool name, call id, and arguments.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when no mapping is recorded for <paramref name="approvalRequestId"/>.
|
||||
/// Without the mapping the original call cannot be reconstructed, so we fail the request.
|
||||
/// </exception>
|
||||
private static ChatMessage ConvertMcpApprovalResponse(string approvalRequestId, bool approve, AgentSessionStateBag? stateBag)
|
||||
{
|
||||
var entry = ToolApprovalIdMap.ResolveEntry(stateBag, approvalRequestId)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No approval mapping recorded for wire id '{approvalRequestId}'.");
|
||||
|
||||
var functionCall = new FunctionCallContent(
|
||||
entry.CallId,
|
||||
entry.Name,
|
||||
ParseFunctionArgumentsObject(entry.Arguments));
|
||||
|
||||
return new ChatMessage(
|
||||
ChatRole.User,
|
||||
[new ToolApprovalResponseContent(entry.AfRequestId, approve, functionCall)]);
|
||||
}
|
||||
|
||||
[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
|
||||
};
|
||||
@@ -305,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;
|
||||
}
|
||||
}
|
||||
@@ -337,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)
|
||||
@@ -482,54 +334,9 @@ internal static class InputConverter
|
||||
|
||||
private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput)
|
||||
{
|
||||
var output = DecodeFunctionResultPayload(funcOutput.Output);
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
[new FunctionResultContent(funcOutput.CallId, output)]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes the wire payload of a <c>function_call_output.output</c> field back into the
|
||||
/// underlying tool-result text suitable for replay as <see cref="FunctionResultContent.Result"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <c>OutputConverter.EncodeFunctionResultAsJsonStringPayload</c>. Per the OpenAI
|
||||
/// Responses spec, <c>output</c> is a JSON string; we extract its underlying value. Legacy
|
||||
/// producers that emitted raw JSON values (arrays/objects) are tolerated by passing the raw
|
||||
/// bytes through unchanged.
|
||||
/// </remarks>
|
||||
private static string DecodeFunctionResultPayload(BinaryData? rawOutput)
|
||||
{
|
||||
if (rawOutput is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var raw = rawOutput.ToString();
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return doc.RootElement.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
// Legacy/non-conforming producers may have emitted a raw JSON value
|
||||
// (array/object/number/bool/null). Pass the raw text through as the
|
||||
// payload so the replayed FunctionResultContent.Result preserves the
|
||||
// original tool output shape.
|
||||
return raw;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not valid JSON — treat the bytes as a literal string payload.
|
||||
return raw;
|
||||
}
|
||||
[new FunctionResultContent(funcOutput.CallId, funcOutput.Output)]);
|
||||
}
|
||||
|
||||
private static ChatRole ConvertMessageRole(MessageRole role)
|
||||
|
||||
@@ -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))
|
||||
@@ -118,13 +113,8 @@ internal static class OutputConverter
|
||||
break;
|
||||
}
|
||||
|
||||
case FunctionCallContent functionCall:
|
||||
case FunctionCallContent funcCall:
|
||||
{
|
||||
if (functionCall.CallId is not { Length: > 0 })
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
@@ -135,15 +125,17 @@ internal static class OutputConverter
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
var arguments = functionCall.Arguments is not null
|
||||
? JsonSerializer.Serialize(functionCall.Arguments)
|
||||
var callId = funcCall.CallId ?? Guid.NewGuid().ToString("N");
|
||||
var funcBuilder = stream.AddOutputItemFunctionCall(funcCall.Name, callId);
|
||||
yield return funcBuilder.EmitAdded();
|
||||
|
||||
var arguments = funcCall.Arguments is not null
|
||||
? JsonSerializer.Serialize(funcCall.Arguments)
|
||||
: "{}";
|
||||
|
||||
var fcBuilder = stream.AddOutputItemFunctionCall(functionCall.Name, functionCall.CallId);
|
||||
yield return fcBuilder.EmitAdded();
|
||||
yield return fcBuilder.EmitArgumentsDelta(arguments);
|
||||
yield return fcBuilder.EmitArgumentsDone(arguments);
|
||||
yield return fcBuilder.EmitDone();
|
||||
yield return funcBuilder.EmitArgumentsDelta(arguments);
|
||||
yield return funcBuilder.EmitArgumentsDone(arguments);
|
||||
yield return funcBuilder.EmitDone();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -174,61 +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);
|
||||
|
||||
var approvalArguments = approvalFunctionCall.Arguments is not null
|
||||
? JsonSerializer.Serialize(approvalFunctionCall.Arguments)
|
||||
: "{}";
|
||||
|
||||
ToolApprovalIdMap.Record(
|
||||
stateBag,
|
||||
wireId,
|
||||
approvalRequest.RequestId,
|
||||
approvalFunctionCall.CallId,
|
||||
approvalFunctionCall.Name,
|
||||
approvalArguments);
|
||||
|
||||
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);
|
||||
@@ -262,35 +199,10 @@ internal static class OutputConverter
|
||||
// These would need to be serialized as base64 or URL references.
|
||||
break;
|
||||
|
||||
case FunctionResultContent functionResult:
|
||||
{
|
||||
if (functionResult.CallId is not { Length: > 0 })
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result);
|
||||
|
||||
var itemId = GenerateItemId("fc");
|
||||
var outputItem = new OutputItemFunctionToolCallOutput(
|
||||
functionResult.CallId,
|
||||
BinaryData.FromString(outputText));
|
||||
|
||||
var outputBuilder = stream.AddOutputItem<OutputItemFunctionToolCallOutput>(itemId);
|
||||
yield return outputBuilder.EmitAdded(outputItem);
|
||||
yield return outputBuilder.EmitDone(outputItem);
|
||||
case FunctionResultContent:
|
||||
// Function results are internal to the agent's tool-calling loop
|
||||
// and are not emitted as output items in the response stream.
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
@@ -443,44 +355,4 @@ internal static class OutputConverter
|
||||
var body = Convert.ToHexString(bytes); // 50 hex chars, uppercase
|
||||
return $"{prefix}_{body}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a <see cref="FunctionResultContent.Result"/> value into the wire payload for
|
||||
/// the OpenAI Responses <c>function_call_output.output</c> field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The OpenAI Responses spec requires <c>output</c> to be a JSON string. The Responses
|
||||
/// SDK's <see cref="OutputItemFunctionToolCallOutput"/> accepts a <see cref="BinaryData"/>
|
||||
/// containing the *raw JSON value* for the field, so the returned text is always a JSON
|
||||
/// string literal (quoted, with escapes). This avoids two bugs:
|
||||
/// <list type="bullet">
|
||||
/// <item>Complex results (e.g. <c>List<TodoItem></c>) landing on the wire as an
|
||||
/// unquoted JSON array, which the strict-parsing OpenAI .NET client
|
||||
/// (<c>FunctionCallOutputResponseItem</c>) rejects with
|
||||
/// "requires an element of type 'String', but the target element has type 'Array'".</item>
|
||||
/// <item>Numeric- or JSON-shaped string results (e.g. <c>"42"</c> or <c>"{\"k\":1}"</c>)
|
||||
/// silently changing type on the wire because <c>BinaryData</c> auto-detects JSON.</item>
|
||||
/// </list>
|
||||
/// <see cref="JsonElement"/> / <see cref="JsonDocument"/> values are unwrapped first so
|
||||
/// a string-kind element does not get double-encoded into <c>"\"value\""</c>.
|
||||
/// </remarks>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call result payload.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call result payload.")]
|
||||
private static string EncodeFunctionResultAsJsonStringPayload(object? result)
|
||||
{
|
||||
string innerText = result switch
|
||||
{
|
||||
null => string.Empty,
|
||||
string s => s,
|
||||
JsonElement je => je.ValueKind == JsonValueKind.String
|
||||
? (je.GetString() ?? string.Empty)
|
||||
: je.GetRawText(),
|
||||
JsonDocument jd => jd.RootElement.ValueKind == JsonValueKind.String
|
||||
? (jd.RootElement.GetString() ?? string.Empty)
|
||||
: jd.RootElement.GetRawText(),
|
||||
_ => JsonSerializer.Serialize(result),
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(innerText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Reflection;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
@@ -12,6 +11,7 @@ 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;
|
||||
|
||||
@@ -49,7 +49,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 +76,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 +84,7 @@ public static class FoundryHostingExtensions
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
services.AddResponsesServer();
|
||||
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
|
||||
agentSessionStore ??= new InMemoryAgentSessionStore();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Name))
|
||||
{
|
||||
@@ -185,6 +185,8 @@ public static class FoundryHostingExtensions
|
||||
|
||||
/// <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";
|
||||
|
||||
@@ -207,45 +209,84 @@ public static class FoundryHostingExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the hosted-agent <c>User-Agent</c> supplement policy
|
||||
/// (<see cref="HostedAgentUserAgentPolicy"/>) on the agent's underlying chat client via the
|
||||
/// MEAI 10.5.1 <see cref="OpenAIRequestPolicies"/> hook so every outgoing OpenAI Responses
|
||||
/// request carries the segment <c>foundry-hosting/agent-framework-dotnet/{version}</c>.
|
||||
/// 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 OpenAI-backed (the <see cref="OpenAIRequestPolicies"/> service lookup returns <see langword="null"/>);</description></item>
|
||||
/// <item><description>the policy was already registered on this client by a prior invocation (deduped via reflection on <c>OpenAIRequestPolicies._entries</c>).</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>
|
||||
/// Returns the same <paramref name="agent"/> instance unchanged. The policy is installed
|
||||
/// on the chat client; the agent itself is not wrapped.
|
||||
/// 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)
|
||||
{
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
if (chatClient?.GetService<OpenAIRequestPolicies>() is { } policies)
|
||||
if (chatClient is null)
|
||||
{
|
||||
// Hosted agents are typically singletons resolved per request, so AddPolicy must be
|
||||
// called at most once per OpenAIRequestPolicies instance to avoid unbounded growth of
|
||||
// the policy list (each entry adds per-request CPU work even though the User-Agent
|
||||
// value stays stable). Track which instances we have already wired with a
|
||||
// ConditionalWeakTable keyed on the OpenAIRequestPolicies reference; the table holds
|
||||
// weak references so it does not extend the lifetime of the chat client.
|
||||
if (s_userAgentRegistrations.TryAdd(policies, s_boxedTrue))
|
||||
{
|
||||
policies.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
var meaiType = s_meaiResponsesChatClientType;
|
||||
if (meaiType is null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
var meaiInstance = chatClient.GetService(meaiType);
|
||||
if (meaiInstance is null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
var field = s_meaiResponseClientField;
|
||||
if (field is null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
var current = field.GetValue(meaiInstance) as ResponsesClient;
|
||||
if (current is null or UserAgentResponsesClient)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
field.SetValue(meaiInstance, new UserAgentResponsesClient(current));
|
||||
return agent;
|
||||
}
|
||||
|
||||
private static readonly object s_boxedTrue = new();
|
||||
private static readonly ConditionalWeakTable<OpenAIRequestPolicies, object> s_userAgentRegistrations = new();
|
||||
/// <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,139 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
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, and for preserving the original <see cref="FunctionCallContent"/> across
|
||||
/// the request/response round trip. The mapping is persisted in
|
||||
/// <see cref="AgentSessionStateBag"/>.
|
||||
/// </summary>
|
||||
internal static class ToolApprovalIdMap
|
||||
{
|
||||
/// <summary>
|
||||
/// State-bag key used to store the wire-id ↔ approval-entry mapping.
|
||||
/// </summary>
|
||||
public const string StateBagKey = "Microsoft.Agents.AI.Foundry.Hosting.ToolApprovalIdMap";
|
||||
|
||||
/// <summary>
|
||||
/// Captures the data needed to reconstruct the original
|
||||
/// <see cref="FunctionCallContent"/> on the inbound (response) side.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// FICC composes <c>RequestId</c> as <c>"ficc_{CallId}"</c>; <c>CallId</c> is stored
|
||||
/// independently so the reconstructed function-call id matches the one the model
|
||||
/// emitted and the backend Conversations API persisted.
|
||||
/// </remarks>
|
||||
internal sealed class ApprovalEntry
|
||||
{
|
||||
public string AfRequestId { get; set; } = string.Empty;
|
||||
public string CallId { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Arguments { get; set; }
|
||||
}
|
||||
|
||||
/// <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 → approval-entry mapping in the supplied state bag.
|
||||
/// Arguments are passed as already-serialized JSON to keep this method
|
||||
/// trim/AOT-friendly (no polymorphic <c>object</c> serialization here).
|
||||
/// No-op when <paramref name="callId"/> or <paramref name="name"/> is empty —
|
||||
/// without those fields the entry cannot be used to faithfully reconstruct
|
||||
/// the original <see cref="FunctionCallContent"/> on the inbound side.
|
||||
/// </summary>
|
||||
public static void Record(AgentSessionStateBag? stateBag, string wireId, string afRequestId, string? callId, string? name, string? argumentsJson)
|
||||
{
|
||||
if (stateBag is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(callId) || string.IsNullOrEmpty(name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var map = LoadMap(stateBag);
|
||||
map[wireId] = new ApprovalEntry
|
||||
{
|
||||
AfRequestId = afRequestId,
|
||||
CallId = callId!,
|
||||
Name = name!,
|
||||
Arguments = argumentsJson,
|
||||
};
|
||||
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.
|
||||
/// </summary>
|
||||
public static string Resolve(AgentSessionStateBag? stateBag, string wireId)
|
||||
{
|
||||
if (TryLoadMap(stateBag, out var map)
|
||||
&& map.TryGetValue(wireId, out var entry))
|
||||
{
|
||||
return entry.AfRequestId;
|
||||
}
|
||||
|
||||
return wireId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the full approval entry for a given wire id, or <see langword="null"/>
|
||||
/// when no mapping is present.
|
||||
/// </summary>
|
||||
public static ApprovalEntry? ResolveEntry(AgentSessionStateBag? stateBag, string wireId)
|
||||
{
|
||||
if (TryLoadMap(stateBag, out var map)
|
||||
&& map.TryGetValue(wireId, out var entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Dictionary<string, ApprovalEntry> LoadMap(AgentSessionStateBag stateBag)
|
||||
=> TryLoadMap(stateBag, out var map) ? map : new Dictionary<string, ApprovalEntry>(StringComparer.Ordinal);
|
||||
|
||||
private static bool TryLoadMap(AgentSessionStateBag? stateBag, out Dictionary<string, ApprovalEntry> map)
|
||||
{
|
||||
if (stateBag is null)
|
||||
{
|
||||
map = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't swallow JsonException: ConvertMcpApprovalResponse fails fast on a missing entry,
|
||||
// so an empty map here would just turn a clear deserialization error into a confusing one.
|
||||
map = stateBag.GetValue<Dictionary<string, ApprovalEntry>>(StateBagKey)
|
||||
?? new Dictionary<string, ApprovalEntry>(StringComparer.Ordinal);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Delegating <see cref="AIAgent"/> that captures any <c>x-client-*</c> headers stored on
|
||||
/// <see cref="ChatClientAgentRunOptions.ChatOptions"/> by callers of
|
||||
/// <see cref="ClientHeadersExtensions.WithClientHeader(ChatOptions, string, string)"/> and pushes
|
||||
/// them onto a <see cref="ClientHeadersScope"/> for the lifetime of the run. The scope is read by
|
||||
/// <see cref="ClientHeadersPolicy"/> inside the SCM transport pipeline and stamped onto the
|
||||
/// outbound request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The decorator snapshots the header dictionary at scope-push time so concurrent runs that share
|
||||
/// the same <see cref="ChatOptions"/> reference are isolated; mutating the source dictionary after
|
||||
/// <c>RunAsync</c> begins does not leak into in-flight requests.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Streaming uses the async-iterator pattern so the AsyncLocal scope stays alive across yields,
|
||||
/// which is required because the underlying HTTP send happens during enumeration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ClientHeadersAgent : DelegatingAIAgent
|
||||
{
|
||||
public ClientHeadersAgent(AIAgent innerAgent)
|
||||
: base(innerAgent)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = TrySnapshot(options);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
|
||||
}
|
||||
|
||||
return RunAsyncCoreAsync(messages, session, options, snapshot, cancellationToken);
|
||||
|
||||
async Task<AgentResponse> RunAsyncCoreAsync(
|
||||
IEnumerable<ChatMessage> innerMessages,
|
||||
AgentSession? innerSession,
|
||||
AgentRunOptions? innerOptions,
|
||||
Dictionary<string, string> innerSnapshot,
|
||||
CancellationToken innerCt)
|
||||
{
|
||||
using var _ = ClientHeadersScope.Push(innerSnapshot);
|
||||
return await this.InnerAgent.RunAsync(innerMessages, innerSession, innerOptions, innerCt).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = TrySnapshot(options);
|
||||
using var _ = snapshot is null ? default : ClientHeadersScope.Push(snapshot);
|
||||
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the header dictionary stamped by <c>WithClientHeader(s)</c> and returns an immutable snapshot, or <see langword="null"/> if none.</summary>
|
||||
private static Dictionary<string, string>? TrySnapshot(AgentRunOptions? options)
|
||||
{
|
||||
if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var headers = chatOptions.GetClientHeaders();
|
||||
if (headers is null || headers.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Copy to defeat caller mutation after RunAsync starts.
|
||||
var copy = new Dictionary<string, string>(headers.Count, System.StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in headers)
|
||||
{
|
||||
copy[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for attaching per-call <c>x-client-*</c> headers to an agent run
|
||||
/// and for opting an existing <see cref="AIAgent"/> into the client-headers pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The Foundry platform forwards headers prefixed with <c>x-client-</c> transparently from the
|
||||
/// Agent Endpoint into the agent container (see the multi-tenant overlay design). Callers use
|
||||
/// <see cref="WithClientHeader(ChatOptions, string, string)"/> or
|
||||
/// <see cref="WithClientHeaders(ChatOptions, IEnumerable{KeyValuePair{string, string}})"/> to
|
||||
/// stamp headers per <c>RunAsync</c> call (for example to attest the SaaS end-user identity
|
||||
/// in <c>x-client-end-user-id</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Headers are only delivered to the wire when:
|
||||
/// <list type="number">
|
||||
/// <item><description>the agent has been wrapped with <see cref="UseClientHeaders(AIAgentBuilder)"/> (or built via a Foundry factory that pre-wires it), and</description></item>
|
||||
/// <item><description>the underlying <see cref="IChatClient"/> exposes the experimental MEAI 10.5.1 <see cref="OpenAIRequestPolicies"/> service (true for OpenAI-backed clients).</description></item>
|
||||
/// </list>
|
||||
/// When either condition is not met the call is a silent no-op.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
|
||||
public static class ClientHeadersExtensions
|
||||
{
|
||||
/// <summary>The well-known <see cref="ChatOptions.AdditionalProperties"/> key used to carry the dictionary across packages.</summary>
|
||||
internal const string ClientHeadersKey = "Microsoft.Agents.AI.Foundry.ClientHeaders";
|
||||
|
||||
/// <summary>The required prefix on every client header name (case-insensitive).</summary>
|
||||
private const string ClientHeaderPrefix = "x-client-";
|
||||
|
||||
/// <summary>
|
||||
/// Adds a single <c>x-client-*</c> header to the per-call carrier on <paramref name="options"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">The <see cref="ChatOptions"/> instance to mutate.</param>
|
||||
/// <param name="name">The header name. Must start with <c>x-client-</c> (case-insensitive).</param>
|
||||
/// <param name="value">The header value. Must be non-empty.</param>
|
||||
/// <returns><paramref name="options"/> for fluent chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="options"/>, <paramref name="name"/>, or <paramref name="value"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="name"/> does not start with <c>x-client-</c>, or is empty/whitespace, or <paramref name="value"/> is empty.</exception>
|
||||
/// <exception cref="InvalidOperationException">The carrier slot on <see cref="ChatOptions.AdditionalProperties"/> is occupied by a value of a foreign type.</exception>
|
||||
public static ChatOptions WithClientHeader(this ChatOptions options, string name, string value)
|
||||
{
|
||||
_ = Throw.IfNull(options);
|
||||
ValidateHeader(name, value);
|
||||
|
||||
var dict = GetOrCreateHeadersDictionary(options);
|
||||
dict[name] = value;
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple <c>x-client-*</c> headers to the per-call carrier on <paramref name="options"/>.
|
||||
/// </summary>
|
||||
/// <remarks>Validation is all-or-nothing: if any entry is invalid no entries are written.</remarks>
|
||||
/// <param name="options">The <see cref="ChatOptions"/> instance to mutate.</param>
|
||||
/// <param name="headers">The headers to add. Each name must start with <c>x-client-</c>.</param>
|
||||
/// <returns><paramref name="options"/> for fluent chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="options"/> or <paramref name="headers"/> is <see langword="null"/>, or any element of <paramref name="headers"/> has a <see langword="null"/> name or value.</exception>
|
||||
/// <exception cref="ArgumentException">Any header name does not start with <c>x-client-</c>, or any name is empty/whitespace, or any value is empty.</exception>
|
||||
/// <exception cref="InvalidOperationException">The carrier slot on <see cref="ChatOptions.AdditionalProperties"/> is occupied by a value of a foreign type.</exception>
|
||||
public static ChatOptions WithClientHeaders(this ChatOptions options, IEnumerable<KeyValuePair<string, string>> headers)
|
||||
{
|
||||
_ = Throw.IfNull(options);
|
||||
_ = Throw.IfNull(headers);
|
||||
|
||||
// Validate first; mutate only when every entry passes.
|
||||
var staged = new List<KeyValuePair<string, string>>();
|
||||
foreach (var kvp in headers)
|
||||
{
|
||||
ValidateHeader(kvp.Key, kvp.Value);
|
||||
staged.Add(kvp);
|
||||
}
|
||||
|
||||
if (staged.Count == 0)
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
var dict = GetOrCreateHeadersDictionary(options);
|
||||
foreach (var kvp in staged)
|
||||
{
|
||||
dict[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the agent built by <paramref name="builder"/> so that headers stamped by
|
||||
/// <see cref="WithClientHeader(ChatOptions, string, string)"/> on the per-call
|
||||
/// <see cref="ChatOptions"/> are forwarded onto the outbound HTTP request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Idempotent: if the inner agent is already wrapped with a <see cref="ClientHeadersAgent"/>
|
||||
/// anywhere in its delegating chain, the agent is returned unchanged. This makes
|
||||
/// <c>myFoundryAgent.AsBuilder().UseClientHeaders().Build()</c> safe even though Foundry
|
||||
/// agents are pre-wired automatically.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Also registers <see cref="ClientHeadersPolicy"/> against the underlying chat client's
|
||||
/// <see cref="OpenAIRequestPolicies"/> service if available. When the underlying chat client
|
||||
/// is not OpenAI-backed (the service lookup returns <see langword="null"/>), the registration
|
||||
/// step is silently skipped; the agent decorator still runs but no headers are stamped on
|
||||
/// the wire. See the type-level remarks for the conditions under which delivery happens.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="builder">The <see cref="AIAgentBuilder"/> to extend.</param>
|
||||
/// <returns>The same builder, to allow fluent chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
|
||||
public static AIAgentBuilder UseClientHeaders(this AIAgentBuilder builder) =>
|
||||
Throw.IfNull(builder).Use((AIAgent innerAgent, IServiceProvider services) =>
|
||||
{
|
||||
// Agent-side dedup: if any decorator in the chain is already a ClientHeadersAgent, no-op.
|
||||
if (innerAgent.GetService<ClientHeadersAgent>() is not null)
|
||||
{
|
||||
return innerAgent;
|
||||
}
|
||||
|
||||
// Best-effort policy registration on the underlying OpenAI-backed chat client.
|
||||
// Silent no-op when the service is unavailable (non-OpenAI providers).
|
||||
if (innerAgent.GetService<OpenAIRequestPolicies>() is { } policies)
|
||||
{
|
||||
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
|
||||
policies,
|
||||
ClientHeadersPolicy.Instance,
|
||||
System.ClientModel.Primitives.PipelinePosition.PerCall);
|
||||
}
|
||||
|
||||
return new ClientHeadersAgent(innerAgent);
|
||||
});
|
||||
|
||||
/// <summary>Reads the headers dictionary stamped by callers, or <see langword="null"/> if none.</summary>
|
||||
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Internal helper.")]
|
||||
internal static IReadOnlyDictionary<string, string>? GetClientHeaders(this ChatOptions options)
|
||||
{
|
||||
if (options.AdditionalProperties is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!options.AdditionalProperties.TryGetValue(ClientHeadersKey, out var raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return raw as Dictionary<string, string>;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> GetOrCreateHeadersDictionary(ChatOptions options)
|
||||
{
|
||||
options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
|
||||
|
||||
if (options.AdditionalProperties.TryGetValue(ClientHeadersKey, out var existing))
|
||||
{
|
||||
if (existing is Dictionary<string, string> dict)
|
||||
{
|
||||
return dict;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"ChatOptions.AdditionalProperties[\"{ClientHeadersKey}\"] is occupied by a value of type '{existing?.GetType().FullName ?? "null"}', expected Dictionary<string, string>.");
|
||||
}
|
||||
|
||||
var fresh = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
options.AdditionalProperties[ClientHeadersKey] = fresh;
|
||||
return fresh;
|
||||
}
|
||||
|
||||
private static void ValidateHeader(string name, string value)
|
||||
{
|
||||
_ = Throw.IfNull(name);
|
||||
_ = Throw.IfNull(value);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new ArgumentException("Header name must not be empty or whitespace.", nameof(name));
|
||||
}
|
||||
|
||||
if (value.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Header value must not be empty.", nameof(value));
|
||||
}
|
||||
|
||||
if (!name.StartsWith(ClientHeaderPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Header name '{name}' must start with '{ClientHeaderPrefix}' (case-insensitive). Only x-client-* headers are forwarded by the Foundry platform.",
|
||||
nameof(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline policy that stamps <c>x-client-*</c> headers from the current
|
||||
/// <see cref="ClientHeadersScope"/> onto outbound OpenAI Responses requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Registered once per <see cref="OpenAIRequestPolicies"/> instance via the new MEAI 10.5.1
|
||||
/// extension hook. Headers are written using <see cref="PipelineRequestHeaders.Set(string, string)"/>
|
||||
/// so per-call values overwrite anything stamped earlier in the pipeline (for example by static
|
||||
/// pipeline policies registered on the underlying client). This also makes accidental double
|
||||
/// registration value-stable.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ClientHeadersPolicy : PipelinePolicy
|
||||
{
|
||||
public static ClientHeadersPolicy Instance { get; } = new ClientHeadersPolicy();
|
||||
|
||||
private ClientHeadersPolicy()
|
||||
{
|
||||
}
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
Stamp(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
Stamp(message);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
private static void Stamp(PipelineMessage message)
|
||||
{
|
||||
var headers = ClientHeadersScope.Current;
|
||||
if (headers is null || headers.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var kvp in headers)
|
||||
{
|
||||
// Per-call wins: Set overwrites any same-name header previously stamped by other policies.
|
||||
message.Request.Headers.Set(kvp.Key, kvp.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort reflection helpers for <see cref="OpenAIRequestPolicies"/>. MEAI 10.5.1 does not
|
||||
/// publicly expose its registered-policies list, so we reach into the private <c>_entries</c>
|
||||
/// field to detect duplicate registrations of <see cref="ClientHeadersPolicy.Instance"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All access is guarded with try/catch and graceful fallback. If MEAI changes the field name
|
||||
/// or shape in a future bump, dedup degrades to "always add" but stamping stays correct because
|
||||
/// <see cref="ClientHeadersPolicy"/> uses <c>Headers.Set</c>. A CI test asserts the field shape
|
||||
/// to fail loudly on future MEAI bumps.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
|
||||
internal static class OpenAIRequestPoliciesReflection
|
||||
{
|
||||
private static readonly Lazy<FieldInfo?> s_entriesField = new(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return typeof(OpenAIRequestPolicies).GetField(
|
||||
"_entries",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
/// <summary>Returns <see langword="true"/> if <paramref name="policies"/> already contains <paramref name="policy"/>.</summary>
|
||||
/// <remarks>Returns <see langword="false"/> on any reflection failure (caller should treat the registration as not yet done).</remarks>
|
||||
#if NET
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2075:RequiresUnreferencedCode",
|
||||
Justification = "Reflecting on the private Entry struct shipped by Microsoft.Extensions.AI.OpenAI; falls back gracefully if shape changes. CI test asserts the field shape on every MEAI bump.")]
|
||||
#endif
|
||||
public static bool ContainsPolicy(OpenAIRequestPolicies policies, PipelinePolicy policy)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (s_entriesField.Value?.GetValue(policies) is not Array entries)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < entries.Length; i++)
|
||||
{
|
||||
var entry = entries.GetValue(i);
|
||||
if (entry is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Entry is a private struct with a Policy property/field. Try property first, then field.
|
||||
var entryType = entry.GetType();
|
||||
var policyMember = entryType.GetProperty("Policy", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
object? value = policyMember is not null
|
||||
? policyMember.GetValue(entry)
|
||||
: entryType.GetField("Policy", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(entry);
|
||||
|
||||
if (ReferenceEquals(value, policy))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers <paramref name="policy"/> on <paramref name="policies"/> if not already present.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if <c>AddPolicy</c> was called on this invocation; <see langword="false"/>
|
||||
/// when the policy was already detected as present and the call was skipped.
|
||||
/// </returns>
|
||||
public static bool AddPolicyIfMissing(OpenAIRequestPolicies policies, PipelinePolicy policy, PipelinePosition position = PipelinePosition.PerCall)
|
||||
{
|
||||
if (ContainsPolicy(policies, policy))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
policies.AddPolicy(policy, position);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry;
|
||||
|
||||
/// <summary>
|
||||
/// AsyncLocal carrier that bridges per-call client-header values from the
|
||||
/// <see cref="ClientHeadersAgent"/> decorator down to the
|
||||
/// <see cref="ClientHeadersPolicy"/> running inside the SCM transport pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AsyncLocal flows the value into downstream awaits but does not roll the value back when the
|
||||
/// setting method returns. This type pairs each <see cref="Push(IReadOnlyDictionary{string, string}?)"/>
|
||||
/// with a disposable that explicitly restores the prior value, giving stack-style LIFO semantics
|
||||
/// for nested or sequential per-call scopes on the same async flow.
|
||||
/// </remarks>
|
||||
internal static class ClientHeadersScope
|
||||
{
|
||||
private static readonly AsyncLocal<IReadOnlyDictionary<string, string>?> s_current = new();
|
||||
|
||||
/// <summary>Gets the dictionary captured by the most recent <see cref="Push(IReadOnlyDictionary{string, string}?)"/> on this async flow.</summary>
|
||||
public static IReadOnlyDictionary<string, string>? Current => s_current.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a new value as the current scope. Disposing the returned token restores the previous value.
|
||||
/// </summary>
|
||||
/// <param name="headers">The header dictionary to surface to the policy. May be <see langword="null"/>.</param>
|
||||
public static Scope Push(IReadOnlyDictionary<string, string>? headers)
|
||||
{
|
||||
var previous = s_current.Value;
|
||||
s_current.Value = headers;
|
||||
return new Scope(previous);
|
||||
}
|
||||
|
||||
/// <summary>Disposable token that restores the previous scope on <see cref="Dispose"/>.</summary>
|
||||
internal readonly struct Scope : System.IDisposable
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, string>? _previous;
|
||||
|
||||
internal Scope(IReadOnlyDictionary<string, string>? previous)
|
||||
{
|
||||
this._previous = previous;
|
||||
}
|
||||
|
||||
public void Dispose() => s_current.Value = this._previous;
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// Internal constructor used by <c>AsAIAgent</c> extension methods that already have an <see cref="AIProjectClient"/> and a configured <see cref="ChatClientAgent"/>.
|
||||
/// </summary>
|
||||
internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgent)
|
||||
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
|
||||
: base(Throw.IfNull(innerAgent))
|
||||
{
|
||||
this._aiProjectClient = Throw.IfNull(aiProjectClient);
|
||||
}
|
||||
@@ -128,7 +128,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ValueTask<AgentSession> CreateSessionAsync(string conversationId, CancellationToken cancellationToken = default)
|
||||
=> this.GetInnerChatClientAgent().CreateSessionAsync(conversationId, cancellationToken);
|
||||
=> ((ChatClientAgent)this.InnerAgent).CreateSessionAsync(conversationId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a server-side conversation session that appears in the Foundry Project UI.
|
||||
@@ -143,14 +143,9 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
|
||||
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
|
||||
|
||||
return (ChatClientAgentSession)await this.GetInnerChatClientAgent().CreateSessionAsync(conversation.Id, cancellationToken).ConfigureAwait(false);
|
||||
return (ChatClientAgentSession)await ((ChatClientAgent)this.InnerAgent).CreateSessionAsync(conversation.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Walks the delegating chain to find the inner <see cref="ChatClientAgent"/>.</summary>
|
||||
private ChatClientAgent GetInnerChatClientAgent() =>
|
||||
this.GetService<ChatClientAgent>()
|
||||
?? throw new InvalidOperationException("FoundryAgent inner chain does not contain a ChatClientAgent.");
|
||||
|
||||
#endregion
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -166,7 +161,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
|
||||
#region Private helpers
|
||||
|
||||
private static AIAgent CreateInnerAgent(
|
||||
private static ChatClientAgent CreateInnerAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
string model, string instructions,
|
||||
string? name, string? description,
|
||||
@@ -196,7 +191,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services);
|
||||
}
|
||||
|
||||
private static AIAgent CreateResponsesChatClientAgent(
|
||||
private static ChatClientAgent CreateResponsesChatClientAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
ChatClientAgentOptions agentOptions,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
@@ -215,36 +210,10 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
chatClient = clientFactory(chatClient);
|
||||
}
|
||||
|
||||
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, loggerFactory, services));
|
||||
return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers <see cref="ClientHeadersPolicy"/> on the agent's underlying chat client (if it
|
||||
/// exposes <see cref="OpenAIRequestPolicies"/>) and wraps the agent in a
|
||||
/// <see cref="ClientHeadersAgent"/> so per-call <c>x-client-*</c> headers stamped via
|
||||
/// <see cref="ClientHeadersExtensions.WithClientHeader(ChatOptions, string, string)"/> reach
|
||||
/// the wire. Idempotent: if the chain already contains a <see cref="ClientHeadersAgent"/>,
|
||||
/// the original instance is returned unchanged.
|
||||
/// </summary>
|
||||
private static AIAgent WireClientHeaders(ChatClientAgent innerAgent)
|
||||
{
|
||||
if (innerAgent.GetService<ClientHeadersAgent>() is not null)
|
||||
{
|
||||
return innerAgent;
|
||||
}
|
||||
|
||||
if (innerAgent.ChatClient.GetService<OpenAIRequestPolicies>() is { } policies)
|
||||
{
|
||||
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
|
||||
policies,
|
||||
ClientHeadersPolicy.Instance,
|
||||
System.ClientModel.Primitives.PipelinePosition.PerCall);
|
||||
}
|
||||
|
||||
return new ClientHeadersAgent(innerAgent);
|
||||
}
|
||||
|
||||
private static AIAgent CreateInnerAgentFromEndpoint(
|
||||
private static ChatClientAgent CreateInnerAgentFromEndpoint(
|
||||
AIProjectClient aiProjectClient,
|
||||
Uri agentEndpoint,
|
||||
IList<AITool>? tools,
|
||||
@@ -269,7 +238,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
chatClient = clientFactory(chatClient);
|
||||
}
|
||||
|
||||
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
|
||||
return new ChatClientAgent(chatClient, agentOptions, services: services);
|
||||
}
|
||||
|
||||
private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null)
|
||||
|
||||
@@ -210,7 +210,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string prompt = string.Join("\n", messages.Select(m => m.Text));
|
||||
|
||||
// Handle DataContent as attachments
|
||||
(List<UserMessageAttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
(List<UserMessageDataAttachmentsItem>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
messages,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -443,11 +443,11 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage };
|
||||
}
|
||||
|
||||
private static async Task<(List<UserMessageAttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
private static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<UserMessageAttachmentFile>? attachments = null;
|
||||
List<UserMessageDataAttachmentsItem>? attachments = null;
|
||||
string? tempDir = null;
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
@@ -461,7 +461,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
attachments ??= [];
|
||||
attachments.Add(new UserMessageAttachmentFile
|
||||
attachments.Add(new UserMessageDataAttachmentsItemFile
|
||||
{
|
||||
Path = tempFilePath,
|
||||
DisplayName = Path.GetFileName(tempFilePath)
|
||||
|
||||
@@ -13,5 +13,5 @@ internal sealed class SequenceNumber
|
||||
/// Gets the next sequence number.
|
||||
/// </summary>
|
||||
/// <returns>The next sequence number.</returns>
|
||||
public int Increment() => System.Threading.Interlocked.Increment(ref this._sequenceNumber) - 1;
|
||||
public int Increment() => this._sequenceNumber++;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-24
@@ -43,11 +43,10 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
|
||||
|
||||
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await this._promptCount.WriteAsync(context, 0).ConfigureAwait(false);
|
||||
|
||||
InitializablePropertyPath variable = Throw.IfNull(this.Model.Variable);
|
||||
bool isValueUndefined = context.ReadState(variable.Path) is BlankValue;
|
||||
// Snapshot prior-execution state before we mutate it below so the SkipQuestionMode
|
||||
// evaluation reflects whether this is the first time the action has run.
|
||||
bool hasExecutedPreviously = await this._hasExecuted.ReadAsync(context).ConfigureAwait(false);
|
||||
bool proceed = this.Evaluator.GetValue(this.Model.AlwaysPrompt).Value;
|
||||
|
||||
if (!proceed)
|
||||
@@ -56,23 +55,16 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
|
||||
proceed =
|
||||
mode switch
|
||||
{
|
||||
SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue => isValueUndefined || hasExecutedPreviously,
|
||||
SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue => isValueUndefined && !await this._hasExecuted.ReadAsync(context).ConfigureAwait(false),
|
||||
SkipQuestionMode.AlwaysSkipIfVariableHasValue => isValueUndefined,
|
||||
SkipQuestionMode.AlwaysAsk => true,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
// Record that the action has executed in the same executor scope as the read above.
|
||||
// (CaptureResponseAsync runs in a different executor's state scope, so writing it there
|
||||
// would not be visible to subsequent ExecuteAsync invocations triggered by GotoAction.)
|
||||
await this._hasExecuted.WriteAsync(context, true).ConfigureAwait(false);
|
||||
|
||||
if (proceed)
|
||||
{
|
||||
// Initial prompt: count is 0 because no responses have been received yet for this turn.
|
||||
// _promptCount itself is tracked in CaptureResponseAsync's scope (see comment on _promptCount).
|
||||
await this.PromptAsync(context, actualCount: 0, cancellationToken).ConfigureAwait(false);
|
||||
await this.PromptAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -84,18 +76,14 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
|
||||
|
||||
public async ValueTask PrepareResponseAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
|
||||
{
|
||||
int count = await this._promptCount.ReadAsync(context).ConfigureAwait(false);
|
||||
ExternalInputRequest inputRequest = new(this.FormatPrompt(this.Model.Prompt));
|
||||
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
|
||||
await this._promptCount.WriteAsync(context, count + 1).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask CaptureResponseAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken)
|
||||
{
|
||||
// _promptCount is tracked in this (Capture) executor's scope so reads and writes are coherent.
|
||||
// Each Capture invocation represents an attempt to satisfy the question; increment up front
|
||||
// and pass the value to PromptAsync explicitly so the retry/default decision is scope-independent.
|
||||
int promptCount = await this._promptCount.ReadAsync(context).ConfigureAwait(false) + 1;
|
||||
await this._promptCount.WriteAsync(context, promptCount).ConfigureAwait(false);
|
||||
|
||||
FormulaValue? extractedValue = null;
|
||||
if (!response.HasMessages)
|
||||
{
|
||||
@@ -118,12 +106,10 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
|
||||
|
||||
if (extractedValue is null)
|
||||
{
|
||||
await this.PromptAsync(context, promptCount, cancellationToken).ConfigureAwait(false);
|
||||
await this.PromptAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Reset for any subsequent Question turn (e.g. via GotoAction re-entry) so the next attempt starts fresh.
|
||||
await this._promptCount.WriteAsync(context, 0).ConfigureAwait(false);
|
||||
bool autoSend = true;
|
||||
|
||||
if (this.Model.ExtensionData?.Properties.TryGetValue("autoSend", out DataValue? autoSendValue) ?? false)
|
||||
@@ -147,6 +133,7 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
|
||||
}
|
||||
|
||||
await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, extractedValue, context).ConfigureAwait(false);
|
||||
await this._hasExecuted.WriteAsync(context, true).ConfigureAwait(false);
|
||||
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -156,9 +143,10 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
|
||||
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask PromptAsync(IWorkflowContext context, int actualCount, CancellationToken cancellationToken)
|
||||
private async ValueTask PromptAsync(IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
long repeatCount = this.Evaluator.GetValue(this.Model.RepeatCount).Value;
|
||||
int actualCount = await this._promptCount.ReadAsync(context).ConfigureAwait(false);
|
||||
if (actualCount >= repeatCount)
|
||||
{
|
||||
DataValue defaultValue = DataValue.Blank();
|
||||
@@ -170,8 +158,6 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
|
||||
await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, defaultValue.ToFormula(), context).ConfigureAwait(false);
|
||||
string defaultValueResponse = this.FormatPrompt(this.Model.DefaultValueResponse);
|
||||
await context.AddEventAsync(new MessageActivityEvent(defaultValueResponse.Trim()), cancellationToken).ConfigureAwait(false);
|
||||
// Reset for any subsequent Question turn (e.g. via GotoAction re-entry) so the next attempt starts fresh.
|
||||
await this._promptCount.WriteAsync(context, 0).ConfigureAwait(false);
|
||||
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
|
||||
-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,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
@@ -11,7 +10,6 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
[JsonDerivedType(typeof(ExecutorInvokedEvent))]
|
||||
[JsonDerivedType(typeof(ExecutorCompletedEvent))]
|
||||
[JsonDerivedType(typeof(ExecutorFailedEvent))]
|
||||
[JsonDerivedType(typeof(MagenticOrchestratorEvent))]
|
||||
public class ExecutorEvent(string executorId, object? data) : WorkflowEvent(data)
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Request for human review of a proposed plan.
|
||||
/// </summary>
|
||||
/// <param name="Plan">The proposed plan.</param>
|
||||
/// <param name="CurrentProgress">The current progress ledger, if available. During the initial plan review,
|
||||
/// this will be <see langword="null"/>. In subsequent reviews after replanning (due to stalls), this will
|
||||
/// contain the latest progress ledger that determined that no progress has been made or the workflow was in
|
||||
/// a loop.</param>
|
||||
/// <param name="IsStalled">Whether the workflow is currently stalled.</param>
|
||||
public record MagenticPlanReviewRequest(ChatMessage Plan, MagenticProgressLedger? CurrentProgress, bool IsStalled)
|
||||
{
|
||||
/// <summary>
|
||||
/// Create an approving <see cref="MagenticPlanReviewResponse"/>.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticPlanReviewResponse Approve() => new([]);
|
||||
|
||||
/// <summary>
|
||||
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticPlanReviewResponse Revise(string message) => new([new(ChatRole.User, message)]);
|
||||
|
||||
/// <summary>
|
||||
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticPlanReviewResponse Revise(ChatMessage message) => new([message]);
|
||||
|
||||
/// <summary>
|
||||
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticPlanReviewResponse Revise(IEnumerable<ChatMessage> messages)
|
||||
=> new(messages is List<ChatMessage> messageList ? messageList : messages.ToList());
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Review feedback for a proposed plan, including any revisions if the plan is not approved as-is. An
|
||||
/// empty list of review messages indicates approval of the proposed plan without any revisions.
|
||||
/// </summary>
|
||||
/// <param name="Review">
|
||||
/// Review feedback for a generated plan. Empty if the plan is approved as-is and changes are requested.
|
||||
/// </param>
|
||||
public record MagenticPlanReviewResponse(List<ChatMessage> Review)
|
||||
{
|
||||
internal bool IsApproved => this.Review.Count == 0;
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Maintains a ledger of progress made by the Magentic workflow.
|
||||
/// </summary>
|
||||
public class MagenticProgressLedger
|
||||
{
|
||||
internal static readonly BooleanProgressLedgerSlot IsRequestSatisfiedSlot = new("is_request_satisfied",
|
||||
"Is the request fully satisfied? (True if complete, or False if the original request has yet to be SUCCESSFULLY and FULLY addressed)");
|
||||
|
||||
internal static readonly BooleanProgressLedgerSlot IsInLoopSlot = new("is_in_loop",
|
||||
"Are we in a loop where we are repeating the same requests and or getting the same responses as before? " +
|
||||
"Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a handful of times.");
|
||||
|
||||
internal static readonly BooleanProgressLedgerSlot IsProgressBeingMadeSlot = new("is_progress_being_made",
|
||||
"Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent " +
|
||||
"messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success " +
|
||||
"such as the inability to read from a required file)");
|
||||
|
||||
internal readonly StringProgressLedgerSlot NextSpeakerSlot;
|
||||
|
||||
internal static readonly StringProgressLedgerSlot InstructionOrQuestionSlot = new("instruction_or_question",
|
||||
"What instruction or question would you give this team member? (Phrase as if speaking directly to them, and " +
|
||||
"include any specific information they may need)");
|
||||
|
||||
internal MagenticProgressLedger(string teamNames, IEnumerable<ProgressLedgerSlot> additionalQuestions, JsonElement? state = null)
|
||||
{
|
||||
this.NextSpeakerSlot = new("next_speaker", $"Who should speak next? (select from: {teamNames})");
|
||||
this.AdditionalQuestions = additionalQuestions as ProgressLedgerSlot[] ?? additionalQuestions.ToArray();
|
||||
|
||||
if (state != null)
|
||||
{
|
||||
this.TryUpdateState(state.Value);
|
||||
}
|
||||
}
|
||||
|
||||
internal ProgressLedgerSlot[] AdditionalQuestions { get; }
|
||||
|
||||
internal bool TryUpdateState(JsonElement element)
|
||||
{
|
||||
// In principle all of these should be inlineable, but the CodeAnalysis fails to properly chain through the and-chain to realize that
|
||||
// all must be true for `requiredQuestionsAnswered` to be true, meaning all of the out parameters would be initialized properly.
|
||||
bool isInLoop = false;
|
||||
bool isProgressBeingMade = false;
|
||||
string? nextSpeaker = string.Empty;
|
||||
string? instructionOrQuestion = string.Empty;
|
||||
|
||||
bool requiredQuestionsAnswered =
|
||||
IsRequestSatisfiedSlot.TryGetValueFrom(element, out bool isRequestSatisfied) &&
|
||||
IsInLoopSlot.TryGetValueFrom(element, out isInLoop) &&
|
||||
IsProgressBeingMadeSlot.TryGetValueFrom(element, out isProgressBeingMade) &&
|
||||
this.NextSpeakerSlot.TryGetValueFrom(element, out nextSpeaker) &&
|
||||
InstructionOrQuestionSlot.TryGetValueFrom(element, out instructionOrQuestion);
|
||||
|
||||
if (requiredQuestionsAnswered)
|
||||
{
|
||||
this.State = element;
|
||||
|
||||
this.IsRequestSatisfied = isRequestSatisfied;
|
||||
this.IsInLoop = isInLoop;
|
||||
this.IsProgressBeingMade = isProgressBeingMade;
|
||||
|
||||
this.NextSpeaker = nextSpeaker!;
|
||||
this.InstructionOrQuestion = instructionOrQuestion!;
|
||||
}
|
||||
|
||||
// TODO: To what extent do we want to enforce that the additional questions are also answered?
|
||||
|
||||
return requiredQuestionsAnswered;
|
||||
}
|
||||
|
||||
[JsonInclude]
|
||||
internal JsonElement? State;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether plan execution has started.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsStarted => this.State != null;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether the task has been fully satisfied.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsRequestSatisfied { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether the team is in a loop.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsInLoop { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether the team is making progress on the task.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsProgressBeingMade { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next team member to take a turn.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string NextSpeaker { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instruction or question to send to the next team member.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string InstructionOrQuestion { get; private set; } = string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
internal IEnumerable<ProgressLedgerSlot> Slots =>
|
||||
[
|
||||
IsRequestSatisfiedSlot,
|
||||
IsInLoopSlot,
|
||||
IsProgressBeingMadeSlot,
|
||||
this.NextSpeakerSlot,
|
||||
InstructionOrQuestionSlot,
|
||||
.. this.AdditionalQuestions
|
||||
];
|
||||
|
||||
internal bool TryGetCurrentSlotValue<T>(ProgressLedgerSlot<T> slot, [NotNullWhen(true)] out T? value)
|
||||
{
|
||||
if (!this.State.HasValue)
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
return slot.TryGetValueFrom(this.State.Value, out value);
|
||||
}
|
||||
|
||||
private (string QuestionBlock, string AnswerSchema)? _questionFormatCache;
|
||||
internal (string QuestionBlock, string AnswerSchema) FormatQuestions()
|
||||
{
|
||||
if (!this._questionFormatCache.HasValue)
|
||||
{
|
||||
StringBuilder questionBuilder = new(), schemaBuilder = new();
|
||||
|
||||
schemaBuilder.AppendLine("{");
|
||||
foreach (ProgressLedgerSlot slot in this.Slots)
|
||||
{
|
||||
questionBuilder.AppendLine(slot.FormattedQuestion);
|
||||
|
||||
schemaBuilder.AppendLine($"\"{slot.Key}\": {{")
|
||||
.AppendLine($" \"{ProgressLedgerSlot.ValueKey}\": {slot.SchemaType}{slot.SuffixString},")
|
||||
.AppendLine($" \"{ProgressLedgerSlot.ReasonKey}\": string")
|
||||
.AppendLine("}");
|
||||
}
|
||||
schemaBuilder.AppendLine("}");
|
||||
|
||||
this._questionFormatCache = (questionBuilder.ToString(), schemaBuilder.ToString());
|
||||
}
|
||||
|
||||
return this._questionFormatCache.Value;
|
||||
}
|
||||
}
|
||||
|
||||
internal abstract record ProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null)
|
||||
{
|
||||
public const string ValueKey = "answer";
|
||||
public const string ReasonKey = "reason";
|
||||
|
||||
internal string SuffixString => this.SchemaTypeSuffix == null ? string.Empty : $"({this.SchemaTypeSuffix})";
|
||||
|
||||
protected internal abstract string SchemaType { get; }
|
||||
|
||||
public string FormattedQuestion
|
||||
{
|
||||
get
|
||||
{
|
||||
if (field == null)
|
||||
{
|
||||
IEnumerable<string> questionLines = this.Question.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line => line.TrimEnd());
|
||||
|
||||
field = $" - {string.Join("\n ", questionLines)}";
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal abstract record ProgressLedgerSlot<T>(string Key, string Question, string? SchemaTypeSuffix = null, JsonSerializerOptions? SerializerOptions = null)
|
||||
: ProgressLedgerSlot(Key, Question, SchemaTypeSuffix)
|
||||
{
|
||||
protected internal virtual JsonTypeInfo<T> GetJsonTypeInfo() =>
|
||||
((this.SerializerOptions ?? WorkflowsJsonUtilities.DefaultOptions).TryGetTypeInfo(typeof(T), out JsonTypeInfo? typeInfo)
|
||||
? typeInfo as JsonTypeInfo<T> : null)
|
||||
?? throw new InvalidOperationException($"Cannot get TypeInfo for {typeof(T)} from {(this.SerializerOptions == null ? "provided" : "default")} SerializationOptions.");
|
||||
|
||||
public bool TryGetValueFrom(JsonElement answers, [NotNullWhen(true)] out T? value)
|
||||
{
|
||||
if (answers.TryGetProperty(this.Key, out JsonElement slotElement) &&
|
||||
slotElement.ValueKind != JsonValueKind.Null &&
|
||||
slotElement.TryGetProperty(ValueKey, out JsonElement answerValue))
|
||||
{
|
||||
try
|
||||
{
|
||||
T? result = answerValue.Deserialize(this.GetJsonTypeInfo());
|
||||
if (result != null)
|
||||
{
|
||||
value = result;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetReasonFrom(JsonElement answers, [NotNullWhen(true)] out string? value)
|
||||
{
|
||||
if (answers.TryGetProperty(this.Key, out JsonElement slotElement) &&
|
||||
slotElement.ValueKind != JsonValueKind.Null &&
|
||||
slotElement.TryGetProperty(ReasonKey, out JsonElement reasonValue))
|
||||
{
|
||||
try
|
||||
{
|
||||
string? result = reasonValue.Deserialize(WorkflowsJsonUtilities.JsonContext.Default.String);
|
||||
if (result != null)
|
||||
{
|
||||
value = result;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record BooleanProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null) : ProgressLedgerSlot<bool>(Key, Question, SchemaTypeSuffix)
|
||||
{
|
||||
// Since we know the type statically, we can directly return the JsonTypeInfo for string from our JsonContext,
|
||||
// which is more efficient than looking it up via the options.
|
||||
protected internal override JsonTypeInfo<bool> GetJsonTypeInfo() => WorkflowsJsonUtilities.JsonContext.Default.Boolean;
|
||||
|
||||
protected internal override string SchemaType => "boolean";
|
||||
}
|
||||
|
||||
internal sealed record StringProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null) : ProgressLedgerSlot<string>(Key, Question, SchemaTypeSuffix)
|
||||
{
|
||||
// Since we know the type statically, we can directly return the JsonTypeInfo for string from our JsonContext,
|
||||
// which is more efficient than looking it up via the options.
|
||||
protected internal override JsonTypeInfo<string> GetJsonTypeInfo() => WorkflowsJsonUtilities.JsonContext.Default.String;
|
||||
|
||||
protected internal override string SchemaType => "string";
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
using ExecutorFactoryFunc = System.Func<Microsoft.Agents.AI.Workflows.ExecutorConfig<Microsoft.Agents.AI.Workflows.ExecutorOptions>,
|
||||
string,
|
||||
System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Specialized.Magentic.MagenticOrchestrator>>;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for creating Magentic One multi-agent orchestration workflows.
|
||||
///
|
||||
/// Magentic One workflows use an LLM-powered manager to coordinate multiple agents through dynamic task planning, progress tracking,
|
||||
/// and adaptive replanning.The manager creates plans, selects agents, monitors progress, and determines when to replan or complete.
|
||||
///
|
||||
/// The builder provides a fluent API for configuring participants, the manager, optional plan review, checkpointing, and event
|
||||
/// callbacks.
|
||||
///
|
||||
/// Human-in-the-loop Support: Magentic provides specialized HITL mechanisms via:
|
||||
/// - `RequirePlanSignoff` - Review and approve/revise plans before execution
|
||||
/// - Tool approval via `function_approval_request`: Approve individual tool calls on participating agents. Note that tool calls are
|
||||
/// not supported on the ManagerAgent.
|
||||
/// </summary>
|
||||
/// <param name="managerAgent"></param>
|
||||
public class MagenticWorkflowBuilder(AIAgent managerAgent)
|
||||
{
|
||||
private readonly List<AIAgent> _team = new();
|
||||
private string? _name;
|
||||
private string? _description;
|
||||
private int _maxStalls = TaskLimits.DefaultMaxStallCount;
|
||||
private int? _maxRounds;
|
||||
private int? _maxResets;
|
||||
private bool _requirePlanSignoff = true;
|
||||
|
||||
/// <inheritdoc cref="GroupChatWorkflowBuilder.AddParticipants(IEnumerable{AIAgent})"/>
|
||||
public MagenticWorkflowBuilder AddParticipants(params IEnumerable<AIAgent> agents)
|
||||
{
|
||||
this._team.AddRange(agents);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
|
||||
public MagenticWorkflowBuilder WithName(string name)
|
||||
{
|
||||
this._name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
|
||||
public MagenticWorkflowBuilder WithDescription(string description)
|
||||
{
|
||||
this._description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the maximum number of coordination rounds. <see langword="null"/> means unlimited.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticWorkflowBuilder WithMaxRounds(int? maxRounds = null)
|
||||
{
|
||||
this._maxRounds = maxRounds;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the maximum number ofnumber of resets allowed. <see langword="null"/> means unlimited.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticWorkflowBuilder WithMaxResets(int? maxResets = null)
|
||||
{
|
||||
this._maxResets = maxResets;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the maximum number of consecutive rounds without progress before replan (default 3).
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticWorkflowBuilder WithMaxStalls(int maxStalls = TaskLimits.DefaultMaxStallCount)
|
||||
{
|
||||
this._maxStalls = maxStalls;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If <see langword="true"/>, requires human approval of the initial plan or any updates before proceeding. True by default.
|
||||
/// </summary>
|
||||
/// <param name="requirePlanSignoff"></param>
|
||||
/// <returns></returns>
|
||||
public MagenticWorkflowBuilder RequirePlanSignoff(bool requirePlanSignoff = true)
|
||||
{
|
||||
this._requirePlanSignoff = requirePlanSignoff;
|
||||
return this;
|
||||
}
|
||||
|
||||
private WorkflowBuilder ReduceToWorkflowBuilder()
|
||||
{
|
||||
// Create a copy of the team so that improper modifications by using the builder after .Build() do not affect the
|
||||
// workflow in unexpected ways.
|
||||
List<AIAgent> team = [.. this._team];
|
||||
|
||||
ExecutorBinding orchestrator = CreateOrchestratorBinding(managerAgent, team, this.Limits, this._requirePlanSignoff);
|
||||
WorkflowBuilder result = new(orchestrator);
|
||||
|
||||
AIAgentHostOptions options = new()
|
||||
{
|
||||
ReassignOtherAgentsAsUsers = true,
|
||||
ForwardIncomingMessages = false
|
||||
};
|
||||
|
||||
List<ExecutorBinding> teamBindings = [];
|
||||
foreach (AIAgent agent in team)
|
||||
{
|
||||
ExecutorBinding binding = agent.BindAsExecutor(options);
|
||||
teamBindings.Add(binding);
|
||||
|
||||
result.AddEdge(binding, orchestrator);
|
||||
}
|
||||
|
||||
result.AddFanOutEdge(orchestrator, teamBindings)
|
||||
.WithOutputFrom(orchestrator);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this._name))
|
||||
{
|
||||
result.WithName(this._name);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this._description))
|
||||
{
|
||||
result.WithDescription(this._description);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.Build"/>
|
||||
public Workflow Build() => this.ReduceToWorkflowBuilder().Build();
|
||||
|
||||
private TaskLimits Limits => new(
|
||||
MaxRoundCount: this._maxRounds,
|
||||
MaxResetCount: this._maxResets,
|
||||
MaxStallCount: this._maxStalls);
|
||||
|
||||
private static ExecutorBinding CreateOrchestratorBinding(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
|
||||
{
|
||||
ExecutorFactoryFunc factory = CreateOrchestratorAsync;
|
||||
return factory.BindExecutor(nameof(MagenticOrchestrator));
|
||||
|
||||
ValueTask<MagenticOrchestrator> CreateOrchestratorAsync(ExecutorConfig<ExecutorOptions> options, string sessionId)
|
||||
{
|
||||
return new(new MagenticOrchestrator(managerAgent, team, limits, requirePlanSignoff));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Notifies an AIAgent-hosting executor that it should reset its conversation state, and start a new session, if appropriate.
|
||||
/// Note that for Agent Orchestrations, only Magentic makes use of this functionality.
|
||||
/// </summary>
|
||||
public sealed record ResetChatSignal();
|
||||
@@ -24,7 +24,7 @@ internal static class TurnExtensions
|
||||
=> handoffState.TurnToken.ShouldEmitStreamingEvents(agentSetting);
|
||||
}
|
||||
|
||||
internal class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private readonly AIAgentHostOptions _options;
|
||||
@@ -40,9 +40,7 @@ internal class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
StringMessageChatRole = ChatRole.User
|
||||
};
|
||||
|
||||
public static string IdFor(AIAgent agent) => agent.GetDescriptiveId();
|
||||
|
||||
public AIAgentHostExecutor(AIAgent agent, AIAgentHostOptions options) : base(id: IdFor(agent),
|
||||
public AIAgentHostExecutor(AIAgent agent, AIAgentHostOptions options) : base(id: agent.GetDescriptiveId(),
|
||||
s_defaultChatProtocolOptions,
|
||||
declareCrossRunShareable: false) // Explicitly false, because we maintain turn state on the instance
|
||||
{
|
||||
@@ -69,14 +67,7 @@ internal class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder))
|
||||
.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<ResetChatSignal>(this.ResetChat));
|
||||
}
|
||||
|
||||
internal void ResetChat(ResetChatSignal signal, IWorkflowContext context)
|
||||
{
|
||||
this._session = null;
|
||||
this._currentTurnEmitEvents = null;
|
||||
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder));
|
||||
}
|
||||
|
||||
private ValueTask HandleUserInputResponseAsync(
|
||||
@@ -190,16 +181,8 @@ internal class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
|
||||
AgentResponse response = await this.InvokeAgentAsync(filteredMessages, context, emitEvents, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Filter out server-side artifacts (reasoning tokens, web search calls, etc.)
|
||||
// that are internal to this agent. Forwarding them to other agents in the workflow
|
||||
// causes invalid request errors when the receiving agent uses the Responses API,
|
||||
// because these item types are not valid as input items.
|
||||
List<ChatMessage> forwardableMessages = FilterForwardableMessages(response.Messages).ToList();
|
||||
if (forwardableMessages.Count > 0)
|
||||
{
|
||||
await context.SendMessageAsync(forwardableMessages, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
await context.SendMessageAsync(response.Messages is List<ChatMessage> list ? list : response.Messages.ToList(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// If we have no outstanding requests, we can yield a turn token back to the workflow.
|
||||
if (!this.HasOutstandingRequests)
|
||||
@@ -258,60 +241,4 @@ internal class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content types that represent meaningful conversational content portable across agents.
|
||||
/// Messages containing only content types not in this set (e.g. reasoning tokens, web search
|
||||
/// calls) are filtered out before forwarding, as they are output-only items that cause
|
||||
/// schema validation errors when sent as input to the Responses API.
|
||||
/// </summary>
|
||||
private static readonly HashSet<Type> s_forwardableContentTypes =
|
||||
[
|
||||
typeof(TextContent),
|
||||
typeof(DataContent),
|
||||
typeof(UriContent),
|
||||
typeof(FunctionCallContent),
|
||||
typeof(FunctionResultContent),
|
||||
typeof(ToolApprovalRequestContent),
|
||||
typeof(ToolApprovalResponseContent),
|
||||
typeof(HostedFileContent),
|
||||
typeof(ErrorContent),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Filters response messages to only include those with portable conversational content,
|
||||
/// and strips <see cref="ChatMessage.RawRepresentation"/> so that provider-specific output
|
||||
/// items (e.g. <c>mcp_list_tools</c>, <c>reasoning</c>, <c>fabric_dataagent_preview_call</c>)
|
||||
/// are not round-tripped by the M.E.AI library when the messages are sent to another agent.
|
||||
/// </summary>
|
||||
private static List<ChatMessage> FilterForwardableMessages(IList<ChatMessage> messages)
|
||||
{
|
||||
List<ChatMessage> result = [];
|
||||
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
// Extract only the content items that are portable across agents.
|
||||
List<AIContent> forwardableContents = message.Contents
|
||||
.Where(c => s_forwardableContentTypes.Any(t => t.IsAssignableFrom(c.GetType())))
|
||||
.ToList();
|
||||
|
||||
if (forwardableContents.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build a clean message without the provider-specific RawRepresentation,
|
||||
// which would otherwise cause the M.E.AI library to round-trip the original
|
||||
// output-only items (e.g. mcp_list_tools) as input to the next agent.
|
||||
result.Add(new ChatMessage(message.Role, forwardableContents)
|
||||
{
|
||||
AuthorName = message.AuthorName,
|
||||
MessageId = message.MessageId,
|
||||
CreatedAt = message.CreatedAt,
|
||||
AdditionalProperties = message.AdditionalProperties is null ? null : new(message.AdditionalProperties),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(Execu
|
||||
sharedState.PreviousAgentId = handoff.PreviousAgentId;
|
||||
}
|
||||
|
||||
await context.YieldOutputAsync(sharedState.Conversation.CloneHistory(), cancellationToken).ConfigureAwait(false);
|
||||
await context.YieldOutputAsync(sharedState.Conversation.CloneAllMessages(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return sharedState;
|
||||
}, context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -24,20 +23,7 @@ internal static class HandoffConstants
|
||||
|
||||
internal sealed class HandoffSharedState
|
||||
{
|
||||
[JsonConstructor]
|
||||
internal HandoffSharedState(MultiPartyConversation conversation, string? previousAgentId)
|
||||
{
|
||||
this.Conversation = conversation;
|
||||
this.PreviousAgentId = previousAgentId;
|
||||
}
|
||||
|
||||
public HandoffSharedState()
|
||||
{
|
||||
this.Conversation = new([]);
|
||||
}
|
||||
|
||||
[JsonInclude]
|
||||
public MultiPartyConversation Conversation { get; internal set; }
|
||||
public MultiPartyConversation Conversation { get; } = new();
|
||||
|
||||
public string? PreviousAgentId { get; set; }
|
||||
}
|
||||
|
||||
-175
@@ -1,175 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
internal static partial class ChatMessageExtensions
|
||||
{
|
||||
private static void ProcessAIContents(StringBuilder resultBuilder, IEnumerable<AIContent> contents, StreamingToolCallResultPairMatcher? pairMatcher = null)
|
||||
{
|
||||
pairMatcher ??= new();
|
||||
|
||||
foreach (AIContent content in contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case TextContent textContent:
|
||||
resultBuilder.AppendLine(textContent.Text);
|
||||
break;
|
||||
|
||||
//case DataContent dataContent:
|
||||
// // We really do not know how to deal with anything other than image data with descriptions, which is not
|
||||
// // a well-defined concept in MEAI (as contrasted with AutoGen's ImageContent type)
|
||||
// break;
|
||||
|
||||
case ErrorContent errorContent:
|
||||
resultBuilder.AppendLine($"[ERROR{(errorContent.ErrorCode != null ? $"(Code={errorContent.ErrorCode})" : string.Empty)}]");
|
||||
resultBuilder.AppendLine(errorContent.Message);
|
||||
|
||||
if (errorContent.Details != null)
|
||||
{
|
||||
resultBuilder.Append("Details:").AppendLine(errorContent.Details);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case FunctionCallContent functionCallContent:
|
||||
pairMatcher.CollectFunctionCall(functionCallContent);
|
||||
break;
|
||||
|
||||
case FunctionResultContent functionResultContent:
|
||||
pairMatcher.TryResolveFunctionCall(functionResultContent, out string? functionName);
|
||||
string result = functionResultContent.Result?.ToString() ?? string.Empty;
|
||||
|
||||
resultBuilder.AppendLine($"[Tool Call '{functionName ?? functionResultContent.CallId}' Result]")
|
||||
.AppendLine(result);
|
||||
|
||||
break;
|
||||
|
||||
case McpServerToolCallContent mstContent:
|
||||
pairMatcher.CollectMcpServerToolCall(mstContent);
|
||||
break;
|
||||
|
||||
case McpServerToolResultContent mstResultContent:
|
||||
if (mstResultContent.Outputs?.Any() is true)
|
||||
{
|
||||
pairMatcher.TryResolveMcpServerToolCall(mstResultContent, out string? mcpServerToolName);
|
||||
resultBuilder.AppendLine($"[Start MCP Server Tool Call '{mcpServerToolName ?? mstResultContent.CallId}' Results]");
|
||||
|
||||
ProcessAIContents(resultBuilder, mstResultContent.Outputs!);
|
||||
|
||||
resultBuilder.AppendLine($"[End MCP Server Tool Call '{mcpServerToolName ?? mstResultContent.CallId}']");
|
||||
}
|
||||
|
||||
break;
|
||||
case TextReasoningContent reasoningContent:
|
||||
if (!string.IsNullOrWhiteSpace(reasoningContent.Text))
|
||||
{
|
||||
resultBuilder.Append("[Reasoning] ")
|
||||
.AppendLine(reasoningContent.Text);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case UriContent uriContent:
|
||||
resultBuilder.AppendLine(uriContent.Uri.ToString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetText(this List<ChatMessage> messages)
|
||||
{
|
||||
if (messages.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
StringBuilder builder = new();
|
||||
StreamingToolCallResultPairMatcher pairMatcher = new();
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
ProcessAIContents(builder, message.Contents, pairMatcher);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private const string FencedJsonRegexPattern = @"```(?<lang>[a-z]+)?\s*(?<json>\{[\s\S]*?\})\s*```";
|
||||
#if NET
|
||||
[GeneratedRegex(FencedJsonRegexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture)]
|
||||
public static partial Regex FencedJsonRegex();
|
||||
#else
|
||||
public static Regex FencedJsonRegex() => s_fencedJsonRegex;
|
||||
private static readonly Regex s_fencedJsonRegex =
|
||||
new(FencedJsonRegexPattern, RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture);
|
||||
#endif
|
||||
|
||||
internal static JsonElement ExtractJson(string messageText)
|
||||
{
|
||||
Match match = FencedJsonRegex().Match(messageText);
|
||||
if (match.Success)
|
||||
{
|
||||
return JsonElement.Parse(match.Groups["json"].Value);
|
||||
}
|
||||
|
||||
int start = messageText.IndexOf('{'), scanHead = start;
|
||||
int? end = null;
|
||||
|
||||
if (scanHead < 0)
|
||||
{
|
||||
throw new InvalidOperationException("No JSON object found.");
|
||||
}
|
||||
|
||||
int depth = 0;
|
||||
bool inQuotes = false, inEscape = false;
|
||||
for (; scanHead < messageText.Length && end is null; scanHead++)
|
||||
{
|
||||
if (inEscape)
|
||||
{
|
||||
inEscape = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (messageText[scanHead])
|
||||
{
|
||||
case '{' when !inQuotes:
|
||||
depth++;
|
||||
break;
|
||||
case '}' when !inQuotes:
|
||||
depth--;
|
||||
if (depth == 0)
|
||||
{
|
||||
end = scanHead;
|
||||
}
|
||||
|
||||
break;
|
||||
case '\"':
|
||||
// We already handled inEscape, so we can always flip inQuotes here
|
||||
inQuotes = !inQuotes;
|
||||
break;
|
||||
case '\\':
|
||||
Debug.Assert(!inEscape);
|
||||
inEscape = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (end is null)
|
||||
{
|
||||
throw new InvalidOperationException("Unbalanced JSON braces.");
|
||||
}
|
||||
|
||||
return JsonElement.Parse(messageText.Substring(start, end.Value - start + 1));
|
||||
}
|
||||
|
||||
public static JsonElement ExtractJson(this ChatMessage message) => ExtractJson(message.Text);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
internal sealed class ExecutorAgentHarness(AIAgent agent, AIAgentUnservicedRequestsCollector collector)
|
||||
{
|
||||
internal const string AgentSessionKey = nameof(AgentSession);
|
||||
private AgentSession? _session;
|
||||
|
||||
private async ValueTask<AgentSession> EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
|
||||
this._session ??= await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
public async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
AgentResponse response;
|
||||
|
||||
if (emitUpdateEvents)
|
||||
{
|
||||
// Run the agent in streaming mode only when agent run update events are to be emitted.
|
||||
IAsyncEnumerable<AgentResponseUpdate> agentStream = agent.RunStreamingAsync(
|
||||
messages,
|
||||
await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
|
||||
{
|
||||
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
|
||||
collector.ProcessAgentResponseUpdate(update);
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
response = updates.ToAgentResponse();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Otherwise, run the agent in non-streaming mode.
|
||||
response = await agent.RunAsync(messages,
|
||||
await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
|
||||
cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
collector.ProcessAgentResponse(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async ValueTask<JsonElement?> SerializeSessionAsync(CancellationToken cancellationToken)
|
||||
=> this._session == null
|
||||
? null
|
||||
: await agent.SerializeSessionAsync(this._session, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
public async ValueTask DeserializeSessionAsync(JsonElement? serializedSession, CancellationToken cancellationToken)
|
||||
{
|
||||
this._session = serializedSession == null
|
||||
? null
|
||||
: await agent.DeserializeSessionAsync(serializedSession.Value, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
this._session = null;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
internal static class MagenticConstants
|
||||
{
|
||||
public const string MagenticTaskContextKey = nameof(MagenticTaskContextKey);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
internal class MagenticManager(AIAgent managerAgent)
|
||||
{
|
||||
private static async ValueTask<ChatMessage> CheckResponseAsync(Task<AgentResponse> responseTask, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
AgentResponse response = await responseTask.ConfigureAwait(false);
|
||||
|
||||
if (response.Messages.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Planner Agent did not return any messages.");
|
||||
}
|
||||
|
||||
if (response.Messages.Count > 1)
|
||||
{
|
||||
await context.AddEventAsync(new WorkflowWarningEvent("Planner Agent returned multiple messages; using the last one."), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return response.Messages[response.Messages.Count - 1];
|
||||
}
|
||||
|
||||
private ValueTask<ChatMessage> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken, AgentSession? session = null)
|
||||
=> CheckResponseAsync(managerAgent.RunAsync(messages, session, cancellationToken: cancellationToken), context, cancellationToken);
|
||||
|
||||
public async ValueTask<TaskLedger> UpdatePlanAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// If we already have a TaskLedger, we need to update the facts based on the existing factset; otherwise, we use the initial facts construction
|
||||
bool isReplan = taskContext.TaskLedger != null;
|
||||
|
||||
AgentSession localSession = await managerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage factsRequest = new(ChatRole.User, isReplan ? taskContext.ToTaskLedgerFactsUpdatePrompt() : taskContext.ToTaskLedgerFactsPrompt());
|
||||
ChatMessage updatedFacts = await this.InvokeAgentAsync(
|
||||
messages: [.. taskContext.ChatHistory, factsRequest],
|
||||
context,
|
||||
cancellationToken,
|
||||
localSession)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
ChatMessage planRequest = new(ChatRole.User, isReplan ? taskContext.ToTaskLedgerPlanUpdatePrompt() : taskContext.ToTaskLedgerPlanPrompt());
|
||||
ChatMessage updatedPlan = await this.InvokeAgentAsync(
|
||||
// We rely on the AgentSession to maintain the context of the conversation, so we don't include the
|
||||
// history, facts request, or updated facts in the messages list.
|
||||
messages: [planRequest],
|
||||
context,
|
||||
cancellationToken,
|
||||
localSession)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
taskContext.ChatHistory.AddRange([factsRequest, updatedFacts, planRequest, updatedPlan]);
|
||||
|
||||
return new(updatedFacts, updatedPlan);
|
||||
}
|
||||
|
||||
public async ValueTask UpdateProgressLedgerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ChatMessage progressRequest = new(ChatRole.User, taskContext.ToProgressLedgerPrompt());
|
||||
|
||||
ExceptionDispatchInfo? lastException = null;
|
||||
int maxRetryCount = taskContext.TaskLimits.MaxProgressLedgerRetryCount;
|
||||
for (int attempts = 0; attempts < maxRetryCount; attempts++)
|
||||
{
|
||||
ChatMessage progressUpdateMessage = await this.InvokeAgentAsync(
|
||||
messages: [.. taskContext.ChatHistory, progressRequest],
|
||||
context,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
lastException = null;
|
||||
JsonElement stateUpdateJson = progressUpdateMessage.ExtractJson();
|
||||
if (!taskContext.ProgressLedger.TryUpdateState(stateUpdateJson))
|
||||
{
|
||||
throw new InvalidOperationException("Could not answer progress ledger questions with provided JSON.");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
lastException = ExceptionDispatchInfo.Capture(e);
|
||||
|
||||
string warnString = $"Progress ledger JSON parse failed (attempt {attempts}/{maxRetryCount}): {e}";
|
||||
await context.AddEventAsync(new WorkflowWarningEvent(warnString), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (attempts < maxRetryCount)
|
||||
{
|
||||
await Task.Delay(250 * attempts, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastException?.Throw();
|
||||
}
|
||||
|
||||
public async ValueTask<ChatMessage> PrepareFinalAnswerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ChatMessage finalAnswerRequest = new(ChatRole.User, taskContext.ToFinalAnswerPrompt());
|
||||
ChatMessage finalAnswer = await this.InvokeAgentAsync([.. taskContext.ChatHistory, finalAnswerRequest], context, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new(ChatRole.Assistant, finalAnswer.Text)
|
||||
{
|
||||
AuthorName = finalAnswer.AuthorName ?? nameof(MagenticManager),
|
||||
MessageId = finalAnswer.MessageId ?? Guid.NewGuid().ToString("N"),
|
||||
CreatedAt = finalAnswer.CreatedAt ?? DateTimeOffset.UtcNow,
|
||||
RawRepresentation = finalAnswer.RawRepresentation,
|
||||
};
|
||||
}
|
||||
}
|
||||
-326
@@ -1,326 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
/// <summary>
|
||||
/// Base type for Magentic Orchestration Events
|
||||
/// </summary>
|
||||
/// <param name="data"></param>
|
||||
[JsonDerivedType(typeof(MagenticPlanCreatedEvent))]
|
||||
[JsonDerivedType(typeof(MagenticReplannedEvent))]
|
||||
[JsonDerivedType(typeof(MagenticProgressLedgerUpdatedEvent))]
|
||||
public abstract class MagenticOrchestratorEvent(object? data) : WorkflowEvent(data)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the creation of the initial plan
|
||||
/// </summary>
|
||||
/// <param name="fullTaskLeger"></param>
|
||||
public sealed class MagenticPlanCreatedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
|
||||
{
|
||||
/// <summary>
|
||||
/// A <see cref="ChatMessage"/> containing the initial plan.
|
||||
/// </summary>
|
||||
public ChatMessage FullTaskLedger { get; } = fullTaskLeger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the creation of a new plan in response to a stall.
|
||||
/// </summary>
|
||||
/// <param name="fullTaskLeger"></param>
|
||||
public sealed class MagenticReplannedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
|
||||
{
|
||||
/// <summary>
|
||||
/// A <see cref="ChatMessage"/> containing the new plan.
|
||||
/// </summary>
|
||||
public ChatMessage FullTaskLedger { get; } = fullTaskLeger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an update to the <see cref="MagenticProgressLedger"/> when running a coordination round.
|
||||
/// </summary>
|
||||
/// <param name="progressLedger"></param>
|
||||
public sealed class MagenticProgressLedgerUpdatedEvent(MagenticProgressLedger progressLedger) : MagenticOrchestratorEvent(progressLedger)
|
||||
{
|
||||
/// <summary>
|
||||
/// The new state of the <see cref="MagenticProgressLedger"/>
|
||||
/// </summary>
|
||||
public MagenticProgressLedger ProgressLedger { get; } = progressLedger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Magentic orchestrator that defines the workflow structure.
|
||||
///
|
||||
/// This orchestrator manages the overall Magentic workflow in the following structure:
|
||||
///
|
||||
/// 1. Upon receiving the task(a list of messages), it creates the plan using the manager then runs the inner loop.
|
||||
/// 2. The inner loop is distributed and implementation is decentralized. In the orchestrator, it is responsible for:
|
||||
/// - Creating the progress ledger using the manager.
|
||||
/// - Checking for task completion.
|
||||
/// - Detecting stalling or looping and triggering replanning if needed.
|
||||
/// - Sending requests to participants based on the progress ledger's next speaker.
|
||||
/// - Issue requests for human intervention if enabled and needed.
|
||||
/// 3. The inner loop waits for responses from the selected participant, then continues the loop.
|
||||
/// 4. The orchestrator breaks out of the inner loop when the replanning or final answer conditions are met.
|
||||
/// 5. The outer loop handles replanning and reenters the inner loop.
|
||||
/// </summary>
|
||||
/// <param name="managerAgent"></param>
|
||||
/// <param name="team"></param>
|
||||
/// <param name="limits"></param>
|
||||
/// <param name="requirePlanSignoff"></param>
|
||||
internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
|
||||
: ChatProtocolExecutor(nameof(MagenticOrchestrator), s_options, declareCrossRunShareable: false)
|
||||
{
|
||||
private readonly MagenticManager _manager = new(managerAgent);
|
||||
|
||||
private static readonly ChatProtocolExecutorOptions s_options = new()
|
||||
{
|
||||
StringMessageChatRole = ChatRole.User,
|
||||
AutoSendTurnToken = false
|
||||
};
|
||||
|
||||
private MagenticTaskContext? _taskContext;
|
||||
private PortBinding? _planReviewPort;
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return base.ConfigureProtocol(protocolBuilder).ConfigureRoutes(ConfigureRoutes);
|
||||
|
||||
void ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddPortHandler<MagenticPlanReviewRequest, MagenticPlanReviewResponse>(
|
||||
"RequestPlanReview",
|
||||
this.ProcessPlanReviewAsync,
|
||||
out this._planReviewPort);
|
||||
}
|
||||
|
||||
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext)
|
||||
{
|
||||
MagenticProgressLedger? progressLedger = taskContext.ProgressLedger;
|
||||
if (progressLedger?.IsStarted is not true)
|
||||
{
|
||||
progressLedger = null;
|
||||
}
|
||||
|
||||
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, taskContext.IsStalled);
|
||||
|
||||
return this._planReviewPort!.PostRequestAsync(request);
|
||||
}
|
||||
|
||||
private async ValueTask ProcessPlanReviewAsync(MagenticPlanReviewResponse response, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
/*
|
||||
Handle the human response to the plan review request.
|
||||
|
||||
Logic:
|
||||
There are code paths which will trigger a plan review request to the human:
|
||||
- Initial plan creation if `require_plan_signoff` is True.
|
||||
- Potentially during the inner loop if stalling is detected (resetting and replanning).
|
||||
|
||||
The human can either approve the plan or request revisions with comments.
|
||||
- If approved, proceed to run the outer loop, which simply adds the task ledger
|
||||
to the conversation and enters the inner loop.
|
||||
- If revision requested, append the review comments to the chat history,
|
||||
trigger replanning via the manager, emit a REPLANNED event, then run the outer loop.
|
||||
|
||||
*/
|
||||
if (this._taskContext == null || this._taskContext.TaskLedger == null)
|
||||
{
|
||||
throw new InvalidOperationException("Magentic Orchestration was not initialized correctly.");
|
||||
}
|
||||
|
||||
if (this._taskContext.IsTerminated)
|
||||
{
|
||||
throw new InvalidOperationException("Magentic Orchestration has already been terminated and cannot process new messages. Please start a new session.");
|
||||
}
|
||||
|
||||
if (response.IsApproved)
|
||||
{
|
||||
await this.DelegateToTeamAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._taskContext.ChatHistory.AddRange(response.Review);
|
||||
|
||||
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
bool isReplan = taskContext.TaskLedger != null;
|
||||
|
||||
taskContext.TaskLedger = await this._manager.UpdatePlanAsync(taskContext, context, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
this._fullTaskLedgerMessage = new(ChatRole.User, taskContext.ToTaskLedgerFullPrompt());
|
||||
taskContext.ChatHistory.Add(this._fullTaskLedgerMessage);
|
||||
|
||||
await context.AddEventAsync(isReplan
|
||||
? new MagenticReplannedEvent(this._fullTaskLedgerMessage)
|
||||
: new MagenticPlanCreatedEvent(this._fullTaskLedgerMessage), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (requirePlanSignoff)
|
||||
{
|
||||
await this.SubmitPlanReviewRequestAsync(taskContext, context).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.DelegateToTeamAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// First Turn: Initialize the task context and send the initial messages to the planner agent
|
||||
this._taskContext ??= new(messages, team, limits, emitEvents, []);
|
||||
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private ChatMessage? _fullTaskLedgerMessage;
|
||||
private ValueTask DelegateToTeamAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.RunCoordinationRoundAsync(taskContext, context, cancellationToken);
|
||||
}
|
||||
|
||||
private async ValueTask RunCoordinationRoundAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
(bool hitRoundLimit, bool hitResetLimit) = taskContext.CheckLimits();
|
||||
|
||||
if (hitRoundLimit || hitResetLimit)
|
||||
{
|
||||
string limitType = hitRoundLimit ? "round" : "reset";
|
||||
|
||||
List<ChatMessage> messages = [new(ChatRole.Assistant, $"Task execution stopped due to hitting the maximum {limitType} count limit.")];
|
||||
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
taskContext.IsTerminated = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
taskContext.TaskCounters.RoundCount++;
|
||||
|
||||
// Update the Progress Ledger
|
||||
try
|
||||
{
|
||||
await this._manager.UpdateProgressLedgerAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await context.AddEventAsync(new MagenticProgressLedgerUpdatedEvent(taskContext.ProgressLedger), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
// Retry on exception to max retry count, unless it is OperationCancelledException - in that case exit the loop right away
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
await context.AddEventAsync(new WorkflowWarningEvent($"Magentic Orchestrator: Progress ledger creation failed, triggering reset: {ex}"), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await this.ResetAndReplanAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check and handle finish condition
|
||||
if (taskContext.ProgressLedger.IsRequestSatisfied)
|
||||
{
|
||||
await this.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check and handle stalls
|
||||
if (taskContext.ProgressLedger.IsInLoop || !taskContext.ProgressLedger.IsProgressBeingMade)
|
||||
{
|
||||
taskContext.TaskCounters.StallCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
taskContext.TaskCounters.StallCount = Math.Max(0, taskContext.TaskCounters.StallCount - 1);
|
||||
}
|
||||
|
||||
if (taskContext.IsStalled)
|
||||
{
|
||||
await this.ResetAndReplanAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare to delegate to the next speaker
|
||||
string nextSpeaker = taskContext.ProgressLedger.NextSpeaker;
|
||||
if (string.IsNullOrEmpty(nextSpeaker))
|
||||
{
|
||||
await context.AddEventAsync(new WorkflowWarningEvent("Next speaker answer empty; selecting first participant as fallback"), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
nextSpeaker = team.First().Name!;
|
||||
}
|
||||
|
||||
AIAgent? nextAgent = team.FirstOrDefault(agent => agent.Name == nextSpeaker);
|
||||
if (nextAgent == null)
|
||||
{
|
||||
await context.AddEventAsync(new WorkflowWarningEvent($"Invalid next speaker: {nextSpeaker}"), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await this.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(taskContext.ProgressLedger.InstructionOrQuestion))
|
||||
{
|
||||
ChatMessage instruction = new(ChatRole.Assistant, taskContext.ProgressLedger.InstructionOrQuestion);
|
||||
taskContext.ChatHistory.Add(instruction);
|
||||
|
||||
await context.SendMessageAsync(instruction, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
string nextExecutorId = AIAgentHostExecutor.IdFor(nextAgent);
|
||||
await context.SendMessageAsync(new TurnToken(taskContext.EmitUpdateEvents), nextExecutorId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask ResetAndReplanAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
taskContext.Reset();
|
||||
await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask PrepareFinalAnswerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
List<ChatMessage> messages = [await this._manager.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false)];
|
||||
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
taskContext.IsTerminated = true;
|
||||
}
|
||||
|
||||
private const string CurrentTurnEmitUpdateEventsKey = nameof(CurrentTurnEmitUpdateEventsKey);
|
||||
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Task contextStateTask = this._taskContext == null
|
||||
? Task.CompletedTask
|
||||
: context.QueueStateUpdateAsync(MagenticConstants.MagenticTaskContextKey,
|
||||
this._taskContext.ExportState(),
|
||||
cancellationToken: cancellationToken)
|
||||
.AsTask();
|
||||
|
||||
await Task.WhenAll(base.OnCheckpointingAsync(context, cancellationToken).AsTask(),
|
||||
contextStateTask).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.WhenAll(base.OnCheckpointRestoredAsync(context, cancellationToken).AsTask(), LoadContextStateAsync())
|
||||
.ConfigureAwait(false);
|
||||
|
||||
async Task LoadContextStateAsync()
|
||||
{
|
||||
MagenticTaskState? state = await context.ReadStateAsync<MagenticTaskState>(MagenticConstants.MagenticTaskContextKey, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (state != null)
|
||||
{
|
||||
this._taskContext = new MagenticTaskContext(state, team, limits, []);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user