mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' of https://github.com/microsoft/agent-framework into issues/update-meai-9.8.0
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
+310
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
+213
@@ -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);
|
||||
}
|
||||
}
|
||||
+368
@@ -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
|
||||
}
|
||||
+1
@@ -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>
|
||||
|
||||
+1762
-67
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user