diff --git a/.gitignore b/.gitignore
index 955d4ee9df..1df3d497a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,7 @@ share/python-wheels/
.installed.cfg
*.egg
MANIFEST
+TestResults/
# PyInstaller
# Usually these files are written by a python script from a template
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 9257f8db0f..50cc9766c3 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -44,6 +44,7 @@
+
@@ -58,6 +59,7 @@
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 253364f376..33156ad0f9 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -4,15 +4,8 @@
-
-
-
-
-
-
-
-
+
@@ -31,15 +24,6 @@
-
-
-
-
-
-
-
-
-
@@ -61,22 +45,10 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
@@ -95,8 +67,8 @@
-
+
@@ -114,6 +86,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -127,14 +120,21 @@
-
+
+
+
+
+
+
+
+
+
+
-
-
diff --git a/dotnet/demos/AgentOpenTelemetry/AgentOpenTelemetry.csproj b/dotnet/demos/AgentOpenTelemetry/AgentOpenTelemetry.csproj
new file mode 100644
index 0000000000..2312d1d87f
--- /dev/null
+++ b/dotnet/demos/AgentOpenTelemetry/AgentOpenTelemetry.csproj
@@ -0,0 +1,31 @@
+
+
+
+ Exe
+ enable
+ $(ProjectsTargetFrameworks)
+ $(ProjectsDebugTargetFrameworks)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/demos/AgentOpenTelemetry/Program.cs b/dotnet/demos/AgentOpenTelemetry/Program.cs
new file mode 100644
index 0000000000..228b4f1be9
--- /dev/null
+++ b/dotnet/demos/AgentOpenTelemetry/Program.cs
@@ -0,0 +1,212 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.AI.Agents;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using OpenAI;
+using OpenTelemetry;
+using OpenTelemetry.Logs;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+
+const string SourceName = "OpenTelemetryAspire.ConsoleApp";
+const string ServiceName = "AgentOpenTelemetry";
+
+// Enable telemetry for agents
+AppContext.SetSwitch("Microsoft.Extensions.AI.Agents.EnableTelemetry", true);
+
+// Configure OpenTelemetry for Aspire dashboard
+var otlpEndpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") ?? "http://localhost:4318";
+
+// Create a resource to identify this service (like Python example)
+var resource = ResourceBuilder.CreateDefault()
+ .AddService(ServiceName, serviceVersion: "1.0.0")
+ .AddAttributes(new Dictionary
+ {
+ ["service.instance.id"] = Environment.MachineName,
+ ["deployment.environment"] = "development"
+ })
+ .Build();
+
+// Setup tracing with resource
+using var tracerProvider = Sdk.CreateTracerProviderBuilder()
+ .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0"))
+ .AddSource(SourceName) // Our custom activity source
+ .AddSource("Microsoft.Extensions.AI.Agents") // Agent Framework telemetry
+ .AddHttpClientInstrumentation() // Capture HTTP calls to OpenAI
+ .AddOtlpExporter(options =>
+ {
+ options.Endpoint = new Uri(otlpEndpoint);
+ })
+ .Build();
+
+// Setup metrics with resource and instrument name filtering (like Python example)
+using var meterProvider = Sdk.CreateMeterProviderBuilder()
+ .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0"))
+ .AddMeter(SourceName) // Our custom meter
+ .AddMeter("Microsoft.Extensions.AI.Agents") // Agent Framework metrics
+ .AddHttpClientInstrumentation() // HTTP client metrics
+ .AddRuntimeInstrumentation() // .NET runtime metrics
+ .AddOtlpExporter(options =>
+ {
+ options.Endpoint = new Uri(otlpEndpoint);
+ })
+ .Build();
+
+// Setup structured logging with OpenTelemetry
+var serviceCollection = new ServiceCollection();
+serviceCollection.AddLogging(loggingBuilder => loggingBuilder
+ .SetMinimumLevel(LogLevel.Debug)
+ .AddOpenTelemetry(options =>
+ {
+ options.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0"));
+ options.AddOtlpExporter(otlpOptions =>
+ {
+ otlpOptions.Endpoint = new Uri(otlpEndpoint);
+ });
+ options.IncludeScopes = true;
+ options.IncludeFormattedMessage = true;
+ }));
+
+var serviceProvider = serviceCollection.BuildServiceProvider();
+var logger = serviceProvider.GetRequiredService>();
+
+using var activitySource = new ActivitySource(SourceName);
+using var meter = new Meter(SourceName);
+
+// Create custom metrics (similar to Python example)
+var interactionCounter = meter.CreateCounter("agent_interactions_total", description: "Total number of agent interactions");
+var responseTimeHistogram = meter.CreateHistogram("agent_response_time_seconds", description: "Agent response time in seconds");
+
+Console.WriteLine("""
+ === OpenTelemetry Aspire Demo ===
+ This demo shows OpenTelemetry integration with the Agent Framework.
+ You can view the telemetry data in the Aspire Dashboard.
+ Type your message and press Enter. Type 'exit' or empty message to quit.
+ """);
+
+// Log application startup
+logger.LogInformation("OpenTelemetry Aspire Demo application started");
+logger.LogInformation("OTLP endpoint configured: {OtlpEndpoint}", otlpEndpoint);
+logger.LogDebug("Service name: {ServiceName}, Source name: {SourceName}", ServiceName, SourceName);
+
+// Create the chat client
+var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT", EnvironmentVariableTarget.Machine) ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT environment variable is not set.");
+var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
+
+logger.LogInformation("Initializing Azure OpenAI client with endpoint: {Endpoint}", endpoint);
+logger.LogDebug("Using deployment: {DeploymentName}", deploymentName);
+
+// Create a logger specifically for the agent
+var loggerFactory = serviceProvider.GetRequiredService();
+
+// Create the agent with OpenTelemetry instrumentation
+logger.LogInformation("Creating Agent with OpenTelemetry instrumentation");
+
+using var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
+ .GetChatClient(deploymentName)
+ .CreateAIAgent(
+ name: "OpenTelemetryDemoAgent",
+ instructions: "You are a helpful assistant that provides concise and informative responses.")
+ .WithOpenTelemetry(loggerFactory, SourceName);
+
+var thread = agent.GetNewThread();
+
+logger.LogInformation("Agent created successfully with ID: {AgentId}", agent.Id);
+
+// Create a parent span for the entire agent session
+using var sessionActivity = activitySource.StartActivity("Agent Session");
+var sessionId = thread.ConversationId ?? Guid.NewGuid().ToString();
+sessionActivity?.SetTag("agent.name", "OpenTelemetryDemoAgent");
+sessionActivity?.SetTag("session.id", sessionId);
+sessionActivity?.SetTag("session.start_time", DateTimeOffset.UtcNow.ToString("O"));
+
+logger.LogInformation("Starting agent session with ID: {SessionId}", sessionId);
+using (logger.BeginScope(new Dictionary { ["SessionId"] = sessionId, ["AgentName"] = "OpenTelemetryDemoAgent" }))
+{
+ var interactionCount = 0;
+
+ while (true)
+ {
+ Console.Write("You: ");
+ var userInput = Console.ReadLine();
+
+ if (string.IsNullOrWhiteSpace(userInput) || userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
+ {
+ logger.LogInformation("User requested to exit the session");
+ break;
+ }
+
+ interactionCount++;
+ logger.LogInformation("Processing user interaction #{InteractionNumber}: {UserInput}", interactionCount, userInput);
+
+ // Create a child span for each individual interaction
+ using var activity = activitySource.StartActivity("Agent Interaction");
+ activity?.SetTag("user.input", userInput);
+ activity?.SetTag("agent.name", "OpenTelemetryDemoAgent");
+ activity?.SetTag("interaction.number", interactionCount);
+
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+
+ try
+ {
+ logger.LogDebug("Starting agent execution for interaction #{InteractionNumber}", interactionCount);
+ Console.Write("Agent: ");
+
+ // Run the agent (this will create its own internal telemetry spans)
+ await foreach (var update in agent.RunStreamingAsync(userInput, thread))
+ {
+ Console.Write(update.Text);
+ }
+
+ Console.WriteLine();
+
+ stopwatch.Stop();
+ var responseTime = stopwatch.Elapsed.TotalSeconds;
+
+ // Record metrics (similar to Python example)
+ interactionCounter.Add(1, new KeyValuePair("status", "success"));
+ responseTimeHistogram.Record(responseTime,
+ new KeyValuePair("status", "success"));
+
+ activity?.SetTag("response.success", true);
+
+ logger.LogInformation("Agent interaction #{InteractionNumber} completed successfully in {ResponseTime:F2} seconds",
+ interactionCount, responseTime);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error: {ex.Message}");
+ Console.WriteLine();
+
+ stopwatch.Stop();
+ var responseTime = stopwatch.Elapsed.TotalSeconds;
+
+ // Record error metrics
+ interactionCounter.Add(1, new KeyValuePair("status", "error"));
+ responseTimeHistogram.Record(responseTime,
+ new KeyValuePair("status", "error"));
+
+ activity?.SetTag("response.success", false);
+ activity?.SetTag("error.message", ex.Message);
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+
+ logger.LogError(ex, "Agent interaction #{InteractionNumber} failed after {ResponseTime:F2} seconds: {ErrorMessage}",
+ interactionCount, responseTime, ex.Message);
+ }
+ }
+
+ // Add session summary to the parent span
+ sessionActivity?.SetTag("session.total_interactions", interactionCount);
+ sessionActivity?.SetTag("session.end_time", DateTimeOffset.UtcNow.ToString("O"));
+
+ logger.LogInformation("Agent session completed. Total interactions: {TotalInteractions}", interactionCount);
+} // End of logging scope
+
+logger.LogInformation("OpenTelemetry Aspire Demo application shutting down");
diff --git a/dotnet/demos/AgentOpenTelemetry/README.md b/dotnet/demos/AgentOpenTelemetry/README.md
new file mode 100644
index 0000000000..884132baa7
--- /dev/null
+++ b/dotnet/demos/AgentOpenTelemetry/README.md
@@ -0,0 +1,210 @@
+# OpenTelemetry Aspire Demo with Azure OpenAI
+
+This demo showcases the integration of OpenTelemetry with the Microsoft Agent Framework using Azure OpenAI and .NET Aspire Dashboard for telemetry visualization.
+
+## Overview
+
+The demo consists of two main components:
+
+1. **Aspire Dashboard** - Provides a web-based interface to visualize OpenTelemetry data
+2. **Console Application** - An interactive console application that demonstrates agent interactions with proper OpenTelemetry instrumentation
+
+## Architecture
+
+```mermaid
+graph TD
+ A["Console App (Interactive)"] --> B["Agent Framework with OpenTel Instrumentation"]
+ B --> C["Azure OpenAI Service"]
+ A --> D["Aspire Dashboard (OpenTelemetry Visualization)"]
+ B --> D
+```
+
+## Prerequisites
+
+- .NET 8.0 SDK or later
+- Azure OpenAI service endpoint and deployment configured
+- Azure CLI installed and authenticated (for Azure credential authentication)
+- Docker installed (for running Aspire Dashboard)
+
+## Configuration
+
+### Azure OpenAI Setup
+Set the following environment variables:
+```powershell
+$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
+$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
+```
+
+**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource.
+
+## Running the Demo
+
+### Quick Start (Using Script)
+
+The easiest way to run the demo is using the provided PowerShell script:
+
+```powershell
+.\start-demo.ps1
+```
+
+This script will automatically:
+- ✅ Check prerequisites (Docker, Azure OpenAI configuration)
+- 🔨 Build the console application
+- 🐳 Start the Aspire Dashboard via Docker (with anonymous access)
+- ⏳ Wait for dashboard to be ready (polls port until listening)
+- 🌐 Open your browser with the dashboard
+- 📊 Configure telemetry endpoints (http://localhost:4317)
+- 🎯 Start the interactive console application
+
+### Manual Setup (Step by Step)
+
+If you prefer to run the components manually:
+
+#### Step 1: Start the Aspire Dashboard via Docker
+
+```powershell
+docker run -d --name aspire-dashboard -p 4318:18888 -p 4317:18889 -e DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true mcr.microsoft.com/dotnet/aspire-dashboard:9.0
+```
+
+#### Step 2: Access the Dashboard
+
+Open your browser to: http://localhost:4318
+
+#### Step 3: Run the Console Application
+
+```powershell
+cd dotnet/demos/AgentOpenTelemetry
+$env:OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317"
+dotnet run
+```
+
+#### Interacting with the Console Application
+
+You should see a welcome message like:
+
+```
+=== OpenTelemetry Aspire Demo ===
+This demo shows OpenTelemetry integration with the Agent Framework.
+You can view the telemetry data in the Aspire Dashboard.
+Type your message and press Enter. Type 'exit' or empty message to quit.
+
+You:
+```
+
+1. Type your message and press Enter to interact with the AI agent
+2. The agent will respond, and you can continue the conversation
+3. Type `exit` to stop the application
+
+**Note**: Make sure the Aspire Dashboard is running before starting the console application, as the telemetry data will be sent to the dashboard.
+
+#### Step 4: Test the Integration
+
+1. **Start the Aspire Dashboard** (if not already running)
+2. **Run the Console Application** in a separate terminal
+3. **Send a test message** like "Hello, how are you?"
+4. **Check the Aspire Dashboard** - you should see:
+ - New traces appearing in the **Traces** tab
+ - Each trace showing the complete agent interaction flow
+ - Metrics in the **Metrics** tab showing token usage and duration
+ - Logs in the **Structured Logs** tab with detailed information
+
+## Viewing Telemetry Data
+
+### Traces
+1. In the Aspire Dashboard, navigate to the **Traces** tab
+2. You'll see traces for each agent interaction
+3. Each trace contains:
+ - An outer span for the entire agent interaction
+ - Inner spans from the Agent Framework's OpenTelemetry instrumentation
+ - Spans from HTTP calls to Azure OpenAI
+
+### Metrics
+1. Navigate to the **Metrics** tab
+2. View metrics related to:
+ - Agent execution duration
+ - Token usage (input/output tokens)
+ - Request counts
+
+### Logs
+1. Navigate to the **Structured Logs** tab
+2. Filter by the console application to see detailed logs
+3. Logs include information about user inputs, agent responses, and any errors
+
+## Key Features Demonstrated
+
+### OpenTelemetry Integration
+- **Automatic instrumentation** of Agent Framework operations
+- **Custom spans** for user interactions
+- **Proper span lifecycle management** (create → execute → close)
+- **Telemetry correlation** across the entire request flow
+
+### Agent Framework Features
+- **ChatClientAgent** with Azure OpenAI integration
+- **OpenTelemetry wrapper** using `.WithOpenTelemetry()`
+- **Conversation threading** for multi-turn conversations
+- **Error handling** with telemetry correlation
+
+### Aspire Dashboard Features
+- **Real-time telemetry visualization**
+- **Distributed tracing** across services
+- **Metrics and logging** integration
+- **Resource management** and monitoring
+
+## Available Script
+
+The demo includes a PowerShell script to make running the demo easy:
+
+### `start-demo.ps1`
+Complete demo startup script that handles everything automatically.
+
+**Usage:**
+```powershell
+.\start-demo.ps1 # Start the complete demo
+```
+
+**Features:**
+- **Automatic configuration detection** - Checks for Azure OpenAI configuration
+- **Project building** - Automatically builds projects before running
+- **Error handling** - Provides clear error messages if something goes wrong
+- **Multi-window support** - Opens dashboard in separate window for better experience
+- **Browser auto-launch** - Automatically opens the Aspire Dashboard in your browser
+- **Docker integration** - Uses Docker to run the Aspire Dashboard
+
+**Docker Endpoints:**
+- **Aspire Dashboard**: `http://localhost:4318`
+- **OTLP Telemetry**: `http://localhost:4317`
+
+## Troubleshooting
+
+### Port Conflicts
+If you encounter port binding errors, try:
+1. Stop any existing Docker containers using the same ports (`docker stop aspire-dashboard`)
+2. Or kill any processes using the conflicting ports
+
+### Authentication Issues
+- Ensure your Azure OpenAI endpoint is correctly configured
+- Check that the environment variables are set in the correct terminal session
+- Verify you're logged in with Azure CLI (`az login`) and have access to the Azure OpenAI resource
+- Ensure the Azure OpenAI deployment name matches your actual deployment
+
+### Build Issues
+- Ensure you're using .NET 9.0 SDK
+- Run `dotnet restore` if you encounter package restore issues
+- Check that all project references are correctly resolved
+
+## Project Structure
+
+```
+AgentOpenTelemetry/
+├── AgentOpenTelemetry.csproj # Project file with dependencies
+├── Program.cs # Main application with Azure OpenAI agent integration
+├── start-demo.ps1 # PowerShell script to start the demo
+└── README.md # This file
+```
+
+## Next Steps
+
+- Experiment with different prompts to see various telemetry patterns
+- Explore the Aspire Dashboard's filtering and search capabilities
+- Try modifying the OpenTelemetry configuration to add custom metrics or spans
+- Integrate additional services to see distributed tracing in action
diff --git a/dotnet/demos/AgentOpenTelemetry/start-demo.ps1 b/dotnet/demos/AgentOpenTelemetry/start-demo.ps1
new file mode 100644
index 0000000000..8445d1e7e3
--- /dev/null
+++ b/dotnet/demos/AgentOpenTelemetry/start-demo.ps1
@@ -0,0 +1,139 @@
+# OpenTelemetry Console Demo with Aspire Dashboard (Docker)
+# This script starts the Aspire Dashboard via Docker and the Console Application
+
+Write-Host "Starting OpenTelemetry Console Demo..." -ForegroundColor Green
+Write-Host ""
+
+# Check if we're in the right directory
+if (!(Test-Path "AgentOpenTelemetry.csproj")) {
+ Write-Host "Error: Please run this script from the AgentOpenTelemetry directory" -ForegroundColor Red
+ Write-Host "Expected to find AgentOpenTelemetry.csproj file" -ForegroundColor Red
+ exit 1
+}
+
+# Check if Docker is running
+try {
+ docker version | Out-Null
+ Write-Host "Docker is running" -ForegroundColor Green
+} catch {
+ Write-Host "Docker is not running or not installed" -ForegroundColor Red
+ Write-Host "Please start Docker Desktop and try again" -ForegroundColor Red
+ exit 1
+}
+
+# Check for Azure OpenAI configuration
+if ($env:AZURE_OPENAI_ENDPOINT) {
+ Write-Host "Found Azure OpenAI endpoint: $($env:AZURE_OPENAI_ENDPOINT)" -ForegroundColor Green
+ if ($env:AZURE_OPENAI_DEPLOYMENT_NAME) {
+ Write-Host "Using deployment: $($env:AZURE_OPENAI_DEPLOYMENT_NAME)" -ForegroundColor Green
+ } else {
+ Write-Host "Using default deployment: gpt-4o-mini" -ForegroundColor Cyan
+ }
+} else {
+ Write-Host "Warning: AZURE_OPENAI_ENDPOINT not found!" -ForegroundColor Yellow
+ Write-Host "Please set the AZURE_OPENAI_ENDPOINT environment variable" -ForegroundColor Yellow
+ Write-Host "Example: `$env:AZURE_OPENAI_ENDPOINT='https://your-resource.openai.azure.com/'" -ForegroundColor Yellow
+ Write-Host ""
+}
+
+# Build console application
+Write-Host ""
+Write-Host "Building console application..." -ForegroundColor Cyan
+
+$buildResult = dotnet build --verbosity quiet
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "Failed to build Console App" -ForegroundColor Red
+ exit 1
+}
+
+Write-Host "Build completed successfully" -ForegroundColor Green
+
+Write-Host ""
+Write-Host "Starting Aspire Dashboard via Docker..." -ForegroundColor Cyan
+
+# Stop any existing Aspire Dashboard container
+Write-Host "Stopping any existing Aspire Dashboard container..." -ForegroundColor Gray
+docker stop aspire-dashboard-afdemo 2>$null | Out-Null
+docker rm aspire-dashboard-afdemo 2>$null | Out-Null
+
+# Start Aspire Dashboard in Docker daemon mode with fixed token
+Write-Host "Starting Aspire Dashboard container..." -ForegroundColor Green
+$fixedToken = "demo-token-12345"
+$dockerResult = docker run -d `
+ --name aspire-dashboard-afdemo `
+ -p 4318:18888 `
+ -p 4317:18889 `
+ -e DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true `
+ --restart unless-stopped `
+ mcr.microsoft.com/dotnet/aspire-dashboard:9.0
+
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "Failed to start Aspire Dashboard container" -ForegroundColor Red
+ Write-Host "Make sure Docker is running and try again" -ForegroundColor Red
+ exit 1
+}
+
+Write-Host "Aspire Dashboard started successfully!" -ForegroundColor Green
+Write-Host "OTLP Endpoint: http://localhost:4318" -ForegroundColor Cyan
+
+# Wait for dashboard to be ready by polling the port
+Write-Host "Waiting for dashboard to be ready..." -ForegroundColor Gray
+$maxWaitSeconds = 10
+$waitCount = 0
+$dashboardReady = $false
+
+while ($waitCount -lt $maxWaitSeconds -and !$dashboardReady) {
+ try {
+ $tcpConnection = Test-NetConnection -ComputerName "localhost" -Port 4317 -InformationLevel Quiet -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
+ if ($tcpConnection) {
+ $dashboardReady = $true
+ Write-Host "Dashboard is ready! (took $waitCount seconds)" -ForegroundColor Green
+ } else {
+ Write-Host "." -NoNewline -ForegroundColor Gray
+ Start-Sleep -Seconds 1
+ $waitCount++
+ }
+ } catch {
+ Write-Host "." -NoNewline -ForegroundColor Gray
+ Start-Sleep -Seconds 1
+ $waitCount++
+ }
+}
+
+if (!$dashboardReady) {
+ Write-Host ""
+ Write-Host "Dashboard port 4317 not responding after $maxWaitSeconds seconds" -ForegroundColor Yellow
+ Write-Host " Continuing anyway - dashboard might still be starting..." -ForegroundColor Yellow
+} else {
+ Write-Host ""
+}
+
+# Open the dashboard in browser (anonymous access enabled)
+Write-Host "Opening dashboard in browser..." -ForegroundColor Green
+Write-Host "Dashboard URL: http://localhost:4318" -ForegroundColor Cyan
+Start-Process "http://localhost:4318"
+
+Write-Host ""
+Write-Host "Starting Console Application..." -ForegroundColor Cyan
+Write-Host "You can now interact with the AI agent!" -ForegroundColor Green
+Write-Host ""
+
+# Set the OTLP endpoint for the console application (Docker Aspire Dashboard)
+$otlpEndpoint = "http://localhost:4317"
+Write-Host "Using OTLP endpoint: $otlpEndpoint" -ForegroundColor Cyan
+
+$env:OTEL_EXPORTER_OTLP_ENDPOINT = $otlpEndpoint
+
+# Start the console application in the current window
+Write-Host ""
+Write-Host "Starting the console application..." -ForegroundColor Green
+Write-Host "Tip: The dashboard should now be open in your browser!" -ForegroundColor Cyan
+Write-Host ""
+
+dotnet run --no-build
+
+Write-Host ""
+Write-Host "Demo completed!" -ForegroundColor Green
+Write-Host "The Aspire Dashboard is still running in Docker." -ForegroundColor Gray
+Write-Host "You can view telemetry data in the browser tab that opened." -ForegroundColor Gray
+Write-Host "To stop the dashboard: docker stop aspire-dashboard-afdemo" -ForegroundColor Gray
diff --git a/dotnet/demos/Directory.Build.props b/dotnet/demos/Directory.Build.props
new file mode 100644
index 0000000000..cf76e03054
--- /dev/null
+++ b/dotnet/demos/Directory.Build.props
@@ -0,0 +1,12 @@
+
+
+
+
+
+ false
+ false
+ net472;net9.0
+ 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0
+
+
+
diff --git a/dotnet/samples/Directory.Build.props b/dotnet/samples/Directory.Build.props
index 5b2fe94743..cf76e03054 100644
--- a/dotnet/samples/Directory.Build.props
+++ b/dotnet/samples/Directory.Build.props
@@ -6,6 +6,7 @@
falsefalsenet472;net9.0
+ 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0
diff --git a/dotnet/samples/GettingStarted/GettingStarted.csproj b/dotnet/samples/GettingStarted/GettingStarted.csproj
index 3722c8294a..ba192b9f86 100644
--- a/dotnet/samples/GettingStarted/GettingStarted.csproj
+++ b/dotnet/samples/GettingStarted/GettingStarted.csproj
@@ -3,7 +3,6 @@
GettingStartedLibrary
- 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0$(NoWarn);CA1707;CA1716;IDE0009;IDE1006; OPENAI001;enabletrue
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs
index b09a3985cd..328d15352f 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs
@@ -38,6 +38,36 @@ public abstract class AIAgent
///
public virtual string? Description { get; }
+ /// Asks the for an object of the specified type .
+ /// The type of object being requested.
+ /// An optional key that can be used to help identify the target service.
+ /// The found object, otherwise .
+ /// is .
+ ///
+ /// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the ,
+ /// including itself or any services it might be wrapping. For example, to access the for the instance,
+ /// may be used to request it.
+ ///
+ public virtual object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ _ = Throw.IfNull(serviceType);
+
+ return serviceKey is null && serviceType.IsInstanceOfType(this)
+ ? this
+ : null;
+ }
+
+ /// Asks the for an object of type .
+ /// The type of the object to be retrieved.
+ /// An optional key that can be used to help identify the target service.
+ /// The found object, otherwise .
+ ///
+ /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the ,
+ /// including itself or any services it might be wrapping.
+ ///
+ public TService? GetService(object? serviceKey = null)
+ => this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
+
///
/// Get a new instance that is compatible with the agent.
///
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgentMetadata.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgentMetadata.cs
new file mode 100644
index 0000000000..90e857814d
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgentMetadata.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Extensions.AI.Agents;
+
+/// Provides metadata about an .
+public class AIAgentMetadata
+{
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the agent provider, if applicable. Where possible, this should map to the
+ /// appropriate name defined in the OpenTelemetry Semantic Conventions for Generative AI systems.
+ ///
+ public AIAgentMetadata(string? providerName = null)
+ {
+ ProviderName = providerName;
+ }
+
+ /// Gets the name of the chat provider.
+ ///
+ /// Where possible, this maps to the appropriate name defined in the
+ /// OpenTelemetry Semantic Conventions for Generative AI systems.
+ ///
+ public string? ProviderName { get; }
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThread.cs
index debc0abadc..1a8d33a53b 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThread.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThread.cs
@@ -44,7 +44,7 @@ public class AgentThread
///
///
///
- /// The id may also change over time where the the id is pointing at a
+ /// The id may also change over time where the id is pointing at a
/// agent service managed thread, and the default behavior of a service is
/// to fork the thread with each iteration.
///
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs
index 9705662786..30aa346d3c 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
@@ -25,6 +26,8 @@ public class CopilotStudioAgent : AIAgent
///
public CopilotClient Client { get; }
+ private readonly static AIAgentMetadata s_agentMetadata = new("copilot-studio");
+
///
/// Initializes a new instance of the class.
///
@@ -33,7 +36,6 @@ public class CopilotStudioAgent : AIAgent
public CopilotStudioAgent(CopilotClient client, ILoggerFactory? loggerFactory = null)
{
this.Client = client;
-
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
}
@@ -124,4 +126,11 @@ public class CopilotStudioAgent : AIAgent
return conversationId!;
}
+
+ ///
+ public override object? GetService(Type serviceType, object? serviceKey = null)
+ => base.GetService(serviceType, serviceKey)
+ ?? (serviceType == typeof(CopilotClient) ? this.Client
+ : serviceType == typeof(AIAgentMetadata) ? s_agentMetadata
+ : null);
}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIChatClientAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIChatClientAgent.cs
index 61c3b12fac..2c9dbdcee3 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIChatClientAgent.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIChatClientAgent.cs
@@ -24,7 +24,12 @@ public class OpenAIChatClientAgent : AIAgent
/// Optional name for the agent.
/// Optional description for the agent.
/// Optional instance of
- public OpenAIChatClientAgent(ChatClient client, string? instructions = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
+ public OpenAIChatClientAgent(
+ ChatClient client,
+ string? instructions = null,
+ string? name = null,
+ string? description = null,
+ ILoggerFactory? loggerFactory = null)
{
Throw.IfNull(client);
@@ -62,7 +67,11 @@ public class OpenAIChatClientAgent : AIAgent
/// Optional parameters for agent invocation.
/// The to monitor for cancellation requests. The default is .
/// A containing the list of items.
- public virtual async Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
+ public virtual async Task RunAsync(
+ IEnumerable messages,
+ AgentThread? thread = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
{
var response = await this.RunAsync([.. messages.AsChatMessages()], thread, options, cancellationToken).ConfigureAwait(false);
@@ -72,19 +81,26 @@ public class OpenAIChatClientAgent : AIAgent
///
public sealed override AgentThread GetNewThread()
- {
- return this._chatClientAgent.GetNewThread();
- }
+ => this._chatClientAgent.GetNewThread();
///
- public sealed override Task RunAsync(IReadOnlyCollection messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
- {
- return this._chatClientAgent.RunAsync(messages, thread, options, cancellationToken);
- }
+ public sealed override Task RunAsync(
+ IReadOnlyCollection messages,
+ AgentThread? thread = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ => this._chatClientAgent.RunAsync(messages, thread, options, cancellationToken);
///
- public sealed override IAsyncEnumerable RunStreamingAsync(IReadOnlyCollection messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
- {
- return this._chatClientAgent.RunStreamingAsync(messages, thread, options, cancellationToken);
- }
+ public sealed override IAsyncEnumerable RunStreamingAsync(
+ IReadOnlyCollection messages,
+ AgentThread? thread = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ => this._chatClientAgent.RunStreamingAsync(messages, thread, options, cancellationToken);
+
+ ///
+ public override object? GetService(Type serviceType, object? serviceKey = null)
+ => base.GetService(serviceType, serviceKey)
+ ?? this._chatClientAgent.GetService(serviceType, serviceKey);
}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/AgentExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/AgentExtensions.cs
index 49620560fa..d45130ce38 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents/AgentExtensions.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents/AgentExtensions.cs
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
+using Microsoft.Extensions.Logging;
+
namespace Microsoft.Extensions.AI.Agents;
///
@@ -11,10 +13,15 @@ public static class AgentExtensions
/// Wraps the agent with OpenTelemetry instrumentation.
///
/// The agent to wrap.
+ /// The to use for emitting events.
/// An optional source name that will be used on the telemetry data.
+ /// When indicates whether potentially sensitive information should be included in telemetry. Default is
/// An that wraps the original agent with telemetry.
- public static OpenTelemetryAgent WithOpenTelemetry(this AIAgent agent, string? sourceName = null)
+ public static OpenTelemetryAgent WithOpenTelemetry(this AIAgent agent, ILoggerFactory? loggerFactory = null, string? sourceName = null, bool? enableSensitiveData = null)
{
- return new OpenTelemetryAgent(agent, sourceName);
+ return new OpenTelemetryAgent(agent, loggerFactory?.CreateLogger(typeof(OpenTelemetryAgent)), sourceName)
+ {
+ EnableSensitiveData = enableSensitiveData ?? false
+ };
}
}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/AgentOpenTelemetryConsts.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/AgentOpenTelemetryConsts.cs
deleted file mode 100644
index f5c86d737d..0000000000
--- a/dotnet/src/Microsoft.Extensions.AI.Agents/AgentOpenTelemetryConsts.cs
+++ /dev/null
@@ -1,234 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-namespace Microsoft.Extensions.AI.Agents;
-
-///
-/// Provides constants used by agent telemetry services following OpenTelemetry semantic conventions.
-///
-///
-internal static class AgentOpenTelemetryConsts
-{
- ///
- /// The default source name for agent telemetry.
- ///
- public const string DefaultSourceName = "Microsoft.Extensions.AI.Agents";
-
- ///
- /// The unit for seconds measurements.
- ///
- public const string SecondsUnit = "s";
-
- ///
- /// The unit for token measurements.
- ///
- public const string TokensUnit = "token";
-
- ///
- /// Constants for generative AI telemetry, following OpenTelemetry semantic conventions.
- ///
- public static class GenAI
- {
- ///
- /// The attribute name for the GenAI operation name (following gen_ai.operation.name convention).
- ///
- public const string OperationName = "gen_ai.operation.name";
-
- ///
- /// The attribute name for the GenAI system (following gen_ai.system convention).
- ///
- public const string System = "gen_ai.system";
-
- ///
- /// The attribute name for the GenAI conversation ID (following gen_ai.conversation.id convention).
- ///
- public const string ConversationId = "gen_ai.conversation.id";
-
- ///
- /// Constants for official GenAI operation names as defined in OpenTelemetry semantic conventions.
- ///
- public static class Operations
- {
- ///
- /// Invoke GenAI agent operation.
- ///
- public const string InvokeAgent = "invoke_agent";
- }
-
- ///
- /// Constants for GenAI system values as defined in OpenTelemetry semantic conventions.
- ///
- public static class Systems
- {
- ///
- /// Microsoft Extensions AI system identifier.
- ///
- public const string MicrosoftExtensionsAI = "microsoft.extensions.ai";
- }
-
- ///
- /// Constants for agent-related telemetry attributes and operations.
- ///
- public static class Agent
- {
- ///
- /// The attribute name for the agent ID (following gen_ai.agent.id convention).
- ///
- public const string Id = "gen_ai.agent.id";
-
- ///
- /// The attribute name for the agent name (following gen_ai.agent.name convention).
- ///
- public const string Name = "gen_ai.agent.name";
-
- ///
- /// The attribute name for the agent description (following gen_ai.agent.description convention).
- ///
- public const string Description = "gen_ai.agent.description";
-
- ///
- /// Constants for agent request attributes.
- ///
- public static class Request
- {
- ///
- /// The attribute name for the agent request instructions.
- ///
- public const string Instructions = "gen_ai.agent.request.instructions";
-
- ///
- /// The attribute name for the agent request message count.
- ///
- public const string MessageCount = "gen_ai.agent.request.message_count";
- }
-
- ///
- /// Constants for agent response attributes.
- ///
- public static class Response
- {
- ///
- /// The attribute name for the agent response ID.
- ///
- public const string Id = "gen_ai.agent.response.id";
-
- ///
- /// The attribute name for the agent response message count.
- ///
- public const string MessageCount = "gen_ai.agent.response.message_count";
- }
-
- ///
- /// Constants for agent usage attributes.
- ///
- public static class Usage
- {
- ///
- /// The attribute name for input tokens used by the agent.
- ///
- public const string InputTokens = "gen_ai.agent.usage.input_tokens";
-
- ///
- /// The attribute name for output tokens used by the agent.
- ///
- public const string OutputTokens = "gen_ai.agent.usage.output_tokens";
- }
-
- ///
- /// Constants for agent token attributes.
- ///
- public static class Token
- {
- ///
- /// The attribute name for the token type.
- ///
- public const string Type = "gen_ai.agent.token.type";
- }
-
- ///
- /// Constants for agent client metrics.
- ///
- public static class Client
- {
- ///
- /// Constants for operation duration metrics.
- ///
- public static class OperationDuration
- {
- ///
- /// The description for the operation duration metric.
- ///
- public const string Description = "Measures the duration of an agent operation";
-
- ///
- /// The name for the operation duration metric.
- ///
- public const string Name = "gen_ai.agent.client.operation.duration";
-
- ///
- /// The explicit bucket boundaries for the operation duration histogram.
- ///
- public static readonly double[] ExplicitBucketBoundaries = [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92];
- }
-
- ///
- /// Constants for token usage metrics.
- ///
- public static class TokenUsage
- {
- ///
- /// The description for the token usage metric.
- ///
- public const string Description = "Measures number of input and output tokens used by agent";
-
- ///
- /// The name for the token usage metric.
- ///
- public const string Name = "gen_ai.agent.client.token.usage";
-
- ///
- /// The explicit bucket boundaries for the token usage histogram.
- ///
- public static readonly int[] ExplicitBucketBoundaries = [1, 4, 16, 64, 256, 1_024, 4_096, 16_384, 65_536, 262_144, 1_048_576, 4_194_304, 16_777_216, 67_108_864];
- }
-
- ///
- /// Constants for request count metrics.
- ///
- public static class RequestCount
- {
- ///
- /// The description for the request count metric.
- ///
- public const string Description = "Measures the number of agent requests";
-
- ///
- /// The name for the request count metric.
- ///
- public const string Name = "gen_ai.agent.client.request.count";
- }
- }
- }
- }
-
- ///
- /// Constants for error attributes.
- ///
- public static class ErrorInfo
- {
- ///
- /// The attribute name for the error type.
- ///
- public const string Type = "error.type";
- }
-
- ///
- /// Constants for event attributes.
- ///
- public static class EventInfo
- {
- ///
- /// The attribute name for the event name.
- ///
- public const string Name = "event.name";
- }
-}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs
index 37ac945ec6..7025fb122c 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs
@@ -17,6 +17,7 @@ namespace Microsoft.Extensions.AI.Agents;
public sealed class ChatClientAgent : AIAgent
{
private readonly ChatClientAgentOptions? _agentOptions;
+ private readonly AIAgentMetadata _agentMetadata;
private readonly ILogger _logger;
private readonly Type _chatClientType;
@@ -59,6 +60,8 @@ public sealed class ChatClientAgent : AIAgent
// Options must be cloned since ChatClientAgentOptions is mutable.
this._agentOptions = options?.Clone();
+ this._agentMetadata = new AIAgentMetadata(chatClient.GetService()?.ProviderName);
+
// Get the type of the chat client before wrapping it as an agent invoking chat client.
this._chatClientType = chatClient.GetType();
@@ -185,6 +188,13 @@ public sealed class ChatClientAgent : AIAgent
await this.NotifyThreadOfNewMessagesAsync(safeThread, chatResponseMessages, cancellationToken).ConfigureAwait(false);
}
+ ///
+ public override object? GetService(Type serviceType, object? serviceKey = null)
+ => base.GetService(serviceType, serviceKey)
+ ?? (serviceType == typeof(AIAgentMetadata) ? this._agentMetadata
+ : serviceType == typeof(IChatClient) ? this.ChatClient
+ : this.ChatClient.GetService(serviceType, serviceKey));
+
///
public override AgentThread GetNewThread()
{
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientExtensions.cs
index b0a61e12c0..e459c62297 100644
--- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientExtensions.cs
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientExtensions.cs
@@ -13,7 +13,7 @@ internal static class ChatClientExtensions
chatBuilder.UseAgentInvocation();
}
- if (chatClient.GetService() is null)
+ if (chatClient.GetService() is null)
{
chatBuilder.UseFunctionInvocation();
}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/JsonSerializerExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/JsonSerializerExtensions.cs
deleted file mode 100644
index 232c10b1ce..0000000000
--- a/dotnet/src/Microsoft.Extensions.AI.Agents/JsonSerializerExtensions.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using System.Text.Json.Serialization.Metadata;
-
-namespace Microsoft.Extensions.AI.Agents;
-
-///
-/// Provides extension methods for JSON serialization with source generation support.
-///
-internal static class JsonSerializerExtensions
-{
- ///
- /// Gets the JsonTypeInfo for a type, preferring the one from options if available,
- /// otherwise falling back to the source-generated context.
- ///
- /// The type to get JsonTypeInfo for.
- /// The JsonSerializerOptions to check first.
- /// The fallback JsonSerializerContext to use if not found in options.
- /// The JsonTypeInfo for the requested type.
- public static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options, JsonSerializerContext fallbackContext)
- {
- // Try to get from the options first (if a context is configured)
- if (options.TypeInfoResolver?.GetTypeInfo(typeof(T), options) is JsonTypeInfo typeInfo)
- {
- return typeInfo;
- }
-
- // Fall back to the provided source-generated context
- return (JsonTypeInfo)fallbackContext.GetTypeInfo(typeof(T))!;
- }
-}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/LoggingHelpers.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/LoggingHelpers.cs
new file mode 100644
index 0000000000..6a0e13677f
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/LoggingHelpers.cs
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// WARNING:
+// This class has been temporarily copied here from MEAI, to allow prototyping
+// functionality that will be moved to MEAI in the future.
+// This file is not intended to be modified.
+
+#pragma warning disable CA1031 // Do not catch general exception types
+#pragma warning disable S108 // Nested blocks of code should not be left empty
+#pragma warning disable S2486 // Generic exceptions should not be ignored
+
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+
+namespace Microsoft.Extensions.AI;
+
+/// Provides internal helpers for implementing logging.
+[ExcludeFromCodeCoverage]
+internal static class LoggingHelpers
+{
+ /// Serializes as JSON for logging purposes.
+ public static string AsJson(T value, JsonSerializerOptions? options)
+ {
+ if (options?.TryGetTypeInfo(typeof(T), out var typeInfo) is true ||
+ AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo))
+ {
+ try
+ {
+ return JsonSerializer.Serialize(value, typeInfo);
+ }
+ catch
+ {
+ }
+ }
+
+ // If we're unable to get a type info for the value, or if we fail to serialize,
+ // return an empty JSON object. We do not want lack of type info to disrupt application behavior with exceptions.
+ return "{}";
+ }
+}
diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/NewFunctionInvokingChatClient.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/NewFunctionInvokingChatClient.cs
new file mode 100644
index 0000000000..2b98879adc
--- /dev/null
+++ b/dotnet/src/Microsoft.Extensions.AI.Agents/MEAI/NewFunctionInvokingChatClient.cs
@@ -0,0 +1,1008 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// WARNING:
+// This class is a copy of FunctionInvokingChatClient from MEAI, and is intended to be modified with
+// changes that we want to prototype here, before updating FunctionInvokingChatClient in MEAI.
+// The intention is to keep the changes in this file to a minimum, so that we can easily
+// merge them back into MEAI when ready.
+
+// AF repo suppressions for code copied from MEAI.
+#pragma warning disable IDE1006 // Naming Styles
+#pragma warning disable IDE0009 // Member access should be qualified.
+#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task
+#pragma warning disable VSTHRD111 // Use ConfigureAwait(bool)
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Runtime.ExceptionServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Shared.Diagnostics;
+
+#pragma warning disable CA2213 // Disposable fields should be disposed
+#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test
+#pragma warning disable SA1202 // 'protected' members should come before 'private' members
+#pragma warning disable S107 // Methods should not have too many parameters
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// A delegating chat client that invokes functions defined on .
+/// Include this in a chat pipeline to resolve function calls automatically.
+///
+///
+///
+/// When this client receives a in a chat response, it responds
+/// by calling the corresponding defined in ,
+/// producing a that it sends back to the inner client. This loop
+/// is repeated until there are no more function calls to make, or until another stop condition is met,
+/// such as hitting .
+///
+///
+/// The provided implementation of is thread-safe for concurrent use so long as the
+/// instances employed as part of the supplied are also safe.
+/// The property can be used to control whether multiple function invocation
+/// requests as part of the same request are invocable concurrently, but even with that set to
+/// (the default), multiple concurrent requests to this same instance and using the same tools could result in those
+/// tools being used concurrently (one per request). For example, a function that accesses the HttpContext of a specific
+/// ASP.NET web request should only be used as part of a single at a time, and only with
+/// set to , in case the inner client decided to issue multiple
+/// invocation requests to that same function.
+///
+///
+[ExcludeFromCodeCoverage]
+public partial class NewFunctionInvokingChatClient : DelegatingChatClient
+{
+ /// The for the current function invocation.
+ private static readonly AsyncLocal _currentContext = new();
+
+ /// Gets the specified when constructing the , if any.
+ protected IServiceProvider? FunctionInvocationServices { get; }
+
+ /// The logger to use for logging information about function invocation.
+ private readonly ILogger _logger;
+
+ /// The to use for telemetry.
+ /// This component does not own the instance and should not dispose it.
+ private readonly ActivitySource? _activitySource;
+
+ /// Maximum number of roundtrips allowed to the inner client.
+ private int _maximumIterationsPerRequest = 40; // arbitrary default to prevent runaway execution
+
+ /// Maximum number of consecutive iterations that are allowed contain at least one exception result. If the limit is exceeded, we rethrow the exception instead of continuing.
+ private int _maximumConsecutiveErrorsPerRequest = 3;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The underlying , or the next instance in a chain of clients.
+ /// An to use for logging information about function invocation.
+ /// An optional to use for resolving services required by the instances being invoked.
+ public NewFunctionInvokingChatClient(IChatClient innerClient, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null)
+ : base(innerClient)
+ {
+ _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance;
+ _activitySource = innerClient.GetService();
+ FunctionInvocationServices = functionInvocationServices;
+ }
+
+ ///
+ /// Gets or sets the for the current function invocation.
+ ///
+ ///
+ /// This value flows across async calls.
+ ///
+ public static FunctionInvocationContext? CurrentContext
+ {
+ get => _currentContext.Value;
+ protected set => _currentContext.Value = value;
+ }
+
+ ///
+ /// Gets or sets a value indicating whether detailed exception information should be included
+ /// in the chat history when calling the underlying .
+ ///
+ ///
+ /// if the full exception message is added to the chat history
+ /// when calling the underlying .
+ /// if a generic error message is included in the chat history.
+ /// The default value is .
+ ///
+ ///
+ ///
+ /// Setting the value to prevents the underlying language model from disclosing
+ /// raw exception details to the end user, since it doesn't receive that information. Even in this
+ /// case, the raw object is available to application code by inspecting
+ /// the property.
+ ///
+ ///
+ /// Setting the value to can help the underlying bypass problems on
+ /// its own, for example by retrying the function call with different arguments. However it might
+ /// result in disclosing the raw exception information to external users, which can be a security
+ /// concern depending on the application scenario.
+ ///
+ ///
+ /// Changing the value of this property while the client is in use might result in inconsistencies
+ /// as to whether detailed errors are provided during an in-flight request.
+ ///
+ ///
+ public bool IncludeDetailedErrors { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to allow concurrent invocation of functions.
+ ///
+ ///
+ /// if multiple function calls can execute in parallel.
+ /// if function calls are processed serially.
+ /// The default value is .
+ ///
+ ///
+ /// An individual response from the inner client might contain multiple function call requests.
+ /// By default, such function calls are processed serially. Set to
+ /// to enable concurrent invocation such that multiple function calls can execute in parallel.
+ ///
+ public bool AllowConcurrentInvocation { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of iterations per request.
+ ///
+ ///
+ /// The maximum number of iterations per request.
+ /// The default value is 40.
+ ///
+ ///
+ ///
+ /// Each request to this might end up making
+ /// multiple requests to the inner client. Each time the inner client responds with
+ /// a function call request, this client might perform that invocation and send the results
+ /// back to the inner client in a new request. This property limits the number of times
+ /// such a roundtrip is performed. The value must be at least one, as it includes the initial request.
+ ///
+ ///
+ /// Changing the value of this property while the client is in use might result in inconsistencies
+ /// as to how many iterations are allowed for an in-flight request.
+ ///
+ ///
+ public int MaximumIterationsPerRequest
+ {
+ get => _maximumIterationsPerRequest;
+ set
+ {
+ if (value < 1)
+ {
+ Throw.ArgumentOutOfRangeException(nameof(value));
+ }
+
+ _maximumIterationsPerRequest = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the maximum number of consecutive iterations that are allowed to fail with an error.
+ ///
+ ///
+ /// The maximum number of consecutive iterations that are allowed to fail with an error.
+ /// The default value is 3.
+ ///
+ ///
+ ///
+ /// When function invocations fail with an exception, the
+ /// continues to make requests to the inner client, optionally supplying exception information (as
+ /// controlled by ). This allows the to
+ /// recover from errors by trying other function parameters that may succeed.
+ ///
+ ///
+ /// However, in case function invocations continue to produce exceptions, this property can be used to
+ /// limit the number of consecutive failing attempts. When the limit is reached, the exception will be
+ /// rethrown to the caller.
+ ///
+ ///
+ /// If the value is set to zero, all function calling exceptions immediately terminate the function
+ /// invocation loop and the exception will be rethrown to the caller.
+ ///
+ ///
+ /// Changing the value of this property while the client is in use might result in inconsistencies
+ /// as to how many iterations are allowed for an in-flight request.
+ ///
+ ///
+ public int MaximumConsecutiveErrorsPerRequest
+ {
+ get => _maximumConsecutiveErrorsPerRequest;
+ set => _maximumConsecutiveErrorsPerRequest = Throw.IfLessThan(value, 0);
+ }
+
+ /// Gets or sets a collection of additional tools the client is able to invoke.
+ ///
+ /// These will not impact the requests sent by the , which will pass through the
+ /// unmodified. However, if the inner client requests the invocation of a tool
+ /// that was not in , this collection will also be consulted
+ /// to look for a corresponding tool to invoke. This is useful when the service may have been pre-configured to be aware
+ /// of certain tools that aren't also sent on each individual request.
+ ///
+ public IList? AdditionalTools { get; set; }
+
+ /// Gets or sets a delegate used to invoke instances.
+ ///
+ /// By default, the protected method is called for each to be invoked,
+ /// invoking the instance and returning its result. If this delegate is set to a non- value,
+ /// will replace its normal invocation with a call to this delegate, enabling
+ /// this delegate to assume all invocation handling of the function.
+ ///
+ public Func>? FunctionInvoker { get; set; }
+
+ ///
+ public override async Task GetResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ _ = Throw.IfNull(messages);
+
+ // A single request into this GetResponseAsync may result in multiple requests to the inner client.
+ // Create an activity to group them together for better observability.
+ using Activity? activity = _activitySource?.StartActivity($"{nameof(FunctionInvokingChatClient)}.{nameof(GetResponseAsync)}");
+
+ // Copy the original messages in order to avoid enumerating the original messages multiple times.
+ // The IEnumerable can represent an arbitrary amount of work.
+ List originalMessages = [.. messages];
+ messages = originalMessages;
+
+ List? augmentedHistory = null; // the actual history of messages sent on turns other than the first
+ ChatResponse? response = null; // the response from the inner client, which is possibly modified and then eventually returned
+ List? responseMessages = null; // tracked list of messages, across multiple turns, to be used for the final response
+ UsageDetails? totalUsage = null; // tracked usage across all turns, to be used for the final response
+ List? functionCallContents = null; // function call contents that need responding to in the current turn
+ bool lastIterationHadConversationId = false; // whether the last iteration's response had a ConversationId set
+ int consecutiveErrorCount = 0;
+
+ for (int iteration = 0; ; iteration++)
+ {
+ functionCallContents?.Clear();
+
+ // Make the call to the inner client.
+ response = await base.GetResponseAsync(messages, options, cancellationToken);
+ if (response is null)
+ {
+ Throw.InvalidOperationException($"The inner {nameof(IChatClient)} returned a null {nameof(ChatResponse)}.");
+ }
+
+ // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents.
+ bool requiresFunctionInvocation =
+ (options?.Tools is { Count: > 0 } || AdditionalTools is { Count: > 0 }) &&
+ iteration < MaximumIterationsPerRequest &&
+ CopyFunctionCalls(response.Messages, ref functionCallContents);
+
+ // In a common case where we make a request and there's no function calling work required,
+ // fast path out by just returning the original response.
+ if (iteration == 0 && !requiresFunctionInvocation)
+ {
+ return response;
+ }
+
+ // Track aggregate details from the response, including all of the response messages and usage details.
+ (responseMessages ??= []).AddRange(response.Messages);
+ if (response.Usage is not null)
+ {
+ if (totalUsage is not null)
+ {
+ totalUsage.Add(response.Usage);
+ }
+ else
+ {
+ totalUsage = response.Usage;
+ }
+ }
+
+ // If there are no tools to call, or for any other reason we should stop, we're done.
+ // Break out of the loop and allow the handling at the end to configure the response
+ // with aggregated data from previous requests.
+ if (!requiresFunctionInvocation)
+ {
+ break;
+ }
+
+ // Prepare the history for the next iteration.
+ FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadConversationId);
+
+ // Add the responses from the function calls into the augmented history and also into the tracked
+ // list of response messages.
+ var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken);
+ responseMessages.AddRange(modeAndMessages.MessagesAdded);
+ consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount;
+
+ if (modeAndMessages.ShouldTerminate)
+ {
+ break;
+ }
+
+ UpdateOptionsForNextIteration(ref options, response.ConversationId);
+ }
+
+ Debug.Assert(responseMessages is not null, "Expected to only be here if we have response messages.");
+ response.Messages = responseMessages!;
+ response.Usage = totalUsage;
+
+ AddUsageTags(activity, totalUsage);
+
+ return response;
+ }
+
+ ///
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ _ = Throw.IfNull(messages);
+
+ // A single request into this GetStreamingResponseAsync may result in multiple requests to the inner client.
+ // Create an activity to group them together for better observability.
+ using Activity? activity = _activitySource?.StartActivity($"{nameof(FunctionInvokingChatClient)}.{nameof(GetStreamingResponseAsync)}");
+ UsageDetails? totalUsage = activity is { IsAllDataRequested: true } ? new() : null; // tracked usage across all turns, to be used for activity purposes
+
+ // Copy the original messages in order to avoid enumerating the original messages multiple times.
+ // The IEnumerable can represent an arbitrary amount of work.
+ List originalMessages = [.. messages];
+ messages = originalMessages;
+
+ List? augmentedHistory = null; // the actual history of messages sent on turns other than the first
+ List? functionCallContents = null; // function call contents that need responding to in the current turn
+ List? responseMessages = null; // tracked list of messages, across multiple turns, to be used in fallback cases to reconstitute history
+ bool lastIterationHadConversationId = false; // whether the last iteration's response had a ConversationId set
+ List updates = []; // updates from the current response
+ int consecutiveErrorCount = 0;
+
+ for (int iteration = 0; ; iteration++)
+ {
+ updates.Clear();
+ functionCallContents?.Clear();
+
+ await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken))
+ {
+ if (update is null)
+ {
+ Throw.InvalidOperationException($"The inner {nameof(IChatClient)} streamed a null {nameof(ChatResponseUpdate)}.");
+ }
+
+ updates.Add(update);
+
+ _ = CopyFunctionCalls(update.Contents, ref functionCallContents);
+
+ if (totalUsage is not null)
+ {
+ IList contents = update.Contents;
+ int contentsCount = contents.Count;
+ for (int i = 0; i < contentsCount; i++)
+ {
+ if (contents[i] is UsageContent uc)
+ {
+ totalUsage.Add(uc.Details);
+ }
+ }
+ }
+
+ yield return update;
+ Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802
+ }
+
+ // If there are no tools to call, or for any other reason we should stop, return the response.
+ if (functionCallContents is not { Count: > 0 } ||
+ (options?.Tools is not { Count: > 0 } && AdditionalTools is not { Count: > 0 }) ||
+ iteration >= _maximumIterationsPerRequest)
+ {
+ break;
+ }
+
+ // Reconstitute a response from the response updates.
+ var response = updates.ToChatResponse();
+ (responseMessages ??= []).AddRange(response.Messages);
+
+ // Prepare the history for the next iteration.
+ FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadConversationId);
+
+ // Process all of the functions, adding their results into the history.
+ var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken);
+ responseMessages.AddRange(modeAndMessages.MessagesAdded);
+ consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount;
+
+ // This is a synthetic ID since we're generating the tool messages instead of getting them from
+ // the underlying provider. When emitting the streamed chunks, it's perfectly valid for us to
+ // use the same message ID for all of them within a given iteration, as this is a single logical
+ // message with multiple content items. We could also use different message IDs per tool content,
+ // but there's no benefit to doing so.
+ string toolResponseId = Guid.NewGuid().ToString("N");
+
+ // Stream any generated function results. This mirrors what's done for GetResponseAsync, where the returned messages
+ // includes all activities, including generated function results.
+ foreach (var message in modeAndMessages.MessagesAdded)
+ {
+ var toolResultUpdate = new ChatResponseUpdate
+ {
+ AdditionalProperties = message.AdditionalProperties,
+ AuthorName = message.AuthorName,
+ ConversationId = response.ConversationId,
+ CreatedAt = DateTimeOffset.UtcNow,
+ Contents = message.Contents,
+ RawRepresentation = message.RawRepresentation,
+ ResponseId = toolResponseId,
+ MessageId = toolResponseId, // See above for why this can be the same as ResponseId
+ Role = message.Role,
+ };
+
+ yield return toolResultUpdate;
+ Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802
+ }
+
+ if (modeAndMessages.ShouldTerminate)
+ {
+ break;
+ }
+
+ UpdateOptionsForNextIteration(ref options, response.ConversationId);
+ }
+
+ AddUsageTags(activity, totalUsage);
+ }
+
+ /// Adds tags to for usage details in .
+ private static void AddUsageTags(Activity? activity, UsageDetails? usage)
+ {
+ if (usage is not null && activity is { IsAllDataRequested: true })
+ {
+ if (usage.InputTokenCount is long inputTokens)
+ {
+ _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, (int)inputTokens);
+ }
+
+ if (usage.OutputTokenCount is long outputTokens)
+ {
+ _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens);
+ }
+ }
+ }
+
+ /// Prepares the various chat message lists after a response from the inner client and before invoking functions.
+ /// The original messages provided by the caller.
+ /// The messages reference passed to the inner client.
+ /// The augmented history containing all the messages to be sent.
+ /// The most recent response being handled.
+ /// A list of all response messages received up until this point.
+ /// Whether the previous iteration's response had a conversation ID.
+ private static void FixupHistories(
+ IEnumerable originalMessages,
+ ref IEnumerable messages,
+ [NotNull] ref List? augmentedHistory,
+ ChatResponse response,
+ List allTurnsResponseMessages,
+ ref bool lastIterationHadConversationId)
+ {
+ // We're now going to need to augment the history with function result contents.
+ // That means we need a separate list to store the augmented history.
+ if (response.ConversationId is not null)
+ {
+ // The response indicates the inner client is tracking the history, so we don't want to send
+ // anything we've already sent or received.
+ if (augmentedHistory is not null)
+ {
+ augmentedHistory.Clear();
+ }
+ else
+ {
+ augmentedHistory = [];
+ }
+
+ lastIterationHadConversationId = true;
+ }
+ else if (lastIterationHadConversationId)
+ {
+ // In the very rare case where the inner client returned a response with a conversation ID but then
+ // returned a subsequent response without one, we want to reconstitute the full history. To do that,
+ // we can populate the history with the original chat messages and then all of the response
+ // messages up until this point, which includes the most recent ones.
+ augmentedHistory ??= [];
+ augmentedHistory.Clear();
+ augmentedHistory.AddRange(originalMessages);
+ augmentedHistory.AddRange(allTurnsResponseMessages);
+
+ lastIterationHadConversationId = false;
+ }
+ else
+ {
+ // If augmentedHistory is already non-null, then we've already populated it with everything up
+ // until this point (except for the most recent response). If it's null, we need to seed it with
+ // the chat history provided by the caller.
+ augmentedHistory ??= originalMessages.ToList();
+
+ // Now add the most recent response messages.
+ augmentedHistory.AddMessages(response);
+
+ lastIterationHadConversationId = false;
+ }
+
+ // Use the augmented history as the new set of messages to send.
+ messages = augmentedHistory;
+ }
+
+ /// Copies any from to .
+ private static bool CopyFunctionCalls(
+ IList messages, [NotNullWhen(true)] ref List? functionCalls)
+ {
+ bool any = false;
+ int count = messages.Count;
+ for (int i = 0; i < count; i++)
+ {
+ any |= CopyFunctionCalls(messages[i].Contents, ref functionCalls);
+ }
+
+ return any;
+ }
+
+ /// Copies any from to .
+ private static bool CopyFunctionCalls(
+ IList content, [NotNullWhen(true)] ref List? functionCalls)
+ {
+ bool any = false;
+ int count = content.Count;
+ for (int i = 0; i < count; i++)
+ {
+ if (content[i] is FunctionCallContent functionCall)
+ {
+ (functionCalls ??= []).Add(functionCall);
+ any = true;
+ }
+ }
+
+ return any;
+ }
+
+ private static void UpdateOptionsForNextIteration(ref ChatOptions? options, string? conversationId)
+ {
+ if (options is null)
+ {
+ if (conversationId is not null)
+ {
+ options = new() { ConversationId = conversationId };
+ }
+ }
+ else if (options.ToolMode is RequiredChatToolMode)
+ {
+ // We have to reset the tool mode to be non-required after the first iteration,
+ // as otherwise we'll be in an infinite loop.
+ options = options.Clone();
+ options.ToolMode = null;
+ options.ConversationId = conversationId;
+ }
+ else if (options.ConversationId != conversationId)
+ {
+ // As with the other modes, ensure we've propagated the chat conversation ID to the options.
+ // We only need to clone the options if we're actually mutating it.
+ options = options.Clone();
+ options.ConversationId = conversationId;
+ }
+ }
+
+ ///
+ /// Processes the function calls in the list.
+ ///
+ /// The current chat contents, inclusive of the function call contents being processed.
+ /// The options used for the response being processed.
+ /// The function call contents representing the functions to be invoked.
+ /// The iteration number of how many roundtrips have been made to the inner client.
+ /// The number of consecutive iterations, prior to this one, that were recorded as having function invocation errors.
+ /// Whether the function calls are being processed in a streaming context.
+ /// The to monitor for cancellation requests.
+ /// A value indicating how the caller should proceed.
+ private async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync(
+ List messages, ChatOptions? options, List functionCallContents, int iteration, int consecutiveErrorCount,
+ bool isStreaming, CancellationToken cancellationToken)
+ {
+ // We must add a response for every tool call, regardless of whether we successfully executed it or not.
+ // If we successfully execute it, we'll add the result. If we don't, we'll add an error.
+
+ Debug.Assert(functionCallContents.Count > 0, "Expected at least one function call.");
+ var shouldTerminate = false;
+ var captureCurrentIterationExceptions = consecutiveErrorCount < _maximumConsecutiveErrorsPerRequest;
+
+ // Process all functions. If there's more than one and concurrent invocation is enabled, do so in parallel.
+ if (functionCallContents.Count == 1)
+ {
+ FunctionInvocationResult result = await ProcessFunctionCallAsync(
+ messages, options, functionCallContents,
+ iteration, 0, captureCurrentIterationExceptions, isStreaming, cancellationToken);
+
+ IList addedMessages = CreateResponseMessages([result]);
+ ThrowIfNoFunctionResultsAdded(addedMessages);
+ UpdateConsecutiveErrorCountOrThrow(addedMessages, ref consecutiveErrorCount);
+ messages.AddRange(addedMessages);
+
+ return (result.Terminate, consecutiveErrorCount, addedMessages);
+ }
+ else
+ {
+ List results = [];
+
+ if (AllowConcurrentInvocation)
+ {
+ // Rather than awaiting each function before invoking the next, invoke all of them
+ // and then await all of them. We avoid forcibly introducing parallelism via Task.Run,
+ // but if a function invocation completes asynchronously, its processing can overlap
+ // with the processing of other the other invocation invocations.
+ results.AddRange(await Task.WhenAll(
+ from callIndex in Enumerable.Range(0, functionCallContents.Count)
+ select ProcessFunctionCallAsync(
+ messages, options, functionCallContents,
+ iteration, callIndex, captureExceptions: true, isStreaming, cancellationToken)));
+
+ shouldTerminate = results.Any(r => r.Terminate);
+ }
+ else
+ {
+ // Invoke each function serially.
+ for (int callIndex = 0; callIndex < functionCallContents.Count; callIndex++)
+ {
+ var functionResult = await ProcessFunctionCallAsync(
+ messages, options, functionCallContents,
+ iteration, callIndex, captureCurrentIterationExceptions, isStreaming, cancellationToken);
+
+ results.Add(functionResult);
+
+ // If any function requested termination, we should stop right away.
+ if (functionResult.Terminate)
+ {
+ shouldTerminate = true;
+ break;
+ }
+ }
+ }
+
+ IList addedMessages = CreateResponseMessages(results.ToArray());
+ ThrowIfNoFunctionResultsAdded(addedMessages);
+ UpdateConsecutiveErrorCountOrThrow(addedMessages, ref consecutiveErrorCount);
+ messages.AddRange(addedMessages);
+
+ return (shouldTerminate, consecutiveErrorCount, addedMessages);
+ }
+ }
+
+#pragma warning disable CA1851 // Possible multiple enumerations of 'IEnumerable' collection
+ ///
+ /// Updates the consecutive error count, and throws an exception if the count exceeds the maximum.
+ ///
+ /// Added messages.
+ /// Consecutive error count.
+ /// Thrown if the maximum consecutive error count is exceeded.
+ private void UpdateConsecutiveErrorCountOrThrow(IList added, ref int consecutiveErrorCount)
+ {
+ var allExceptions = added.SelectMany(m => m.Contents.OfType())
+ .Select(frc => frc.Exception!)
+ .Where(e => e is not null);
+
+ if (allExceptions.Any())
+ {
+ consecutiveErrorCount++;
+ if (consecutiveErrorCount > _maximumConsecutiveErrorsPerRequest)
+ {
+ var allExceptionsArray = allExceptions.ToArray();
+ if (allExceptionsArray.Length == 1)
+ {
+ ExceptionDispatchInfo.Capture(allExceptionsArray[0]).Throw();
+ }
+ else
+ {
+ throw new AggregateException(allExceptionsArray);
+ }
+ }
+ }
+ else
+ {
+ consecutiveErrorCount = 0;
+ }
+ }
+#pragma warning restore CA1851
+
+ ///
+ /// Throws an exception if doesn't create any messages.
+ ///
+ private void ThrowIfNoFunctionResultsAdded(IList? messages)
+ {
+ if (messages is null || messages.Count == 0)
+ {
+ Throw.InvalidOperationException($"{GetType().Name}.{nameof(CreateResponseMessages)} returned null or an empty collection of messages.");
+ }
+ }
+
+ /// Processes the function call described in [].
+ /// The current chat contents, inclusive of the function call contents being processed.
+ /// The options used for the response being processed.
+ /// The function call contents representing all the functions being invoked.
+ /// The iteration number of how many roundtrips have been made to the inner client.
+ /// The 0-based index of the function being called out of .
+ /// If true, handles function-invocation exceptions by returning a value with . Otherwise, rethrows.
+ /// Whether the function calls are being processed in a streaming context.
+ /// The to monitor for cancellation requests.
+ /// A value indicating how the caller should proceed.
+ private async Task ProcessFunctionCallAsync(
+ List messages, ChatOptions? options, List callContents,
+ int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken)
+ {
+ var callContent = callContents[functionCallIndex];
+
+ // Look up the AIFunction for the function call. If the requested function isn't available, send back an error.
+ AIFunction? aiFunction = FindAIFunction(options?.Tools, callContent.Name) ?? FindAIFunction(AdditionalTools, callContent.Name);
+ if (aiFunction is null)
+ {
+ return new(terminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null);
+ }
+
+ FunctionInvocationContext context = new()
+ {
+ Function = aiFunction,
+ Arguments = new(callContent.Arguments) { Services = FunctionInvocationServices },
+ Messages = messages,
+ Options = options,
+ CallContent = callContent,
+ Iteration = iteration,
+ FunctionCallIndex = functionCallIndex,
+ FunctionCount = callContents.Count,
+ IsStreaming = isStreaming
+ };
+
+ object? result;
+ try
+ {
+ result = await InstrumentedInvokeFunctionAsync(context, cancellationToken);
+ }
+ catch (Exception e) when (!cancellationToken.IsCancellationRequested)
+ {
+ if (!captureExceptions)
+ {
+ throw;
+ }
+
+ return new(
+ terminate: false,
+ FunctionInvocationStatus.Exception,
+ callContent,
+ result: null,
+ exception: e);
+ }
+
+ return new(
+ terminate: context.Terminate,
+ FunctionInvocationStatus.RanToCompletion,
+ callContent,
+ result,
+ exception: null);
+
+ static AIFunction? FindAIFunction(IList? tools, string functionName)
+ {
+ if (tools is not null)
+ {
+ int count = tools.Count;
+ for (int i = 0; i < count; i++)
+ {
+ if (tools[i] is AIFunction function && function.Name == functionName)
+ {
+ return function;
+ }
+ }
+ }
+
+ return null;
+ }
+ }
+
+ /// Creates one or more response messages for function invocation results.
+ /// Information about the function call invocations and results.
+ /// A list of all chat messages created from .
+ protected virtual IList CreateResponseMessages(
+ ReadOnlySpan results)
+ {
+ var contents = new List(results.Length);
+ for (int i = 0; i < results.Length; i++)
+ {
+ contents.Add(CreateFunctionResultContent(results[i]));
+ }
+
+ return [new(ChatRole.Tool, contents)];
+
+ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult result)
+ {
+ _ = Throw.IfNull(result);
+
+ object? functionResult;
+ if (result.Status == FunctionInvocationStatus.RanToCompletion)
+ {
+ functionResult = result.Result ?? "Success: Function completed.";
+ }
+ else
+ {
+ string message = result.Status switch
+ {
+ FunctionInvocationStatus.NotFound => $"Error: Requested function \"{result.CallContent.Name}\" not found.",
+ FunctionInvocationStatus.Exception => "Error: Function failed.",
+ _ => "Error: Unknown error.",
+ };
+
+ if (IncludeDetailedErrors && result.Exception is not null)
+ {
+ message = $"{message} Exception: {result.Exception.Message}";
+ }
+
+ functionResult = message;
+ }
+
+ return new FunctionResultContent(result.CallContent.CallId, functionResult) { Exception = result.Exception };
+ }
+ }
+
+ /// Invokes the function asynchronously.
+ ///
+ /// The function invocation context detailing the function to be invoked and its arguments along with additional request information.
+ ///
+ /// The to monitor for cancellation requests. The default is .
+ /// The result of the function invocation, or if the function invocation returned .
+ /// is .
+ private async Task