Merge branch 'main' of https://github.com/microsoft/agent-framework into issues/update-meai-9.8.0

This commit is contained in:
Roger Barreto
2025-08-15 17:45:53 +01:00
Unverified
32 changed files with 5333 additions and 452 deletions
+1
View File
@@ -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
+2
View File
@@ -44,6 +44,7 @@
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.8" />
<PackageVersion Include="OpenAI" Version="2.3.0" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.8.0" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.8.0-preview.1.25412.6" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.8.0" />
@@ -58,6 +59,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging.Testing" Version="9.0.8" />
<!-- Vector Stores -->
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.61.0-preview" />
+37 -37
View File
@@ -4,15 +4,8 @@
<BuildType Name="Publish" />
<BuildType Name="Release" />
</Configurations>
<Folder Name="/ConformanceTests/">
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj" />
<Project Path="tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj" />
</Folder>
<Folder Name="/Demos/">
<Project Path="demos/AgentOpenTelemetry/AgentOpenTelemetry.csproj" />
<Project Path="demos/MinimalConsole/MinimalConsole.csproj" />
</Folder>
<Folder Name="/Samples/">
@@ -31,15 +24,6 @@
<File Path="Directory.Build.targets" />
<File Path="Directory.Packages.props" />
</Folder>
<Folder Name="/Solution Items/nuget/">
<File Path="nuget/icon.png" />
<File Path="nuget/nuget-package.props" />
<File Path="nuget/NUGET.md" />
</Folder>
<Folder Name="/Solution Items/tests/">
<File Path="tests/.editorconfig" />
<File Path="tests/Directory.Build.props" />
</Folder>
<Folder Name="/Solution Items/.github/" />
<Folder Name="/Solution Items/.github/workflows/">
<File Path="../.github/workflows/dotnet-build-and-test.yml" />
@@ -61,22 +45,10 @@
<File Path="eng/MSBuild/Shared.props" />
<File Path="eng/MSBuild/Shared.targets" />
</Folder>
<Folder Name="/Solution Items/src/Shared/" />
<Folder Name="/Solution Items/src/Shared/Throw/">
<File Path="src/Shared/Throw/README.md" />
<File Path="src/Shared/Throw/Throw.cs" />
</Folder>
<Folder Name="/Solution Items/src/Shared/IntegrationTests/">
<File Path="src/Shared/IntegrationTests/AzureAIConfiguration.cs" />
<File Path="src/Shared/IntegrationTests/OpenAIConfiguration.cs" />
<File Path="src/Shared/IntegrationTests/README.md" />
</Folder>
<Folder Name="/Solution Items/src/Shared/Samples/">
<File Path="src/Shared/Samples/README.md" />
<File Path="src/Shared/Samples/BaseSample.cs" />
<File Path="src/Shared/Samples/TestConfiguration.cs" />
<File Path="src/Shared/Samples/TextOutputHelperExtensions.cs" />
<File Path="src/Shared/Samples/XunitLogger.cs" />
<Folder Name="/Solution Items/nuget/">
<File Path="nuget/icon.png" />
<File Path="nuget/nuget-package.props" />
<File Path="nuget/NUGET.md" />
</Folder>
<Folder Name="/Solution Items/samples/">
<File Path="samples/.editorconfig" />
@@ -95,8 +67,8 @@
<File Path="src/LegacySupport/DiagnosticAttributes/README.md" />
</Folder>
<Folder Name="/Solution Items/src/LegacySupport/DiagnosticClasses/">
<File Path="src/LegacySupport/DiagnosticClasses/UnreachableException.cs" />
<File Path="src/LegacySupport/DiagnosticClasses/README.md" />
<File Path="src/LegacySupport/DiagnosticClasses/UnreachableException.cs" />
</Folder>
<Folder Name="/Solution Items/src/LegacySupport/ExperimentalAttribute/">
<File Path="src/LegacySupport/ExperimentalAttribute/ExperimentalAttribute.cs" />
@@ -114,6 +86,27 @@
<File Path="src/LegacySupport/TrimAttributes/RequiresUnreferencedCodeAttribute.cs" />
<File Path="src/LegacySupport/TrimAttributes/UnconditionalSuppressMessageAttribute.cs" />
</Folder>
<Folder Name="/Solution Items/src/Shared/" />
<Folder Name="/Solution Items/src/Shared/IntegrationTests/">
<File Path="src/Shared/IntegrationTests/AzureAIConfiguration.cs" />
<File Path="src/Shared/IntegrationTests/OpenAIConfiguration.cs" />
<File Path="src/Shared/IntegrationTests/README.md" />
</Folder>
<Folder Name="/Solution Items/src/Shared/Samples/">
<File Path="src/Shared/Samples/BaseSample.cs" />
<File Path="src/Shared/Samples/README.md" />
<File Path="src/Shared/Samples/TestConfiguration.cs" />
<File Path="src/Shared/Samples/TextOutputHelperExtensions.cs" />
<File Path="src/Shared/Samples/XunitLogger.cs" />
</Folder>
<Folder Name="/Solution Items/src/Shared/Throw/">
<File Path="src/Shared/Throw/README.md" />
<File Path="src/Shared/Throw/Throw.cs" />
</Folder>
<Folder Name="/Solution Items/tests/">
<File Path="tests/.editorconfig" />
<File Path="tests/Directory.Build.props" />
</Folder>
<Folder Name="/src/">
<Project Path="src/Microsoft.Agents.Orchestration/Microsoft.Agents.Orchestration.csproj" />
<Project Path="src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj" />
@@ -127,14 +120,21 @@
<Project Path="src/Microsoft.Extensions.AI.Agents.Runtime/Microsoft.Extensions.AI.Agents.Runtime.csproj" />
<Project Path="src/Microsoft.Extensions.AI.Agents/Microsoft.Extensions.AI.Agents.csproj" />
</Folder>
<Folder Name="/UnitTests/">
<Folder Name="/Tests/" />
<Folder Name="/Tests/IntegrationTests/">
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj" />
<Project Path="tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj" />
</Folder>
<Folder Name="/Tests/UnitTests/">
<Project Path="tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj" />
<Project Path="tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Extensions.AI.Agents.UnitTests/Microsoft.Extensions.AI.Agents.UnitTests.csproj" />
<Project Path="tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests.AppHost/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests.AppHost.csproj" />
<Project Path="tests/CosmosDB.IntegrationTests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests/Microsoft.Extensions.AI.Agents.Runtime.Storage.CosmosDB.Tests.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="OpenAI" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.Console" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Extensions.AI.Agents.OpenAI\Microsoft.Extensions.AI.Agents.OpenAI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Extensions.AI.Agents\Microsoft.Extensions.AI.Agents.csproj" />
</ItemGroup>
</Project>
+212
View File
@@ -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<string, object>
{
["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<ILogger<Program>>();
using var activitySource = new ActivitySource(SourceName);
using var meter = new Meter(SourceName);
// Create custom metrics (similar to Python example)
var interactionCounter = meter.CreateCounter<int>("agent_interactions_total", description: "Total number of agent interactions");
var responseTimeHistogram = meter.CreateHistogram<double>("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<ILoggerFactory>();
// 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<string, object> { ["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<string, object?>("status", "success"));
responseTimeHistogram.Record(responseTime,
new KeyValuePair<string, object?>("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<string, object?>("status", "error"));
responseTimeHistogram.Record(responseTime,
new KeyValuePair<string, object?>("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");
+210
View File
@@ -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<br/>(Interactive)"] --> B["Agent Framework<br/>with OpenTel<br/>Instrumentation"]
B --> C["Azure OpenAI<br/>Service"]
A --> D["Aspire Dashboard<br/>(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
@@ -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
+12
View File
@@ -0,0 +1,12 @@
<Project>
<Import Project="../Directory.Build.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsAotCompatible>false</IsAotCompatible>
<ProjectsTargetFrameworks>net472;net9.0</ProjectsTargetFrameworks>
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
</PropertyGroup>
</Project>
+1
View File
@@ -6,6 +6,7 @@
<IsPackable>false</IsPackable>
<IsAotCompatible>false</IsAotCompatible>
<ProjectsTargetFrameworks>net472;net9.0</ProjectsTargetFrameworks>
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
</PropertyGroup>
</Project>
@@ -3,7 +3,6 @@
<PropertyGroup>
<RootNamespace>GettingStarted</RootNamespace>
<OutputType>Library</OutputType>
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
<NoWarn>$(NoWarn);CA1707;CA1716;IDE0009;IDE1006; OPENAI001;</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<InjectSharedSamples>true</InjectSharedSamples>
@@ -38,6 +38,36 @@ public abstract class AIAgent
/// </summary>
public virtual string? Description { get; }
/// <summary>Asks the <see cref="AIAgent"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="AIAgent"/>,
/// including itself or any services it might be wrapping. For example, to access the <see cref="AIAgentMetadata"/> for the instance,
/// <see cref="GetService"/> may be used to request it.
/// </remarks>
public virtual object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
return serviceKey is null && serviceType.IsInstanceOfType(this)
? this
: null;
}
/// <summary>Asks the <see cref="AIAgent"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="AIAgent"/>,
/// including itself or any services it might be wrapping.
/// </remarks>
public TService? GetService<TService>(object? serviceKey = null)
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
/// <summary>
/// Get a new <see cref="AgentThread"/> instance that is compatible with the agent.
/// </summary>
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents;
/// <summary>Provides metadata about an <see cref="AIAgent"/>.</summary>
public class AIAgentMetadata
{
/// <summary>Initializes a new instance of the <see cref="AIAgentMetadata"/> class.</summary>
/// <param name="providerName">
/// 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.
/// </param>
public AIAgentMetadata(string? providerName = null)
{
ProviderName = providerName;
}
/// <summary>Gets the name of the chat provider.</summary>
/// <remarks>
/// Where possible, this maps to the appropriate name defined in the
/// OpenTelemetry Semantic Conventions for Generative AI systems.
/// </remarks>
public string? ProviderName { get; }
}
@@ -44,7 +44,7 @@ public class AgentThread
/// </list>
/// </para>
/// <para>
/// 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.
/// </para>
@@ -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
/// </summary>
public CopilotClient Client { get; }
private readonly static AIAgentMetadata s_agentMetadata = new("copilot-studio");
/// <summary>
/// Initializes a new instance of the <see cref="CopilotStudioAgent"/> class.
/// </summary>
@@ -33,7 +36,6 @@ public class CopilotStudioAgent : AIAgent
public CopilotStudioAgent(CopilotClient client, ILoggerFactory? loggerFactory = null)
{
this.Client = client;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<CopilotStudioAgent>();
}
@@ -124,4 +126,11 @@ public class CopilotStudioAgent : AIAgent
return conversationId!;
}
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
=> base.GetService(serviceType, serviceKey)
?? (serviceType == typeof(CopilotClient) ? this.Client
: serviceType == typeof(AIAgentMetadata) ? s_agentMetadata
: null);
}
@@ -24,7 +24,12 @@ public class OpenAIChatClientAgent : AIAgent
/// <param name="name">Optional name for the agent.</param>
/// <param name="description">Optional description for the agent.</param>
/// <param name="loggerFactory">Optional instance of <see cref="ILoggerFactory"/></param>
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
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatCompletion"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public virtual async Task<ChatCompletion> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
public virtual async Task<ChatCompletion> RunAsync(
IEnumerable<ChatMessage> 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
/// <inheritdoc/>
public sealed override AgentThread GetNewThread()
{
return this._chatClientAgent.GetNewThread();
}
=> this._chatClientAgent.GetNewThread();
/// <inheritdoc/>
public sealed override Task<AgentRunResponse> RunAsync(IReadOnlyCollection<Microsoft.Extensions.AI.ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this._chatClientAgent.RunAsync(messages, thread, options, cancellationToken);
}
public sealed override Task<AgentRunResponse> RunAsync(
IReadOnlyCollection<Microsoft.Extensions.AI.ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
=> this._chatClientAgent.RunAsync(messages, thread, options, cancellationToken);
/// <inheritdoc/>
public sealed override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IReadOnlyCollection<Microsoft.Extensions.AI.ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this._chatClientAgent.RunStreamingAsync(messages, thread, options, cancellationToken);
}
public sealed override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IReadOnlyCollection<Microsoft.Extensions.AI.ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
=> this._chatClientAgent.RunStreamingAsync(messages, thread, options, cancellationToken);
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
=> base.GetService(serviceType, serviceKey)
?? this._chatClientAgent.GetService(serviceType, serviceKey);
}
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Logging;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>
@@ -11,10 +13,15 @@ public static class AgentExtensions
/// Wraps the agent with OpenTelemetry instrumentation.
/// </summary>
/// <param name="agent">The agent to wrap.</param>
/// <param name="loggerFactory">The <see cref="ILogger"/> to use for emitting events.</param>
/// <param name="sourceName">An optional source name that will be used on the telemetry data.</param>
/// <param name="enableSensitiveData">When <see langword="true"/> indicates whether potentially sensitive information should be included in telemetry. Default is <see langword="false"/></param>
/// <returns>An <see cref="OpenTelemetryAgent"/> that wraps the original agent with telemetry.</returns>
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
};
}
}
@@ -1,234 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// Provides constants used by agent telemetry services following OpenTelemetry semantic conventions.
/// <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/"/>
/// </summary>
internal static class AgentOpenTelemetryConsts
{
/// <summary>
/// The default source name for agent telemetry.
/// </summary>
public const string DefaultSourceName = "Microsoft.Extensions.AI.Agents";
/// <summary>
/// The unit for seconds measurements.
/// </summary>
public const string SecondsUnit = "s";
/// <summary>
/// The unit for token measurements.
/// </summary>
public const string TokensUnit = "token";
/// <summary>
/// Constants for generative AI telemetry, following OpenTelemetry semantic conventions.
/// </summary>
public static class GenAI
{
/// <summary>
/// The attribute name for the GenAI operation name (following gen_ai.operation.name convention).
/// </summary>
public const string OperationName = "gen_ai.operation.name";
/// <summary>
/// The attribute name for the GenAI system (following gen_ai.system convention).
/// </summary>
public const string System = "gen_ai.system";
/// <summary>
/// The attribute name for the GenAI conversation ID (following gen_ai.conversation.id convention).
/// </summary>
public const string ConversationId = "gen_ai.conversation.id";
/// <summary>
/// Constants for official GenAI operation names as defined in OpenTelemetry semantic conventions.
/// </summary>
public static class Operations
{
/// <summary>
/// Invoke GenAI agent operation.
/// </summary>
public const string InvokeAgent = "invoke_agent";
}
/// <summary>
/// Constants for GenAI system values as defined in OpenTelemetry semantic conventions.
/// </summary>
public static class Systems
{
/// <summary>
/// Microsoft Extensions AI system identifier.
/// </summary>
public const string MicrosoftExtensionsAI = "microsoft.extensions.ai";
}
/// <summary>
/// Constants for agent-related telemetry attributes and operations.
/// </summary>
public static class Agent
{
/// <summary>
/// The attribute name for the agent ID (following gen_ai.agent.id convention).
/// </summary>
public const string Id = "gen_ai.agent.id";
/// <summary>
/// The attribute name for the agent name (following gen_ai.agent.name convention).
/// </summary>
public const string Name = "gen_ai.agent.name";
/// <summary>
/// The attribute name for the agent description (following gen_ai.agent.description convention).
/// </summary>
public const string Description = "gen_ai.agent.description";
/// <summary>
/// Constants for agent request attributes.
/// </summary>
public static class Request
{
/// <summary>
/// The attribute name for the agent request instructions.
/// </summary>
public const string Instructions = "gen_ai.agent.request.instructions";
/// <summary>
/// The attribute name for the agent request message count.
/// </summary>
public const string MessageCount = "gen_ai.agent.request.message_count";
}
/// <summary>
/// Constants for agent response attributes.
/// </summary>
public static class Response
{
/// <summary>
/// The attribute name for the agent response ID.
/// </summary>
public const string Id = "gen_ai.agent.response.id";
/// <summary>
/// The attribute name for the agent response message count.
/// </summary>
public const string MessageCount = "gen_ai.agent.response.message_count";
}
/// <summary>
/// Constants for agent usage attributes.
/// </summary>
public static class Usage
{
/// <summary>
/// The attribute name for input tokens used by the agent.
/// </summary>
public const string InputTokens = "gen_ai.agent.usage.input_tokens";
/// <summary>
/// The attribute name for output tokens used by the agent.
/// </summary>
public const string OutputTokens = "gen_ai.agent.usage.output_tokens";
}
/// <summary>
/// Constants for agent token attributes.
/// </summary>
public static class Token
{
/// <summary>
/// The attribute name for the token type.
/// </summary>
public const string Type = "gen_ai.agent.token.type";
}
/// <summary>
/// Constants for agent client metrics.
/// </summary>
public static class Client
{
/// <summary>
/// Constants for operation duration metrics.
/// </summary>
public static class OperationDuration
{
/// <summary>
/// The description for the operation duration metric.
/// </summary>
public const string Description = "Measures the duration of an agent operation";
/// <summary>
/// The name for the operation duration metric.
/// </summary>
public const string Name = "gen_ai.agent.client.operation.duration";
/// <summary>
/// The explicit bucket boundaries for the operation duration histogram.
/// </summary>
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];
}
/// <summary>
/// Constants for token usage metrics.
/// </summary>
public static class TokenUsage
{
/// <summary>
/// The description for the token usage metric.
/// </summary>
public const string Description = "Measures number of input and output tokens used by agent";
/// <summary>
/// The name for the token usage metric.
/// </summary>
public const string Name = "gen_ai.agent.client.token.usage";
/// <summary>
/// The explicit bucket boundaries for the token usage histogram.
/// </summary>
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];
}
/// <summary>
/// Constants for request count metrics.
/// </summary>
public static class RequestCount
{
/// <summary>
/// The description for the request count metric.
/// </summary>
public const string Description = "Measures the number of agent requests";
/// <summary>
/// The name for the request count metric.
/// </summary>
public const string Name = "gen_ai.agent.client.request.count";
}
}
}
}
/// <summary>
/// Constants for error attributes.
/// </summary>
public static class ErrorInfo
{
/// <summary>
/// The attribute name for the error type.
/// </summary>
public const string Type = "error.type";
}
/// <summary>
/// Constants for event attributes.
/// </summary>
public static class EventInfo
{
/// <summary>
/// The attribute name for the event name.
/// </summary>
public const string Name = "event.name";
}
}
@@ -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<ChatClientMetadata>()?.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);
}
/// <inheritdoc/>
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));
/// <inheritdoc/>
public override AgentThread GetNewThread()
{
@@ -13,7 +13,7 @@ internal static class ChatClientExtensions
chatBuilder.UseAgentInvocation();
}
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
if (chatClient.GetService<NewFunctionInvokingChatClient>() is null)
{
chatBuilder.UseFunctionInvocation();
}
@@ -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;
/// <summary>
/// Provides extension methods for JSON serialization with source generation support.
/// </summary>
internal static class JsonSerializerExtensions
{
/// <summary>
/// Gets the JsonTypeInfo for a type, preferring the one from options if available,
/// otherwise falling back to the source-generated context.
/// </summary>
/// <typeparam name="T">The type to get JsonTypeInfo for.</typeparam>
/// <param name="options">The JsonSerializerOptions to check first.</param>
/// <param name="fallbackContext">The fallback JsonSerializerContext to use if not found in options.</param>
/// <returns>The JsonTypeInfo for the requested type.</returns>
public static JsonTypeInfo<T> GetTypeInfo<T>(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<T> typeInfo)
{
return typeInfo;
}
// Fall back to the provided source-generated context
return (JsonTypeInfo<T>)fallbackContext.GetTypeInfo(typeof(T))!;
}
}
@@ -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;
/// <summary>Provides internal helpers for implementing logging.</summary>
[ExcludeFromCodeCoverage]
internal static class LoggingHelpers
{
/// <summary>Serializes <paramref name="value"/> as JSON for logging purposes.</summary>
public static string AsJson<T>(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 "{}";
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,144 @@
// 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.
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.Extensions.AI;
#pragma warning disable CA1716 // Identifiers should not match keywords
#pragma warning disable S4041 // Type names should not match namespaces
/// <summary>Provides constants used by various telemetry services.</summary>
[ExcludeFromCodeCoverage]
internal static class OpenTelemetryConsts
{
public const string DefaultSourceName = "Experimental.Microsoft.Extensions.AI";
public const string SecondsUnit = "s";
public const string TokensUnit = "token";
public static class Event
{
public const string Name = "event.name";
}
public static class Error
{
public const string Type = "error.type";
}
public static class GenAI
{
public const string Choice = "gen_ai.choice";
public const string SystemName = "gen_ai.system";
public const string Chat = "chat";
public const string Embeddings = "embeddings";
public const string ExecuteTool = "execute_tool";
public static class Assistant
{
public const string Message = "gen_ai.assistant.message";
}
public static class Client
{
public static class OperationDuration
{
public const string Description = "Measures the duration of a GenAI operation";
public const string Name = "gen_ai.client.operation.duration";
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];
}
public static class TokenUsage
{
public const string Description = "Measures number of input and output tokens used";
public const string Name = "gen_ai.client.token.usage";
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];
}
}
public static class Conversation
{
public const string Id = "gen_ai.conversation.id";
}
public static class Operation
{
public const string Name = "gen_ai.operation.name";
}
public static class Output
{
public const string Type = "gen_ai.output.type";
}
public static class Request
{
public const string EmbeddingDimensions = "gen_ai.request.embedding.dimensions";
public const string FrequencyPenalty = "gen_ai.request.frequency_penalty";
public const string Model = "gen_ai.request.model";
public const string MaxTokens = "gen_ai.request.max_tokens";
public const string PresencePenalty = "gen_ai.request.presence_penalty";
public const string Seed = "gen_ai.request.seed";
public const string StopSequences = "gen_ai.request.stop_sequences";
public const string Temperature = "gen_ai.request.temperature";
public const string TopK = "gen_ai.request.top_k";
public const string TopP = "gen_ai.request.top_p";
public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.request.{parameterName}";
}
public static class Response
{
public const string FinishReasons = "gen_ai.response.finish_reasons";
public const string Id = "gen_ai.response.id";
public const string Model = "gen_ai.response.model";
public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.response.{parameterName}";
}
public static class System
{
public const string Message = "gen_ai.system.message";
}
public static class Token
{
public const string Type = "gen_ai.token.type";
}
public static class Tool
{
public const string Name = "gen_ai.tool.name";
public const string Description = "gen_ai.tool.description";
public const string Message = "gen_ai.tool.message";
public static class Call
{
public const string Id = "gen_ai.tool.call.id";
}
}
public static class Usage
{
public const string InputTokens = "gen_ai.usage.input_tokens";
public const string OutputTokens = "gen_ai.usage.output_tokens";
}
public static class User
{
public const string Message = "gen_ai.user.message";
}
}
public static class Server
{
public const string Address = "server.address";
public const string Port = "server.port";
}
}
@@ -4,9 +4,15 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents;
@@ -18,51 +24,98 @@ namespace Microsoft.Extensions.AI.Agents;
/// This class provides telemetry instrumentation for agent operations including activities, metrics, and logging.
/// The telemetry output follows OpenTelemetry semantic conventions in <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/"/> and is subject to change as the conventions evolve.
/// </remarks>
public sealed class OpenTelemetryAgent : AIAgent, IDisposable
public sealed partial class OpenTelemetryAgent : AIAgent, IDisposable
{
private const LogLevel EventLogLevel = LogLevel.Information;
private JsonSerializerOptions _jsonSerializerOptions;
private readonly OpenTelemetryChatClient? _openTelemetryChatClient;
private readonly string? _system;
private readonly AIAgent _innerAgent;
private readonly ActivitySource _activitySource;
private readonly Meter _meter;
private readonly Histogram<double> _operationDurationHistogram;
private readonly Histogram<int> _tokenUsageHistogram;
private readonly Counter<int> _requestCounter;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="OpenTelemetryAgent"/> class.
/// </summary>
/// <param name="innerAgent">The underlying agent to wrap with telemetry.</param>
/// <param name="logger">The <see cref="ILogger"/> to use for emitting events.</param>
/// <param name="sourceName">An optional source name that will be used on the telemetry data.</param>
public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null)
public OpenTelemetryAgent(AIAgent innerAgent, ILogger? logger = null, string? sourceName = null)
{
this._innerAgent = Throw.IfNull(innerAgent);
string name = string.IsNullOrEmpty(sourceName) ? AgentOpenTelemetryConsts.DefaultSourceName : sourceName!;
string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!;
this._activitySource = new(name);
this._meter = new(name);
this._logger = logger ?? NullLogger.Instance;
this._system = this.GetService<AIAgentMetadata>()?.ProviderName ?? OpenTelemetryConsts.GenAI.SystemNameValues.MicrosoftExtensionsAIAgents;
// Attempt to get the open telemetry chat client if the inner agent is a ChatClientAgent.
this._openTelemetryChatClient = (innerAgent as ChatClientAgent)?.ChatClient.GetService<OpenTelemetryChatClient>();
// Inherit by default the EnableSensitiveData setting from the TelemetryChatClient if available.
this.EnableSensitiveData = this._openTelemetryChatClient?.EnableSensitiveData ?? false;
this._operationDurationHistogram = this._meter.CreateHistogram<double>(
AgentOpenTelemetryConsts.GenAI.Agent.Client.OperationDuration.Name,
AgentOpenTelemetryConsts.SecondsUnit,
AgentOpenTelemetryConsts.GenAI.Agent.Client.OperationDuration.Description
OpenTelemetryConsts.GenAI.Client.OperationDuration.Name,
OpenTelemetryConsts.SecondsUnit,
OpenTelemetryConsts.GenAI.Client.OperationDuration.Description
#if NET9_0_OR_GREATER
, advice: new() { HistogramBucketBoundaries = AgentOpenTelemetryConsts.GenAI.Agent.Client.OperationDuration.ExplicitBucketBoundaries }
, advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries }
#endif
);
this._tokenUsageHistogram = this._meter.CreateHistogram<int>(
AgentOpenTelemetryConsts.GenAI.Agent.Client.TokenUsage.Name,
AgentOpenTelemetryConsts.TokensUnit,
AgentOpenTelemetryConsts.GenAI.Agent.Client.TokenUsage.Description
OpenTelemetryConsts.GenAI.Client.TokenUsage.Name,
OpenTelemetryConsts.TokensUnit,
OpenTelemetryConsts.GenAI.Client.TokenUsage.Description
#if NET9_0_OR_GREATER
, advice: new() { HistogramBucketBoundaries = AgentOpenTelemetryConsts.GenAI.Agent.Client.TokenUsage.ExplicitBucketBoundaries }
, advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries }
#endif
);
this._requestCounter = this._meter.CreateCounter<int>(
AgentOpenTelemetryConsts.GenAI.Agent.Client.RequestCount.Name,
description: AgentOpenTelemetryConsts.GenAI.Agent.Client.RequestCount.Description);
this._jsonSerializerOptions = AIJsonUtilities.DefaultOptions;
}
/// <summary>Gets or sets JSON serialization options to use when formatting chat data into telemetry strings.</summary>
public JsonSerializerOptions JsonSerializerOptions
{
get => this._jsonSerializerOptions;
set => this._jsonSerializerOptions = Throw.IfNull(value);
}
/// <summary>
/// Disposes the telemetry resources.
/// </summary>
public void Dispose()
{
this._activitySource.Dispose();
this._meter.Dispose();
}
/// <summary>
/// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry.
/// </summary>
/// <value>
/// <see langword="true"/> if potentially sensitive information should be included in telemetry;
/// <see langword="false"/> if telemetry shouldn't include raw inputs and outputs.
/// The default value is <see langword="false"/>.
/// </value>
/// <remarks>
/// By default, telemetry includes metadata, such as token counts, but not raw inputs
/// and outputs, such as message content, function call arguments, and function call results.
/// </remarks>
public bool EnableSensitiveData { get; set; }
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
=> base.GetService(serviceType, serviceKey)
?? (serviceType == typeof(ActivitySource) ? this._activitySource
: this._innerAgent.GetService(serviceType, serviceKey));
/// <inheritdoc/>
public override string Id => this._innerAgent.Id;
@@ -84,12 +137,13 @@ public sealed class OpenTelemetryAgent : AIAgent, IDisposable
{
_ = Throw.IfNull(messages);
using Activity? activity = this.CreateAndConfigureActivity(AgentOpenTelemetryConsts.GenAI.Operations.InvokeAgent, messages, thread);
using Activity? activity = this.CreateAndConfigureActivity(OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent, messages, thread);
Stopwatch? stopwatch = this._operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null;
this.LogChatMessages(messages);
AgentRunResponse? response = null;
Exception? error = null;
try
{
response = await this._innerAgent.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false);
@@ -115,7 +169,7 @@ public sealed class OpenTelemetryAgent : AIAgent, IDisposable
{
_ = Throw.IfNull(messages);
using Activity? activity = this.CreateAndConfigureActivity(AgentOpenTelemetryConsts.GenAI.Operations.InvokeAgent, messages, thread);
using Activity? activity = this.CreateAndConfigureActivity(OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent, messages, thread);
Stopwatch? stopwatch = this._operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null;
IAsyncEnumerable<AgentRunResponseUpdate> updates;
@@ -164,15 +218,6 @@ public sealed class OpenTelemetryAgent : AIAgent, IDisposable
}
}
/// <summary>
/// Disposes the telemetry resources.
/// </summary>
public void Dispose()
{
this._activitySource.Dispose();
this._meter.Dispose();
}
/// <summary>
/// Creates an activity for an agent request, or returns null if not enabled.
/// </summary>
@@ -180,7 +225,6 @@ public sealed class OpenTelemetryAgent : AIAgent, IDisposable
{
// Get the GenAI system name for telemetry
var chatClientAgent = this._innerAgent as ChatClientAgent;
var genAISystem = chatClientAgent?.ChatClient.GetService<ChatClientMetadata>()?.ProviderName;
Activity? activity = null;
if (this._activitySource.HasListeners())
{
@@ -191,34 +235,33 @@ public sealed class OpenTelemetryAgent : AIAgent, IDisposable
{
_ = activity
// Required attributes per OpenTelemetry semantic conventions
.AddTag(AgentOpenTelemetryConsts.GenAI.OperationName, operationName)
.AddTag(AgentOpenTelemetryConsts.GenAI.System, genAISystem ?? AgentOpenTelemetryConsts.GenAI.Systems.MicrosoftExtensionsAI)
.AddTag(OpenTelemetryConsts.GenAI.Operation.Name, operationName)
.AddTag(OpenTelemetryConsts.GenAI.SystemName, this._system)
// Agent-specific attributes
.AddTag(AgentOpenTelemetryConsts.GenAI.Agent.Id, this.Id)
.AddTag(AgentOpenTelemetryConsts.GenAI.Agent.Request.MessageCount, messages.Count);
.AddTag(OpenTelemetryConsts.GenAI.Agent.Id, this.Id);
// Add agent name if available (following gen_ai.agent.name convention - conditionally required when available)
if (!string.IsNullOrWhiteSpace(this.Name))
{
_ = activity.AddTag(AgentOpenTelemetryConsts.GenAI.Agent.Name, this.Name);
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Agent.Name, this.Name);
}
// Add description if available (following gen_ai.agent.description convention)
if (!string.IsNullOrWhiteSpace(this.Description))
{
_ = activity.AddTag(AgentOpenTelemetryConsts.GenAI.Agent.Description, this.Description);
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Agent.Description, this.Description);
}
// Add conversation ID if thread is available (following gen_ai.conversation.id convention)
if (!string.IsNullOrWhiteSpace(thread?.ConversationId))
{
_ = activity.AddTag(AgentOpenTelemetryConsts.GenAI.ConversationId, thread.ConversationId);
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Conversation.Id, thread.ConversationId);
}
// Add instructions if available (for ChatClientAgent)
if (!string.IsNullOrWhiteSpace(chatClientAgent?.Instructions))
{
_ = activity.AddTag(AgentOpenTelemetryConsts.GenAI.Agent.Request.Instructions, chatClientAgent.Instructions);
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.Instructions, chatClientAgent.Instructions);
}
}
}
@@ -253,32 +296,19 @@ public sealed class OpenTelemetryAgent : AIAgent, IDisposable
{
TagList tags = new()
{
{ AgentOpenTelemetryConsts.GenAI.OperationName, AgentOpenTelemetryConsts.GenAI.Operations.InvokeAgent }
{ OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Operation.NameValues.InvokeAgent }
};
AddIfNotWhiteSpace(ref tags, AgentOpenTelemetryConsts.GenAI.Agent.Name, this.Name);
AddIfNotWhiteSpace(ref tags, OpenTelemetryConsts.GenAI.Agent.Name, this.DisplayName);
if (error is not null)
{
tags.Add(AgentOpenTelemetryConsts.ErrorInfo.Type, error.GetType().FullName);
tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName);
}
this._operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags);
}
// Record request count metric
if (this._requestCounter.Enabled)
{
TagList tags = new()
{
{ AgentOpenTelemetryConsts.GenAI.OperationName, AgentOpenTelemetryConsts.GenAI.Operations.InvokeAgent }
};
AddIfNotWhiteSpace(ref tags, AgentOpenTelemetryConsts.GenAI.Agent.Name, this.Name);
this._requestCounter.Add(1, tags);
}
// Record token usage metrics
if (this._tokenUsageHistogram.Enabled && response?.Usage is { } usage)
{
@@ -286,10 +316,10 @@ public sealed class OpenTelemetryAgent : AIAgent, IDisposable
{
TagList tags = new()
{
{ AgentOpenTelemetryConsts.GenAI.Agent.Token.Type, "input" }
{ OpenTelemetryConsts.GenAI.Token.Type, "input" }
};
AddIfNotWhiteSpace(ref tags, AgentOpenTelemetryConsts.GenAI.Agent.Name, this.Name);
AddIfNotWhiteSpace(ref tags, OpenTelemetryConsts.GenAI.Agent.Name, this.Name);
this._tokenUsageHistogram.Record((int)inputTokens, tags);
}
@@ -298,10 +328,10 @@ public sealed class OpenTelemetryAgent : AIAgent, IDisposable
{
TagList tags = new()
{
{ AgentOpenTelemetryConsts.GenAI.Agent.Token.Type, "output" }
{ OpenTelemetryConsts.GenAI.Token.Type, "output" }
};
AddIfNotWhiteSpace(ref tags, AgentOpenTelemetryConsts.GenAI.Agent.Name, this.Name);
AddIfNotWhiteSpace(ref tags, OpenTelemetryConsts.GenAI.Agent.Name, this.Name);
this._tokenUsageHistogram.Record((int)outputTokens, tags);
}
@@ -313,29 +343,215 @@ public sealed class OpenTelemetryAgent : AIAgent, IDisposable
if (error is not null)
{
_ = activity
.AddTag(AgentOpenTelemetryConsts.ErrorInfo.Type, error.GetType().FullName)
.AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName)
.SetStatus(ActivityStatusCode.Error, error.Message);
}
if (response is not null)
{
_ = activity.AddTag(AgentOpenTelemetryConsts.GenAI.Agent.Response.MessageCount, response.Messages.Count);
if (!string.IsNullOrWhiteSpace(response.ResponseId))
{
_ = activity.AddTag(AgentOpenTelemetryConsts.GenAI.Agent.Response.Id, response.ResponseId);
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Id, response.ResponseId);
}
if (response.Usage?.InputTokenCount is long inputTokens)
{
_ = activity.AddTag(AgentOpenTelemetryConsts.GenAI.Agent.Usage.InputTokens, (int)inputTokens);
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, (int)inputTokens);
}
if (response.Usage?.OutputTokenCount is long outputTokens)
{
_ = activity.AddTag(AgentOpenTelemetryConsts.GenAI.Agent.Usage.OutputTokens, (int)outputTokens);
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens);
}
}
}
// Log the agent response for choice events
if (response is not null)
{
this.LogAgentResponse(response);
}
}
private void LogChatMessages(IEnumerable<ChatMessage> messages)
{
if (this._openTelemetryChatClient is not null)
{
// To avoid duplication of telemetry data the logging will be skipped if the agent is a ChatClientAgent and
// its innerChatClient already has telemetry enabled,
return;
}
if (!this._logger.IsEnabled(EventLogLevel))
{
return;
}
foreach (ChatMessage message in messages)
{
if (message.Role == ChatRole.Assistant)
{
this.Log(new EventId(1, OpenTelemetryConsts.GenAI.Assistant.Message),
JsonSerializer.Serialize(this.CreateAssistantEvent(message.Contents), OtelContext.Default.AssistantEvent));
}
else if (message.Role == ChatRole.Tool)
{
foreach (FunctionResultContent frc in message.Contents.OfType<FunctionResultContent>())
{
this.Log(new EventId(1, OpenTelemetryConsts.GenAI.Tool.Message),
JsonSerializer.Serialize(new ToolEvent()
{
Id = frc.CallId,
Content = this.EnableSensitiveData && frc.Result is object result ?
JsonSerializer.SerializeToNode(result, this._jsonSerializerOptions.GetTypeInfo(result.GetType())) :
null,
}, OtelContext.Default.ToolEvent));
}
}
else
{
this.Log(new EventId(1, message.Role == ChatRole.System ? OpenTelemetryConsts.GenAI.System.Message : OpenTelemetryConsts.GenAI.User.Message),
JsonSerializer.Serialize(new SystemOrUserEvent()
{
Role = message.Role != ChatRole.System && message.Role != ChatRole.User && !string.IsNullOrWhiteSpace(message.Role.Value) ? message.Role.Value : null,
Content = this.GetMessageContent(message.Contents),
}, OtelContext.Default.SystemOrUserEvent));
}
}
}
private void LogChatResponse(ChatResponse response)
{
if (!this._logger.IsEnabled(EventLogLevel))
{
return;
}
EventId id = new(1, OpenTelemetryConsts.GenAI.Choice);
this.Log(id, JsonSerializer.Serialize(new ChoiceEvent()
{
FinishReason = response.FinishReason?.Value ?? "error",
Index = 0,
Message = this.CreateAssistantEvent(response.Messages is { Count: 1 } ? response.Messages[0].Contents : response.Messages.SelectMany(m => m.Contents)),
}, OtelContext.Default.ChoiceEvent));
}
private void LogAgentResponse(AgentRunResponse response)
{
if (this._openTelemetryChatClient is not null)
{
// To avoid duplication of telemetry data the logging will be skipped if the agent is a ChatClientAgent and
// its innerChatClient already has telemetry enabled
return;
}
if (!this._logger.IsEnabled(EventLogLevel))
{
return;
}
EventId id = new(1, OpenTelemetryConsts.GenAI.Choice);
this.Log(id, JsonSerializer.Serialize(new ChoiceEvent()
{
FinishReason = (response.RawRepresentation as ChatResponse)?.FinishReason?.Value ?? string.Empty,
Index = 0,
Message = this.CreateAssistantEvent(response.Messages is { Count: 1 } ? response.Messages[0].Contents : response.Messages.SelectMany(m => m.Contents)),
}, OtelContext.Default.ChoiceEvent));
}
private void Log(EventId id, string eventBodyJson)
{
// This is not the idiomatic way to log, but it's necessary for now in order to structure
// the data in a way that the OpenTelemetry collector can work with it. The event body
// can be very large and should not be logged as an attribute.
KeyValuePair<string, object?>[] tags =
[
new(OpenTelemetryConsts.Event.Name, id.Name),
new(OpenTelemetryConsts.GenAI.SystemName, this._system),
];
this._logger.Log(EventLogLevel, id, tags, null, (_, __) => eventBodyJson);
}
private AssistantEvent CreateAssistantEvent(IEnumerable<AIContent> contents)
{
var toolCalls = contents.OfType<FunctionCallContent>().Select(fc => new ToolCall
{
Id = fc.CallId,
Function = new()
{
Name = fc.Name,
Arguments = this.EnableSensitiveData ?
JsonSerializer.SerializeToNode(fc.Arguments, this._jsonSerializerOptions.GetTypeInfo(typeof(IDictionary<string, object?>))) :
null,
},
}).ToArray();
return new()
{
Content = this.GetMessageContent(contents),
ToolCalls = toolCalls.Length > 0 ? toolCalls : null,
};
}
private string? GetMessageContent(IEnumerable<AIContent> contents)
{
if (this.EnableSensitiveData)
{
string content = string.Concat(contents.OfType<TextContent>());
if (content.Length > 0)
{
return content;
}
}
return null;
}
private sealed partial class SystemOrUserEvent
{
public string? Role { get; set; }
public string? Content { get; set; }
}
private sealed class AssistantEvent
{
public string? Content { get; set; }
public ToolCall[]? ToolCalls { get; set; }
}
private sealed partial class ToolEvent
{
public string? Id { get; set; }
public JsonNode? Content { get; set; }
}
private sealed partial class ChoiceEvent
{
public string? FinishReason { get; set; }
public int Index { get; set; }
public AssistantEvent? Message { get; set; }
}
private sealed partial class ToolCall
{
public string? Id { get; set; }
public string? Type { get; set; } = "function";
public ToolCallFunction? Function { get; set; }
}
private sealed partial class ToolCallFunction
{
public string? Name { get; set; }
public JsonNode? Arguments { get; set; }
}
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(SystemOrUserEvent))]
[JsonSerializable(typeof(AssistantEvent))]
[JsonSerializable(typeof(ToolEvent))]
[JsonSerializable(typeof(ChoiceEvent))]
[JsonSerializable(typeof(object))]
private sealed partial class OtelContext : JsonSerializerContext;
}
@@ -0,0 +1,154 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents;
/// <summary>Provides constants used by various telemetry services.</summary>
internal static class OpenTelemetryConsts
{
public const string DefaultSourceName = "Experimental.Microsoft.Extensions.AI.Agents";
public const string SecondsUnit = "s";
public const string TokensUnit = "token";
public static class Event
{
public const string Name = "event.name";
}
public static class Error
{
public const string Type = "error.type";
}
public static class GenAI
{
public const string Choice = "gen_ai.choice";
public const string SystemName = "gen_ai.system";
public static class SystemNameValues
{
public const string MicrosoftExtensionsAIAgents = "microsoft.extensions.ai.agents";
}
public const string Chat = "chat";
public const string Embeddings = "embeddings";
public const string ExecuteTool = "execute_tool";
public static class Agent
{
public const string Id = "gen_ai.agent.id";
public const string Name = "gen_ai.agent.name";
public const string Description = "gen_ai.agent.description";
}
public static class Assistant
{
public const string Message = "gen_ai.assistant.message";
}
public static class Client
{
public static class OperationDuration
{
public const string Description = "Measures the duration of a GenAI operation";
public const string Name = "gen_ai.client.operation.duration";
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];
}
public static class TokenUsage
{
public const string Description = "Measures number of input and output tokens used";
public const string Name = "gen_ai.client.token.usage";
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];
}
}
public static class Conversation
{
public const string Id = "gen_ai.conversation.id";
}
public static class Operation
{
public const string Name = "gen_ai.operation.name";
public static class NameValues
{
public const string InvokeAgent = "invoke_agent";
}
}
public static class Output
{
public const string Type = "gen_ai.output.type";
}
public static class Request
{
public const string EmbeddingDimensions = "gen_ai.request.embedding.dimensions";
public const string FrequencyPenalty = "gen_ai.request.frequency_penalty";
public const string Model = "gen_ai.request.model";
public const string MaxTokens = "gen_ai.request.max_tokens";
public const string PresencePenalty = "gen_ai.request.presence_penalty";
public const string Seed = "gen_ai.request.seed";
public const string StopSequences = "gen_ai.request.stop_sequences";
public const string Temperature = "gen_ai.request.temperature";
public const string TopK = "gen_ai.request.top_k";
public const string TopP = "gen_ai.request.top_p";
// Not available in OTEL : Potential proposals
public const string Instructions = "gen_ai.request.instructions";
public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.request.{parameterName}";
}
public static class Response
{
public const string FinishReasons = "gen_ai.response.finish_reasons";
public const string Id = "gen_ai.response.id";
public const string Model = "gen_ai.response.model";
public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.response.{parameterName}";
}
public static class System
{
public const string Message = "gen_ai.system.message";
}
public static class Token
{
public const string Type = "gen_ai.token.type";
}
public static class Tool
{
public const string Name = "gen_ai.tool.name";
public const string Description = "gen_ai.tool.description";
public const string Message = "gen_ai.tool.message";
public static class Call
{
public const string Id = "gen_ai.tool.call.id";
}
}
public static class Usage
{
public const string InputTokens = "gen_ai.usage.input_tokens";
public const string OutputTokens = "gen_ai.usage.output_tokens";
}
public static class User
{
public const string Message = "gen_ai.user.message";
}
}
public static class Server
{
public const string Address = "server.address";
public const string Port = "server.port";
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -236,6 +237,122 @@ public class AgentTests
threadMock.Protected().Verify("OnNewMessagesAsync", Times.Once(), messages, cancellationToken);
}
#region GetService Method Tests
/// <summary>
/// Verify that GetService returns the agent itself when requesting the exact agent type.
/// </summary>
[Fact]
public void GetService_RequestingExactAgentType_ReturnsAgent()
{
// Arrange
var agent = new MockAgent();
// Act
var result = agent.GetService(typeof(MockAgent));
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
}
/// <summary>
/// Verify that GetService returns the agent itself when requesting the base AIAgent type.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentType_ReturnsAgent()
{
// Arrange
var agent = new MockAgent();
// Act
var result = agent.GetService(typeof(AIAgent));
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
}
/// <summary>
/// Verify that GetService returns null when requesting an unrelated type.
/// </summary>
[Fact]
public void GetService_RequestingUnrelatedType_ReturnsNull()
{
// Arrange
var agent = new MockAgent();
// Act
var result = agent.GetService(typeof(string));
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetService returns null when a service key is provided, even for matching types.
/// </summary>
[Fact]
public void GetService_WithServiceKey_ReturnsNull()
{
// Arrange
var agent = new MockAgent();
// Act
var result = agent.GetService(typeof(MockAgent), "some-key");
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetService throws ArgumentNullException when serviceType is null.
/// </summary>
[Fact]
public void GetService_WithNullServiceType_ThrowsArgumentNullException()
{
// Arrange
var agent = new MockAgent();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => agent.GetService(null!));
}
/// <summary>
/// Verify that GetService generic method works correctly.
/// </summary>
[Fact]
public void GetService_Generic_ReturnsCorrectType()
{
// Arrange
var agent = new MockAgent();
// Act
var result = agent.GetService<MockAgent>();
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
}
/// <summary>
/// Verify that GetService generic method returns null for unrelated types.
/// </summary>
[Fact]
public void GetService_Generic_ReturnsNullForUnrelatedType()
{
// Arrange
var agent = new MockAgent();
// Act
var result = agent.GetService<string>();
// Assert
Assert.Null(result);
}
#endregion
/// <summary>
/// Typed mock thread.
/// </summary>
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
@@ -97,4 +98,313 @@ public class InMemoryChatMessageStoreTests
Assert.Empty(store);
}
[Fact]
public async Task AddMessagesAsync_WithNullMessages_ThrowsArgumentNullExceptionAsync()
{
// Arrange
var store = new InMemoryChatMessageStore();
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() => store.AddMessagesAsync(null!, CancellationToken.None));
}
[Fact]
public async Task DeserializeStateAsync_WithNullSerializedState_DoesNothingAsync()
{
// Arrange
var store = new InMemoryChatMessageStore();
store.Add(new ChatMessage(ChatRole.User, "Existing message"));
// Act
await store.DeserializeStateAsync(null);
// Assert - Should still have the existing message
Assert.Single(store);
Assert.Equal("Existing message", store[0].Text);
}
[Fact]
public async Task DeserializeStateAsync_WithEmptyMessages_DoesNotAddMessagesAsync()
{
// Arrange
var store = new InMemoryChatMessageStore();
var stateWithEmptyMessages = JsonSerializer.SerializeToElement(new { Messages = new List<ChatMessage>() });
// Act
await store.DeserializeStateAsync(stateWithEmptyMessages);
// Assert
Assert.Empty(store);
}
[Fact]
public async Task DeserializeStateAsync_WithNullMessages_DoesNotAddMessagesAsync()
{
// Arrange
var store = new InMemoryChatMessageStore();
var stateWithNullMessages = JsonSerializer.SerializeToElement(new { Messages = (List<ChatMessage>?)null });
// Act
await store.DeserializeStateAsync(stateWithNullMessages);
// Assert
Assert.Empty(store);
}
[Fact]
public async Task DeserializeStateAsync_WithValidMessages_AddsMessagesAsync()
{
// Arrange
var store = new InMemoryChatMessageStore();
var messages = new List<ChatMessage>
{
new(ChatRole.User, "User message"),
new(ChatRole.Assistant, "Assistant message")
};
var state = new { Messages = messages };
var serializedState = JsonSerializer.SerializeToElement(state);
// Act
await store.DeserializeStateAsync(serializedState);
// Assert
Assert.Equal(2, store.Count);
Assert.Equal("User message", store[0].Text);
Assert.Equal("Assistant message", store[1].Text);
}
[Fact]
public void IndexerGet_ReturnsCorrectMessage()
{
// Arrange
var store = new InMemoryChatMessageStore();
var message1 = new ChatMessage(ChatRole.User, "First");
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
store.Add(message1);
store.Add(message2);
// Act & Assert
Assert.Same(message1, store[0]);
Assert.Same(message2, store[1]);
}
[Fact]
public void IndexerSet_UpdatesMessage()
{
// Arrange
var store = new InMemoryChatMessageStore();
var originalMessage = new ChatMessage(ChatRole.User, "Original");
var newMessage = new ChatMessage(ChatRole.User, "Updated");
store.Add(originalMessage);
// Act
store[0] = newMessage;
// Assert
Assert.Same(newMessage, store[0]);
Assert.Equal("Updated", store[0].Text);
}
[Fact]
public void IsReadOnly_ReturnsFalse()
{
// Arrange
var store = new InMemoryChatMessageStore();
// Act & Assert
Assert.False(store.IsReadOnly);
}
[Fact]
public void IndexOf_ReturnsCorrectIndex()
{
// Arrange
var store = new InMemoryChatMessageStore();
var message1 = new ChatMessage(ChatRole.User, "First");
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
var message3 = new ChatMessage(ChatRole.User, "Third");
store.Add(message1);
store.Add(message2);
// Act & Assert
Assert.Equal(0, store.IndexOf(message1));
Assert.Equal(1, store.IndexOf(message2));
Assert.Equal(-1, store.IndexOf(message3)); // Not in store
}
[Fact]
public void Insert_InsertsMessageAtCorrectIndex()
{
// Arrange
var store = new InMemoryChatMessageStore();
var message1 = new ChatMessage(ChatRole.User, "First");
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
var insertMessage = new ChatMessage(ChatRole.User, "Inserted");
store.Add(message1);
store.Add(message2);
// Act
store.Insert(1, insertMessage);
// Assert
Assert.Equal(3, store.Count);
Assert.Same(message1, store[0]);
Assert.Same(insertMessage, store[1]);
Assert.Same(message2, store[2]);
}
[Fact]
public void RemoveAt_RemovesMessageAtIndex()
{
// Arrange
var store = new InMemoryChatMessageStore();
var message1 = new ChatMessage(ChatRole.User, "First");
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
var message3 = new ChatMessage(ChatRole.User, "Third");
store.Add(message1);
store.Add(message2);
store.Add(message3);
// Act
store.RemoveAt(1);
// Assert
Assert.Equal(2, store.Count);
Assert.Same(message1, store[0]);
Assert.Same(message3, store[1]);
}
[Fact]
public void Clear_RemovesAllMessages()
{
// Arrange
var store = new InMemoryChatMessageStore();
store.Add(new ChatMessage(ChatRole.User, "First"));
store.Add(new ChatMessage(ChatRole.Assistant, "Second"));
// Act
store.Clear();
// Assert
Assert.Empty(store);
}
[Fact]
public void Contains_ReturnsTrueForExistingMessage()
{
// Arrange
var store = new InMemoryChatMessageStore();
var message1 = new ChatMessage(ChatRole.User, "First");
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
store.Add(message1);
// Act & Assert
Assert.Contains(message1, store);
Assert.DoesNotContain(message2, store);
}
[Fact]
public void CopyTo_CopiesMessagesToArray()
{
// Arrange
var store = new InMemoryChatMessageStore();
var message1 = new ChatMessage(ChatRole.User, "First");
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
store.Add(message1);
store.Add(message2);
var array = new ChatMessage[4];
// Act
store.CopyTo(array, 1);
// Assert
Assert.Null(array[0]);
Assert.Same(message1, array[1]);
Assert.Same(message2, array[2]);
Assert.Null(array[3]);
}
[Fact]
public void Remove_RemovesSpecificMessage()
{
// Arrange
var store = new InMemoryChatMessageStore();
var message1 = new ChatMessage(ChatRole.User, "First");
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
var message3 = new ChatMessage(ChatRole.User, "Third");
store.Add(message1);
store.Add(message2);
store.Add(message3);
// Act
var removed = store.Remove(message2);
// Assert
Assert.True(removed);
Assert.Equal(2, store.Count);
Assert.Same(message1, store[0]);
Assert.Same(message3, store[1]);
}
[Fact]
public void Remove_ReturnsFalseForNonExistentMessage()
{
// Arrange
var store = new InMemoryChatMessageStore();
var message1 = new ChatMessage(ChatRole.User, "First");
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
store.Add(message1);
// Act
var removed = store.Remove(message2);
// Assert
Assert.False(removed);
Assert.Single(store);
}
[Fact]
public void GetEnumerator_Generic_ReturnsAllMessages()
{
// Arrange
var store = new InMemoryChatMessageStore();
var message1 = new ChatMessage(ChatRole.User, "First");
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
store.Add(message1);
store.Add(message2);
// Act
var messages = new List<ChatMessage>();
messages.AddRange(store);
// Assert
Assert.Equal(2, messages.Count);
Assert.Same(message1, messages[0]);
Assert.Same(message2, messages[1]);
}
[Fact]
public void GetEnumerator_NonGeneric_ReturnsAllMessages()
{
// Arrange
var store = new InMemoryChatMessageStore();
var message1 = new ChatMessage(ChatRole.User, "First");
var message2 = new ChatMessage(ChatRole.Assistant, "Second");
store.Add(message1);
store.Add(message2);
// Act
var messages = new List<ChatMessage>();
var enumerator = ((System.Collections.IEnumerable)store).GetEnumerator();
while (enumerator.MoveNext())
{
messages.Add((ChatMessage)enumerator.Current);
}
// Assert
Assert.Equal(2, messages.Count);
Assert.Same(message1, messages[0]);
Assert.Same(message2, messages[1]);
}
}
@@ -0,0 +1,213 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Moq;
namespace Microsoft.Extensions.AI.Agents.UnitTests.ChatCompletion;
/// <summary>
/// Unit tests for the <see cref="ChatClientAgentOptions"/> class.
/// </summary>
public class ChatClientAgentOptionsTests
{
[Fact]
public void DefaultConstructor_InitializesWithNullValues()
{
// Act
var options = new ChatClientAgentOptions();
// Assert
Assert.Null(options.Name);
Assert.Null(options.Instructions);
Assert.Null(options.Description);
Assert.Null(options.ChatOptions);
Assert.Null(options.ChatMessageStoreFactory);
}
[Fact]
public void ParameterizedConstructor_WithNullValues_SetsPropertiesCorrectly()
{
// Act
var options = new ChatClientAgentOptions(
instructions: null,
name: null,
description: null,
tools: null);
// Assert
Assert.Null(options.Name);
Assert.Null(options.Instructions);
Assert.Null(options.Description);
Assert.Null(options.ChatOptions);
}
[Fact]
public void ParameterizedConstructor_WithInstructionsOnly_SetsChatOptionsWithInstructions()
{
// Arrange
const string Instructions = "Test instructions";
// Act
var options = new ChatClientAgentOptions(
instructions: Instructions,
name: null,
description: null,
tools: null);
// Assert
Assert.Null(options.Name);
Assert.Equal(Instructions, options.Instructions);
Assert.Null(options.Description);
Assert.NotNull(options.ChatOptions);
Assert.Equal(Instructions, options.ChatOptions.Instructions);
Assert.Null(options.ChatOptions.Tools);
}
[Fact]
public void ParameterizedConstructor_WithToolsOnly_SetsChatOptionsWithTools()
{
// Arrange
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
// Act
var options = new ChatClientAgentOptions(
instructions: null,
name: null,
description: null,
tools: tools);
// Assert
Assert.Null(options.Name);
Assert.Null(options.Instructions);
Assert.Null(options.Description);
Assert.NotNull(options.ChatOptions);
Assert.Null(options.ChatOptions.Instructions);
Assert.Same(tools, options.ChatOptions.Tools);
}
[Fact]
public void ParameterizedConstructor_WithInstructionsAndTools_SetsChatOptionsWithBoth()
{
// Arrange
const string Instructions = "Test instructions";
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
// Act
var options = new ChatClientAgentOptions(
instructions: Instructions,
name: null,
description: null,
tools: tools);
// Assert
Assert.Null(options.Name);
Assert.Equal(Instructions, options.Instructions);
Assert.Null(options.Description);
Assert.NotNull(options.ChatOptions);
Assert.Equal(Instructions, options.ChatOptions.Instructions);
Assert.Same(tools, options.ChatOptions.Tools);
}
[Fact]
public void ParameterizedConstructor_WithAllParameters_SetsAllPropertiesCorrectly()
{
// Arrange
const string Instructions = "Test instructions";
const string Name = "Test name";
const string Description = "Test description";
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
// Act
var options = new ChatClientAgentOptions(
instructions: Instructions,
name: Name,
description: Description,
tools: tools);
// Assert
Assert.Equal(Name, options.Name);
Assert.Equal(Instructions, options.Instructions);
Assert.Equal(Description, options.Description);
Assert.NotNull(options.ChatOptions);
Assert.Equal(Instructions, options.ChatOptions.Instructions);
Assert.Same(tools, options.ChatOptions.Tools);
}
[Fact]
public void ParameterizedConstructor_WithNameAndDescriptionOnly_DoesNotCreateChatOptions()
{
// Arrange
const string Name = "Test name";
const string Description = "Test description";
// Act
var options = new ChatClientAgentOptions(
instructions: null,
name: Name,
description: Description,
tools: null);
// Assert
Assert.Equal(Name, options.Name);
Assert.Null(options.Instructions);
Assert.Equal(Description, options.Description);
Assert.Null(options.ChatOptions);
}
[Fact]
public void Clone_CreatesDeepCopyWithSameValues()
{
// Arrange
const string Instructions = "Test instructions";
const string Name = "Test name";
const string Description = "Test description";
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
static IChatMessageStore ChatMessageStoreFactory() => new Mock<IChatMessageStore>().Object;
var original = new ChatClientAgentOptions(Instructions, Name, Description, tools)
{
Id = "test-id",
ChatMessageStoreFactory = ChatMessageStoreFactory
};
// Act
var clone = original.Clone();
// Assert
Assert.NotSame(original, clone);
Assert.Equal(original.Id, clone.Id);
Assert.Equal(original.Name, clone.Name);
Assert.Equal(original.Instructions, clone.Instructions);
Assert.Equal(original.Description, clone.Description);
Assert.Same(original.ChatMessageStoreFactory, clone.ChatMessageStoreFactory);
// ChatOptions should be cloned, not the same reference
Assert.NotSame(original.ChatOptions, clone.ChatOptions);
Assert.Equal(original.ChatOptions?.Instructions, clone.ChatOptions?.Instructions);
Assert.Equal(original.ChatOptions?.Tools, clone.ChatOptions?.Tools);
}
[Fact]
public void Clone_WithNullChatOptions_ClonesCorrectly()
{
// Arrange
var original = new ChatClientAgentOptions
{
Id = "test-id",
Name = "Test name",
Instructions = "Test instructions",
Description = "Test description"
};
// Act
var clone = original.Clone();
// Assert
Assert.NotSame(original, clone);
Assert.Equal(original.Id, clone.Id);
Assert.Equal(original.Name, clone.Name);
Assert.Equal(original.Instructions, clone.Instructions);
Assert.Equal(original.Description, clone.Description);
Assert.Null(clone.ChatOptions);
}
}
@@ -1092,6 +1092,374 @@ public class ChatClientAgentTests
#endregion
#region GetService Method Tests
/// <summary>
/// Verify that GetService returns AIAgentMetadata when requested.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentMetadata_ReturnsMetadata()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var metadata = new ChatClientMetadata("test-provider");
mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null))
.Returns(metadata);
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Id = "test-agent-id",
Name = "TestAgent",
Instructions = "Test instructions"
});
// Act
var result = agent.GetService(typeof(AIAgentMetadata));
// Assert
Assert.NotNull(result);
Assert.IsType<AIAgentMetadata>(result);
var agentMetadata = (AIAgentMetadata)result;
Assert.Equal("test-provider", agentMetadata.ProviderName);
}
/// <summary>
/// Verify that GetService returns IChatClient when requested.
/// </summary>
[Fact]
public void GetService_RequestingIChatClient_ReturnsChatClient()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions"
});
// Act
var result = agent.GetService(typeof(IChatClient));
// Assert
Assert.NotNull(result);
Assert.IsAssignableFrom<IChatClient>(result);
// Note: The result will be the AgentInvokingChatClient wrapper, not the original mock
Assert.Equal("AgentInvokingChatClient", result.GetType().Name);
}
/// <summary>
/// Verify that GetService delegates to the underlying ChatClient for unknown service types.
/// </summary>
[Fact]
public void GetService_RequestingUnknownServiceType_DelegatesToChatClient()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var customService = new object();
mockChatClient.Setup(c => c.GetService(typeof(string), null))
.Returns(customService);
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions"
});
// Act
var result = agent.GetService(typeof(string));
// Assert
Assert.Same(customService, result);
mockChatClient.Verify(c => c.GetService(typeof(string), null), Times.Once);
}
/// <summary>
/// Verify that GetService returns null for unknown service types when ChatClient returns null.
/// </summary>
[Fact]
public void GetService_RequestingUnknownServiceTypeWithNullFromChatClient_ReturnsNull()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(c => c.GetService(typeof(string), null))
.Returns((object?)null);
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions"
});
// Act
var result = agent.GetService(typeof(string));
// Assert
Assert.Null(result);
mockChatClient.Verify(c => c.GetService(typeof(string), null), Times.Once);
}
/// <summary>
/// Verify that GetService with serviceKey parameter delegates correctly to ChatClient.
/// </summary>
[Fact]
public void GetService_WithServiceKey_DelegatesToChatClient()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var customService = new object();
var serviceKey = "test-key";
mockChatClient.Setup(c => c.GetService(typeof(string), serviceKey))
.Returns(customService);
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions"
});
// Act
var result = agent.GetService(typeof(string), serviceKey);
// Assert
Assert.Same(customService, result);
mockChatClient.Verify(c => c.GetService(typeof(string), serviceKey), Times.Once);
}
/// <summary>
/// Verify that GetService returns AIAgentMetadata with correct provider name from ChatClientMetadata.
/// </summary>
[Theory]
[InlineData("openai")]
[InlineData("azure")]
[InlineData("anthropic")]
[InlineData(null)]
public void GetService_RequestingAIAgentMetadata_ReturnsMetadataWithCorrectProviderName(string? providerName)
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var chatClientMetadata = providerName != null ? new ChatClientMetadata(providerName) : null;
mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null))
.Returns(chatClientMetadata);
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions"
});
// Act
var result = agent.GetService(typeof(AIAgentMetadata));
// Assert
Assert.NotNull(result);
Assert.IsType<AIAgentMetadata>(result);
var agentMetadata = (AIAgentMetadata)result;
Assert.Equal(providerName, agentMetadata.ProviderName);
}
/// <summary>
/// Verify that ChatClientAgent returns correct AIAgentMetadata based on ChatClientMetadata.
/// </summary>
[Theory]
[InlineData("openai", "openai")]
[InlineData("azure", "azure")]
[InlineData("anthropic", "anthropic")]
[InlineData(null, null)]
public void GetService_RequestingAIAgentMetadata_ReturnsCorrectAIAgentMetadataBasedOnProvider(string? chatClientProviderName, string? expectedProviderName)
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var chatClientMetadata = chatClientProviderName != null ? new ChatClientMetadata(chatClientProviderName) : null;
mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null))
.Returns(chatClientMetadata);
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Id = "test-agent-id",
Name = "TestAgent",
Instructions = "Test instructions"
});
// Act
var result = agent.GetService(typeof(AIAgentMetadata));
// Assert
Assert.NotNull(result);
Assert.IsType<AIAgentMetadata>(result);
var agentMetadata = (AIAgentMetadata)result;
Assert.Equal(expectedProviderName, agentMetadata.ProviderName);
}
/// <summary>
/// Verify that ChatClientAgent metadata is consistent across multiple calls.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentMetadata_ReturnsConsistentMetadata()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var chatClientMetadata = new ChatClientMetadata("test-provider");
mockChatClient.Setup(c => c.GetService(typeof(ChatClientMetadata), null))
.Returns(chatClientMetadata);
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions"
});
// Act
var result1 = agent.GetService(typeof(AIAgentMetadata));
var result2 = agent.GetService(typeof(AIAgentMetadata));
// Assert
Assert.NotNull(result1);
Assert.NotNull(result2);
Assert.Same(result1, result2); // Should return the same instance
Assert.IsType<AIAgentMetadata>(result1);
var agentMetadata = (AIAgentMetadata)result1;
Assert.Equal("test-provider", agentMetadata.ProviderName);
}
/// <summary>
/// Verify that AIAgentMetadata structure is consistent across different ChatClientAgent configurations.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentMetadata_StructureIsConsistentAcrossConfigurations()
{
// Arrange
var mockChatClient1 = new Mock<IChatClient>();
var chatClientMetadata1 = new ChatClientMetadata("openai");
mockChatClient1.Setup(c => c.GetService(typeof(ChatClientMetadata), null))
.Returns(chatClientMetadata1);
var mockChatClient2 = new Mock<IChatClient>();
var chatClientMetadata2 = new ChatClientMetadata("azure");
mockChatClient2.Setup(c => c.GetService(typeof(ChatClientMetadata), null))
.Returns(chatClientMetadata2);
var chatClientAgent1 = new ChatClientAgent(mockChatClient1.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions 1"
});
var chatClientAgent2 = new ChatClientAgent(mockChatClient2.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions 2"
});
// Act
var metadata1 = chatClientAgent1.GetService(typeof(AIAgentMetadata)) as AIAgentMetadata;
var metadata2 = chatClientAgent2.GetService(typeof(AIAgentMetadata)) as AIAgentMetadata;
// Assert
Assert.NotNull(metadata1);
Assert.NotNull(metadata2);
// Both should have the same type and structure
Assert.Equal(typeof(AIAgentMetadata), metadata1.GetType());
Assert.Equal(typeof(AIAgentMetadata), metadata2.GetType());
// Both should have ProviderName property
Assert.NotNull(metadata1.ProviderName);
Assert.NotNull(metadata2.ProviderName);
// Provider names should be different
Assert.Equal("openai", metadata1.ProviderName);
Assert.Equal("azure", metadata2.ProviderName);
Assert.NotEqual(metadata1.ProviderName, metadata2.ProviderName);
}
/// <summary>
/// Verify that GetService calls base.GetService() first and returns the agent itself when requesting ChatClientAgent type.
/// </summary>
[Fact]
public void GetService_RequestingChatClientAgentType_ReturnsBaseImplementation()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions"
});
// Act
var result = agent.GetService(typeof(ChatClientAgent));
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
// Verify that the ChatClient's GetService was not called for this type since base.GetService() handled it
mockChatClient.Verify(c => c.GetService(typeof(ChatClientAgent), null), Times.Never);
}
/// <summary>
/// Verify that GetService calls base.GetService() first and returns the agent itself when requesting AIAgent type.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentType_ReturnsBaseImplementation()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions"
});
// Act
var result = agent.GetService(typeof(AIAgent));
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
// Verify that the ChatClient's GetService was not called for this type since base.GetService() handled it
mockChatClient.Verify(c => c.GetService(typeof(AIAgent), null), Times.Never);
}
/// <summary>
/// Verify that GetService calls base.GetService() first but continues to derived logic when base returns null.
/// For IChatClient, it returns the agent's own ChatClient regardless of service key.
/// </summary>
[Fact]
public void GetService_RequestingIChatClientWithServiceKey_ReturnsOwnChatClient()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions"
});
// Act - Request IChatClient with a service key (base.GetService will return null due to serviceKey)
var result = agent.GetService(typeof(IChatClient), "some-key");
// Assert
Assert.NotNull(result);
Assert.IsAssignableFrom<IChatClient>(result);
// Verify that the ChatClient's GetService was NOT called because IChatClient is handled by the agent itself
mockChatClient.Verify(c => c.GetService(typeof(IChatClient), "some-key"), Times.Never);
}
/// <summary>
/// Verify that GetService calls base.GetService() first but continues to underlying ChatClient when base returns null and it's not IChatClient or AIAgentMetadata.
/// </summary>
[Fact]
public void GetService_RequestingUnknownServiceWithServiceKey_CallsUnderlyingChatClient()
{
// Arrange
var mockChatClient = new Mock<IChatClient>();
mockChatClient.Setup(c => c.GetService(typeof(string), "some-key")).Returns("test-result");
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions"
});
// Act - Request string with a service key (base.GetService will return null due to serviceKey)
var result = agent.GetService(typeof(string), "some-key");
// Assert
Assert.NotNull(result);
Assert.Equal("test-result", result);
// Verify that the ChatClient's GetService was called after base.GetService() returned null
mockChatClient.Verify(c => c.GetService(typeof(string), "some-key"), Times.Once);
}
#endregion
#region RunStreamingAsync Tests
/// <summary>
@@ -0,0 +1,179 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net.Http;
using Microsoft.Agents.CopilotStudio.Client;
using Microsoft.Extensions.AI.Agents.CopilotStudio;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Microsoft.Extensions.AI.Agents.UnitTests;
/// <summary>
/// Unit tests for the <see cref="CopilotStudioAgent"/> class.
/// </summary>
public class CopilotStudioAgentTests
{
private CopilotClient CreateTestCopilotClient()
{
// Create mock dependencies for CopilotClient
var mockSettings = new Mock<ConnectionSettings>();
var mockHttpClientFactory = new Mock<IHttpClientFactory>();
var mockHttpClient = new Mock<HttpClient>();
mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(mockHttpClient.Object);
return new CopilotClient(mockSettings.Object, mockHttpClientFactory.Object, NullLogger.Instance, "test-client");
}
#region GetService Method Tests
/// <summary>
/// Verify that GetService returns CopilotClient when requested.
/// </summary>
[Fact]
public void GetService_RequestingCopilotClient_ReturnsCopilotClient()
{
// Arrange
var client = this.CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(CopilotClient));
// Assert
Assert.NotNull(result);
Assert.Same(client, result);
}
/// <summary>
/// Verify that GetService returns AIAgentMetadata when requested.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentMetadata_ReturnsMetadata()
{
// Arrange
var client = this.CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(AIAgentMetadata));
// Assert
Assert.NotNull(result);
Assert.IsType<AIAgentMetadata>(result);
var metadata = (AIAgentMetadata)result;
Assert.Equal("copilot-studio", metadata.ProviderName);
}
/// <summary>
/// Verify that GetService returns null for unknown service types.
/// </summary>
[Fact]
public void GetService_RequestingUnknownServiceType_ReturnsNull()
{
// Arrange
var client = this.CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(string));
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetService with serviceKey parameter returns null for unknown service types.
/// </summary>
[Fact]
public void GetService_WithServiceKey_ReturnsNull()
{
// Arrange
var client = this.CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(string), "test-key");
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetService calls base.GetService() first and returns the agent itself when requesting CopilotStudioAgent type.
/// </summary>
[Fact]
public void GetService_RequestingCopilotStudioAgentType_ReturnsBaseImplementation()
{
// Arrange
var client = this.CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(CopilotStudioAgent));
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
}
/// <summary>
/// Verify that GetService calls base.GetService() first and returns the agent itself when requesting AIAgent type.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentType_ReturnsBaseImplementation()
{
// Arrange
var client = this.CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result = agent.GetService(typeof(AIAgent));
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
}
/// <summary>
/// Verify that GetService calls base.GetService() first but continues to derived logic when base returns null.
/// </summary>
[Fact]
public void GetService_RequestingCopilotClientWithServiceKey_CallsBaseFirstThenDerivedLogic()
{
// Arrange
var client = this.CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act - Request CopilotClient with a service key (base.GetService will return null due to serviceKey)
var result = agent.GetService(typeof(CopilotClient), "some-key");
// Assert
Assert.NotNull(result);
Assert.Same(client, result);
}
/// <summary>
/// Verify that GetService returns consistent AIAgentMetadata across multiple calls.
/// </summary>
[Fact]
public void GetService_RequestingAIAgentMetadata_ReturnsConsistentMetadata()
{
// Arrange
var client = this.CreateTestCopilotClient();
var agent = new CopilotStudioAgent(client, NullLoggerFactory.Instance);
// Act
var result1 = agent.GetService(typeof(AIAgentMetadata));
var result2 = agent.GetService(typeof(AIAgentMetadata));
// Assert
Assert.NotNull(result1);
Assert.NotNull(result2);
Assert.Same(result1, result2); // Should return the same instance
Assert.IsType<AIAgentMetadata>(result1);
var metadata = (AIAgentMetadata)result1;
Assert.Equal("copilot-studio", metadata.ProviderName);
}
#endregion
}
@@ -6,6 +6,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Extensions.AI.Agents\Microsoft.Extensions.AI.Agents.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Extensions.AI.Agents.CopilotStudio\Microsoft.Extensions.AI.Agents.CopilotStudio.csproj" />
</ItemGroup>
<ItemGroup>
File diff suppressed because it is too large Load Diff