mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
merge with latest main
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# AGENTS.md
|
||||
|
||||
Instructions for AI coding agents working in the .NET codebase.
|
||||
|
||||
## Build, Test, and Lint Commands
|
||||
|
||||
```bash
|
||||
# From dotnet/ directory
|
||||
dotnet build # Build all projects
|
||||
dotnet test # Run all tests
|
||||
dotnet format # Auto-fix formatting
|
||||
|
||||
# Build/test a specific project (preferred for isolated changes)
|
||||
dotnet build src/Microsoft.Agents.AI.<Package>
|
||||
dotnet test tests/Microsoft.Agents.AI.<Package>.UnitTests
|
||||
|
||||
# Run a single test
|
||||
dotnet test --filter "FullyQualifiedName~TestClassName.TestMethodName"
|
||||
```
|
||||
|
||||
**Note**: Changes to core packages (`Microsoft.Agents.AI`, `Microsoft.Agents.AI.Abstractions`) affect dependent projects - run checks across the entire solution. For isolated changes, build/test only the affected project to save time.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
dotnet/
|
||||
├── src/
|
||||
│ ├── Microsoft.Agents.AI/ # Core AI agent abstractions
|
||||
│ ├── Microsoft.Agents.AI.Abstractions/ # Shared abstractions and interfaces
|
||||
│ ├── Microsoft.Agents.AI.OpenAI/ # OpenAI provider
|
||||
│ ├── Microsoft.Agents.AI.AzureAI/ # Azure AI provider
|
||||
│ ├── Microsoft.Agents.AI.Anthropic/ # Anthropic provider
|
||||
│ ├── Microsoft.Agents.AI.Workflows/ # Workflow orchestration
|
||||
│ └── ... # Other packages
|
||||
├── samples/ # Sample applications
|
||||
└── tests/ # Unit and integration tests
|
||||
```
|
||||
|
||||
### External Dependencies
|
||||
|
||||
The framework integrates with `Microsoft.Extensions.AI` and `Microsoft.Extensions.AI.Abstractions` (external NuGet packages) using types like `IChatClient`, `FunctionInvokingChatClient`, `AITool`, and `AIContent`.
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- **Copyright header**: `// Copyright (c) Microsoft. All rights reserved.` at top of all `.cs` files
|
||||
- **XML docs**: Required for all public methods and classes
|
||||
- **Async**: Use `Async` suffix for methods returning `Task`/`ValueTask`
|
||||
- **Private classes**: Should be `sealed` unless subclassed
|
||||
- **Config**: Read from environment variables with `UPPER_SNAKE_CASE` naming
|
||||
- **Tests**: Add Arrange/Act/Assert comments; use Moq for mocking
|
||||
|
||||
## Sample Structure
|
||||
|
||||
1. Copyright header: `// Copyright (c) Microsoft. All rights reserved.`
|
||||
2. Description comment explaining what the sample demonstrates
|
||||
3. Using statements
|
||||
4. Main code logic
|
||||
5. Helper methods at bottom
|
||||
|
||||
Configuration via environment variables (never hardcode secrets). Keep samples simple and focused.
|
||||
|
||||
When adding a new sample:
|
||||
- Create a standalone project in `samples/` with matching directory and project names
|
||||
- Include a README.md explaining what the sample does and how to run it
|
||||
- Add the project to the solution file
|
||||
- Reference the sample in the parent directory's README.md
|
||||
@@ -81,7 +81,7 @@
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Agent_Step07_3rdPartyThreadStorage.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step07_3rdPartyChatHistoryStorage/Agent_Step07_3rdPartyChatHistoryStorage.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step08_Observability/Agent_Step08_Observability.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Agent_Step09_DependencyInjection.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj" />
|
||||
@@ -131,6 +131,7 @@
|
||||
<Project Path="samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step01_Running/Agent_Anthropic_Step01_Running.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step02_Reasoning/Agent_Anthropic_Step02_Reasoning.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step03_UsingFunctionTools/Agent_Anthropic_Step03_UsingFunctionTools.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithAnthropic/Agent_Anthropic_Step04_UsingSkills/Agent_Anthropic_Step04_UsingSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/AgentWithMemory/">
|
||||
<File Path="samples/GettingStarted/AgentWithMemory/README.md" />
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.0.0</VersionPrefix>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260128.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260128.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.260128.1</GitTag>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260205.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260205.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.260205.1</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+20
-7
@@ -28,11 +28,21 @@ namespace SampleApp
|
||||
{
|
||||
public override string? Name => "UpperCaseParrotAgent";
|
||||
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new CustomAgentSession());
|
||||
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new CustomAgentSession(serializedSession, jsonSerializerOptions));
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (session is not CustomAgentSession typedSession)
|
||||
{
|
||||
throw new ArgumentException($"The provided session is not of type {nameof(CustomAgentSession)}.", nameof(session));
|
||||
}
|
||||
|
||||
return typedSession.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new CustomAgentSession(serializedState, jsonSerializerOptions));
|
||||
|
||||
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -45,14 +55,14 @@ namespace SampleApp
|
||||
}
|
||||
|
||||
// Get existing messages from the store
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(messages);
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(this, session, messages);
|
||||
var storeMessages = await typedSession.ChatHistoryProvider.InvokingAsync(invokingContext, cancellationToken);
|
||||
|
||||
// Clone the input messages and turn them into response messages with upper case text.
|
||||
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
|
||||
|
||||
// Notify the session of the input and output messages.
|
||||
var invokedContext = new ChatHistoryProvider.InvokedContext(messages, storeMessages)
|
||||
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, messages, storeMessages)
|
||||
{
|
||||
ResponseMessages = responseMessages
|
||||
};
|
||||
@@ -77,14 +87,14 @@ namespace SampleApp
|
||||
}
|
||||
|
||||
// Get existing messages from the store
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(messages);
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(this, session, messages);
|
||||
var storeMessages = await typedSession.ChatHistoryProvider.InvokingAsync(invokingContext, cancellationToken);
|
||||
|
||||
// Clone the input messages and turn them into response messages with upper case text.
|
||||
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
|
||||
|
||||
// Notify the session of the input and output messages.
|
||||
var invokedContext = new ChatHistoryProvider.InvokedContext(messages, storeMessages)
|
||||
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, messages, storeMessages)
|
||||
{
|
||||
ResponseMessages = responseMessages
|
||||
};
|
||||
@@ -136,6 +146,9 @@ namespace SampleApp
|
||||
|
||||
internal CustomAgentSession(JsonElement serializedSessionState, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
: base(serializedSessionState, jsonSerializerOptions) { }
|
||||
|
||||
internal new JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> base.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Anthropic\Microsoft.Agents.AI.Anthropic.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use Anthropic-managed Skills with an AI agent.
|
||||
// Skills are pre-built capabilities provided by Anthropic that can be used with the Claude API.
|
||||
// This sample shows how to:
|
||||
// 1. List available Anthropic-managed skills
|
||||
// 2. Use the pptx skill to create PowerPoint presentations
|
||||
// 3. Download and save generated files
|
||||
|
||||
using Anthropic;
|
||||
using Anthropic.Core;
|
||||
using Anthropic.Models.Beta;
|
||||
using Anthropic.Models.Beta.Files;
|
||||
using Anthropic.Models.Beta.Messages;
|
||||
using Anthropic.Models.Beta.Skills;
|
||||
using Anthropic.Services;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
string apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("ANTHROPIC_API_KEY is not set.");
|
||||
// Skills require Claude 4.5 models (Sonnet 4.5, Haiku 4.5, or Opus 4.5)
|
||||
string model = Environment.GetEnvironmentVariable("ANTHROPIC_MODEL") ?? "claude-sonnet-4-5-20250929";
|
||||
|
||||
// Create the Anthropic client
|
||||
AnthropicClient anthropicClient = new() { ApiKey = apiKey };
|
||||
|
||||
// List available Anthropic-managed skills (optional - API may not be available in all regions)
|
||||
Console.WriteLine("Available Anthropic-managed skills:");
|
||||
try
|
||||
{
|
||||
SkillListPage skills = await anthropicClient.Beta.Skills.List(
|
||||
new SkillListParams { Source = "anthropic", Betas = [AnthropicBeta.Skills2025_10_02] });
|
||||
|
||||
foreach (var skill in skills.Items)
|
||||
{
|
||||
Console.WriteLine($" {skill.Source}: {skill.ID} (version: {skill.LatestVersion})");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" (Skills listing not available: {ex.Message})");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
// Define the pptx skill - the SDK handles all beta flags and container configuration automatically
|
||||
// when using AsAITool(), so no manual RawRepresentationFactory configuration is needed.
|
||||
BetaSkillParams pptxSkill = new()
|
||||
{
|
||||
Type = BetaSkillParamsType.Anthropic,
|
||||
SkillID = "pptx",
|
||||
Version = "latest"
|
||||
};
|
||||
|
||||
// Create an agent with the pptx skill enabled.
|
||||
// Skills require extended thinking and higher max tokens for complex file generation.
|
||||
// The SDK's AsAITool() handles beta flags and container config automatically.
|
||||
ChatClientAgent agent = anthropicClient.Beta.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a helpful agent for creating PowerPoint presentations.",
|
||||
tools: [pptxSkill.AsAITool()],
|
||||
clientFactory: (chatClient) => chatClient
|
||||
.AsBuilder()
|
||||
.ConfigureOptions(options =>
|
||||
{
|
||||
options.RawRepresentationFactory = (_) => new MessageCreateParams()
|
||||
{
|
||||
Model = model,
|
||||
MaxTokens = 20000,
|
||||
Messages = [],
|
||||
Thinking = new BetaThinkingConfigParam(
|
||||
new BetaThinkingConfigEnabled(budgetTokens: 10000))
|
||||
};
|
||||
})
|
||||
.Build());
|
||||
|
||||
Console.WriteLine("Creating a presentation about renewable energy...\n");
|
||||
|
||||
// Run the agent with a request to create a presentation
|
||||
AgentResponse response = await agent.RunAsync("Create a simple 3-slide presentation about renewable energy sources. Include a title slide, a slide about solar energy, and a slide about wind energy.");
|
||||
|
||||
Console.WriteLine("#### Agent Response ####");
|
||||
Console.WriteLine(response.Text);
|
||||
|
||||
// Display any reasoning/thinking content
|
||||
List<TextReasoningContent> reasoningContents = response.Messages.SelectMany(m => m.Contents.OfType<TextReasoningContent>()).ToList();
|
||||
if (reasoningContents.Count > 0)
|
||||
{
|
||||
Console.WriteLine("\n#### Agent Reasoning ####");
|
||||
Console.WriteLine($"\e[92m{string.Join("\n", reasoningContents.Select(c => c.Text))}\e[0m");
|
||||
}
|
||||
|
||||
// Collect generated files from CodeInterpreterToolResultContent outputs
|
||||
List<HostedFileContent> hostedFiles = response.Messages
|
||||
.SelectMany(m => m.Contents.OfType<CodeInterpreterToolResultContent>())
|
||||
.Where(c => c.Outputs is not null)
|
||||
.SelectMany(c => c.Outputs!.OfType<HostedFileContent>())
|
||||
.ToList();
|
||||
|
||||
if (hostedFiles.Count > 0)
|
||||
{
|
||||
Console.WriteLine("\n#### Generated Files ####");
|
||||
foreach (HostedFileContent file in hostedFiles)
|
||||
{
|
||||
Console.WriteLine($" FileId: {file.FileId}");
|
||||
|
||||
// Download the file using the Anthropic Files API
|
||||
using HttpResponse fileResponse = await anthropicClient.Beta.Files.Download(
|
||||
file.FileId,
|
||||
new FileDownloadParams { Betas = ["files-api-2025-04-14"] });
|
||||
|
||||
// Save the file to disk
|
||||
string fileName = $"presentation_{file.FileId.Substring(0, 8)}.pptx";
|
||||
using FileStream fileStream = File.Create(fileName);
|
||||
Stream contentStream = await fileResponse.ReadAsStream();
|
||||
await contentStream.CopyToAsync(fileStream);
|
||||
|
||||
Console.WriteLine($" Saved to: {fileName}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("\nToken usage:");
|
||||
Console.WriteLine($"Input: {response.Usage?.InputTokenCount}, Output: {response.Usage?.OutputTokenCount}");
|
||||
if (response.Usage?.AdditionalCounts is not null)
|
||||
{
|
||||
Console.WriteLine($"Additional: {string.Join(", ", response.Usage.AdditionalCounts)}");
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# Using Anthropic Skills with agents
|
||||
|
||||
This sample demonstrates how to use Anthropic-managed Skills with AI agents. Skills are pre-built capabilities provided by Anthropic that can be used with the Claude API.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Listing available Anthropic-managed skills
|
||||
- Creating an AI agent with Anthropic Claude Skills support using the simplified `AsAITool()` approach
|
||||
- Using the pptx skill to create PowerPoint presentations
|
||||
- Downloading and saving generated files to disk
|
||||
- Handling agent responses with generated content
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10.0 SDK or later
|
||||
- Anthropic API key configured
|
||||
- Access to Anthropic Claude models with Skills support
|
||||
|
||||
**Note**: This sample uses Anthropic Claude models with Skills. Skills are a beta feature. For more information, see [Anthropic documentation](https://docs.anthropic.com/).
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:ANTHROPIC_API_KEY="your-anthropic-api-key" # Replace with your Anthropic API key
|
||||
$env:ANTHROPIC_MODEL="your-anthropic-model" # Replace with your Anthropic model (e.g., claude-sonnet-4-5-20250929)
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
Navigate to the AgentWithAnthropic sample directory and run:
|
||||
|
||||
```powershell
|
||||
cd dotnet\samples\GettingStarted\AgentWithAnthropic
|
||||
dotnet run --project .\Agent_Anthropic_Step04_UsingSkills
|
||||
```
|
||||
|
||||
## Available Anthropic Skills
|
||||
|
||||
Anthropic provides several managed skills that can be used with the Claude API:
|
||||
|
||||
- `pptx` - Create PowerPoint presentations
|
||||
- `xlsx` - Create Excel spreadsheets
|
||||
- `docx` - Create Word documents
|
||||
- `pdf` - Create and analyze PDF documents
|
||||
|
||||
You can list available skills using the Anthropic SDK:
|
||||
|
||||
```csharp
|
||||
SkillListPage skills = await anthropicClient.Beta.Skills.List(
|
||||
new SkillListParams { Source = "anthropic", Betas = [AnthropicBeta.Skills2025_10_02] });
|
||||
|
||||
foreach (var skill in skills.Items)
|
||||
{
|
||||
Console.WriteLine($"{skill.Source}: {skill.ID} (version: {skill.LatestVersion})");
|
||||
}
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
The sample will:
|
||||
|
||||
1. List all available Anthropic-managed skills
|
||||
2. Create an agent with the pptx skill enabled
|
||||
3. Run the agent with a request to create a presentation
|
||||
4. Display the agent's response text
|
||||
5. Download any generated files and save them to disk
|
||||
6. Display token usage statistics
|
||||
|
||||
## Code highlights
|
||||
|
||||
### Simplified skill configuration
|
||||
|
||||
The Anthropic SDK handles all beta flags and container configuration automatically when using `AsAITool()`:
|
||||
|
||||
```csharp
|
||||
// Define the pptx skill
|
||||
BetaSkillParams pptxSkill = new()
|
||||
{
|
||||
Type = BetaSkillParamsType.Anthropic,
|
||||
SkillID = "pptx",
|
||||
Version = "latest"
|
||||
};
|
||||
|
||||
// Create an agent - the SDK handles beta flags automatically!
|
||||
ChatClientAgent agent = anthropicClient.Beta.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a helpful agent for creating PowerPoint presentations.",
|
||||
tools: [pptxSkill.AsAITool()]);
|
||||
```
|
||||
|
||||
**Note**: No manual `RawRepresentationFactory`, `Betas`, or `Container` configuration is needed. The SDK automatically adds the required beta headers (`skills-2025-10-02`, `code-execution-2025-08-25`) and configures the container with the skill.
|
||||
|
||||
### Handling generated files
|
||||
|
||||
Generated files are returned as `HostedFileContent` within `CodeInterpreterToolResultContent`:
|
||||
|
||||
```csharp
|
||||
// Collect generated files from response
|
||||
List<HostedFileContent> hostedFiles = response.Messages
|
||||
.SelectMany(m => m.Contents.OfType<CodeInterpreterToolResultContent>())
|
||||
.Where(c => c.Outputs is not null)
|
||||
.SelectMany(c => c.Outputs!.OfType<HostedFileContent>())
|
||||
.ToList();
|
||||
|
||||
// Download and save each file
|
||||
foreach (HostedFileContent file in hostedFiles)
|
||||
{
|
||||
using HttpResponse fileResponse = await anthropicClient.Beta.Files.Download(
|
||||
file.FileId,
|
||||
new FileDownloadParams { Betas = ["files-api-2025-04-14"] });
|
||||
|
||||
string fileName = $"presentation_{file.FileId.Substring(0, 8)}.pptx";
|
||||
await using FileStream fileStream = File.Create(fileName);
|
||||
Stream contentStream = await fileResponse.ReadAsStream();
|
||||
await contentStream.CopyToAsync(fileStream);
|
||||
}
|
||||
```
|
||||
@@ -29,6 +29,7 @@ To use Anthropic with Azure Foundry, you can check the sample [AgentProviders/Ag
|
||||
|[Running a simple agent](./Agent_Anthropic_Step01_Running/)|This sample demonstrates how to create and run a basic agent with Anthropic Claude|
|
||||
|[Using reasoning with an agent](./Agent_Anthropic_Step02_Reasoning/)|This sample demonstrates how to use extended thinking/reasoning capabilities with Anthropic Claude agents|
|
||||
|[Using function tools with an agent](./Agent_Anthropic_Step03_UsingFunctionTools/)|This sample demonstrates how to use function tools with an Anthropic Claude agent|
|
||||
|[Using Skills with an agent](./Agent_Anthropic_Step04_UsingSkills/)|This sample demonstrates how to use Anthropic-managed Skills (e.g., pptx) with an Anthropic Claude agent|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
Console.WriteLine(await agent.RunAsync("What do you already know about my upcoming trip?", session));
|
||||
|
||||
Console.WriteLine("\n>> Serialize and deserialize the session to demonstrate persisted state\n");
|
||||
JsonElement serializedSession = session.Serialize();
|
||||
JsonElement serializedSession = agent.SerializeSession(session);
|
||||
AgentSession restoredSession = await agent.DeserializeSessionAsync(serializedSession);
|
||||
Console.WriteLine(await agent.RunAsync("Can you recap the personal details you remember?", restoredSession));
|
||||
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ Console.WriteLine(await agent.RunAsync("My name is Ruaidhrí", session));
|
||||
Console.WriteLine(await agent.RunAsync("I am 20 years old", session));
|
||||
|
||||
// We can serialize the session. The serialized state will include the state of the memory component.
|
||||
var sesionElement = session.Serialize();
|
||||
JsonElement sesionElement = agent.SerializeSession(session);
|
||||
|
||||
Console.WriteLine("\n>> Use deserialized session with previously created memories\n");
|
||||
|
||||
|
||||
+11
-13
@@ -29,36 +29,34 @@ AIAgent agent = new AzureOpenAIClient(
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(instructions: "You are a helpful assistant", tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))]);
|
||||
|
||||
// Call the agent and check if there are any user input requests to handle.
|
||||
// Call the agent and check if there are any function approval requests to handle.
|
||||
// For simplicity, we are assuming here that only function approvals are pending.
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
var response = await agent.RunAsync("What is the weather like in Amsterdam?", session);
|
||||
var userInputRequests = response.UserInputRequests.ToList();
|
||||
AgentResponse response = await agent.RunAsync("What is the weather like in Amsterdam?", session);
|
||||
List<FunctionApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
|
||||
// For streaming use:
|
||||
// var updates = await agent.RunStreamingAsync("What is the weather like in Amsterdam?", session).ToListAsync();
|
||||
// userInputRequests = updates.SelectMany(x => x.UserInputRequests).ToList();
|
||||
// approvalRequests = updates.SelectMany(x => x.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
|
||||
while (userInputRequests.Count > 0)
|
||||
while (approvalRequests.Count > 0)
|
||||
{
|
||||
// Ask the user to approve each function call request.
|
||||
// For simplicity, we are assuming here that only function approval requests are being made.
|
||||
var userInputResponses = userInputRequests
|
||||
.OfType<FunctionApprovalRequestContent>()
|
||||
.Select(functionApprovalRequest =>
|
||||
List<ChatMessage> userInputResponses = approvalRequests
|
||||
.ConvertAll(functionApprovalRequest =>
|
||||
{
|
||||
Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {functionApprovalRequest.FunctionCall.Name}");
|
||||
return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]);
|
||||
})
|
||||
.ToList();
|
||||
});
|
||||
|
||||
// Pass the user input responses back to the agent for further processing.
|
||||
response = await agent.RunAsync(userInputResponses, session);
|
||||
|
||||
userInputRequests = response.UserInputRequests.ToList();
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
|
||||
// For streaming use:
|
||||
// updates = await agent.RunStreamingAsync(userInputResponses, session).ToListAsync();
|
||||
// userInputRequests = updates.SelectMany(x => x.UserInputRequests).ToList();
|
||||
// approvalRequests = updates.SelectMany(x => x.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nAgent: {response}");
|
||||
|
||||
@@ -25,7 +25,7 @@ AgentSession session = await agent.CreateSessionAsync();
|
||||
Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session));
|
||||
|
||||
// Serialize the session state to a JsonElement, so it can be stored for later use.
|
||||
JsonElement serializedSession = session.Serialize();
|
||||
JsonElement serializedSession = agent.SerializeSession(session);
|
||||
|
||||
// Save the serialized session to a temporary file (for demonstration purposes).
|
||||
string tempFilePath = Path.GetTempFileName();
|
||||
|
||||
+4
-2
@@ -2,7 +2,9 @@
|
||||
|
||||
#pragma warning disable CA1869 // Cache and reuse 'JsonSerializerOptions' instances
|
||||
|
||||
// This sample shows how to create and use a simple AI agent with a conversation that can be persisted to disk.
|
||||
// This sample shows how to create and use a simple AI agent with custom ChatHistoryProvider that stores chat history in a custom storage location.
|
||||
// The state of the custom ChatHistoryProvider (SessionDbKey) is stored with the agent session, so that when the session is resumed later,
|
||||
// the chat history can be retrieved from the custom storage location.
|
||||
|
||||
using System.Text.Json;
|
||||
using Azure.AI.OpenAI;
|
||||
@@ -47,7 +49,7 @@ Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session
|
||||
// Serialize the session state, so it can be stored for later use.
|
||||
// Since the chat history is stored in the vector store, the serialized session
|
||||
// only contains the guid that the messages are stored under in the vector store.
|
||||
JsonElement serializedSession = session.Serialize();
|
||||
JsonElement serializedSession = agent.SerializeSession(session);
|
||||
|
||||
Console.WriteLine("\n--- Serialized session ---\n");
|
||||
Console.WriteLine(JsonSerializer.Serialize(serializedSession, new JsonSerializerOptions { WriteIndented = true }));
|
||||
+3
-3
@@ -40,7 +40,7 @@ AgentResponse response = await agent.RunAsync("Write a very long novel about a t
|
||||
// Poll for background responses until complete.
|
||||
while (response.ContinuationToken is not null)
|
||||
{
|
||||
PersistAgentState(session, response.ContinuationToken);
|
||||
PersistAgentState(agent, session, response.ContinuationToken);
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(10));
|
||||
|
||||
@@ -52,9 +52,9 @@ while (response.ContinuationToken is not null)
|
||||
|
||||
Console.WriteLine(response.Text);
|
||||
|
||||
void PersistAgentState(AgentSession? session, ResponseContinuationToken? continuationToken)
|
||||
void PersistAgentState(AIAgent agent, AgentSession? session, ResponseContinuationToken? continuationToken)
|
||||
{
|
||||
stateStore["session"] = session!.Serialize();
|
||||
stateStore["session"] = agent.SerializeSession(session!);
|
||||
stateStore["continuationToken"] = JsonSerializer.SerializeToElement(continuationToken, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
|
||||
}
|
||||
|
||||
|
||||
@@ -210,28 +210,25 @@ async Task<AgentResponse> GuardrailMiddleware(IEnumerable<ChatMessage> messages,
|
||||
// This middleware handles Human in the loop console interaction for any user approval required during function calling.
|
||||
async Task<AgentResponse> ConsolePromptingApprovalMiddleware(IEnumerable<ChatMessage> messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await innerAgent.RunAsync(messages, session, options, cancellationToken);
|
||||
AgentResponse response = await innerAgent.RunAsync(messages, session, options, cancellationToken);
|
||||
|
||||
var userInputRequests = response.UserInputRequests.ToList();
|
||||
// For simplicity, we are assuming here that only function approvals are pending.
|
||||
List<FunctionApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
|
||||
while (userInputRequests.Count > 0)
|
||||
while (approvalRequests.Count > 0)
|
||||
{
|
||||
// Ask the user to approve each function call request.
|
||||
// For simplicity, we are assuming here that only function approval requests are being made.
|
||||
|
||||
// Pass the user input responses back to the agent for further processing.
|
||||
response.Messages = userInputRequests
|
||||
.OfType<FunctionApprovalRequestContent>()
|
||||
.Select(functionApprovalRequest =>
|
||||
response.Messages = approvalRequests
|
||||
.ConvertAll(functionApprovalRequest =>
|
||||
{
|
||||
Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {functionApprovalRequest.FunctionCall.Name}");
|
||||
return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]);
|
||||
})
|
||||
.ToList();
|
||||
});
|
||||
|
||||
response = await innerAgent.RunAsync(response.Messages, session, options, cancellationToken);
|
||||
|
||||
userInputRequests = response.UserInputRequests.ToList();
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -65,7 +65,7 @@ Console.WriteLine(await agent.RunAsync("I need to make a dentist appointment for
|
||||
Console.WriteLine(await agent.RunAsync("I've taken Sally to soccer practice.", session) + "\n");
|
||||
|
||||
// We can serialize the session, and it will contain both the chat history and the data that each AI context provider serialized.
|
||||
JsonElement serializedSession = session.Serialize();
|
||||
JsonElement serializedSession = agent.SerializeSession(session);
|
||||
// Let's print it to console to show the contents.
|
||||
Console.WriteLine(JsonSerializer.Serialize(serializedSession, options: new JsonSerializerOptions() { WriteIndented = true, IndentSize = 2 }) + "\n");
|
||||
// The serialized session can be stored long term in a persistent store, but in this case we will just deserialize again and continue the conversation.
|
||||
|
||||
@@ -33,7 +33,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Using function tools with approvals](./Agent_Step04_UsingFunctionToolsWithApprovals/)|This sample demonstrates how to use function tools where approvals require human in the loop approvals before execution|
|
||||
|[Structured output with a simple agent](./Agent_Step05_StructuredOutput/)|This sample demonstrates how to use structured output with a simple agent|
|
||||
|[Persisted conversations with a simple agent](./Agent_Step06_PersistedConversations/)|This sample demonstrates how to persist conversations and reload them later. This is useful for cases where an agent is hosted in a stateless service|
|
||||
|[3rd party thread storage with a simple agent](./Agent_Step07_3rdPartyThreadStorage/)|This sample demonstrates how to store conversation history in a 3rd party storage solution|
|
||||
|[3rd party chat history storage with a simple agent](./Agent_Step07_3rdPartyChatHistoryStorage/)|This sample demonstrates how to store chat history in a 3rd party storage solution|
|
||||
|[Observability with a simple agent](./Agent_Step08_Observability/)|This sample demonstrates how to add telemetry to a simple agent|
|
||||
|[Dependency injection with a simple agent](./Agent_Step09_DependencyInjection/)|This sample demonstrates how to add and resolve an agent with a dependency injection container|
|
||||
|[Exposing a simple agent as MCP tool](./Agent_Step10_AsMcpTool/)|This sample demonstrates how to expose an agent as an MCP tool|
|
||||
|
||||
+8
-10
@@ -35,27 +35,25 @@ AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, mo
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
AgentResponse response = await agent.RunAsync("What is the weather like in Amsterdam?", session);
|
||||
|
||||
// Check if there are any user input requests (approvals needed).
|
||||
List<UserInputRequestContent> userInputRequests = response.UserInputRequests.ToList();
|
||||
// Check if there are any approval requests.
|
||||
// For simplicity, we are assuming here that only function approvals are pending.
|
||||
List<FunctionApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
|
||||
while (userInputRequests.Count > 0)
|
||||
while (approvalRequests.Count > 0)
|
||||
{
|
||||
// Ask the user to approve each function call request.
|
||||
// For simplicity, we are assuming here that only function approval requests are being made.
|
||||
List<ChatMessage> userInputMessages = userInputRequests
|
||||
.OfType<FunctionApprovalRequestContent>()
|
||||
.Select(functionApprovalRequest =>
|
||||
List<ChatMessage> userInputMessages = approvalRequests
|
||||
.ConvertAll(functionApprovalRequest =>
|
||||
{
|
||||
Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {functionApprovalRequest.FunctionCall.Name}");
|
||||
bool approved = Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false;
|
||||
return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved)]);
|
||||
})
|
||||
.ToList();
|
||||
});
|
||||
|
||||
// Pass the user input responses back to the agent for further processing.
|
||||
response = await agent.RunAsync(userInputMessages, session);
|
||||
|
||||
userInputRequests = response.UserInputRequests.ToList();
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nAgent: {response}");
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ AgentSession session = await agent.CreateSessionAsync();
|
||||
Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session));
|
||||
|
||||
// Serialize the session state to a JsonElement, so it can be stored for later use.
|
||||
JsonElement serializedSession = session.Serialize();
|
||||
JsonElement serializedSession = agent.SerializeSession(session);
|
||||
|
||||
// Save the serialized session to a temporary file (for demonstration purposes).
|
||||
string tempFilePath = Path.GetTempFileName();
|
||||
|
||||
+7
-10
@@ -193,27 +193,24 @@ async Task<AgentResponse> ConsolePromptingApprovalMiddleware(IEnumerable<ChatMes
|
||||
{
|
||||
AgentResponse response = await innerAgent.RunAsync(messages, session, options, cancellationToken);
|
||||
|
||||
List<UserInputRequestContent> userInputRequests = response.UserInputRequests.ToList();
|
||||
// For simplicity, we are assuming here that only function approvals are pending.
|
||||
List<FunctionApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
|
||||
while (userInputRequests.Count > 0)
|
||||
while (approvalRequests.Count > 0)
|
||||
{
|
||||
// Ask the user to approve each function call request.
|
||||
// For simplicity, we are assuming here that only function approval requests are being made.
|
||||
|
||||
// Pass the user input responses back to the agent for further processing.
|
||||
response.Messages = userInputRequests
|
||||
.OfType<FunctionApprovalRequestContent>()
|
||||
.Select(functionApprovalRequest =>
|
||||
response.Messages = approvalRequests
|
||||
.ConvertAll(functionApprovalRequest =>
|
||||
{
|
||||
Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {functionApprovalRequest.FunctionCall.Name}");
|
||||
bool approved = Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false;
|
||||
return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved)]);
|
||||
})
|
||||
.ToList();
|
||||
});
|
||||
|
||||
response = await innerAgent.RunAsync(response.Messages, session, options, cancellationToken);
|
||||
|
||||
userInputRequests = response.UserInputRequests.ToList();
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>().ToList();
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
+9
-11
@@ -75,17 +75,16 @@ AIAgent agentWithRequiredApproval = await persistentAgentsClient.CreateAIAgentAs
|
||||
});
|
||||
|
||||
// You can then invoke the agent like any other AIAgent.
|
||||
var sessionWithRequiredApproval = await agentWithRequiredApproval.CreateSessionAsync();
|
||||
var response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", sessionWithRequiredApproval);
|
||||
var userInputRequests = response.UserInputRequests.ToList();
|
||||
// For simplicity, we are assuming here that only mcp tool approvals are pending.
|
||||
AgentSession sessionWithRequiredApproval = await agentWithRequiredApproval.CreateSessionAsync();
|
||||
AgentResponse response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", sessionWithRequiredApproval);
|
||||
List<McpServerToolApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolApprovalRequestContent>().ToList();
|
||||
|
||||
while (userInputRequests.Count > 0)
|
||||
while (approvalRequests.Count > 0)
|
||||
{
|
||||
// Ask the user to approve each MCP call request.
|
||||
// For simplicity, we are assuming here that only MCP approval requests are being made.
|
||||
var userInputResponses = userInputRequests
|
||||
.OfType<McpServerToolApprovalRequestContent>()
|
||||
.Select(approvalRequest =>
|
||||
List<ChatMessage> userInputResponses = approvalRequests
|
||||
.ConvertAll(approvalRequest =>
|
||||
{
|
||||
Console.WriteLine($"""
|
||||
The agent would like to invoke the following MCP Tool, please reply Y to approve.
|
||||
@@ -94,13 +93,12 @@ while (userInputRequests.Count > 0)
|
||||
Arguments: {string.Join(", ", approvalRequest.ToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])}
|
||||
""");
|
||||
return new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]);
|
||||
})
|
||||
.ToList();
|
||||
});
|
||||
|
||||
// Pass the user input responses back to the agent for further processing.
|
||||
response = await agentWithRequiredApproval.RunAsync(userInputResponses, sessionWithRequiredApproval);
|
||||
|
||||
userInputRequests = response.UserInputRequests.ToList();
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolApprovalRequestContent>().ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nAgent: {response}");
|
||||
|
||||
+9
-11
@@ -64,17 +64,16 @@ AIAgent agentWithRequiredApproval = new AzureOpenAIClient(
|
||||
tools: [mcpToolWithApproval]);
|
||||
|
||||
// You can then invoke the agent like any other AIAgent.
|
||||
var sessionWithRequiredApproval = await agentWithRequiredApproval.CreateSessionAsync();
|
||||
var response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", sessionWithRequiredApproval);
|
||||
var userInputRequests = response.UserInputRequests.ToList();
|
||||
// For simplicity, we are assuming here that only mcp tool approvals are pending.
|
||||
AgentSession sessionWithRequiredApproval = await agentWithRequiredApproval.CreateSessionAsync();
|
||||
AgentResponse response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", sessionWithRequiredApproval);
|
||||
List<McpServerToolApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolApprovalRequestContent>().ToList();
|
||||
|
||||
while (userInputRequests.Count > 0)
|
||||
while (approvalRequests.Count > 0)
|
||||
{
|
||||
// Ask the user to approve each MCP call request.
|
||||
// For simplicity, we are assuming here that only MCP approval requests are being made.
|
||||
var userInputResponses = userInputRequests
|
||||
.OfType<McpServerToolApprovalRequestContent>()
|
||||
.Select(approvalRequest =>
|
||||
List<ChatMessage> userInputResponses = approvalRequests
|
||||
.ConvertAll(approvalRequest =>
|
||||
{
|
||||
Console.WriteLine($"""
|
||||
The agent would like to invoke the following MCP Tool, please reply Y to approve.
|
||||
@@ -83,13 +82,12 @@ while (userInputRequests.Count > 0)
|
||||
Arguments: {string.Join(", ", approvalRequest.ToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])}
|
||||
""");
|
||||
return new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]);
|
||||
})
|
||||
.ToList();
|
||||
});
|
||||
|
||||
// Pass the user input responses back to the agent for further processing.
|
||||
response = await agentWithRequiredApproval.RunAsync(userInputResponses, sessionWithRequiredApproval);
|
||||
|
||||
userInputRequests = response.UserInputRequests.ToList();
|
||||
approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolApprovalRequestContent>().ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nAgent: {response}");
|
||||
|
||||
@@ -72,8 +72,8 @@ public static class Program
|
||||
/// <summary>
|
||||
/// Executor that starts the concurrent processing by sending messages to the agents.
|
||||
/// </summary>
|
||||
internal sealed class ConcurrentStartExecutor() :
|
||||
Executor<string>("ConcurrentStartExecutor")
|
||||
internal sealed partial class ConcurrentStartExecutor() :
|
||||
Executor("ConcurrentStartExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts the concurrent processing by sending messages to the agents.
|
||||
@@ -83,7 +83,8 @@ internal sealed class ConcurrentStartExecutor() :
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task representing the asynchronous operation</returns>
|
||||
public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
[MessageHandler]
|
||||
public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Broadcast the message to all connected agents. Receiving agents will queue
|
||||
// the message but will not start processing until they receive a turn token.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project>
|
||||
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
|
||||
<!-- Include Workflows source generator for samples using [MessageHandler] attribute -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="$(RepoRoot)/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false"
|
||||
GlobalPropertiesToRemove="TargetFramework" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -80,7 +80,7 @@ internal sealed class AFAgentApplication : AgentApplication
|
||||
}
|
||||
|
||||
// Serialize and save the updated conversation history back to turn state.
|
||||
JsonElement sessionElementEnd = agentSession.Serialize(JsonUtilities.DefaultOptions);
|
||||
JsonElement sessionElementEnd = this._agent.SerializeSession(agentSession, JsonUtilities.DefaultOptions);
|
||||
turnState.SetValue("conversation.chatHistory", sessionElementEnd);
|
||||
|
||||
// End the streaming response
|
||||
@@ -131,58 +131,54 @@ internal sealed class AFAgentApplication : AgentApplication
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the agent returns any user input requests, this method converts them into adaptive cards that
|
||||
/// When the agent returns any function approval requests, this method converts them into adaptive cards that
|
||||
/// asks the user to approve or deny the requests.
|
||||
/// </summary>
|
||||
/// <param name="response">The <see cref="AgentResponse"/> that may contain the user input requests.</param>
|
||||
/// <param name="response">The <see cref="AgentResponse"/> that may contain the function approval requests.</param>
|
||||
/// <param name="attachments">The list of <see cref="Attachment"/> to which the adaptive cards will be added.</param>
|
||||
private static void HandleUserInputRequests(AgentResponse response, ref List<Attachment>? attachments)
|
||||
{
|
||||
var userInputRequests = response.UserInputRequests.ToList();
|
||||
if (userInputRequests.Count > 0)
|
||||
foreach (FunctionApprovalRequestContent functionApprovalRequest in response.Messages.SelectMany(m => m.Contents).OfType<FunctionApprovalRequestContent>())
|
||||
{
|
||||
foreach (var functionApprovalRequest in userInputRequests.OfType<FunctionApprovalRequestContent>())
|
||||
var functionApprovalRequestJson = JsonSerializer.Serialize(functionApprovalRequest, JsonUtilities.DefaultOptions);
|
||||
|
||||
var card = new AdaptiveCard("1.5");
|
||||
card.Body.Add(new AdaptiveTextBlock
|
||||
{
|
||||
var functionApprovalRequestJson = JsonSerializer.Serialize(functionApprovalRequest, JsonUtilities.DefaultOptions);
|
||||
Text = "Function Call Approval Required",
|
||||
Size = AdaptiveTextSize.Large,
|
||||
Weight = AdaptiveTextWeight.Bolder,
|
||||
HorizontalAlignment = AdaptiveHorizontalAlignment.Center
|
||||
});
|
||||
card.Body.Add(new AdaptiveTextBlock
|
||||
{
|
||||
Text = $"Function: {functionApprovalRequest.FunctionCall.Name}"
|
||||
});
|
||||
card.Body.Add(new AdaptiveActionSet()
|
||||
{
|
||||
Actions =
|
||||
[
|
||||
new AdaptiveSubmitAction
|
||||
{
|
||||
Id = "Approve",
|
||||
Title = "Approve",
|
||||
Data = new { type = "functionApproval", approved = true, requestJson = functionApprovalRequestJson }
|
||||
},
|
||||
new AdaptiveSubmitAction
|
||||
{
|
||||
Id = "Deny",
|
||||
Title = "Deny",
|
||||
Data = new { type = "functionApproval", approved = false, requestJson = functionApprovalRequestJson }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
var card = new AdaptiveCard("1.5");
|
||||
card.Body.Add(new AdaptiveTextBlock
|
||||
{
|
||||
Text = "Function Call Approval Required",
|
||||
Size = AdaptiveTextSize.Large,
|
||||
Weight = AdaptiveTextWeight.Bolder,
|
||||
HorizontalAlignment = AdaptiveHorizontalAlignment.Center
|
||||
});
|
||||
card.Body.Add(new AdaptiveTextBlock
|
||||
{
|
||||
Text = $"Function: {functionApprovalRequest.FunctionCall.Name}"
|
||||
});
|
||||
card.Body.Add(new AdaptiveActionSet()
|
||||
{
|
||||
Actions =
|
||||
[
|
||||
new AdaptiveSubmitAction
|
||||
{
|
||||
Id = "Approve",
|
||||
Title = "Approve",
|
||||
Data = new { type = "functionApproval", approved = true, requestJson = functionApprovalRequestJson }
|
||||
},
|
||||
new AdaptiveSubmitAction
|
||||
{
|
||||
Id = "Deny",
|
||||
Title = "Deny",
|
||||
Data = new { type = "functionApproval", approved = false, requestJson = functionApprovalRequestJson }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
attachments ??= [];
|
||||
attachments.Add(new Attachment()
|
||||
{
|
||||
ContentType = "application/vnd.microsoft.card.adaptive",
|
||||
Content = card.ToJson(),
|
||||
});
|
||||
}
|
||||
attachments ??= [];
|
||||
attachments.Add(new Attachment()
|
||||
{
|
||||
ContentType = "application/vnd.microsoft.card.adaptive",
|
||||
Content = card.ToJson(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class A2AAgent : AIAgent
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public sealed override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
|
||||
protected sealed override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new A2AAgentSession());
|
||||
|
||||
/// <summary>
|
||||
@@ -66,8 +66,21 @@ public sealed class A2AAgent : AIAgent
|
||||
=> new(new A2AAgentSession() { ContextId = contextId });
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new A2AAgentSession(serializedSession, jsonSerializerOptions));
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
_ = Throw.IfNull(session);
|
||||
|
||||
if (session is not A2AAgentSession typedSession)
|
||||
{
|
||||
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
|
||||
}
|
||||
|
||||
return typedSession.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new A2AAgentSession(serializedState, jsonSerializerOptions));
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -46,7 +46,7 @@ public sealed class A2AAgentSession : AgentSession
|
||||
public string? TaskId { get; internal set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
var state = new A2AAgentSessionState
|
||||
{
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -22,6 +24,8 @@ namespace Microsoft.Agents.AI;
|
||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||
public abstract partial class AIAgent
|
||||
{
|
||||
private static readonly AsyncLocal<AgentRunContext?> s_currentContext = new();
|
||||
|
||||
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
|
||||
private string DebuggerDisplay =>
|
||||
this.Name is { } name ? $"Id = {this.Id}, Name = {name}" : $"Id = {this.Id}";
|
||||
@@ -76,6 +80,18 @@ public abstract partial class AIAgent
|
||||
/// </remarks>
|
||||
public virtual string? Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="AgentRunContext"/> for the current agent run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This value flows across async calls.
|
||||
/// </remarks>
|
||||
public static AgentRunContext? CurrentRunContext
|
||||
{
|
||||
get => s_currentContext.Value;
|
||||
protected set => s_currentContext.Value = value;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -123,23 +139,74 @@ public abstract partial class AIAgent
|
||||
/// may be deferred until first use to optimize performance.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default);
|
||||
public ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
|
||||
=> this.CreateSessionCoreAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Core implementation of session creation logic.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A value task that represents the asynchronous operation. The task result contains a new <see cref="AgentSession"/> instance ready for use with this agent.</returns>
|
||||
/// <remarks>
|
||||
/// This is the primary session creation method that implementations must override.
|
||||
/// </remarks>
|
||||
protected abstract ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes an agent session to its JSON representation.
|
||||
/// </summary>
|
||||
/// <param name="session">The <see cref="AgentSession"/> to serialize.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings to customize the serialization process.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> containing the serialized session state.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="session"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The type of <paramref name="session"/> is not supported by this agent.</exception>
|
||||
/// <remarks>
|
||||
/// This method enables saving conversation sessions to persistent storage,
|
||||
/// allowing conversations to resume across application restarts or be migrated between
|
||||
/// different agent instances. Use <see cref="DeserializeSessionAsync"/> to restore the session.
|
||||
/// </remarks>
|
||||
public JsonElement SerializeSession(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> this.SerializeSessionCore(session, jsonSerializerOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Core implementation of session serialization logic.
|
||||
/// </summary>
|
||||
/// <param name="session">The <see cref="AgentSession"/> to serialize.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings to customize the serialization process.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> containing the serialized session state.</returns>
|
||||
/// <remarks>
|
||||
/// This is the primary session serialization method that implementations must override.
|
||||
/// </remarks>
|
||||
protected abstract JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null);
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes an agent session from its JSON serialized representation.
|
||||
/// </summary>
|
||||
/// <param name="serializedSession">A <see cref="JsonElement"/> containing the serialized session state.</param>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> containing the serialized session state.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings to customize the deserialization process.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A value task that represents the asynchronous operation. The task result contains a restored <see cref="AgentSession"/> instance with the state from <paramref name="serializedSession"/>.</returns>
|
||||
/// <exception cref="ArgumentException">The <paramref name="serializedSession"/> is not in the expected format.</exception>
|
||||
/// <returns>A value task that represents the asynchronous operation. The task result contains a restored <see cref="AgentSession"/> instance with the state from <paramref name="serializedState"/>.</returns>
|
||||
/// <exception cref="ArgumentException">The <paramref name="serializedState"/> is not in the expected format.</exception>
|
||||
/// <exception cref="JsonException">The serialized data is invalid or cannot be deserialized.</exception>
|
||||
/// <remarks>
|
||||
/// This method enables restoration of conversation sessions from previously saved state,
|
||||
/// allowing conversations to resume across application restarts or be migrated between
|
||||
/// different agent instances.
|
||||
/// </remarks>
|
||||
public abstract ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default);
|
||||
public ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> this.DeserializeSessionCoreAsync(serializedState, jsonSerializerOptions, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Core implementation of session deserialization logic.
|
||||
/// </summary>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> containing the serialized session state.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings to customize the deserialization process.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A value task that represents the asynchronous operation. The task result contains a restored <see cref="AgentSession"/> instance with the state from <paramref name="serializedState"/>.</returns>
|
||||
/// <remarks>
|
||||
/// This is the primary session deserialization method that implementations must override.
|
||||
/// </remarks>
|
||||
protected abstract ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the session.
|
||||
@@ -237,8 +304,11 @@ public abstract partial class AIAgent
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunCoreAsync(messages, session, options, cancellationToken);
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
CurrentRunContext = new(this, session, messages as IReadOnlyCollection<ChatMessage> ?? messages.ToList(), options);
|
||||
return this.RunCoreAsync(messages, session, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core implementation of the agent invocation logic with a collection of chat messages.
|
||||
@@ -355,12 +425,22 @@ public abstract partial class AIAgent
|
||||
/// to display partial results, implement progressive loading, or provide immediate feedback to users.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IAsyncEnumerable<AgentResponseUpdate> RunStreamingAsync(
|
||||
public async IAsyncEnumerable<AgentResponseUpdate> RunStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunCoreStreamingAsync(messages, session, options, cancellationToken);
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
AgentRunContext context = new(this, session, messages as IReadOnlyCollection<ChatMessage> ?? messages.ToList(), options);
|
||||
CurrentRunContext = context;
|
||||
await foreach (var update in this.RunCoreStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
|
||||
// Restore context again when resuming after the caller code executes.
|
||||
CurrentRunContext = context;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core implementation of the agent streaming invocation logic with a collection of chat messages.
|
||||
|
||||
@@ -129,13 +129,30 @@ public abstract class AIContextProvider
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InvokingContext"/> class with the specified request messages.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent being invoked.</param>
|
||||
/// <param name="session">The session associated with the agent invocation.</param>
|
||||
/// <param name="requestMessages">The messages to be used by the agent for this invocation.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="requestMessages"/> is <see langword="null"/>.</exception>
|
||||
public InvokingContext(IEnumerable<ChatMessage> requestMessages)
|
||||
public InvokingContext(
|
||||
AIAgent agent,
|
||||
AgentSession? session,
|
||||
IEnumerable<ChatMessage> requestMessages)
|
||||
{
|
||||
this.RequestMessages = requestMessages ?? throw new ArgumentNullException(nameof(requestMessages));
|
||||
this.Agent = Throw.IfNull(agent);
|
||||
this.Session = session;
|
||||
this.RequestMessages = Throw.IfNull(requestMessages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent that is being invoked.
|
||||
/// </summary>
|
||||
public AIAgent Agent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent session associated with the agent invocation.
|
||||
/// </summary>
|
||||
public AgentSession? Session { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the caller provided messages that will be used by the agent for this invocation.
|
||||
/// </summary>
|
||||
@@ -158,15 +175,33 @@ public abstract class AIContextProvider
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InvokedContext"/> class with the specified request messages.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent being invoked.</param>
|
||||
/// <param name="session">The session associated with the agent invocation.</param>
|
||||
/// <param name="requestMessages">The caller provided messages that were used by the agent for this invocation.</param>
|
||||
/// <param name="aiContextProviderMessages">The messages provided by the <see cref="AIContextProvider"/> for this invocation, if any.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="requestMessages"/> is <see langword="null"/>.</exception>
|
||||
public InvokedContext(IEnumerable<ChatMessage> requestMessages, IEnumerable<ChatMessage>? aiContextProviderMessages)
|
||||
public InvokedContext(
|
||||
AIAgent agent,
|
||||
AgentSession? session,
|
||||
IEnumerable<ChatMessage> requestMessages,
|
||||
IEnumerable<ChatMessage>? aiContextProviderMessages)
|
||||
{
|
||||
this.RequestMessages = requestMessages ?? throw new ArgumentNullException(nameof(requestMessages));
|
||||
this.Agent = Throw.IfNull(agent);
|
||||
this.Session = session;
|
||||
this.RequestMessages = Throw.IfNull(requestMessages);
|
||||
this.AIContextProviderMessages = aiContextProviderMessages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent that is being invoked.
|
||||
/// </summary>
|
||||
public AIAgent Agent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent session associated with the agent invocation.
|
||||
/// </summary>
|
||||
public AgentSession? Session { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the caller provided messages that were used by the agent for this invocation.
|
||||
/// </summary>
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
|
||||
#if NET
|
||||
using System.Text;
|
||||
#endif
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
@@ -139,21 +143,6 @@ public class AgentResponse
|
||||
[JsonIgnore]
|
||||
public string Text => this._messages?.ConcatText() ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all user input requests present in the response messages.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// An enumerable collection of <see cref="UserInputRequestContent"/> instances found
|
||||
/// across all messages in the response.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// User input requests indicate that the agent is asking for additional information
|
||||
/// from the user before it can continue processing. This property aggregates all such
|
||||
/// requests across all messages in the response.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public IEnumerable<UserInputRequestContent> UserInputRequests => this._messages?.SelectMany(x => x.Contents).OfType<UserInputRequestContent>() ?? [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the agent that generated this response.
|
||||
/// </summary>
|
||||
|
||||
@@ -4,7 +4,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
@@ -95,13 +94,6 @@ public class AgentResponseUpdate
|
||||
[JsonIgnore]
|
||||
public string Text => this._contents is not null ? this._contents.ConcatText() : string.Empty;
|
||||
|
||||
/// <summary>Gets the user input requests associated with the response.</summary>
|
||||
/// <remarks>
|
||||
/// This property concatenates all <see cref="UserInputRequestContent"/> instances in the response.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public IEnumerable<UserInputRequestContent> UserInputRequests => this._contents?.OfType<UserInputRequestContent>() ?? [];
|
||||
|
||||
/// <summary>Gets or sets the agent run response update content items.</summary>
|
||||
[AllowNull]
|
||||
public IList<AIContent> Contents
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>Provides context for an in-flight agent run.</summary>
|
||||
public sealed class AgentRunContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentRunContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="agent">The <see cref="AIAgent"/> that is executing the current run.</param>
|
||||
/// <param name="session">The <see cref="AgentSession"/> that is associated with the current run if any.</param>
|
||||
/// <param name="requestMessages">The request messages passed into the current run.</param>
|
||||
/// <param name="agentRunOptions">The <see cref="AgentRunOptions"/> that was passed to the current run.</param>
|
||||
public AgentRunContext(
|
||||
AIAgent agent,
|
||||
AgentSession? session,
|
||||
IReadOnlyCollection<ChatMessage> requestMessages,
|
||||
AgentRunOptions? agentRunOptions)
|
||||
{
|
||||
this.Agent = Throw.IfNull(agent);
|
||||
this.Session = session;
|
||||
this.RequestMessages = Throw.IfNull(requestMessages);
|
||||
this.RunOptions = agentRunOptions;
|
||||
}
|
||||
|
||||
/// <summary>Gets the <see cref="AIAgent"/> that is executing the current run.</summary>
|
||||
public AIAgent Agent { get; }
|
||||
|
||||
/// <summary>Gets the <see cref="AgentSession"/> that is associated with the current run.</summary>
|
||||
public AgentSession? Session { get; }
|
||||
|
||||
/// <summary>Gets the request messages passed into the current run.</summary>
|
||||
public IReadOnlyCollection<ChatMessage> RequestMessages { get; }
|
||||
|
||||
/// <summary>Gets the <see cref="AgentRunOptions"/> that was passed to the current run.</summary>
|
||||
public AgentRunOptions? RunOptions { get; }
|
||||
}
|
||||
@@ -36,7 +36,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// <para>
|
||||
/// To support conversations that may need to survive application restarts or separate service requests, an <see cref="AgentSession"/> can be serialized
|
||||
/// and deserialized, so that it can be saved in a persistent store.
|
||||
/// The <see cref="AgentSession"/> provides the <see cref="Serialize(JsonSerializerOptions?)"/> method to serialize the session to a
|
||||
/// The <see cref="AIAgent"/> provides the <see cref="AIAgent.SerializeSession(AgentSession, JsonSerializerOptions?)"/> method to serialize the session to a
|
||||
/// <see cref="JsonElement"/> and the <see cref="AIAgent.DeserializeSessionAsync(JsonElement, JsonSerializerOptions?, System.Threading.CancellationToken)"/> method
|
||||
/// can be used to deserialize the session.
|
||||
/// </para>
|
||||
@@ -53,14 +53,6 @@ public abstract class AgentSession
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
|
||||
public virtual JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> default;
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSession"/> 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>
|
||||
|
||||
@@ -143,13 +143,30 @@ public abstract class ChatHistoryProvider
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InvokingContext"/> class with the specified request messages.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent being invoked.</param>
|
||||
/// <param name="session">The session associated with the agent invocation.</param>
|
||||
/// <param name="requestMessages">The new messages to be used by the agent for this invocation.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="requestMessages"/> is <see langword="null"/>.</exception>
|
||||
public InvokingContext(IEnumerable<ChatMessage> requestMessages)
|
||||
public InvokingContext(
|
||||
AIAgent agent,
|
||||
AgentSession? session,
|
||||
IEnumerable<ChatMessage> requestMessages)
|
||||
{
|
||||
this.RequestMessages = requestMessages ?? throw new ArgumentNullException(nameof(requestMessages));
|
||||
this.Agent = Throw.IfNull(agent);
|
||||
this.Session = session;
|
||||
this.RequestMessages = Throw.IfNull(requestMessages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent that is being invoked.
|
||||
/// </summary>
|
||||
public AIAgent Agent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent session associated with the agent invocation.
|
||||
/// </summary>
|
||||
public AgentSession? Session { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the caller provided messages that will be used by the agent for this invocation.
|
||||
/// </summary>
|
||||
@@ -172,15 +189,33 @@ public abstract class ChatHistoryProvider
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InvokedContext"/> class with the specified request messages.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent being invoked.</param>
|
||||
/// <param name="session">The session associated with the agent invocation.</param>
|
||||
/// <param name="requestMessages">The caller provided messages that were used by the agent for this invocation.</param>
|
||||
/// <param name="chatHistoryProviderMessages">The messages retrieved from the <see cref="ChatHistoryProvider"/> for this invocation.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="requestMessages"/> is <see langword="null"/>.</exception>
|
||||
public InvokedContext(IEnumerable<ChatMessage> requestMessages, IEnumerable<ChatMessage>? chatHistoryProviderMessages)
|
||||
public InvokedContext(
|
||||
AIAgent agent,
|
||||
AgentSession? session,
|
||||
IEnumerable<ChatMessage> requestMessages,
|
||||
IEnumerable<ChatMessage>? chatHistoryProviderMessages)
|
||||
{
|
||||
this.Agent = Throw.IfNull(agent);
|
||||
this.Session = session;
|
||||
this.RequestMessages = Throw.IfNull(requestMessages);
|
||||
this.ChatHistoryProviderMessages = chatHistoryProviderMessages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent that is being invoked.
|
||||
/// </summary>
|
||||
public AIAgent Agent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent session associated with the agent invocation.
|
||||
/// </summary>
|
||||
public AgentSession? Session { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the caller provided messages that were used by the agent for this invocation.
|
||||
/// </summary>
|
||||
|
||||
@@ -74,11 +74,15 @@ public abstract class DelegatingAIAgent : AIAgent
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default) => this.InnerAgent.CreateSessionAsync(cancellationToken);
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) => this.InnerAgent.CreateSessionAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> this.InnerAgent.DeserializeSessionAsync(serializedSession, jsonSerializerOptions, cancellationToken);
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> this.InnerAgent.SerializeSession(session, jsonSerializerOptions);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> this.InnerAgent.DeserializeSessionAsync(serializedState, jsonSerializerOptions, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
|
||||
@@ -58,29 +58,29 @@ public abstract class InMemoryAgentSession : AgentSession
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InMemoryAgentSession"/> class from previously serialized state.
|
||||
/// </summary>
|
||||
/// <param name="serializedSessionState">A <see cref="JsonElement"/> representing the serialized state of the session.</param>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the session.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <param name="chatHistoryProviderFactory">
|
||||
/// Optional factory function to create the <see cref="InMemoryChatHistoryProvider"/> from its serialized state.
|
||||
/// If not provided, a default factory will be used that creates a basic <see cref="InMemoryChatHistoryProvider"/>.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentException">The <paramref name="serializedSessionState"/> is not a JSON object.</exception>
|
||||
/// <exception cref="JsonException">The <paramref name="serializedSessionState"/> is invalid or cannot be deserialized to the expected type.</exception>
|
||||
/// <exception cref="ArgumentException">The <paramref name="serializedState"/> is not a JSON object.</exception>
|
||||
/// <exception cref="JsonException">The <paramref name="serializedState"/> is invalid or cannot be deserialized to the expected type.</exception>
|
||||
/// <remarks>
|
||||
/// This constructor enables restoration of in-memory threads from previously saved state, allowing
|
||||
/// conversations to be resumed across application restarts or migrated between different instances.
|
||||
/// </remarks>
|
||||
protected InMemoryAgentSession(
|
||||
JsonElement serializedSessionState,
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
Func<JsonElement, JsonSerializerOptions?, InMemoryChatHistoryProvider>? chatHistoryProviderFactory = null)
|
||||
{
|
||||
if (serializedSessionState.ValueKind != JsonValueKind.Object)
|
||||
if (serializedState.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedSessionState));
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
|
||||
}
|
||||
|
||||
var state = serializedSessionState.Deserialize(
|
||||
var state = serializedState.Deserialize(
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InMemoryAgentSessionState))) as InMemoryAgentSessionState;
|
||||
|
||||
this.ChatHistoryProvider =
|
||||
@@ -98,7 +98,7 @@ public abstract class InMemoryAgentSession : AgentSession
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
protected internal virtual JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
var chatHistoryProviderState = this.ChatHistoryProvider.Serialize(jsonSerializerOptions);
|
||||
|
||||
|
||||
@@ -42,24 +42,24 @@ public abstract class ServiceIdAgentSession : AgentSession
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ServiceIdAgentSession"/> class from previously serialized state.
|
||||
/// </summary>
|
||||
/// <param name="serializedSessionState">A <see cref="JsonElement"/> representing the serialized state of the session.</param>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the session.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <exception cref="ArgumentException">The <paramref name="serializedSessionState"/> is not a JSON object.</exception>
|
||||
/// <exception cref="JsonException">The <paramref name="serializedSessionState"/> is invalid or cannot be deserialized to the expected type.</exception>
|
||||
/// <exception cref="ArgumentException">The <paramref name="serializedState"/> is not a JSON object.</exception>
|
||||
/// <exception cref="JsonException">The <paramref name="serializedState"/> is invalid or cannot be deserialized to the expected type.</exception>
|
||||
/// <remarks>
|
||||
/// This constructor enables restoration of a service-backed session from serialized state, typically used
|
||||
/// when deserializing session information that was previously saved or transmitted across application boundaries.
|
||||
/// </remarks>
|
||||
protected ServiceIdAgentSession(
|
||||
JsonElement serializedSessionState,
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (serializedSessionState.ValueKind != JsonValueKind.Object)
|
||||
if (serializedState.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedSessionState));
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
|
||||
}
|
||||
|
||||
var state = serializedSessionState.Deserialize(
|
||||
var state = serializedState.Deserialize(
|
||||
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ServiceIdAgentSessionState))) as ServiceIdAgentSessionState;
|
||||
|
||||
if (state?.ServiceSessionId is string serviceSessionId)
|
||||
@@ -85,13 +85,9 @@ public abstract class ServiceIdAgentSession : AgentSession
|
||||
/// <summary>
|
||||
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializerOptions">The JSON serialization options to use for the serialization process.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> representation of the object's state, containing the service session identifier.</returns>
|
||||
/// <remarks>
|
||||
/// The serialized state contains only the service session identifier, as all other conversation state
|
||||
/// is maintained remotely by the backing service. This makes the serialized representation very lightweight.
|
||||
/// </remarks>
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
|
||||
protected internal virtual JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
var state = new ServiceIdAgentSessionState
|
||||
{
|
||||
|
||||
@@ -64,13 +64,27 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient
|
||||
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentVersion agentVersion, ChatOptions? chatOptions)
|
||||
: this(
|
||||
aiProjectClient,
|
||||
new AgentReference(Throw.IfNull(agentVersion).Name, agentVersion.Version),
|
||||
CreateAgentReference(Throw.IfNull(agentVersion)),
|
||||
(agentVersion.Definition as PromptAgentDefinition)?.Model,
|
||||
chatOptions)
|
||||
{
|
||||
this._agentVersion = agentVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="AgentReference"/> from an <see cref="AgentVersion"/>.
|
||||
/// Uses the agent version's version if available, otherwise defaults to "latest".
|
||||
/// </summary>
|
||||
/// <param name="agentVersion">The agent version to create a reference from.</param>
|
||||
/// <returns>An <see cref="AgentReference"/> for the specified agent version.</returns>
|
||||
private static AgentReference CreateAgentReference(AgentVersion agentVersion)
|
||||
{
|
||||
// If the version is null, empty, or whitespace, use "latest" as the default.
|
||||
// This handles cases where hosted agents (like MCP agents) may not have a version assigned.
|
||||
var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version;
|
||||
return new AgentReference(agentVersion.Name, version);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
|
||||
@@ -543,9 +543,16 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
}
|
||||
}
|
||||
|
||||
// Use the agent version's ID if available, otherwise generate one from name and version.
|
||||
// This handles cases where hosted agents (like MCP agents) may not have an ID assigned.
|
||||
var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version;
|
||||
var agentId = string.IsNullOrWhiteSpace(agentVersion.Id)
|
||||
? $"{agentVersion.Name}:{version}"
|
||||
: agentVersion.Id;
|
||||
|
||||
var agentOptions = new ChatClientAgentOptions()
|
||||
{
|
||||
Id = agentVersion.Id,
|
||||
Id = agentId,
|
||||
Name = agentVersion.Name,
|
||||
Description = agentVersion.Description,
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ public class CopilotStudioAgent : AIAgent
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public sealed override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
|
||||
protected sealed override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new CopilotStudioAgentSession());
|
||||
|
||||
/// <summary>
|
||||
@@ -54,8 +54,21 @@ public class CopilotStudioAgent : AIAgent
|
||||
=> new(new CopilotStudioAgentSession() { ConversationId = conversationId });
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new CopilotStudioAgentSession(serializedSession, jsonSerializerOptions));
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
Throw.IfNull(session);
|
||||
|
||||
if (session is not CopilotStudioAgentSession typedSession)
|
||||
{
|
||||
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
|
||||
}
|
||||
|
||||
return typedSession.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new CopilotStudioAgentSession(serializedState, jsonSerializerOptions));
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<AgentResponse> RunCoreAsync(
|
||||
|
||||
@@ -25,4 +25,12 @@ public sealed class CopilotStudioAgentSession : ServiceIdAgentSession
|
||||
get { return this.ServiceSessionId; }
|
||||
internal set { this.ServiceSessionId = value; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
|
||||
internal new JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> base.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
- Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843))
|
||||
- Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067));
|
||||
- Renamed AgentThread to AgentSession ([#3430](https://github.com/microsoft/agent-framework/pull/3430))
|
||||
- Moved AgentSession.Serialize to AIAgent.SerializeSession ([#3650](https://github.com/microsoft/agent-framework/pull/3650))
|
||||
- Renamed serializedSession parameter to serializedState on DeserializeSessionAsync for consistency ([#3681](https://github.com/microsoft/agent-framework/pull/3681))
|
||||
- Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699))
|
||||
- Updated to use base `AgentRunOptions.ResponseFormat` for structured output configuration ([#3658](https://github.com/microsoft/agent-framework/pull/3658))
|
||||
|
||||
## v1.0.0-preview.251204.1
|
||||
|
||||
@@ -32,24 +32,45 @@ public sealed class DurableAIAgent : AIAgent
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A value task that represents the asynchronous operation. The task result contains a new agent session.</returns>
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
AgentSessionId sessionId = this._context.NewAgentSessionId(this._agentName);
|
||||
return ValueTask.FromResult<AgentSession>(new DurableAgentSession(sessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes an agent session to JSON.
|
||||
/// </summary>
|
||||
/// <param name="session">The session to serialize.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional JSON serializer options.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> containing the serialized session state.</returns>
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(session));
|
||||
}
|
||||
|
||||
if (session is not DurableAgentSession durableSession)
|
||||
{
|
||||
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
|
||||
}
|
||||
|
||||
return durableSession.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes an agent session from JSON.
|
||||
/// </summary>
|
||||
/// <param name="serializedSession">The serialized session data.</param>
|
||||
/// <param name="serializedState">The serialized session data.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional JSON serializer options.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A value task that represents the asynchronous operation. The task result contains the deserialized agent session.</returns>
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(
|
||||
JsonElement serializedSession,
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult<AgentSession>(DurableAgentSession.Deserialize(serializedSession, jsonSerializerOptions));
|
||||
return ValueTask.FromResult<AgentSession>(DurableAgentSession.Deserialize(serializedState, jsonSerializerOptions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -11,14 +11,29 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
|
||||
|
||||
public override string? Name { get; } = name;
|
||||
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(
|
||||
JsonElement serializedSession,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
return ValueTask.FromResult<AgentSession>(DurableAgentSession.Deserialize(serializedSession, jsonSerializerOptions));
|
||||
if (session is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(session));
|
||||
}
|
||||
|
||||
if (session is not DurableAgentSession durableSession)
|
||||
{
|
||||
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
|
||||
}
|
||||
|
||||
return durableSession.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult<AgentSession>(DurableAgentSession.Deserialize(serializedState, jsonSerializerOptions));
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult<AgentSession>(new DurableAgentSession(AgentSessionId.WithRandomKey(this.Name!)));
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class DurableAgentSession : AgentSession
|
||||
internal AgentSessionId SessionId { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
return JsonSerializer.SerializeToElement(
|
||||
this,
|
||||
|
||||
@@ -86,7 +86,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public sealed override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
|
||||
protected sealed override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new GitHubCopilotAgentSession());
|
||||
|
||||
/// <summary>
|
||||
@@ -98,11 +98,24 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
=> new(new GitHubCopilotAgentSession() { SessionId = sessionId });
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(
|
||||
JsonElement serializedSession,
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
_ = Throw.IfNull(session);
|
||||
|
||||
if (session is not GitHubCopilotAgentSession typedSession)
|
||||
{
|
||||
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
|
||||
}
|
||||
|
||||
return typedSession.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> new(new GitHubCopilotAgentSession(serializedSession, jsonSerializerOptions));
|
||||
=> new(new GitHubCopilotAgentSession(serializedState, jsonSerializerOptions));
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class GitHubCopilotAgentSession : AgentSession
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
State state = new()
|
||||
{
|
||||
|
||||
@@ -33,7 +33,7 @@ public sealed class InMemoryAgentSessionStore : AgentSessionStore
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(conversationId, agent.Id);
|
||||
this._threads[key] = session.Serialize();
|
||||
this._threads[key] = agent.SerializeSession(session);
|
||||
return default;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,13 +30,19 @@ internal class PurviewAgent : AIAgent, IDisposable
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
return this._innerAgent.DeserializeSessionAsync(serializedSession, jsonSerializerOptions, cancellationToken);
|
||||
return this._innerAgent.SerializeSession(session, jsonSerializerOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this._innerAgent.DeserializeSessionAsync(serializedState, jsonSerializerOptions, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this._innerAgent.CreateSessionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Generators.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A wrapper around <see cref="ImmutableArray{T}"/> that provides value-based equality.
|
||||
/// This is necessary for incremental generator caching since ImmutableArray uses reference equality.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Creates a new <see cref="EquatableArray{T}"/> from an <see cref="ImmutableArray{T}"/>.
|
||||
/// </remarks>
|
||||
internal readonly struct EquatableArray<T>(ImmutableArray<T> array) : IEquatable<EquatableArray<T>>, IEnumerable<T>
|
||||
where T : IEquatable<T>
|
||||
{
|
||||
private readonly ImmutableArray<T> _array = array.IsDefault ? ImmutableArray<T>.Empty : array;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying array.
|
||||
/// </summary>
|
||||
public ImmutableArray<T> AsImmutableArray() => this._array;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of elements in the array.
|
||||
/// </summary>
|
||||
public int Length => this._array.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the element at the specified index.
|
||||
/// </summary>
|
||||
public T this[int index] => this._array[index];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the array is empty.
|
||||
/// </summary>
|
||||
public bool IsEmpty => this._array.IsEmpty;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(EquatableArray<T> other)
|
||||
{
|
||||
if (this._array.Length != other._array.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < this._array.Length; i++)
|
||||
{
|
||||
if (!this._array[i].Equals(other._array[i]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return obj is EquatableArray<T> other && this.Equals(other);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode()
|
||||
{
|
||||
if (this._array.IsEmpty)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var hashCode = 17;
|
||||
foreach (var item in this._array)
|
||||
{
|
||||
hashCode = hashCode * 31 + (item?.GetHashCode() ?? 0);
|
||||
}
|
||||
|
||||
return hashCode;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerator<T> GetEnumerator()
|
||||
{
|
||||
return ((IEnumerable<T>)this._array).GetEnumerator();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return this.GetEnumerator();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equality operator.
|
||||
/// </summary>
|
||||
public static bool operator ==(EquatableArray<T> left, EquatableArray<T> right)
|
||||
{
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inequality operator.
|
||||
/// </summary>
|
||||
public static bool operator !=(EquatableArray<T> left, EquatableArray<T> right)
|
||||
{
|
||||
return !left.Equals(right);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty <see cref="EquatableArray{T}"/>.
|
||||
/// </summary>
|
||||
public static EquatableArray<T> Empty => new(ImmutableArray<T>.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Implicit conversion from <see cref="ImmutableArray{T}"/>.
|
||||
/// </summary>
|
||||
public static implicit operator EquatableArray<T>(ImmutableArray<T> array) => new(array);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Declares that an executor may yield messages of the specified type as workflow outputs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Apply this attribute to an <see cref="Executor"/> class to declare the types of messages
|
||||
/// it may yield via <see cref="IWorkflowContext.YieldOutputAsync"/>. This information is used
|
||||
/// for protocol validation and documentation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This attribute can be applied multiple times to declare multiple output types.
|
||||
/// It is inherited by derived classes, allowing base executors to declare common output types.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// [YieldsMessage(typeof(FinalResult))]
|
||||
/// [YieldsMessage(typeof(StreamChunk))]
|
||||
/// public partial class MyExecutor : Executor
|
||||
/// {
|
||||
/// // ...
|
||||
/// }
|
||||
/// </code>
|
||||
/// </example>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
|
||||
public sealed class YieldsMessageAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the type of message that the executor may yield.
|
||||
/// </summary>
|
||||
public Type Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="YieldsMessageAttribute"/> class.
|
||||
/// </summary>
|
||||
/// <param name="type">The type of message that the executor may yield.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="type"/> is <see langword="null"/>.</exception>
|
||||
public YieldsMessageAttribute(Type type)
|
||||
{
|
||||
this.Type = Throw.IfNull(type);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -9,6 +10,12 @@ namespace Microsoft.Agents.AI.Workflows.Reflection;
|
||||
/// A message handler interface for handling messages of type <typeparamref name="TMessage"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage"></typeparam>
|
||||
/// <remarks>
|
||||
/// This interface is obsolete. Use the <see cref="MessageHandlerAttribute"/> on methods in a partial class
|
||||
/// deriving from <see cref="Executor"/> instead.
|
||||
/// </remarks>
|
||||
[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " +
|
||||
"This interface will be removed in a future version.")]
|
||||
public interface IMessageHandler<TMessage>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -28,6 +35,12 @@ public interface IMessageHandler<TMessage>
|
||||
/// </summary>
|
||||
/// <typeparam name="TMessage">The type of message to handle.</typeparam>
|
||||
/// <typeparam name="TResult">The type of result returned after handling the message.</typeparam>
|
||||
/// <remarks>
|
||||
/// This interface is obsolete. Use the <see cref="MessageHandlerAttribute"/> on methods in a partial class
|
||||
/// deriving from <see cref="Executor"/> instead.
|
||||
/// </remarks>
|
||||
[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " +
|
||||
"This interface will be removed in a future version.")]
|
||||
public interface IMessageHandler<TMessage, TResult>
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Reflection;
|
||||
@@ -10,6 +11,12 @@ namespace Microsoft.Agents.AI.Workflows.Reflection;
|
||||
/// <typeparam name="TExecutor">The actual type of the <see cref="ReflectingExecutor{TExecutor}"/>.
|
||||
/// This is used to reflectively discover handlers for messages without violating ILTrim requirements.
|
||||
/// </typeparam>
|
||||
/// <remarks>
|
||||
/// This type is obsolete. Use the <see cref="MessageHandlerAttribute"/> on methods in a partial class
|
||||
/// deriving from <see cref="Executor"/> instead.
|
||||
/// </remarks>
|
||||
[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " +
|
||||
"This type will be removed in a future version.")]
|
||||
public class ReflectingExecutor<
|
||||
[DynamicallyAccessedMembers(
|
||||
ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
|
||||
@@ -101,7 +101,8 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
|
||||
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
AIAgentHostState state = new(this._session?.Serialize(), this._currentTurnEmitEvents);
|
||||
JsonElement? sessionState = this._session is not null ? this._agent.SerializeSession(this._session) : null;
|
||||
AIAgentHostState state = new(sessionState, this._currentTurnEmitEvents);
|
||||
Task coreStateTask = context.QueueStateUpdateAsync(AIAgentHostStateKey, state, cancellationToken: cancellationToken).AsTask();
|
||||
Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
|
||||
Task functionCallRequestsTask = this._functionCallHandler?.OnCheckpointingAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -65,11 +65,23 @@ internal sealed class WorkflowHostAgent : AIAgent
|
||||
protocol.ThrowIfNotChatProtocol(allowCatchAll: true);
|
||||
}
|
||||
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new WorkflowSession(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse));
|
||||
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new WorkflowSession(this._workflow, serializedSession, this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse, jsonSerializerOptions));
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
_ = Throw.IfNull(session);
|
||||
|
||||
if (session is not WorkflowSession workflowSession)
|
||||
{
|
||||
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
|
||||
}
|
||||
|
||||
return workflowSession.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new WorkflowSession(this._workflow, serializedState, this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails, this._includeWorkflowOutputsInResponse, jsonSerializerOptions));
|
||||
|
||||
private async ValueTask<WorkflowSession> UpdateSessionAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -75,7 +75,7 @@ internal sealed class WorkflowSession : AgentSession
|
||||
|
||||
public CheckpointInfo? LastCheckpoint { get; set; }
|
||||
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
JsonMarshaller marshaller = new(jsonSerializerOptions);
|
||||
SessionState info = new(
|
||||
|
||||
@@ -231,8 +231,8 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessages, continuationToken), chatHistoryProviderMessages, aiContextProviderMessages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessages, continuationToken), chatHistoryProviderMessages, aiContextProviderMessages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -246,8 +246,8 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessages, continuationToken), chatHistoryProviderMessages, aiContextProviderMessages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessages, continuationToken), chatHistoryProviderMessages, aiContextProviderMessages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -273,8 +273,8 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessages, continuationToken), chatHistoryProviderMessages, aiContextProviderMessages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessages, continuationToken), chatHistoryProviderMessages, aiContextProviderMessages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -286,10 +286,10 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
await this.UpdateSessionWithTypeAndConversationIdAsync(safeSession, chatResponse.ConversationId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// To avoid inconsistent state we only notify the session of the input messages if no error occurs after the initial request.
|
||||
await NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, GetInputMessages(inputMessages, continuationToken), chatHistoryProviderMessages, aiContextProviderMessages, chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, GetInputMessages(inputMessages, continuationToken), chatHistoryProviderMessages, aiContextProviderMessages, chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Notify the AIContextProvider of all new messages.
|
||||
await NotifyAIContextProviderOfSuccessAsync(safeSession, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyAIContextProviderOfSuccessAsync(safeSession, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -302,7 +302,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
: this.ChatClient.GetService(serviceType, serviceKey));
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
|
||||
protected override async ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ChatHistoryProvider? chatHistoryProvider = this._agentOptions?.ChatHistoryProviderFactory is not null
|
||||
? await this._agentOptions.ChatHistoryProviderFactory.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }, cancellationToken).ConfigureAwait(false)
|
||||
@@ -386,7 +386,20 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
_ = Throw.IfNull(session);
|
||||
|
||||
if (session is not ChatClientAgentSession typedSession)
|
||||
{
|
||||
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
|
||||
}
|
||||
|
||||
return typedSession.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Func<JsonElement, JsonSerializerOptions?, CancellationToken, ValueTask<ChatHistoryProvider>>? chatHistoryProviderFactory = this._agentOptions?.ChatHistoryProviderFactory is null ?
|
||||
null :
|
||||
@@ -397,7 +410,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
(jse, jso, ct) => this._agentOptions.AIContextProviderFactory.Invoke(new() { SerializedState = jse, JsonSerializerOptions = jso }, ct);
|
||||
|
||||
return await ChatClientAgentSession.DeserializeAsync(
|
||||
serializedSession,
|
||||
serializedState,
|
||||
jsonSerializerOptions,
|
||||
chatHistoryProviderFactory,
|
||||
aiContextProviderFactory,
|
||||
@@ -442,8 +455,8 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, inputMessages, chatHistoryProviderMessages, aiContextProviderMessages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeSession, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyChatHistoryProviderOfFailureAsync(safeSession, ex, inputMessages, chatHistoryProviderMessages, aiContextProviderMessages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyAIContextProviderOfFailureAsync(safeSession, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -460,10 +473,10 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
|
||||
// Only notify the session of new messages if the chatResponse was successful to avoid inconsistent message state in the session.
|
||||
await NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, inputMessages, chatHistoryProviderMessages, aiContextProviderMessages, chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, inputMessages, chatHistoryProviderMessages, aiContextProviderMessages, chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Notify the AIContextProvider of all new messages.
|
||||
await NotifyAIContextProviderOfSuccessAsync(safeSession, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
|
||||
await this.NotifyAIContextProviderOfSuccessAsync(safeSession, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var agentResponse = agentResponseFactoryFunc(chatResponse);
|
||||
|
||||
@@ -475,7 +488,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
/// <summary>
|
||||
/// Notify the <see cref="AIContextProvider"/> when an agent run succeeded, if there is an <see cref="AIContextProvider"/>.
|
||||
/// </summary>
|
||||
private static async Task NotifyAIContextProviderOfSuccessAsync(
|
||||
private async Task NotifyAIContextProviderOfSuccessAsync(
|
||||
ChatClientAgentSession session,
|
||||
IEnumerable<ChatMessage> inputMessages,
|
||||
IList<ChatMessage>? aiContextProviderMessages,
|
||||
@@ -484,7 +497,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
{
|
||||
if (session.AIContextProvider is not null)
|
||||
{
|
||||
await session.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProviderMessages) { ResponseMessages = responseMessages },
|
||||
await session.AIContextProvider.InvokedAsync(new(this, session, inputMessages, aiContextProviderMessages) { ResponseMessages = responseMessages },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -492,7 +505,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
/// <summary>
|
||||
/// Notify the <see cref="AIContextProvider"/> of any failure during an agent run, if there is an <see cref="AIContextProvider"/>.
|
||||
/// </summary>
|
||||
private static async Task NotifyAIContextProviderOfFailureAsync(
|
||||
private async Task NotifyAIContextProviderOfFailureAsync(
|
||||
ChatClientAgentSession session,
|
||||
Exception ex,
|
||||
IEnumerable<ChatMessage> inputMessages,
|
||||
@@ -501,7 +514,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
{
|
||||
if (session.AIContextProvider is not null)
|
||||
{
|
||||
await session.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProviderMessages) { InvokeException = ex },
|
||||
await session.AIContextProvider.InvokedAsync(new(this, session, inputMessages, aiContextProviderMessages) { InvokeException = ex },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -719,7 +732,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
// Add any existing messages from the session to the messages to be sent to the chat client.
|
||||
if (chatHistoryProvider is not null)
|
||||
{
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(inputMessages);
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(this, typedSession, inputMessages);
|
||||
var providerMessages = await chatHistoryProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
|
||||
inputMessagesForChatClient.AddRange(providerMessages);
|
||||
chatHistoryProviderMessages = providerMessages as IList<ChatMessage> ?? providerMessages.ToList();
|
||||
@@ -732,7 +745,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
// messages and options with the additional context.
|
||||
if (typedSession.AIContextProvider is not null)
|
||||
{
|
||||
var invokingContext = new AIContextProvider.InvokingContext(inputMessages);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this, typedSession, inputMessages);
|
||||
var aiContext = await typedSession.AIContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
|
||||
if (aiContext.Messages is { Count: > 0 })
|
||||
{
|
||||
@@ -805,7 +818,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
}
|
||||
|
||||
private static Task NotifyChatHistoryProviderOfFailureAsync(
|
||||
private Task NotifyChatHistoryProviderOfFailureAsync(
|
||||
ChatClientAgentSession session,
|
||||
Exception ex,
|
||||
IEnumerable<ChatMessage> requestMessages,
|
||||
@@ -820,7 +833,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
// If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages.
|
||||
if (provider is not null)
|
||||
{
|
||||
var invokedContext = new ChatHistoryProvider.InvokedContext(requestMessages, chatHistoryProviderMessages!)
|
||||
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, chatHistoryProviderMessages!)
|
||||
{
|
||||
AIContextProviderMessages = aiContextProviderMessages,
|
||||
InvokeException = ex
|
||||
@@ -832,7 +845,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task NotifyChatHistoryProviderOfNewMessagesAsync(
|
||||
private Task NotifyChatHistoryProviderOfNewMessagesAsync(
|
||||
ChatClientAgentSession session,
|
||||
IEnumerable<ChatMessage> requestMessages,
|
||||
IEnumerable<ChatMessage>? chatHistoryProviderMessages,
|
||||
@@ -847,7 +860,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
// If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages.
|
||||
if (provider is not null)
|
||||
{
|
||||
var invokedContext = new ChatHistoryProvider.InvokedContext(requestMessages, chatHistoryProviderMessages!)
|
||||
var invokedContext = new ChatHistoryProvider.InvokedContext(this, session, requestMessages, chatHistoryProviderMessages!)
|
||||
{
|
||||
AIContextProviderMessages = aiContextProviderMessages,
|
||||
ResponseMessages = responseMessages
|
||||
|
||||
@@ -115,7 +115,7 @@ public sealed class ChatClientAgentSession : AgentSession
|
||||
/// <summary>
|
||||
/// Creates a new instance of the <see cref="ChatClientAgentSession"/> class from previously serialized state.
|
||||
/// </summary>
|
||||
/// <param name="serializedSessionState">A <see cref="JsonElement"/> representing the serialized state of the session.</param>
|
||||
/// <param name="serializedState">A <see cref="JsonElement"/> representing the serialized state of the session.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <param name="chatHistoryProviderFactory">
|
||||
/// An optional factory function to create a custom <see cref="AI.ChatHistoryProvider"/> from its serialized state.
|
||||
@@ -128,18 +128,18 @@ public sealed class ChatClientAgentSession : AgentSession
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result contains the deserialized <see cref="ChatClientAgentSession"/>.</returns>
|
||||
internal static async Task<ChatClientAgentSession> DeserializeAsync(
|
||||
JsonElement serializedSessionState,
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
Func<JsonElement, JsonSerializerOptions?, CancellationToken, ValueTask<ChatHistoryProvider>>? chatHistoryProviderFactory = null,
|
||||
Func<JsonElement, JsonSerializerOptions?, CancellationToken, ValueTask<AIContextProvider>>? aiContextProviderFactory = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (serializedSessionState.ValueKind != JsonValueKind.Object)
|
||||
if (serializedState.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedSessionState));
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
|
||||
}
|
||||
|
||||
var state = serializedSessionState.Deserialize(
|
||||
var state = serializedState.Deserialize(
|
||||
AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(SessionState))) as SessionState;
|
||||
|
||||
var session = new ChatClientAgentSession();
|
||||
@@ -165,7 +165,7 @@ public sealed class ChatClientAgentSession : AgentSession
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
JsonElement? chatHistoryProviderState = this._chatHistoryProvider?.Serialize(jsonSerializerOptions);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ public interface IAgentFixture : IAsyncLifetime
|
||||
{
|
||||
AIAgent Agent { get; }
|
||||
|
||||
Task<List<ChatMessage>> GetChatHistoryAsync(AgentSession session);
|
||||
Task<List<ChatMessage>> GetChatHistoryAsync(AIAgent agent, AgentSession session);
|
||||
|
||||
Task DeleteSessionAsync(AgentSession session);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ public abstract class RunStreamingTests<TAgentFixture>(Func<TAgentFixture> creat
|
||||
Assert.Contains("Paris", response1Text);
|
||||
Assert.Contains("Vienna", response2Text);
|
||||
|
||||
var chatHistory = await this.Fixture.GetChatHistoryAsync(session);
|
||||
var chatHistory = await this.Fixture.GetChatHistoryAsync(agent, session);
|
||||
Assert.Equal(4, chatHistory.Count);
|
||||
Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.User));
|
||||
Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.Assistant));
|
||||
|
||||
@@ -111,7 +111,7 @@ public abstract class RunTests<TAgentFixture>(Func<TAgentFixture> createAgentFix
|
||||
Assert.Contains("Paris", result1.Text);
|
||||
Assert.Contains("Vienna", result2.Text);
|
||||
|
||||
var chatHistory = await this.Fixture.GetChatHistoryAsync(session);
|
||||
var chatHistory = await this.Fixture.GetChatHistoryAsync(agent, session);
|
||||
Assert.Equal(4, chatHistory.Count);
|
||||
Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.User));
|
||||
Assert.Equal(2, chatHistory.Count(x => x.Role == ChatRole.Assistant));
|
||||
|
||||
+2
-2
@@ -35,7 +35,7 @@ public class AnthropicChatCompletionFixture : IChatClientAgentFixture
|
||||
|
||||
public IChatClient ChatClient => this._agent.ChatClient;
|
||||
|
||||
public async Task<List<ChatMessage>> GetChatHistoryAsync(AgentSession session)
|
||||
public async Task<List<ChatMessage>> GetChatHistoryAsync(AIAgent agent, AgentSession session)
|
||||
{
|
||||
var typedSession = (ChatClientAgentSession)session;
|
||||
|
||||
@@ -44,7 +44,7 @@ public class AnthropicChatCompletionFixture : IChatClientAgentFixture
|
||||
return [];
|
||||
}
|
||||
|
||||
return (await typedSession.ChatHistoryProvider.InvokingAsync(new([]))).ToList();
|
||||
return (await typedSession.ChatHistoryProvider.InvokingAsync(new(agent, session, []))).ToList();
|
||||
}
|
||||
|
||||
public Task<ChatClientAgent> CreateChatClientAgentAsync(
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Anthropic;
|
||||
using Anthropic.Models.Beta;
|
||||
using Anthropic.Models.Beta.Messages;
|
||||
using Anthropic.Models.Beta.Skills;
|
||||
using Anthropic.Services;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace AnthropicChatCompletion.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for Anthropic Skills functionality.
|
||||
/// These tests are designed to be run locally with a valid Anthropic API key.
|
||||
/// </summary>
|
||||
public sealed class AnthropicSkillsIntegrationTests
|
||||
{
|
||||
// All tests for Anthropic are intended to be ran locally as the CI pipeline for Anthropic is not setup.
|
||||
private const string SkipReason = "Integrations tests for local execution only";
|
||||
|
||||
private static readonly AnthropicConfiguration s_config = TestConfiguration.LoadSection<AnthropicConfiguration>();
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task CreateAgentWithPptxSkillAsync()
|
||||
{
|
||||
// Arrange
|
||||
AnthropicClient anthropicClient = new() { ApiKey = s_config.ApiKey };
|
||||
string model = s_config.ChatModelId;
|
||||
|
||||
BetaSkillParams pptxSkill = new()
|
||||
{
|
||||
Type = BetaSkillParamsType.Anthropic,
|
||||
SkillID = "pptx",
|
||||
Version = "latest"
|
||||
};
|
||||
|
||||
ChatClientAgent agent = anthropicClient.Beta.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a helpful agent for creating PowerPoint presentations.",
|
||||
tools: [pptxSkill.AsAITool()]);
|
||||
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync(
|
||||
"Create a simple 2-slide presentation: a title slide and one content slide about AI.");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotNull(response.Text);
|
||||
Assert.NotEmpty(response.Text);
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
public async Task ListAnthropicManagedSkillsAsync()
|
||||
{
|
||||
// Arrange
|
||||
AnthropicClient anthropicClient = new() { ApiKey = s_config.ApiKey };
|
||||
|
||||
// Act
|
||||
SkillListPage skills = await anthropicClient.Beta.Skills.List(
|
||||
new SkillListParams { Source = "anthropic", Betas = [AnthropicBeta.Skills2025_10_02] });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(skills);
|
||||
Assert.NotNull(skills.Items);
|
||||
Assert.Contains(skills.Items, skill => skill.ID == "pptx");
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public class AIProjectClientFixture : IChatClientAgentFixture
|
||||
return response.Value.Id;
|
||||
}
|
||||
|
||||
public async Task<List<ChatMessage>> GetChatHistoryAsync(AgentSession session)
|
||||
public async Task<List<ChatMessage>> GetChatHistoryAsync(AIAgent agent, AgentSession session)
|
||||
{
|
||||
var chatClientSession = (ChatClientAgentSession)session;
|
||||
|
||||
@@ -53,7 +53,7 @@ public class AIProjectClientFixture : IChatClientAgentFixture
|
||||
return [];
|
||||
}
|
||||
|
||||
return (await chatClientSession.ChatHistoryProvider.InvokingAsync(new([]))).ToList();
|
||||
return (await chatClientSession.ChatHistoryProvider.InvokingAsync(new(agent, session, []))).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<ChatMessage>> GetChatHistoryFromResponsesChainAsync(string conversationId)
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ public class AzureAIAgentsPersistentFixture : IChatClientAgentFixture
|
||||
|
||||
public AIAgent Agent => this._agent;
|
||||
|
||||
public async Task<List<ChatMessage>> GetChatHistoryAsync(AgentSession session)
|
||||
public async Task<List<ChatMessage>> GetChatHistoryAsync(AIAgent agent, AgentSession session)
|
||||
{
|
||||
List<ChatMessage> messages = [];
|
||||
var typedSession = (ChatClientAgentSession)session;
|
||||
|
||||
@@ -20,7 +20,7 @@ public class CopilotStudioFixture : IAgentFixture
|
||||
{
|
||||
public AIAgent Agent { get; private set; } = null!;
|
||||
|
||||
public Task<List<ChatMessage>> GetChatHistoryAsync(AgentSession session) =>
|
||||
public Task<List<ChatMessage>> GetChatHistoryAsync(AIAgent agent, AgentSession session) =>
|
||||
throw new NotSupportedException("CopilotStudio doesn't allow retrieval of chat history.");
|
||||
|
||||
public Task DeleteSessionAsync(AgentSession session) =>
|
||||
|
||||
@@ -251,7 +251,7 @@ public sealed class AGUIAgentTests
|
||||
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
|
||||
AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "agent1", description: "Test agent", tools: []);
|
||||
AgentSession originalSession = await agent.CreateSessionAsync();
|
||||
JsonElement serialized = originalSession.Serialize();
|
||||
JsonElement serialized = agent.SerializeSession(originalSession);
|
||||
|
||||
// Act
|
||||
AgentSession deserialized = await agent.DeserializeSessionAsync(serialized);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AIAgentMetadata"/> class.
|
||||
/// </summary>
|
||||
public class AIAgentMetadataTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNoArguments_SetsProviderNameToNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
AIAgentMetadata metadata = new();
|
||||
|
||||
// Assert
|
||||
Assert.Null(metadata.ProviderName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithProviderName_SetsProperty()
|
||||
{
|
||||
// Arrange
|
||||
const string ProviderName = "TestProvider";
|
||||
|
||||
// Act
|
||||
AIAgentMetadata metadata = new(ProviderName);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ProviderName, metadata.ProviderName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullProviderName_SetsProviderNameToNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
AIAgentMetadata metadata = new(null);
|
||||
|
||||
// Assert
|
||||
Assert.Null(metadata.ProviderName);
|
||||
}
|
||||
}
|
||||
@@ -220,6 +220,133 @@ public class AIAgentTests
|
||||
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Theory data for RunAsync overloads.
|
||||
/// </summary>
|
||||
public static TheoryData<string> RunAsyncOverloads => new()
|
||||
{
|
||||
"NoMessage",
|
||||
"StringMessage",
|
||||
"ChatMessage",
|
||||
"MessagesCollection"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that CurrentRunContext is properly set and accessible from RunCoreAsync for all RunAsync overloads.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(RunAsyncOverloads))]
|
||||
public async Task RunAsync_SetsCurrentRunContext_AccessibleFromRunCoreAsync(string overload)
|
||||
{
|
||||
// Arrange
|
||||
AgentRunContext? capturedContext = null;
|
||||
var session = new TestAgentSession();
|
||||
var options = new AgentRunOptions();
|
||||
|
||||
var agentMock = new Mock<AIAgent> { CallBase = true };
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((IEnumerable<ChatMessage> _, AgentSession? _, AgentRunOptions? _, CancellationToken _) =>
|
||||
{
|
||||
capturedContext = AIAgent.CurrentRunContext;
|
||||
return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "Response")));
|
||||
});
|
||||
|
||||
// Act
|
||||
switch (overload)
|
||||
{
|
||||
case "NoMessage":
|
||||
await agentMock.Object.RunAsync(session, options);
|
||||
break;
|
||||
case "StringMessage":
|
||||
await agentMock.Object.RunAsync("Hello", session, options);
|
||||
break;
|
||||
case "ChatMessage":
|
||||
await agentMock.Object.RunAsync(new ChatMessage(ChatRole.User, "Hello"), session, options);
|
||||
break;
|
||||
case "MessagesCollection":
|
||||
await agentMock.Object.RunAsync([new ChatMessage(ChatRole.User, "Hello")], session, options);
|
||||
break;
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Same(agentMock.Object, capturedContext!.Agent);
|
||||
Assert.Same(session, capturedContext.Session);
|
||||
Assert.Same(options, capturedContext.RunOptions);
|
||||
|
||||
if (overload == "NoMessage")
|
||||
{
|
||||
Assert.Empty(capturedContext.RequestMessages);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Single(capturedContext.RequestMessages);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that CurrentRunContext is properly set and accessible from RunCoreStreamingAsync for all RunStreamingAsync overloads.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(RunAsyncOverloads))]
|
||||
public async Task RunStreamingAsync_SetsCurrentRunContext_AccessibleFromRunCoreStreamingAsync(string overload)
|
||||
{
|
||||
// Arrange
|
||||
AgentRunContext? capturedContext = null;
|
||||
var session = new TestAgentSession();
|
||||
var options = new AgentRunOptions();
|
||||
|
||||
var agentMock = new Mock<AIAgent> { CallBase = true };
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((IEnumerable<ChatMessage> _, AgentSession? _, AgentRunOptions? _, CancellationToken _) =>
|
||||
{
|
||||
capturedContext = AIAgent.CurrentRunContext;
|
||||
return ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "Response")]);
|
||||
});
|
||||
|
||||
// Act
|
||||
IAsyncEnumerable<AgentResponseUpdate> stream = overload switch
|
||||
{
|
||||
"NoMessage" => agentMock.Object.RunStreamingAsync(session, options),
|
||||
"StringMessage" => agentMock.Object.RunStreamingAsync("Hello", session, options),
|
||||
"ChatMessage" => agentMock.Object.RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello"), session, options),
|
||||
"MessagesCollection" => agentMock.Object.RunStreamingAsync(new[] { new ChatMessage(ChatRole.User, "Hello") }, session, options),
|
||||
_ => throw new InvalidOperationException($"Unknown overload: {overload}")
|
||||
};
|
||||
|
||||
await foreach (AgentResponseUpdate _ in stream)
|
||||
{
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Same(agentMock.Object, capturedContext!.Agent);
|
||||
Assert.Same(session, capturedContext.Session);
|
||||
Assert.Same(options, capturedContext.RunOptions);
|
||||
|
||||
if (overload == "NoMessage")
|
||||
{
|
||||
Assert.Empty(capturedContext.RequestMessages);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Single(capturedContext.RequestMessages);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAgentIDIsIdempotent()
|
||||
{
|
||||
@@ -364,10 +491,78 @@ public class AIAgentTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Name and Description Property Tests
|
||||
|
||||
/// <summary>
|
||||
/// Typed mock session.
|
||||
/// Verify that Name property returns the value from the derived class.
|
||||
/// </summary>
|
||||
public abstract class TestAgentSession : AgentSession;
|
||||
[Fact]
|
||||
public void Name_ReturnsValueFromDerivedClass()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgentWithName("TestAgentName", "TestAgentDescription");
|
||||
|
||||
// Act
|
||||
string? name = agent.Name;
|
||||
|
||||
// Assert
|
||||
Assert.Equal("TestAgentName", name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that Description property returns the value from the derived class.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Description_ReturnsValueFromDerivedClass()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgentWithName("TestAgentName", "TestAgentDescription");
|
||||
|
||||
// Act
|
||||
string? description = agent.Description;
|
||||
|
||||
// Assert
|
||||
Assert.Equal("TestAgentDescription", description);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that Name property returns null when not overridden.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Name_ReturnsNullByDefault()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgent();
|
||||
|
||||
// Act
|
||||
string? name = agent.Name;
|
||||
|
||||
// Assert
|
||||
Assert.Null(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that Description property returns null when not overridden.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Description_ReturnsNullByDefault()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgent();
|
||||
|
||||
// Act
|
||||
string? description = agent.Description;
|
||||
|
||||
// Assert
|
||||
Assert.Null(description);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Typed mock session for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
private sealed class MockAgent : AIAgent
|
||||
{
|
||||
@@ -378,10 +573,51 @@ public class AIAgentTests
|
||||
|
||||
protected override string? IdCore { get; }
|
||||
|
||||
public override async ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public override async ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class MockAgentWithName : AIAgent
|
||||
{
|
||||
private readonly string? _name;
|
||||
private readonly string? _description;
|
||||
|
||||
public MockAgentWithName(string? name, string? description)
|
||||
{
|
||||
this._name = name;
|
||||
this._description = description;
|
||||
}
|
||||
|
||||
public override string? Name => this._name;
|
||||
public override string? Description => this._description;
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
public class AIContextProviderTests
|
||||
{
|
||||
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
|
||||
private static readonly AgentSession s_mockSession = new Mock<AgentSession>().Object;
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsync_ReturnsCompletedTaskAsync()
|
||||
{
|
||||
var provider = new TestAIContextProvider();
|
||||
var messages = new ReadOnlyCollection<ChatMessage>([]);
|
||||
var task = provider.InvokedAsync(new(messages, aiContextProviderMessages: null));
|
||||
var task = provider.InvokedAsync(new(s_mockAgent, s_mockSession, messages, aiContextProviderMessages: null));
|
||||
Assert.Equal(default, task);
|
||||
}
|
||||
|
||||
@@ -30,13 +35,13 @@ public class AIContextProviderTests
|
||||
[Fact]
|
||||
public void InvokingContext_Constructor_ThrowsForNullMessages()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokingContext(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_Constructor_ThrowsForNullMessages()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokedContext(null!, aiContextProviderMessages: null));
|
||||
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, null!, aiContextProviderMessages: null));
|
||||
}
|
||||
|
||||
#region GetService Method Tests
|
||||
@@ -155,6 +160,209 @@ public class AIContextProviderTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokingContext Tests
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_RequestMessages_SetterThrowsForNull()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
var context = new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, messages);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => context.RequestMessages = null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_RequestMessages_SetterRoundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var initialMessages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
var newMessages = new List<ChatMessage> { new(ChatRole.User, "New message") };
|
||||
var context = new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, initialMessages);
|
||||
|
||||
// Act
|
||||
context.RequestMessages = newMessages;
|
||||
|
||||
// Assert
|
||||
Assert.Same(newMessages, context.RequestMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_Agent_ReturnsConstructorValue()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
var context = new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, messages);
|
||||
|
||||
// Assert
|
||||
Assert.Same(s_mockAgent, context.Agent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_Session_ReturnsConstructorValue()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
var context = new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, messages);
|
||||
|
||||
// Assert
|
||||
Assert.Same(s_mockSession, context.Session);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_Session_CanBeNull()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
var context = new AIContextProvider.InvokingContext(s_mockAgent, null, messages);
|
||||
|
||||
// Assert
|
||||
Assert.Null(context.Session);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_Constructor_ThrowsForNullAgent()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokingContext(null!, s_mockSession, messages));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokedContext Tests
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_RequestMessages_SetterThrowsForNull()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
var context = new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, messages, aiContextProviderMessages: null);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => context.RequestMessages = null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_RequestMessages_SetterRoundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var initialMessages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
var newMessages = new List<ChatMessage> { new(ChatRole.User, "New message") };
|
||||
var context = new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, initialMessages, aiContextProviderMessages: null);
|
||||
|
||||
// Act
|
||||
context.RequestMessages = newMessages;
|
||||
|
||||
// Assert
|
||||
Assert.Same(newMessages, context.RequestMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_AIContextProviderMessages_Roundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
var aiContextMessages = new List<ChatMessage> { new(ChatRole.System, "AI context message") };
|
||||
var context = new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, aiContextProviderMessages: null);
|
||||
|
||||
// Act
|
||||
context.AIContextProviderMessages = aiContextMessages;
|
||||
|
||||
// Assert
|
||||
Assert.Same(aiContextMessages, context.AIContextProviderMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_ResponseMessages_Roundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
var responseMessages = new List<ChatMessage> { new(ChatRole.Assistant, "Response message") };
|
||||
var context = new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, aiContextProviderMessages: null);
|
||||
|
||||
// Act
|
||||
context.ResponseMessages = responseMessages;
|
||||
|
||||
// Assert
|
||||
Assert.Same(responseMessages, context.ResponseMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_InvokeException_Roundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
var exception = new InvalidOperationException("Test exception");
|
||||
var context = new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, aiContextProviderMessages: null);
|
||||
|
||||
// Act
|
||||
context.InvokeException = exception;
|
||||
|
||||
// Assert
|
||||
Assert.Same(exception, context.InvokeException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_Agent_ReturnsConstructorValue()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
var context = new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, aiContextProviderMessages: null);
|
||||
|
||||
// Assert
|
||||
Assert.Same(s_mockAgent, context.Agent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_Session_ReturnsConstructorValue()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
var context = new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, aiContextProviderMessages: null);
|
||||
|
||||
// Assert
|
||||
Assert.Same(s_mockSession, context.Session);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_Session_CanBeNull()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
var context = new AIContextProvider.InvokedContext(s_mockAgent, null, requestMessages, aiContextProviderMessages: null);
|
||||
|
||||
// Assert
|
||||
Assert.Null(context.Session);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_Constructor_ThrowsForNullAgent()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new ReadOnlyCollection<ChatMessage>([new(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokedContext(null!, s_mockSession, requestMessages, aiContextProviderMessages: null));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class TestAIContextProvider : AIContextProvider
|
||||
{
|
||||
public override ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -230,4 +230,103 @@ public class AgentResponseTests
|
||||
Assert.Equal(expectedResult.FullName, animal.FullName);
|
||||
Assert.Equal(expectedResult.Species, animal.Species);
|
||||
}
|
||||
#endif
|
||||
|
||||
[Fact]
|
||||
public void TryParseAsStructuredOutputWithJSOSuccess()
|
||||
{
|
||||
// Arrange.
|
||||
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
|
||||
|
||||
// Act.
|
||||
response.TryDeserialize(TestJsonSerializerContext.Default.Options, out Animal? animal);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(animal);
|
||||
Assert.Equal(expectedResult.Id, animal.Id);
|
||||
Assert.Equal(expectedResult.FullName, animal.FullName);
|
||||
Assert.Equal(expectedResult.Species, animal.Species);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseAsStructuredOutputFailsWithEmptyText()
|
||||
{
|
||||
// Arrange.
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, string.Empty));
|
||||
|
||||
// Act & Assert.
|
||||
Assert.False(response.TryDeserialize<Animal>(TestJsonSerializerContext.Default.Options, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseAsStructuredOutputFailsWithIncorrectTypedJson()
|
||||
{
|
||||
// Arrange.
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "[]"));
|
||||
|
||||
// Act & Assert.
|
||||
Assert.False(response.TryDeserialize<Animal>(TestJsonSerializerContext.Default.Options, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAgentResponseUpdatesWithNoMessagesProducesEmptyArray()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new();
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate[] updates = response.ToAgentResponseUpdates();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(updates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAgentResponseUpdatesWithUsageOnlyProducesSingleUpdate()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new()
|
||||
{
|
||||
Usage = new UsageDetails { TotalTokenCount = 100 }
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate[] updates = response.ToAgentResponseUpdates();
|
||||
|
||||
// Assert
|
||||
AgentResponseUpdate update = Assert.Single(updates);
|
||||
UsageContent usageContent = Assert.IsType<UsageContent>(update.Contents[0]);
|
||||
Assert.Equal(100, usageContent.Details.TotalTokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAgentResponseUpdatesWithAdditionalPropertiesOnlyProducesSingleUpdate()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new()
|
||||
{
|
||||
AdditionalProperties = new() { ["key"] = "value" }
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate[] updates = response.ToAgentResponseUpdates();
|
||||
|
||||
// Assert
|
||||
AgentResponseUpdate update = Assert.Single(updates);
|
||||
Assert.NotNull(update.AdditionalProperties);
|
||||
Assert.Equal("value", update.AdditionalProperties!["key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_ThrowsWhenDeserializationReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new(new ChatMessage(ChatRole.Assistant, "null"));
|
||||
|
||||
// Act & Assert
|
||||
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
|
||||
() => response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options));
|
||||
Assert.Equal("The deserialized response is null.", exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
+155
@@ -299,6 +299,161 @@ public class AgentResponseUpdateExtensionsTests
|
||||
Assert.Equal(expected, response.CreatedAt);
|
||||
}
|
||||
|
||||
#region AsChatResponse Tests
|
||||
|
||||
[Fact]
|
||||
public void AsChatResponse_WithNullArgument_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("response", () => ((AgentResponse)null!).AsChatResponse());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsChatResponse_WithRawRepresentationAsChatResponse_ReturnsSameInstance()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponse originalChatResponse = new()
|
||||
{
|
||||
ResponseId = "original-response",
|
||||
Messages = [new ChatMessage(ChatRole.Assistant, "Hello")]
|
||||
};
|
||||
AgentResponse agentResponse = new(originalChatResponse);
|
||||
|
||||
// Act
|
||||
ChatResponse result = agentResponse.AsChatResponse();
|
||||
|
||||
// Assert
|
||||
Assert.Same(originalChatResponse, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsChatResponse_WithoutRawRepresentation_CreatesNewChatResponse()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse agentResponse = new(new ChatMessage(ChatRole.Assistant, "Test message"))
|
||||
{
|
||||
ResponseId = "test-response-id",
|
||||
CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero),
|
||||
Usage = new UsageDetails { TotalTokenCount = 50 },
|
||||
AdditionalProperties = new() { ["key"] = "value" },
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
|
||||
};
|
||||
|
||||
// Act
|
||||
ChatResponse result = agentResponse.AsChatResponse();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("test-response-id", result.ResponseId);
|
||||
Assert.Equal(new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), result.CreatedAt);
|
||||
Assert.Same(agentResponse.Messages, result.Messages);
|
||||
Assert.Same(agentResponse, result.RawRepresentation);
|
||||
Assert.Same(agentResponse.Usage, result.Usage);
|
||||
Assert.Same(agentResponse.AdditionalProperties, result.AdditionalProperties);
|
||||
Assert.Equal(agentResponse.ContinuationToken, result.ContinuationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AsChatResponseUpdate Tests
|
||||
|
||||
[Fact]
|
||||
public void AsChatResponseUpdate_WithNullArgument_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("responseUpdate", () => ((AgentResponseUpdate)null!).AsChatResponseUpdate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsChatResponseUpdate_WithRawRepresentationAsChatResponseUpdate_ReturnsSameInstance()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseUpdate originalChatResponseUpdate = new()
|
||||
{
|
||||
ResponseId = "original-update",
|
||||
Contents = [new TextContent("Hello")]
|
||||
};
|
||||
AgentResponseUpdate agentResponseUpdate = new(originalChatResponseUpdate);
|
||||
|
||||
// Act
|
||||
ChatResponseUpdate result = agentResponseUpdate.AsChatResponseUpdate();
|
||||
|
||||
// Assert
|
||||
Assert.Same(originalChatResponseUpdate, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsChatResponseUpdate_WithoutRawRepresentation_CreatesNewChatResponseUpdate()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate agentResponseUpdate = new(ChatRole.Assistant, "Test")
|
||||
{
|
||||
AuthorName = "TestAuthor",
|
||||
ResponseId = "update-id",
|
||||
MessageId = "message-id",
|
||||
CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero),
|
||||
AdditionalProperties = new() { ["key"] = "value" },
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
|
||||
};
|
||||
|
||||
// Act
|
||||
ChatResponseUpdate result = agentResponseUpdate.AsChatResponseUpdate();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("TestAuthor", result.AuthorName);
|
||||
Assert.Equal("update-id", result.ResponseId);
|
||||
Assert.Equal("message-id", result.MessageId);
|
||||
Assert.Equal(new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero), result.CreatedAt);
|
||||
Assert.Equal(ChatRole.Assistant, result.Role);
|
||||
Assert.Same(agentResponseUpdate.Contents, result.Contents);
|
||||
Assert.Same(agentResponseUpdate, result.RawRepresentation);
|
||||
Assert.Same(agentResponseUpdate.AdditionalProperties, result.AdditionalProperties);
|
||||
Assert.Equal(agentResponseUpdate.ContinuationToken, result.ContinuationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AsChatResponseUpdatesAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_WithNullArgument_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>("responseUpdates", async () =>
|
||||
{
|
||||
await foreach (ChatResponseUpdate _ in ((IAsyncEnumerable<AgentResponseUpdate>)null!).AsChatResponseUpdatesAsync())
|
||||
{
|
||||
// Do nothing
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsChatResponseUpdatesAsync_ConvertsUpdatesAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new(ChatRole.Assistant, "First"),
|
||||
new(ChatRole.Assistant, "Second"),
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> results = [];
|
||||
await foreach (ChatResponseUpdate update in YieldAsync(updates).AsChatResponseUpdatesAsync())
|
||||
{
|
||||
results.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.Equal("First", Assert.IsType<TextContent>(results[0].Contents[0]).Text);
|
||||
Assert.Equal("Second", Assert.IsType<TextContent>(results[1].Contents[0]).Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static async IAsyncEnumerable<AgentResponseUpdate> YieldAsync(IEnumerable<AgentResponseUpdate> updates)
|
||||
{
|
||||
foreach (AgentResponseUpdate update in updates)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AgentRunContext"/> class.
|
||||
/// </summary>
|
||||
public sealed class AgentRunContextTests
|
||||
{
|
||||
#region Constructor Validation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that passing null for agent throws ArgumentNullException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullAgent_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AgentSession session = new TestAgentSession();
|
||||
IReadOnlyCollection<ChatMessage> messages = new List<ChatMessage>();
|
||||
AgentRunOptions options = new();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AgentRunContext(null!, session, messages, options));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that passing null for session does not throw
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullSession_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new TestAgent();
|
||||
IReadOnlyCollection<ChatMessage> messages = new List<ChatMessage>();
|
||||
AgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentRunContext context = new(agent, null, messages, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(context);
|
||||
Assert.Null(context.Session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that passing null for requestMessages throws ArgumentNullException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullRequestMessages_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new TestAgent();
|
||||
AgentSession session = new TestAgentSession();
|
||||
AgentRunOptions options = new();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AgentRunContext(agent, session, null!, options));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that passing null for agentRunOptions does not throw.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullAgentRunOptions_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new TestAgent();
|
||||
AgentSession session = new TestAgentSession();
|
||||
IReadOnlyCollection<ChatMessage> messages = new List<ChatMessage>();
|
||||
|
||||
// Act
|
||||
AgentRunContext context = new(agent, session, messages, null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(context);
|
||||
Assert.Null(context.RunOptions);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Roundtrip Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the Agent property returns the value passed to the constructor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Agent_ReturnsValueFromConstructor()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new TestAgent();
|
||||
AgentSession session = new TestAgentSession();
|
||||
IReadOnlyCollection<ChatMessage> messages = new List<ChatMessage>();
|
||||
AgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentRunContext context = new(agent, session, messages, options);
|
||||
|
||||
// Assert
|
||||
Assert.Same(agent, context.Agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the Session property returns the value passed to the constructor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Session_ReturnsValueFromConstructor()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new TestAgent();
|
||||
AgentSession session = new TestAgentSession();
|
||||
IReadOnlyCollection<ChatMessage> messages = new List<ChatMessage>();
|
||||
AgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentRunContext context = new(agent, session, messages, options);
|
||||
|
||||
// Assert
|
||||
Assert.Same(session, context.Session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the RequestMessages property returns the value passed to the constructor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequestMessages_ReturnsValueFromConstructor()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new TestAgent();
|
||||
AgentSession session = new TestAgentSession();
|
||||
IReadOnlyCollection<ChatMessage> messages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there!")
|
||||
};
|
||||
AgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentRunContext context = new(agent, session, messages, options);
|
||||
|
||||
// Assert
|
||||
Assert.Same(messages, context.RequestMessages);
|
||||
Assert.Equal(2, context.RequestMessages.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the RunOptions property returns the value passed to the constructor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RunOptions_ReturnsValueFromConstructor()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new TestAgent();
|
||||
AgentSession session = new TestAgentSession();
|
||||
IReadOnlyCollection<ChatMessage> messages = new List<ChatMessage>();
|
||||
AgentRunOptions options = new()
|
||||
{
|
||||
AllowBackgroundResponses = true,
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
["key1"] = "value1"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentRunContext context = new(agent, session, messages, options);
|
||||
|
||||
// Assert
|
||||
Assert.Same(options, context.RunOptions);
|
||||
Assert.True(context.RunOptions!.AllowBackgroundResponses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an empty messages collection is handled correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequestMessages_EmptyCollection_ReturnsEmptyCollection()
|
||||
{
|
||||
// Arrange
|
||||
AIAgent agent = new TestAgent();
|
||||
AgentSession session = new TestAgentSession();
|
||||
IReadOnlyCollection<ChatMessage> messages = new List<ChatMessage>();
|
||||
AgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentRunContext context = new(agent, session, messages, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(context.RequestMessages);
|
||||
Assert.Empty(context.RequestMessages);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
private sealed class TestAgent : AIAgent
|
||||
{
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -11,14 +11,6 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
/// </summary>
|
||||
public class AgentSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_ReturnsDefaultJsonElement()
|
||||
{
|
||||
var session = new TestAgentSession();
|
||||
var result = session.Serialize();
|
||||
Assert.Equal(default, result);
|
||||
}
|
||||
|
||||
#region GetService Method Tests
|
||||
|
||||
/// <summary>
|
||||
|
||||
+6
-3
@@ -14,6 +14,9 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
/// </summary>
|
||||
public sealed class ChatHistoryProviderExtensionsTests
|
||||
{
|
||||
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
|
||||
private static readonly AgentSession s_mockSession = new Mock<AgentSession>().Object;
|
||||
|
||||
[Fact]
|
||||
public void WithMessageFilters_ReturnsChatHistoryProviderMessageFilter()
|
||||
{
|
||||
@@ -35,7 +38,7 @@ public sealed class ChatHistoryProviderExtensionsTests
|
||||
// Arrange
|
||||
Mock<ChatHistoryProvider> providerMock = new();
|
||||
List<ChatMessage> innerMessages = [new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi")];
|
||||
ChatHistoryProvider.InvokingContext context = new([new ChatMessage(ChatRole.User, "Test")]);
|
||||
ChatHistoryProvider.InvokingContext context = new(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Test")]);
|
||||
|
||||
providerMock
|
||||
.Setup(p => p.InvokingAsync(context, It.IsAny<CancellationToken>()))
|
||||
@@ -59,7 +62,7 @@ public sealed class ChatHistoryProviderExtensionsTests
|
||||
Mock<ChatHistoryProvider> providerMock = new();
|
||||
List<ChatMessage> requestMessages = [new(ChatRole.User, "Hello")];
|
||||
List<ChatMessage> chatHistoryProviderMessages = [new(ChatRole.System, "System")];
|
||||
ChatHistoryProvider.InvokedContext context = new(requestMessages, chatHistoryProviderMessages)
|
||||
ChatHistoryProvider.InvokedContext context = new(s_mockAgent, s_mockSession, requestMessages, chatHistoryProviderMessages)
|
||||
{
|
||||
ResponseMessages = [new ChatMessage(ChatRole.Assistant, "Response")]
|
||||
};
|
||||
@@ -106,7 +109,7 @@ public sealed class ChatHistoryProviderExtensionsTests
|
||||
List<ChatMessage> requestMessages = [new(ChatRole.User, "Hello")];
|
||||
List<ChatMessage> chatHistoryProviderMessages = [new(ChatRole.System, "System")];
|
||||
List<ChatMessage> aiContextProviderMessages = [new(ChatRole.System, "Context")];
|
||||
ChatHistoryProvider.InvokedContext context = new(requestMessages, chatHistoryProviderMessages)
|
||||
ChatHistoryProvider.InvokedContext context = new(s_mockAgent, s_mockSession, requestMessages, chatHistoryProviderMessages)
|
||||
{
|
||||
AIContextProviderMessages = aiContextProviderMessages
|
||||
};
|
||||
|
||||
+8
-5
@@ -16,6 +16,9 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
/// </summary>
|
||||
public sealed class ChatHistoryProviderMessageFilterTests
|
||||
{
|
||||
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
|
||||
private static readonly AgentSession s_mockSession = new Mock<AgentSession>().Object;
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullInnerProvider_ThrowsArgumentNullException()
|
||||
{
|
||||
@@ -59,7 +62,7 @@ public sealed class ChatHistoryProviderMessageFilterTests
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there!")
|
||||
};
|
||||
var context = new ChatHistoryProvider.InvokingContext([new ChatMessage(ChatRole.User, "Test")]);
|
||||
var context = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Test")]);
|
||||
|
||||
innerProviderMock
|
||||
.Setup(s => s.InvokingAsync(context, It.IsAny<CancellationToken>()))
|
||||
@@ -88,7 +91,7 @@ public sealed class ChatHistoryProviderMessageFilterTests
|
||||
new(ChatRole.Assistant, "Hi there!"),
|
||||
new(ChatRole.User, "How are you?")
|
||||
};
|
||||
var context = new ChatHistoryProvider.InvokingContext([new ChatMessage(ChatRole.User, "Test")]);
|
||||
var context = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Test")]);
|
||||
|
||||
innerProviderMock
|
||||
.Setup(s => s.InvokingAsync(context, It.IsAny<CancellationToken>()))
|
||||
@@ -118,7 +121,7 @@ public sealed class ChatHistoryProviderMessageFilterTests
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there!")
|
||||
};
|
||||
var context = new ChatHistoryProvider.InvokingContext([new ChatMessage(ChatRole.User, "Test")]);
|
||||
var context = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Test")]);
|
||||
|
||||
innerProviderMock
|
||||
.Setup(s => s.InvokingAsync(context, It.IsAny<CancellationToken>()))
|
||||
@@ -147,7 +150,7 @@ public sealed class ChatHistoryProviderMessageFilterTests
|
||||
var requestMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
var chatHistoryProviderMessages = new List<ChatMessage> { new(ChatRole.System, "System") };
|
||||
var responseMessages = new List<ChatMessage> { new(ChatRole.Assistant, "Response") };
|
||||
var context = new ChatHistoryProvider.InvokedContext(requestMessages, chatHistoryProviderMessages)
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, chatHistoryProviderMessages)
|
||||
{
|
||||
ResponseMessages = responseMessages
|
||||
};
|
||||
@@ -162,7 +165,7 @@ public sealed class ChatHistoryProviderMessageFilterTests
|
||||
ChatHistoryProvider.InvokedContext InvokedFilter(ChatHistoryProvider.InvokedContext ctx)
|
||||
{
|
||||
var modifiedRequestMessages = ctx.RequestMessages.Select(m => new ChatMessage(m.Role, $"[FILTERED] {m.Text}")).ToList();
|
||||
return new ChatHistoryProvider.InvokedContext(modifiedRequestMessages, ctx.ChatHistoryProviderMessages)
|
||||
return new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, modifiedRequestMessages, ctx.ChatHistoryProviderMessages)
|
||||
{
|
||||
ResponseMessages = ctx.ResponseMessages,
|
||||
AIContextProviderMessages = ctx.AIContextProviderMessages,
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
@@ -14,6 +15,9 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
/// </summary>
|
||||
public class ChatHistoryProviderTests
|
||||
{
|
||||
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
|
||||
private static readonly AgentSession s_mockSession = new Mock<AgentSession>().Object;
|
||||
|
||||
#region GetService Method Tests
|
||||
|
||||
[Fact]
|
||||
@@ -76,6 +80,238 @@ public class ChatHistoryProviderTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokingContext Tests
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_Constructor_ThrowsForNullMessages()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_RequestMessages_SetterThrowsForNull()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
var context = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, messages);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => context.RequestMessages = null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_RequestMessages_SetterRoundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var initialMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
var newMessages = new List<ChatMessage> { new(ChatRole.User, "New message") };
|
||||
var context = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, initialMessages);
|
||||
|
||||
// Act
|
||||
context.RequestMessages = newMessages;
|
||||
|
||||
// Assert
|
||||
Assert.Same(newMessages, context.RequestMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_Agent_ReturnsConstructorValue()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
|
||||
// Act
|
||||
var context = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, messages);
|
||||
|
||||
// Assert
|
||||
Assert.Same(s_mockAgent, context.Agent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_Session_ReturnsConstructorValue()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
|
||||
// Act
|
||||
var context = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, messages);
|
||||
|
||||
// Assert
|
||||
Assert.Same(s_mockSession, context.Session);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_Session_CanBeNull()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
|
||||
// Act
|
||||
var context = new ChatHistoryProvider.InvokingContext(s_mockAgent, null, messages);
|
||||
|
||||
// Assert
|
||||
Assert.Null(context.Session);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokingContext_Constructor_ThrowsForNullAgent()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new ChatHistoryProvider.InvokingContext(null!, s_mockSession, messages));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InvokedContext Tests
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_Constructor_ThrowsForNullRequestMessages()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, null!, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_RequestMessages_SetterThrowsForNull()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, []);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => context.RequestMessages = null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_RequestMessages_SetterRoundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var initialMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
var newMessages = new List<ChatMessage> { new(ChatRole.User, "New message") };
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, initialMessages, []);
|
||||
|
||||
// Act
|
||||
context.RequestMessages = newMessages;
|
||||
|
||||
// Assert
|
||||
Assert.Same(newMessages, context.RequestMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_ChatHistoryProviderMessages_SetterRoundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
var newProviderMessages = new List<ChatMessage> { new(ChatRole.System, "System message") };
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, []);
|
||||
|
||||
// Act
|
||||
context.ChatHistoryProviderMessages = newProviderMessages;
|
||||
|
||||
// Assert
|
||||
Assert.Same(newProviderMessages, context.ChatHistoryProviderMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_AIContextProviderMessages_Roundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
var aiContextMessages = new List<ChatMessage> { new(ChatRole.System, "AI context message") };
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, []);
|
||||
|
||||
// Act
|
||||
context.AIContextProviderMessages = aiContextMessages;
|
||||
|
||||
// Assert
|
||||
Assert.Same(aiContextMessages, context.AIContextProviderMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_ResponseMessages_Roundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
var responseMessages = new List<ChatMessage> { new(ChatRole.Assistant, "Response message") };
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, []);
|
||||
|
||||
// Act
|
||||
context.ResponseMessages = responseMessages;
|
||||
|
||||
// Assert
|
||||
Assert.Same(responseMessages, context.ResponseMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_InvokeException_Roundtrips()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
var exception = new InvalidOperationException("Test exception");
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, []);
|
||||
|
||||
// Act
|
||||
context.InvokeException = exception;
|
||||
|
||||
// Assert
|
||||
Assert.Same(exception, context.InvokeException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_Agent_ReturnsConstructorValue()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
|
||||
// Act
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, []);
|
||||
|
||||
// Assert
|
||||
Assert.Same(s_mockAgent, context.Agent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_Session_ReturnsConstructorValue()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
|
||||
// Act
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, []);
|
||||
|
||||
// Assert
|
||||
Assert.Same(s_mockSession, context.Session);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_Session_CanBeNull()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
|
||||
// Act
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, null, requestMessages, []);
|
||||
|
||||
// Assert
|
||||
Assert.Null(context.Session);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokedContext_Constructor_ThrowsForNullAgent()
|
||||
{
|
||||
// Arrange
|
||||
var requestMessages = new List<ChatMessage> { new(ChatRole.User, "Hello") };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new ChatHistoryProvider.InvokedContext(null!, s_mockSession, requestMessages, []));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class TestChatHistoryProvider : ChatHistoryProvider
|
||||
{
|
||||
public override ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -26,7 +27,7 @@ public class DelegatingAIAgentTests
|
||||
/// </summary>
|
||||
public DelegatingAIAgentTests()
|
||||
{
|
||||
this._innerAgentMock = new Mock<AIAgent>();
|
||||
this._innerAgentMock = new Mock<AIAgent> { CallBase = true };
|
||||
this._testResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
|
||||
this._testStreamingResponses = [new AgentResponseUpdate(ChatRole.Assistant, "Test streaming response")];
|
||||
this._testSession = new TestAgentSession();
|
||||
@@ -35,7 +36,10 @@ public class DelegatingAIAgentTests
|
||||
this._innerAgentMock.Protected().SetupGet<string>("IdCore").Returns("test-agent-id");
|
||||
this._innerAgentMock.Setup(x => x.Name).Returns("Test Agent");
|
||||
this._innerAgentMock.Setup(x => x.Description).Returns("Test Description");
|
||||
this._innerAgentMock.Setup(x => x.CreateSessionAsync()).ReturnsAsync(this._testSession);
|
||||
this._innerAgentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerAgentMock
|
||||
.Protected()
|
||||
@@ -142,7 +146,32 @@ public class DelegatingAIAgentTests
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testSession, session);
|
||||
this._innerAgentMock.Verify(x => x.CreateSessionAsync(), Times.Once);
|
||||
this._innerAgentMock
|
||||
.Protected()
|
||||
.Verify<ValueTask<AgentSession>>("CreateSessionCoreAsync", Times.Once(), ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that DeserializeSessionAsync delegates to inner agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DeserializeSessionAsync_DelegatesToInnerAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var serializedSession = JsonSerializer.SerializeToElement("test-session-id", TestJsonSerializerContext.Default.String);
|
||||
this._innerAgentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("DeserializeSessionCoreAsync", ItExpr.IsAny<JsonElement>(), ItExpr.IsAny<JsonSerializerOptions?>(), ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
// Act
|
||||
var session = await this._delegatingAgent.DeserializeSessionAsync(serializedSession);
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._testSession, session);
|
||||
this._innerAgentMock
|
||||
.Protected()
|
||||
.Verify<ValueTask<AgentSession>>("DeserializeSessionCoreAsync", Times.Once(), ItExpr.IsAny<JsonElement>(), ItExpr.IsAny<JsonSerializerOptions?>(), ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+47
-8
@@ -18,6 +18,9 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
/// </summary>
|
||||
public class InMemoryChatHistoryProviderTests
|
||||
{
|
||||
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
|
||||
private static readonly AgentSession s_mockSession = new Mock<AgentSession>().Object;
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Throws_ForNullReducer() =>
|
||||
// Arrange & Act & Assert
|
||||
@@ -68,7 +71,7 @@ public class InMemoryChatHistoryProviderTests
|
||||
|
||||
var provider = new InMemoryChatHistoryProvider();
|
||||
provider.Add(providerMessages[0]);
|
||||
var context = new ChatHistoryProvider.InvokedContext(requestMessages, providerMessages)
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, providerMessages)
|
||||
{
|
||||
AIContextProviderMessages = aiContextProviderMessages,
|
||||
ResponseMessages = responseMessages
|
||||
@@ -87,7 +90,7 @@ public class InMemoryChatHistoryProviderTests
|
||||
{
|
||||
var provider = new InMemoryChatHistoryProvider();
|
||||
|
||||
var context = new ChatHistoryProvider.InvokedContext([], []);
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, [], []);
|
||||
await provider.InvokedAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Empty(provider);
|
||||
@@ -102,7 +105,7 @@ public class InMemoryChatHistoryProviderTests
|
||||
new ChatMessage(ChatRole.Assistant, "Test2")
|
||||
};
|
||||
|
||||
var context = new ChatHistoryProvider.InvokingContext([]);
|
||||
var context = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
var result = (await provider.InvokingAsync(context, CancellationToken.None)).ToList();
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
@@ -183,7 +186,7 @@ public class InMemoryChatHistoryProviderTests
|
||||
var provider = new InMemoryChatHistoryProvider();
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
var context = new ChatHistoryProvider.InvokedContext(messages, []);
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, messages, []);
|
||||
await provider.InvokedAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Empty(provider);
|
||||
@@ -520,7 +523,7 @@ public class InMemoryChatHistoryProviderTests
|
||||
var provider = new InMemoryChatHistoryProvider(reducerMock.Object, InMemoryChatHistoryProvider.ChatReducerTriggerEvent.AfterMessageAdded);
|
||||
|
||||
// Act
|
||||
var context = new ChatHistoryProvider.InvokedContext(originalMessages, []);
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, originalMessages, []);
|
||||
await provider.InvokedAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
@@ -556,7 +559,7 @@ public class InMemoryChatHistoryProviderTests
|
||||
}
|
||||
|
||||
// Act
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(Array.Empty<ChatMessage>());
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, Array.Empty<ChatMessage>());
|
||||
var result = (await provider.InvokingAsync(invokingContext, CancellationToken.None)).ToList();
|
||||
|
||||
// Assert
|
||||
@@ -579,7 +582,7 @@ public class InMemoryChatHistoryProviderTests
|
||||
var provider = new InMemoryChatHistoryProvider(reducerMock.Object, InMemoryChatHistoryProvider.ChatReducerTriggerEvent.BeforeMessagesRetrieval);
|
||||
|
||||
// Act
|
||||
var context = new ChatHistoryProvider.InvokedContext(originalMessages, []);
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, originalMessages, []);
|
||||
await provider.InvokedAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
@@ -605,7 +608,7 @@ public class InMemoryChatHistoryProviderTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(Array.Empty<ChatMessage>());
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, Array.Empty<ChatMessage>());
|
||||
var result = (await provider.InvokingAsync(invokingContext, CancellationToken.None)).ToList();
|
||||
|
||||
// Assert
|
||||
@@ -614,6 +617,42 @@ public class InMemoryChatHistoryProviderTests
|
||||
reducerMock.Verify(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsync_WithException_DoesNotAddMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new InMemoryChatHistoryProvider();
|
||||
var requestMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello")
|
||||
};
|
||||
var responseMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.Assistant, "Hi there!")
|
||||
};
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, [])
|
||||
{
|
||||
ResponseMessages = responseMessages,
|
||||
InvokeException = new InvalidOperationException("Test exception")
|
||||
};
|
||||
|
||||
// Act
|
||||
await provider.InvokedAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_WithNullContext_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new InMemoryChatHistoryProvider();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => provider.InvokingAsync(null!, CancellationToken.None).AsTask());
|
||||
}
|
||||
|
||||
public class TestAIContent(string testData) : AIContent
|
||||
{
|
||||
public string TestData => testData;
|
||||
|
||||
+221
-11
@@ -2384,6 +2384,134 @@ public sealed class AzureAIProjectChatClientExtensionsTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Empty Version and ID Handling Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync handles an agent with empty version by using "latest" as fallback.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithEmptyVersion_CreatesAgentSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion();
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "test-agent",
|
||||
ChatOptions = new ChatOptions { Instructions = "Test" }
|
||||
};
|
||||
|
||||
// Act
|
||||
ChatClientAgent agent = await client.GetAIAgentAsync(options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<ChatClientAgent>(agent);
|
||||
// Verify the agent ID is generated from server-returned name ("agent_abc123") and "latest"
|
||||
Assert.Equal("agent_abc123:latest", agent.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsAIAgent with AgentRecord handles empty version by using "latest" as fallback.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentRecordEmptyVersion_CreatesAgentWithGeneratedId()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion();
|
||||
AgentRecord agentRecord = this.CreateTestAgentRecordWithEmptyVersion();
|
||||
|
||||
// Act
|
||||
var agent = client.AsAIAgent(agentRecord);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
// Verify the agent ID is generated from agent record name ("agent_abc123") and "latest"
|
||||
Assert.Equal("agent_abc123:latest", agent.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsAIAgent with AgentVersion handles empty version by using "latest" as fallback.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentVersionEmptyVersion_CreatesAgentWithGeneratedId()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClientWithEmptyVersion();
|
||||
AgentVersion agentVersion = this.CreateTestAgentVersionWithEmptyVersion();
|
||||
|
||||
// Act
|
||||
var agent = client.AsAIAgent(agentVersion);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
// Verify the agent ID is generated from agent version name ("agent_abc123") and "latest"
|
||||
Assert.Equal("agent_abc123:latest", agent.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync handles an agent with whitespace-only version by using "latest" as fallback.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithWhitespaceVersion_CreatesAgentSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion();
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "test-agent",
|
||||
ChatOptions = new ChatOptions { Instructions = "Test" }
|
||||
};
|
||||
|
||||
// Act
|
||||
ChatClientAgent agent = await client.GetAIAgentAsync(options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<ChatClientAgent>(agent);
|
||||
// Verify the agent ID is generated from server-returned name ("agent_abc123") and "latest"
|
||||
Assert.Equal("agent_abc123:latest", agent.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsAIAgent with AgentRecord handles whitespace-only version by using "latest" as fallback.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentRecordWhitespaceVersion_CreatesAgentWithGeneratedId()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion();
|
||||
AgentRecord agentRecord = this.CreateTestAgentRecordWithWhitespaceVersion();
|
||||
|
||||
// Act
|
||||
var agent = client.AsAIAgent(agentRecord);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
// Verify the agent ID is generated from agent record name ("agent_abc123") and "latest"
|
||||
Assert.Equal("agent_abc123:latest", agent.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsAIAgent with AgentVersion handles whitespace-only version by using "latest" as fallback.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsAIAgent_WithAgentVersionWhitespaceVersion_CreatesAgentWithGeneratedId()
|
||||
{
|
||||
// Arrange
|
||||
AIProjectClient client = this.CreateTestAgentClientWithWhitespaceVersion();
|
||||
AgentVersion agentVersion = this.CreateTestAgentVersionWithWhitespaceVersion();
|
||||
|
||||
// Act
|
||||
var agent = client.AsAIAgent(agentVersion);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
// Verify the agent ID is generated from agent version name ("agent_abc123") and "latest"
|
||||
Assert.Equal("agent_abc123:latest", agent.Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ApplyToolsToAgentDefinition Tests
|
||||
|
||||
/// <summary>
|
||||
@@ -2678,6 +2806,54 @@ public sealed class AzureAIProjectChatClientExtensionsTests
|
||||
return ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(TestDataUtil.GetAgentResponseJson(agentDefinition: agentDefinition)))!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test AIProjectClient with empty version fields for testing hosted MCP agents.
|
||||
/// </summary>
|
||||
private FakeAgentClient CreateTestAgentClientWithEmptyVersion(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null)
|
||||
{
|
||||
return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse, useEmptyVersion: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test AgentRecord with empty version for testing hosted MCP agents.
|
||||
/// </summary>
|
||||
private AgentRecord CreateTestAgentRecordWithEmptyVersion(AgentDefinition? agentDefinition = null)
|
||||
{
|
||||
return ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(TestDataUtil.GetAgentResponseJsonWithEmptyVersion(agentDefinition: agentDefinition)))!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test AgentVersion with empty version for testing hosted MCP agents.
|
||||
/// </summary>
|
||||
private AgentVersion CreateTestAgentVersionWithEmptyVersion()
|
||||
{
|
||||
return ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJsonWithEmptyVersion()))!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test AIProjectClient with whitespace-only version fields for testing hosted MCP agents.
|
||||
/// </summary>
|
||||
private FakeAgentClient CreateTestAgentClientWithWhitespaceVersion(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null)
|
||||
{
|
||||
return new FakeAgentClient(agentName, instructions, description, agentDefinitionResponse, versionMode: VersionMode.Whitespace);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test AgentRecord with whitespace-only version for testing hosted MCP agents.
|
||||
/// </summary>
|
||||
private AgentRecord CreateTestAgentRecordWithWhitespaceVersion(AgentDefinition? agentDefinition = null)
|
||||
{
|
||||
return ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(TestDataUtil.GetAgentResponseJsonWithWhitespaceVersion(agentDefinition: agentDefinition)))!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test AgentVersion with whitespace-only version for testing hosted MCP agents.
|
||||
/// </summary>
|
||||
private AgentVersion CreateTestAgentVersionWithWhitespaceVersion()
|
||||
{
|
||||
return ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJsonWithWhitespaceVersion()))!;
|
||||
}
|
||||
|
||||
private const string OpenAPISpec = """
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
@@ -2716,14 +2892,26 @@ public sealed class AzureAIProjectChatClientExtensionsTests
|
||||
return ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the version mode for test data generation.
|
||||
/// </summary>
|
||||
private enum VersionMode
|
||||
{
|
||||
Normal,
|
||||
Empty,
|
||||
Whitespace
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake AIProjectClient for testing.
|
||||
/// </summary>
|
||||
private sealed class FakeAgentClient : AIProjectClient
|
||||
{
|
||||
public FakeAgentClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null)
|
||||
public FakeAgentClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null, bool useEmptyVersion = false, VersionMode versionMode = VersionMode.Normal)
|
||||
{
|
||||
this.Agents = new FakeAIProjectAgentsOperations(agentName, instructions, description, agentDefinitionResponse);
|
||||
// Handle backward compatibility with bool parameter
|
||||
var effectiveVersionMode = useEmptyVersion ? VersionMode.Empty : versionMode;
|
||||
this.Agents = new FakeAIProjectAgentsOperations(agentName, instructions, description, agentDefinitionResponse, effectiveVersionMode);
|
||||
}
|
||||
|
||||
public override ClientConnection GetConnection(string connectionId)
|
||||
@@ -2739,60 +2927,82 @@ public sealed class AzureAIProjectChatClientExtensionsTests
|
||||
private readonly string? _instructions;
|
||||
private readonly string? _description;
|
||||
private readonly AgentDefinition? _agentDefinition;
|
||||
private readonly VersionMode _versionMode;
|
||||
|
||||
public FakeAIProjectAgentsOperations(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null)
|
||||
public FakeAIProjectAgentsOperations(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null, VersionMode versionMode = VersionMode.Normal)
|
||||
{
|
||||
this._agentName = agentName;
|
||||
this._instructions = instructions;
|
||||
this._description = description;
|
||||
this._agentDefinition = agentDefinitionResponse;
|
||||
this._versionMode = versionMode;
|
||||
}
|
||||
|
||||
private string GetAgentResponseJson()
|
||||
{
|
||||
return this._versionMode switch
|
||||
{
|
||||
VersionMode.Empty => TestDataUtil.GetAgentResponseJsonWithEmptyVersion(this._agentName, this._agentDefinition, this._instructions, this._description),
|
||||
VersionMode.Whitespace => TestDataUtil.GetAgentResponseJsonWithWhitespaceVersion(this._agentName, this._agentDefinition, this._instructions, this._description),
|
||||
_ => TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description)
|
||||
};
|
||||
}
|
||||
|
||||
private string GetAgentVersionResponseJson()
|
||||
{
|
||||
return this._versionMode switch
|
||||
{
|
||||
VersionMode.Empty => TestDataUtil.GetAgentVersionResponseJsonWithEmptyVersion(this._agentName, this._agentDefinition, this._instructions, this._description),
|
||||
VersionMode.Whitespace => TestDataUtil.GetAgentVersionResponseJsonWithWhitespaceVersion(this._agentName, this._agentDefinition, this._instructions, this._description),
|
||||
_ => TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description)
|
||||
};
|
||||
}
|
||||
|
||||
public override ClientResult GetAgent(string agentName, RequestOptions options)
|
||||
{
|
||||
var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
|
||||
var responseJson = this.GetAgentResponseJson();
|
||||
return ClientResult.FromValue(ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson)));
|
||||
}
|
||||
|
||||
public override ClientResult<AgentRecord> GetAgent(string agentName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
|
||||
var responseJson = this.GetAgentResponseJson();
|
||||
return ClientResult.FromValue(ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200));
|
||||
}
|
||||
|
||||
public override Task<ClientResult> GetAgentAsync(string agentName, RequestOptions options)
|
||||
{
|
||||
var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
|
||||
var responseJson = this.GetAgentResponseJson();
|
||||
return Task.FromResult<ClientResult>(ClientResult.FromValue(ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson))));
|
||||
}
|
||||
|
||||
public override Task<ClientResult<AgentRecord>> GetAgentAsync(string agentName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var responseJson = TestDataUtil.GetAgentResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
|
||||
var responseJson = this.GetAgentResponseJson();
|
||||
return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read<AgentRecord>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)));
|
||||
}
|
||||
|
||||
public override ClientResult CreateAgentVersion(string agentName, BinaryContent content, RequestOptions? options = null)
|
||||
{
|
||||
var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
|
||||
var responseJson = this.GetAgentVersionResponseJson();
|
||||
return ClientResult.FromValue(ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson)));
|
||||
}
|
||||
|
||||
public override ClientResult<AgentVersion> CreateAgentVersion(string agentName, AgentVersionCreationOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
|
||||
var responseJson = this.GetAgentVersionResponseJson();
|
||||
return ClientResult.FromValue(ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200));
|
||||
}
|
||||
|
||||
public override Task<ClientResult> CreateAgentVersionAsync(string agentName, BinaryContent content, RequestOptions? options = null)
|
||||
{
|
||||
var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
|
||||
var responseJson = this.GetAgentVersionResponseJson();
|
||||
return Task.FromResult<ClientResult>(ClientResult.FromValue(ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200, BinaryData.FromString(responseJson))));
|
||||
}
|
||||
|
||||
public override Task<ClientResult<AgentVersion>> CreateAgentVersionAsync(string agentName, AgentVersionCreationOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var responseJson = TestDataUtil.GetAgentVersionResponseJson(this._agentName, this._agentDefinition, this._instructions, this._description);
|
||||
var responseJson = this.GetAgentVersionResponseJson();
|
||||
return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read<AgentVersion>(BinaryData.FromString(responseJson))!, new MockPipelineResponse(200)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,70 @@ internal static class TestDataUtil
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent version response JSON with empty version and ID fields for testing hosted agents like MCP agents.
|
||||
/// </summary>
|
||||
public static string GetAgentVersionResponseJsonWithEmptyVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
|
||||
{
|
||||
var json = s_agentVersionResponseJson;
|
||||
json = ApplyAgentName(json, agentName);
|
||||
json = ApplyAgentDefinition(json, agentDefinition);
|
||||
json = ApplyInstructions(json, instructions);
|
||||
json = ApplyDescription(json, description);
|
||||
// Remove the version and id fields to simulate hosted agents without version
|
||||
json = json.Replace("\"version\": \"1\",", "\"version\": \"\",");
|
||||
json = json.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\",");
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent response JSON with empty version and ID fields in the latest version for testing hosted agents like MCP agents.
|
||||
/// </summary>
|
||||
public static string GetAgentResponseJsonWithEmptyVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
|
||||
{
|
||||
var json = s_agentResponseJson;
|
||||
json = ApplyAgentName(json, agentName);
|
||||
json = ApplyAgentDefinition(json, agentDefinition);
|
||||
json = ApplyInstructions(json, instructions);
|
||||
json = ApplyDescription(json, description);
|
||||
// Remove the version and id fields to simulate hosted agents without version
|
||||
json = json.Replace("\"version\": \"1\",", "\"version\": \"\",");
|
||||
json = json.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \"\",");
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent version response JSON with whitespace-only version and ID fields for testing hosted agents like MCP agents.
|
||||
/// </summary>
|
||||
public static string GetAgentVersionResponseJsonWithWhitespaceVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
|
||||
{
|
||||
var json = s_agentVersionResponseJson;
|
||||
json = ApplyAgentName(json, agentName);
|
||||
json = ApplyAgentDefinition(json, agentDefinition);
|
||||
json = ApplyInstructions(json, instructions);
|
||||
json = ApplyDescription(json, description);
|
||||
// Use whitespace-only version and id fields to simulate hosted agents without version
|
||||
return json
|
||||
.Replace("\"version\": \"1\",", "\"version\": \" \",")
|
||||
.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \" \",");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent response JSON with whitespace-only version and ID fields in the latest version for testing hosted agents like MCP agents.
|
||||
/// </summary>
|
||||
public static string GetAgentResponseJsonWithWhitespaceVersion(string? agentName = null, AgentDefinition? agentDefinition = null, string? instructions = null, string? description = null)
|
||||
{
|
||||
var json = s_agentResponseJson;
|
||||
json = ApplyAgentName(json, agentName);
|
||||
json = ApplyAgentDefinition(json, agentDefinition);
|
||||
json = ApplyInstructions(json, instructions);
|
||||
json = ApplyDescription(json, description);
|
||||
// Use whitespace-only version and id fields to simulate hosted agents without version
|
||||
return json
|
||||
.Replace("\"version\": \"1\",", "\"version\": \" \",")
|
||||
.Replace("\"id\": \"agent_abc123:1\",", "\"id\": \" \",");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the OpenAI default response JSON with optional placeholder replacements applied.
|
||||
/// </summary>
|
||||
|
||||
+31
-28
@@ -41,6 +41,9 @@ namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests;
|
||||
[Collection("CosmosDB")]
|
||||
public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
{
|
||||
private static readonly AIAgent s_mockAgent = new Moq.Mock<AIAgent>().Object;
|
||||
private static readonly AgentSession s_mockSession = new Moq.Mock<AgentSession>().Object;
|
||||
|
||||
// Cosmos DB Emulator connection settings
|
||||
private const string EmulatorEndpoint = "https://localhost:8081";
|
||||
private const string EmulatorKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
|
||||
@@ -214,7 +217,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, conversationId);
|
||||
var message = new ChatMessage(ChatRole.User, "Hello, world!");
|
||||
|
||||
var context = new ChatHistoryProvider.InvokedContext([message], [])
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, [message], [])
|
||||
{
|
||||
ResponseMessages = []
|
||||
};
|
||||
@@ -226,7 +229,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
var messages = await provider.InvokingAsync(invokingContext);
|
||||
var messageList = messages.ToList();
|
||||
|
||||
@@ -293,7 +296,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
new ChatMessage(ChatRole.Assistant, "Response message")
|
||||
};
|
||||
|
||||
var context = new ChatHistoryProvider.InvokedContext(requestMessages, [])
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, [])
|
||||
{
|
||||
AIContextProviderMessages = aiContextProviderMessages,
|
||||
ResponseMessages = responseMessages
|
||||
@@ -303,7 +306,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
await provider.InvokedAsync(context);
|
||||
|
||||
// Assert
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
var retrievedMessages = await provider.InvokingAsync(invokingContext);
|
||||
var messageList = retrievedMessages.ToList();
|
||||
Assert.Equal(5, messageList.Count);
|
||||
@@ -327,7 +330,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, Guid.NewGuid().ToString());
|
||||
|
||||
// Act
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
var messages = await provider.InvokingAsync(invokingContext);
|
||||
|
||||
// Assert
|
||||
@@ -346,15 +349,15 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
using var store1 = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, conversation1);
|
||||
using var store2 = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId, conversation2);
|
||||
|
||||
var context1 = new ChatHistoryProvider.InvokedContext([new ChatMessage(ChatRole.User, "Message for conversation 1")], []);
|
||||
var context2 = new ChatHistoryProvider.InvokedContext([new ChatMessage(ChatRole.User, "Message for conversation 2")], []);
|
||||
var context1 = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Message for conversation 1")], []);
|
||||
var context2 = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Message for conversation 2")], []);
|
||||
|
||||
await store1.InvokedAsync(context1);
|
||||
await store2.InvokedAsync(context2);
|
||||
|
||||
// Act
|
||||
var invokingContext1 = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext2 = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext1 = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
var invokingContext2 = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
|
||||
var messages1 = await store1.InvokingAsync(invokingContext1);
|
||||
var messages2 = await store2.InvokingAsync(invokingContext2);
|
||||
@@ -391,11 +394,11 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
};
|
||||
|
||||
// Act 1: Add messages
|
||||
var invokedContext = new ChatHistoryProvider.InvokedContext(messages, []);
|
||||
var invokedContext = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, messages, []);
|
||||
await originalStore.InvokedAsync(invokedContext);
|
||||
|
||||
// Act 2: Verify messages were added
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
var retrievedMessages = await originalStore.InvokingAsync(invokingContext);
|
||||
var retrievedList = retrievedMessages.ToList();
|
||||
Assert.Equal(5, retrievedList.Count);
|
||||
@@ -545,7 +548,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId);
|
||||
var message = new ChatMessage(ChatRole.User, "Hello from hierarchical partitioning!");
|
||||
|
||||
var context = new ChatHistoryProvider.InvokedContext([message], []);
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, [message], []);
|
||||
|
||||
// Act
|
||||
await provider.InvokedAsync(context);
|
||||
@@ -554,7 +557,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
var messages = await provider.InvokingAsync(invokingContext);
|
||||
var messageList = messages.ToList();
|
||||
|
||||
@@ -602,7 +605,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
new ChatMessage(ChatRole.User, "Third hierarchical message")
|
||||
};
|
||||
|
||||
var context = new ChatHistoryProvider.InvokedContext(messages, []);
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, messages, []);
|
||||
|
||||
// Act
|
||||
await provider.InvokedAsync(context);
|
||||
@@ -611,7 +614,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
var retrievedMessages = await provider.InvokingAsync(invokingContext);
|
||||
var messageList = retrievedMessages.ToList();
|
||||
|
||||
@@ -637,8 +640,8 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
using var store2 = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId2, SessionId);
|
||||
|
||||
// Add messages to both stores
|
||||
var context1 = new ChatHistoryProvider.InvokedContext([new ChatMessage(ChatRole.User, "Message from user 1")], []);
|
||||
var context2 = new ChatHistoryProvider.InvokedContext([new ChatMessage(ChatRole.User, "Message from user 2")], []);
|
||||
var context1 = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Message from user 1")], []);
|
||||
var context2 = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Message from user 2")], []);
|
||||
|
||||
await store1.InvokedAsync(context1);
|
||||
await store2.InvokedAsync(context2);
|
||||
@@ -647,8 +650,8 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act & Assert
|
||||
var invokingContext1 = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext2 = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext1 = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
var invokingContext2 = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
|
||||
var messages1 = await store1.InvokingAsync(invokingContext1);
|
||||
var messageList1 = messages1.ToList();
|
||||
@@ -675,7 +678,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
|
||||
using var originalStore = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId);
|
||||
|
||||
var context = new ChatHistoryProvider.InvokedContext([new ChatMessage(ChatRole.User, "Test serialization message")], []);
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Test serialization message")], []);
|
||||
await originalStore.InvokedAsync(context);
|
||||
|
||||
// Act - Serialize the provider state
|
||||
@@ -693,7 +696,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert - The deserialized provider should have the same functionality
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
var messages = await deserializedStore.InvokingAsync(invokingContext);
|
||||
var messageList = messages.ToList();
|
||||
|
||||
@@ -717,8 +720,8 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
using var hierarchicalProvider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-coexist", "user-coexist", SessionId);
|
||||
|
||||
// Add messages to both
|
||||
var simpleContext = new ChatHistoryProvider.InvokedContext([new ChatMessage(ChatRole.User, "Simple partitioning message")], []);
|
||||
var hierarchicalContext = new ChatHistoryProvider.InvokedContext([new ChatMessage(ChatRole.User, "Hierarchical partitioning message")], []);
|
||||
var simpleContext = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Simple partitioning message")], []);
|
||||
var hierarchicalContext = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Hierarchical partitioning message")], []);
|
||||
|
||||
await simpleProvider.InvokedAsync(simpleContext);
|
||||
await hierarchicalProvider.InvokedAsync(hierarchicalContext);
|
||||
@@ -727,7 +730,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act & Assert
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
|
||||
var simpleMessages = await simpleProvider.InvokingAsync(invokingContext);
|
||||
var simpleMessageList = simpleMessages.ToList();
|
||||
@@ -760,7 +763,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
await Task.Delay(10); // Small delay to ensure different timestamps
|
||||
}
|
||||
|
||||
var context = new ChatHistoryProvider.InvokedContext(messages, []);
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, messages, []);
|
||||
await provider.InvokedAsync(context);
|
||||
|
||||
// Wait for eventual consistency
|
||||
@@ -768,7 +771,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
|
||||
// Act - Set max to 5 and retrieve
|
||||
provider.MaxMessagesToRetrieve = 5;
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
var retrievedMessages = await provider.InvokingAsync(invokingContext);
|
||||
var messageList = retrievedMessages.ToList();
|
||||
|
||||
@@ -798,14 +801,14 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
|
||||
messages.Add(new ChatMessage(ChatRole.User, $"Message {i}"));
|
||||
}
|
||||
|
||||
var context = new ChatHistoryProvider.InvokedContext(messages, []);
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, s_mockSession, messages, []);
|
||||
await provider.InvokedAsync(context);
|
||||
|
||||
// Wait for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - No limit set (default null)
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext([]);
|
||||
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, s_mockSession, []);
|
||||
var retrievedMessages = await provider.InvokingAsync(invokingContext);
|
||||
var messageList = retrievedMessages.ToList();
|
||||
|
||||
|
||||
+7
-2
@@ -66,12 +66,17 @@ public sealed class AggregatorPromptAgentFactoryTests
|
||||
|
||||
private sealed class TestAgent : AIAgent
|
||||
{
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public sealed class DurableAgentSessionTests
|
||||
public void BuiltInSerialization()
|
||||
{
|
||||
AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent");
|
||||
AgentSession session = new DurableAgentSession(sessionId);
|
||||
DurableAgentSession session = new(sessionId);
|
||||
|
||||
JsonElement serializedSession = session.Serialize();
|
||||
|
||||
|
||||
@@ -175,7 +175,10 @@ public sealed class AIAgentExtensionsTests
|
||||
{
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock.Setup(x => x.CreateSessionAsync()).ReturnsAsync(new TestAgentSession());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
@@ -194,7 +197,10 @@ public sealed class AIAgentExtensionsTests
|
||||
{
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock.Setup(x => x.CreateSessionAsync()).ReturnsAsync(new TestAgentSession());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
|
||||
+21
-6
@@ -280,11 +280,14 @@ internal sealed class FakeChatClientAgent : AIAgent
|
||||
|
||||
public override string? Description => "A fake agent for testing";
|
||||
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default) =>
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
|
||||
new(new FakeInMemoryAgentSession());
|
||||
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(new FakeInMemoryAgentSession(serializedSession, jsonSerializerOptions));
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(new FakeInMemoryAgentSession(serializedState, jsonSerializerOptions));
|
||||
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
protected override async Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
@@ -344,11 +347,21 @@ internal sealed class FakeMultiMessageAgent : AIAgent
|
||||
|
||||
public override string? Description => "A fake agent that sends multiple messages for testing";
|
||||
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default) =>
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
|
||||
new(new FakeInMemoryAgentSession());
|
||||
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(new FakeInMemoryAgentSession(serializedSession, jsonSerializerOptions));
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(new FakeInMemoryAgentSession(serializedState, jsonSerializerOptions));
|
||||
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (session is not FakeInMemoryAgentSession fakeSession)
|
||||
{
|
||||
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
|
||||
}
|
||||
|
||||
return fakeSession.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
protected override async Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
@@ -425,6 +438,8 @@ internal sealed class FakeMultiMessageAgent : AIAgent
|
||||
: base(serializedSession, jsonSerializerOptions)
|
||||
{
|
||||
}
|
||||
internal new JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> base.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null) => null;
|
||||
|
||||
+16
-3
@@ -334,11 +334,21 @@ internal sealed class FakeForwardedPropsAgent : AIAgent
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default) =>
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
|
||||
new(new FakeInMemoryAgentSession());
|
||||
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(new FakeInMemoryAgentSession(serializedSession, jsonSerializerOptions));
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(new FakeInMemoryAgentSession(serializedState, jsonSerializerOptions));
|
||||
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (session is not FakeInMemoryAgentSession fakeSession)
|
||||
{
|
||||
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
|
||||
}
|
||||
|
||||
return fakeSession.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
private sealed class FakeInMemoryAgentSession : InMemoryAgentSession
|
||||
{
|
||||
@@ -351,6 +361,9 @@ internal sealed class FakeForwardedPropsAgent : AIAgent
|
||||
: base(serializedSession, jsonSerializerOptions)
|
||||
{
|
||||
}
|
||||
|
||||
internal new JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> base.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null) => null;
|
||||
|
||||
+16
-3
@@ -417,11 +417,21 @@ internal sealed class FakeStateAgent : AIAgent
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default) =>
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
|
||||
new(new FakeInMemoryAgentSession());
|
||||
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(new FakeInMemoryAgentSession(serializedSession, jsonSerializerOptions));
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(new FakeInMemoryAgentSession(serializedState, jsonSerializerOptions));
|
||||
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (session is not FakeInMemoryAgentSession fakeSession)
|
||||
{
|
||||
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
|
||||
}
|
||||
|
||||
return fakeSession.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
private sealed class FakeInMemoryAgentSession : InMemoryAgentSession
|
||||
{
|
||||
@@ -434,6 +444,9 @@ internal sealed class FakeStateAgent : AIAgent
|
||||
: base(serializedSession, jsonSerializerOptions)
|
||||
{
|
||||
}
|
||||
|
||||
internal new JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> base.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null) => null;
|
||||
|
||||
+29
-6
@@ -425,11 +425,21 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
|
||||
|
||||
public override string? Description => "Agent that produces multiple text chunks";
|
||||
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default) =>
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
|
||||
new(new TestInMemoryAgentSession());
|
||||
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(new TestInMemoryAgentSession(serializedSession, jsonSerializerOptions));
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(new TestInMemoryAgentSession(serializedState, jsonSerializerOptions));
|
||||
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (session is not TestInMemoryAgentSession testSession)
|
||||
{
|
||||
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
|
||||
}
|
||||
|
||||
return testSession.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -507,6 +517,9 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
|
||||
: base(serializedSessionState, jsonSerializerOptions, null)
|
||||
{
|
||||
}
|
||||
|
||||
internal new JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> base.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
private sealed class TestAgent : AIAgent
|
||||
@@ -515,11 +528,21 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
|
||||
|
||||
public override string? Description => "Test agent";
|
||||
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default) =>
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
|
||||
new(new TestInMemoryAgentSession());
|
||||
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(new TestInMemoryAgentSession(serializedSession, jsonSerializerOptions));
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
|
||||
new(new TestInMemoryAgentSession(serializedState, jsonSerializerOptions));
|
||||
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (session is not TestInMemoryAgentSession testSession)
|
||||
{
|
||||
throw new InvalidOperationException("The provided session is not compatible with the agent. Only sessions created by the agent can be serialized.");
|
||||
}
|
||||
|
||||
return testSession.Serialize(jsonSerializerOptions);
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -11,10 +11,13 @@ internal sealed class TestAgent(string name, string description) : AIAgent
|
||||
|
||||
public override string? Description => description;
|
||||
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default) => new(new DummyAgentSession());
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) => new(new DummyAgentSession());
|
||||
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(
|
||||
JsonElement serializedSession,
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new DummyAgentSession());
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
|
||||
@@ -18,6 +18,9 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
{
|
||||
private const string SkipReason = "Requires a Mem0 service configured"; // Set to null to enable.
|
||||
|
||||
private static readonly AIAgent s_mockAgent = new Moq.Mock<AIAgent>().Object;
|
||||
private static readonly AgentSession s_mockSession = new Moq.Mock<AgentSession>().Object;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public Mem0ProviderTests()
|
||||
@@ -49,14 +52,14 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
var sut = new Mem0Provider(this._httpClient, storageScope);
|
||||
|
||||
await sut.ClearStoredMemoriesAsync();
|
||||
var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext([question]));
|
||||
var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [question]));
|
||||
Assert.DoesNotContain("Caoimhe", ctxBefore.Messages?[0].Text ?? string.Empty);
|
||||
|
||||
// Act
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext([input], aiContextProviderMessages: null));
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, [input], aiContextProviderMessages: null));
|
||||
var ctxAfterAdding = await GetContextWithRetryAsync(sut, question);
|
||||
await sut.ClearStoredMemoriesAsync();
|
||||
var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext([question]));
|
||||
var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [question]));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Caoimhe", ctxAfterAdding.Messages?[0].Text ?? string.Empty);
|
||||
@@ -73,14 +76,14 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
var sut = new Mem0Provider(this._httpClient, storageScope);
|
||||
|
||||
await sut.ClearStoredMemoriesAsync();
|
||||
var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext([question]));
|
||||
var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [question]));
|
||||
Assert.DoesNotContain("Caoimhe", ctxBefore.Messages?[0].Text ?? string.Empty);
|
||||
|
||||
// Act
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext([assistantIntro], aiContextProviderMessages: null));
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, [assistantIntro], aiContextProviderMessages: null));
|
||||
var ctxAfterAdding = await GetContextWithRetryAsync(sut, question);
|
||||
await sut.ClearStoredMemoriesAsync();
|
||||
var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext([question]));
|
||||
var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [question]));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Caoimhe", ctxAfterAdding.Messages?[0].Text ?? string.Empty);
|
||||
@@ -99,13 +102,13 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
await sut1.ClearStoredMemoriesAsync();
|
||||
await sut2.ClearStoredMemoriesAsync();
|
||||
|
||||
var ctxBefore1 = await sut1.InvokingAsync(new AIContextProvider.InvokingContext([question]));
|
||||
var ctxBefore2 = await sut2.InvokingAsync(new AIContextProvider.InvokingContext([question]));
|
||||
var ctxBefore1 = await sut1.InvokingAsync(new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [question]));
|
||||
var ctxBefore2 = await sut2.InvokingAsync(new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [question]));
|
||||
Assert.DoesNotContain("Caoimhe", ctxBefore1.Messages?[0].Text ?? string.Empty);
|
||||
Assert.DoesNotContain("Caoimhe", ctxBefore2.Messages?[0].Text ?? string.Empty);
|
||||
|
||||
// Act
|
||||
await sut1.InvokedAsync(new AIContextProvider.InvokedContext([assistantIntro], aiContextProviderMessages: null));
|
||||
await sut1.InvokedAsync(new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, [assistantIntro], aiContextProviderMessages: null));
|
||||
var ctxAfterAdding1 = await GetContextWithRetryAsync(sut1, question);
|
||||
var ctxAfterAdding2 = await GetContextWithRetryAsync(sut2, question);
|
||||
|
||||
@@ -123,7 +126,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
AIContext? ctx = null;
|
||||
for (int i = 0; i < attempts; i++)
|
||||
{
|
||||
ctx = await provider.InvokingAsync(new AIContextProvider.InvokingContext([question]), CancellationToken.None);
|
||||
ctx = await provider.InvokingAsync(new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [question]), CancellationToken.None);
|
||||
var text = ctx.Messages?[0].Text;
|
||||
if (!string.IsNullOrEmpty(text) && text.IndexOf("Caoimhe", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
|
||||
@@ -18,6 +18,9 @@ namespace Microsoft.Agents.AI.Mem0.UnitTests;
|
||||
/// </summary>
|
||||
public sealed class Mem0ProviderTests : IDisposable
|
||||
{
|
||||
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
|
||||
private static readonly AgentSession s_mockSession = new Mock<AgentSession>().Object;
|
||||
|
||||
private readonly Mock<ILogger<Mem0Provider>> _loggerMock;
|
||||
private readonly Mock<ILoggerFactory> _loggerFactoryMock;
|
||||
private readonly RecordingHandler _handler = new();
|
||||
@@ -96,7 +99,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
UserId = "user"
|
||||
};
|
||||
var sut = new Mem0Provider(this._httpClient, storageScope, options: new() { EnableSensitiveTelemetryData = true }, loggerFactory: this._loggerFactoryMock.Object);
|
||||
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "What is my name?")]);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "What is my name?")]);
|
||||
|
||||
// Act
|
||||
var aiContext = await sut.InvokingAsync(invokingContext);
|
||||
@@ -161,7 +164,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
var options = new Mem0ProviderOptions { EnableSensitiveTelemetryData = enableSensitiveTelemetryData };
|
||||
|
||||
var sut = new Mem0Provider(this._httpClient, storageScope, options: options, loggerFactory: this._loggerFactoryMock.Object);
|
||||
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "Who am I?")]);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Who am I?")]);
|
||||
|
||||
// Act
|
||||
await sut.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
@@ -215,7 +218,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages });
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages });
|
||||
|
||||
// Assert
|
||||
var memoryPosts = this._handler.Requests.Where(r => r.RequestMessage.RequestUri!.AbsolutePath == "/v1/memories/" && r.RequestMessage.Method == HttpMethod.Post).ToList();
|
||||
@@ -242,7 +245,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = null, InvokeException = new InvalidOperationException("Request Failed") });
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, aiContextProviderMessages: null) { ResponseMessages = null, InvokeException = new InvalidOperationException("Request Failed") });
|
||||
|
||||
// Assert
|
||||
Assert.Empty(this._handler.Requests);
|
||||
@@ -268,7 +271,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages });
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages });
|
||||
|
||||
// Assert
|
||||
this._loggerMock.Verify(
|
||||
@@ -318,7 +321,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages });
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(s_mockAgent, s_mockSession, requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages });
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedLogCount, this._loggerMock.Invocations.Count);
|
||||
@@ -400,7 +403,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
// Arrange
|
||||
var storageScope = new Mem0ProviderScope { ApplicationId = "app" };
|
||||
var provider = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object);
|
||||
var invokingContext = new AIContextProvider.InvokingContext([new ChatMessage(ChatRole.User, "Q?")]);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(s_mockAgent, s_mockSession, [new ChatMessage(ChatRole.User, "Q?")]);
|
||||
|
||||
// Act
|
||||
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
@@ -418,6 +418,51 @@ public class AIAgentBuilderTests
|
||||
Assert.IsType<AnonymousDelegatingAIAgent>(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that Use with both delegates allows both to access AgentRunContext.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Use_WithBothDelegates_AllowsDelegateToAccessAgentRunContextAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var mockSession = new Mock<AgentSession>();
|
||||
var builder = new AIAgentBuilder(mockAgent.Object);
|
||||
|
||||
AIAgent? builtAgent = null;
|
||||
|
||||
bool nonStreamingMiddlewareExecuted = false;
|
||||
bool streamingMiddlwareExecuted = true;
|
||||
|
||||
builtAgent = builder.Use(
|
||||
(_, _, _, _, _) =>
|
||||
{
|
||||
Assert.NotNull(AIAgent.CurrentRunContext);
|
||||
Assert.Same(builtAgent, AIAgent.CurrentRunContext.Agent);
|
||||
Assert.Same(mockSession.Object, AIAgent.CurrentRunContext.Session);
|
||||
nonStreamingMiddlewareExecuted = true;
|
||||
return Task.FromResult(new AgentResponse());
|
||||
},
|
||||
(_, _, _, _, _) =>
|
||||
{
|
||||
Assert.NotNull(AIAgent.CurrentRunContext);
|
||||
Assert.Same(builtAgent, AIAgent.CurrentRunContext.Agent);
|
||||
Assert.Same(mockSession.Object, AIAgent.CurrentRunContext.Session);
|
||||
streamingMiddlwareExecuted = true;
|
||||
return AsyncEnumerable.Empty<AgentResponseUpdate>();
|
||||
}).Build();
|
||||
|
||||
// Act
|
||||
await builtAgent.RunAsync("Input message", mockSession.Object);
|
||||
await foreach (var update in builtAgent.RunStreamingAsync("Input message", mockSession.Object))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.True(nonStreamingMiddlewareExecuted);
|
||||
Assert.True(streamingMiddlwareExecuted);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -382,10 +382,13 @@ public class AgentExtensionsTests
|
||||
this._exceptionToThrow = exceptionToThrow;
|
||||
}
|
||||
|
||||
public override ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public override ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedSession, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
protected override JsonElement SerializeSessionCore(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public override string? Name { get; }
|
||||
|
||||
@@ -327,4 +327,9 @@ public class ChatClientAgentSessionTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
internal sealed class Animal
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user