mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
394e9c1692 | ||
|
|
7e98b0cd29 | ||
|
|
f7e4143c61 | ||
|
|
d8d6ac1c59 | ||
|
|
4bd5469798 | ||
|
|
1ac68f65bf | ||
|
|
8664d19285 | ||
|
|
ce7b5b17c1 |
@@ -56,7 +56,6 @@
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step15_DeepResearch/Agent_Step15_DeepResearch.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step16_Declarative/Agent_Step16_Declarative.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
@@ -285,8 +284,11 @@
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/HostedAgents/">
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTools/AgentWithTools.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj" />
|
||||
</Folder>
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,120 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a ChatHistoryCompactionPipeline as the ChatReducer for an agent's
|
||||
// in-memory chat history. The pipeline chains multiple compaction strategies from gentle to aggressive:
|
||||
// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries
|
||||
// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans
|
||||
// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns
|
||||
// 4. TruncationCompactionStrategy - Emergency token-budget backstop
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
// Create a chat client for the agent and a separate one for the summarization strategy.
|
||||
// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization.
|
||||
IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
|
||||
IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
|
||||
|
||||
// Define a tool the agent can use, so we can see tool-result compaction in action.
|
||||
[Description("Look up the current price of a product by name.")]
|
||||
static string LookupPrice([Description("The product name to look up.")] string productName) =>
|
||||
productName.ToUpperInvariant() switch
|
||||
{
|
||||
"LAPTOP" => "The laptop costs $999.99.",
|
||||
"KEYBOARD" => "The keyboard costs $79.99.",
|
||||
"MOUSE" => "The mouse costs $29.99.",
|
||||
_ => $"Sorry, I don't have pricing for '{productName}'."
|
||||
};
|
||||
|
||||
// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive.
|
||||
const int MaxTokens = 512;
|
||||
const int MaxTurns = 4;
|
||||
|
||||
ChatHistoryCompactionPipeline compactionPipeline =
|
||||
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
|
||||
new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2),
|
||||
|
||||
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
|
||||
new SummarizationCompactionStrategy(summarizerChatClient, MaxTokens, preserveRecentGroups: 2),
|
||||
|
||||
// 3. Aggressive: keep only the last N user turns and their responses
|
||||
new SlidingWindowCompactionStrategy(MaxTurns),
|
||||
|
||||
// 4. Emergency: drop oldest groups until under the token budget
|
||||
new TruncationCompactionStrategy(MaxTokens, preserveRecentGroups: 1));
|
||||
|
||||
// TODO: PRECONFIGURED PIPELINE
|
||||
////Create(
|
||||
//// Approach.Balanced,
|
||||
//// Size.Compact,
|
||||
//// summarizerChatClient);
|
||||
|
||||
// Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline.
|
||||
AIAgent agent =
|
||||
agentChatClient.AsAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ShoppingAssistant",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are a helpful, but long winded, shopping assistant.
|
||||
Help the user look up prices and compare products.
|
||||
When responding, Be sure to be extra descriptive and use as
|
||||
many words as possible without sounding ridiculous.
|
||||
""",
|
||||
Tools = [AIFunctionFactory.Create(LookupPrice)],
|
||||
},
|
||||
ChatHistoryProvider = new InMemoryChatHistoryProvider(new() { ChatReducer = compactionPipeline }),
|
||||
});
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Helper to print chat history size
|
||||
void PrintChatHistory()
|
||||
{
|
||||
if (session.TryGetInMemoryChatHistory(out var history))
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"\n[Messages: x{history.Count}]\n");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
// Run a multi-turn conversation with tool calls to exercise the pipeline.
|
||||
string[] prompts =
|
||||
[
|
||||
"What's the price of a laptop?",
|
||||
"How about a keyboard?",
|
||||
"And a mouse?",
|
||||
"Which product is the cheapest?",
|
||||
"Can you compare the laptop and the keyboard for me?",
|
||||
"What was the first product I asked about?",
|
||||
"Thank you!",
|
||||
];
|
||||
|
||||
foreach (string prompt in prompts)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("\n[User] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(prompt);
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("\n[Agent] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(await agent.RunAsync(prompt, session));
|
||||
PrintChatHistory();
|
||||
}
|
||||
@@ -44,7 +44,6 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Deep research with an agent](./Agent_Step15_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics|
|
||||
|[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|
||||
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|
||||
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
+3
-4
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -36,11 +36,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -11,9 +11,10 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
[Description("Get the weather for a given location.")]
|
||||
static string GetWeather([Description("The location to get the weather for.")] string location)
|
||||
@@ -22,17 +23,19 @@ static string GetWeather([Description("The location to get the weather for.")] s
|
||||
// Create the chat client and agent.
|
||||
// Note: ApprovalRequiredAIFunction wraps the tool to require user approval before invocation.
|
||||
// User should reply with 'approve' or 'reject' when prompted.
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.CreateAIAgent(
|
||||
.AsAIAgent(
|
||||
instructions: "You are a helpful assistant",
|
||||
tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))]
|
||||
);
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
var threadRepository = new InMemoryAgentThreadRepository(agent);
|
||||
InMemoryAgentThreadRepository threadRepository = new(agent);
|
||||
await agent.RunAIAgentAsync(telemetrySourceName: "Agents", threadRepository: threadRepository);
|
||||
|
||||
+2
-2
@@ -35,10 +35,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.6" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -9,9 +9,10 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// Create an MCP tool that can be called without approval.
|
||||
AITool mcpTool = new HostedMcpServerTool(serverName: "microsoft_learn", serverAddress: "https://learn.microsoft.com/api/mcp")
|
||||
@@ -28,8 +29,7 @@ AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetResponsesClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.CreateAIAgent(
|
||||
.AsAIAgent(
|
||||
instructions: "You answer questions by searching the Microsoft Learn content only.",
|
||||
name: "MicrosoftLearnAgent",
|
||||
tools: [mcpTool]);
|
||||
|
||||
@@ -18,7 +18,7 @@ Before running this sample, ensure you have:
|
||||
2. A deployment of a chat model (e.g., gpt-4o-mini)
|
||||
3. Azure CLI installed and authenticated
|
||||
|
||||
**Note**: This sample uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource.
|
||||
**Note**: This sample uses `DefaultAzureCredential` for authentication, which probes multiple sources automatically. For local development, make sure you're logged in with `az login` and have access to the Azure OpenAI resource.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
||||
+2
-2
@@ -36,11 +36,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
|
||||
<PackageReference Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -15,21 +15,21 @@ using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
Console.WriteLine($"Project Endpoint: {endpoint}");
|
||||
Console.WriteLine($"Model Deployment: {deploymentName}");
|
||||
|
||||
var seattleHotels = new[]
|
||||
{
|
||||
Hotel[] seattleHotels =
|
||||
[
|
||||
new Hotel("Contoso Suites", 189, 4.5, "Downtown"),
|
||||
new Hotel("Fabrikam Residences", 159, 4.2, "Pike Place Market"),
|
||||
new Hotel("Alpine Ski House", 249, 4.7, "Seattle Center"),
|
||||
new Hotel("Margie's Travel Lodge", 219, 4.4, "Waterfront"),
|
||||
new Hotel("Northwind Inn", 139, 4.0, "Capitol Hill"),
|
||||
new Hotel("Relecloud Hotel", 99, 3.8, "University District"),
|
||||
};
|
||||
];
|
||||
|
||||
[Description("Get available hotels in Seattle for the specified dates. This simulates a call to a hotel availability API.")]
|
||||
string GetAvailableHotels(
|
||||
@@ -54,21 +54,21 @@ string GetAvailableHotels(
|
||||
return "Error: Check-out date must be after check-in date.";
|
||||
}
|
||||
|
||||
var nights = (checkOut - checkIn).Days;
|
||||
var availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
|
||||
int nights = (checkOut - checkIn).Days;
|
||||
List<Hotel> availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
|
||||
|
||||
if (availableHotels.Count == 0)
|
||||
{
|
||||
return $"No hotels found in Seattle within your budget of ${maxPrice}/night.";
|
||||
}
|
||||
|
||||
var result = new StringBuilder();
|
||||
StringBuilder result = new();
|
||||
result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):");
|
||||
result.AppendLine();
|
||||
|
||||
foreach (var hotel in availableHotels)
|
||||
foreach (Hotel hotel in availableHotels)
|
||||
{
|
||||
var totalCost = hotel.PricePerNight * nights;
|
||||
int totalCost = hotel.PricePerNight * nights;
|
||||
result.AppendLine($"**{hotel.Name}**");
|
||||
result.AppendLine($" Location: {hotel.Location}");
|
||||
result.AppendLine($" Rating: {hotel.Rating}/5");
|
||||
@@ -84,7 +84,10 @@ string GetAvailableHotels(
|
||||
}
|
||||
}
|
||||
|
||||
var credential = new AzureCliCredential();
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
DefaultAzureCredential credential = new();
|
||||
AIProjectClient projectClient = new(new Uri(endpoint), credential);
|
||||
|
||||
ClientConnection connection = projectClient.GetConnection(typeof(AzureOpenAIClient).FullName!);
|
||||
@@ -96,14 +99,14 @@ if (!connection.TryGetLocatorAsUri(out Uri? openAiEndpoint) || openAiEndpoint is
|
||||
openAiEndpoint = new Uri($"https://{openAiEndpoint.Host}");
|
||||
Console.WriteLine($"OpenAI Endpoint: {openAiEndpoint}");
|
||||
|
||||
var chatClient = new AzureOpenAIClient(openAiEndpoint, credential)
|
||||
IChatClient chatClient = new AzureOpenAIClient(openAiEndpoint, credential)
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false)
|
||||
.Build();
|
||||
|
||||
var agent = new ChatClientAgent(chatClient,
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "SeattleHotelAgent",
|
||||
instructions: """
|
||||
You are a helpful travel assistant specializing in finding hotels in Seattle, Washington.
|
||||
|
||||
+2
-3
@@ -35,11 +35,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -11,8 +11,8 @@ using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
TextSearchProviderOptions textSearchOptions = new()
|
||||
{
|
||||
@@ -28,13 +28,13 @@ AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
},
|
||||
AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
AIContextProviders = [new TextSearchProvider(MockSearchAsync, textSearchOptions)]
|
||||
});
|
||||
|
||||
await agent.RunAIAgentAsync();
|
||||
|
||||
@@ -35,11 +35,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -9,13 +9,16 @@ using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var toolConnectionId = Environment.GetEnvironmentVariable("MCP_TOOL_CONNECTION_ID") ?? throw new InvalidOperationException("MCP_TOOL_CONNECTION_ID is not set.");
|
||||
string openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string toolConnectionId = Environment.GetEnvironmentVariable("MCP_TOOL_CONNECTION_ID") ?? throw new InvalidOperationException("MCP_TOOL_CONNECTION_ID is not set.");
|
||||
|
||||
var credential = new AzureCliCredential();
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
DefaultAzureCredential credential = new();
|
||||
|
||||
var chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
|
||||
IChatClient chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
@@ -23,7 +26,7 @@ var chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
|
||||
.UseOpenTelemetry(sourceName: "Agents", configure: (cfg) => cfg.EnableSensitiveData = true)
|
||||
.Build();
|
||||
|
||||
var agent = new ChatClientAgent(chatClient,
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "AgentWithTools",
|
||||
instructions: @"You are a helpful assistant with access to tools for fetching Microsoft documentation.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Key features:
|
||||
|
||||
- Configuring Foundry tools using `UseFoundryTools` with MCP and code interpreter
|
||||
- Connecting to an external MCP tool via a Foundry project connection
|
||||
- Using `AzureCliCredential` for Azure authentication
|
||||
- Using `DefaultAzureCredential` for Azure authentication
|
||||
- OpenTelemetry instrumentation for both the chat client and agent
|
||||
|
||||
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
|
||||
@@ -36,7 +36,7 @@ $env:MCP_TOOL_CONNECTION_ID="SampleMCPTool"
|
||||
|
||||
## How It Works
|
||||
|
||||
1. An `AzureOpenAIClient` is created with `AzureCliCredential` and used to get a chat client
|
||||
1. An `AzureOpenAIClient` is created with `DefaultAzureCredential` and used to get a chat client
|
||||
2. The chat client is wrapped with `UseFoundryTools` which registers two Foundry tool types:
|
||||
- **MCP connection**: Connects to an external MCP server (Microsoft Learn) via the project connection name, providing documentation fetch and search capabilities
|
||||
- **Code interpreter**: Allows the agent to execute code snippets when needed
|
||||
|
||||
+3
-4
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -35,11 +35,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251219.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -12,8 +12,8 @@ using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Set up the Azure OpenAI client
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
@@ -32,9 +32,9 @@ AIAgent agent = new WorkflowBuilder(frenchAgent)
|
||||
.AddEdge(frenchAgent, spanishAgent)
|
||||
.AddEdge(spanishAgent, englishAgent)
|
||||
.Build()
|
||||
.AsAgent();
|
||||
.AsAIAgent();
|
||||
|
||||
await agent.RunAIAgentAsync();
|
||||
|
||||
static ChatClientAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
|
||||
new(chatClient, $"You are a translation assistant that translates the provided text to {targetLanguage}.");
|
||||
static AIAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
|
||||
chatClient.AsAIAgent($"You are a translation assistant that translates the provided text to {targetLanguage}.");
|
||||
|
||||
@@ -19,7 +19,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
- Azure OpenAI service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
|
||||
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
**Note**: This demo uses `DefaultAzureCredential` for authentication, which probes multiple sources automatically. For local development, make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Before running any sample, ensure you have:
|
||||
|
||||
### Authenticate with Azure CLI
|
||||
|
||||
All samples use `AzureCliCredential` for authentication. Make sure you're logged in:
|
||||
All samples use `DefaultAzureCredential` for authentication, which automatically probes multiple credential sources (environment variables, managed identity, Azure CLI, etc.). For local development, the simplest approach is to authenticate via Azure CLI:
|
||||
|
||||
```powershell
|
||||
az login
|
||||
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
public partial class ChatHistoryCompactionPipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// %%% COMMENT
|
||||
/// </summary>
|
||||
public enum Size
|
||||
{
|
||||
/// <summary>
|
||||
/// %%% COMMENT
|
||||
/// </summary>
|
||||
Compact,
|
||||
/// <summary>
|
||||
/// %%% COMMENT
|
||||
/// </summary>
|
||||
Adequate,
|
||||
/// <summary>
|
||||
/// %%% COMMENT
|
||||
/// </summary>
|
||||
Accomodating,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// %%% COMMENT
|
||||
/// </summary>
|
||||
public enum Approach
|
||||
{
|
||||
/// <summary>
|
||||
/// %%% COMMENT
|
||||
/// </summary>
|
||||
Aggressive,
|
||||
/// <summary>
|
||||
/// %%% COMMENT
|
||||
/// </summary>
|
||||
Balanced,
|
||||
/// <summary>
|
||||
/// %%% COMMENT
|
||||
/// </summary>
|
||||
Gentle,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// %%% COMMENT
|
||||
/// </summary>
|
||||
/// <param name="approach"></param>
|
||||
/// <param name="size"></param>
|
||||
/// <param name="chatClient"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="NotImplementedException"></exception>
|
||||
public static ChatHistoryCompactionPipeline Create(Approach approach, Size size, IChatClient chatClient) =>
|
||||
approach switch
|
||||
{
|
||||
Approach.Aggressive => CreateAgressive(size, chatClient),
|
||||
Approach.Balanced => CreateBalanced(size),
|
||||
Approach.Gentle => CreateGentle(size),
|
||||
_ => throw new NotImplementedException(), // %%% EXCEPTION
|
||||
};
|
||||
|
||||
private static ChatHistoryCompactionPipeline CreateAgressive(Size size, IChatClient chatClient) =>
|
||||
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
|
||||
new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2),
|
||||
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
|
||||
new SummarizationCompactionStrategy(chatClient, MaxTokens(size), preserveRecentGroups: 2),
|
||||
// 3. Aggressive: keep only the last N user turns and their responses
|
||||
new SlidingWindowCompactionStrategy(MaxTurns(size)),
|
||||
// 4. Emergency: drop oldest groups until under the token budget
|
||||
new TruncationCompactionStrategy(MaxTokens(size), preserveRecentGroups: 1));
|
||||
|
||||
private static ChatHistoryCompactionPipeline CreateBalanced(Size size) =>
|
||||
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
|
||||
new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2),
|
||||
// 2. Aggressive: keep only the last N user turns and their responses
|
||||
new SlidingWindowCompactionStrategy(MaxTurns(size)));
|
||||
|
||||
private static ChatHistoryCompactionPipeline CreateGentle(Size size) =>
|
||||
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
|
||||
new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2));
|
||||
|
||||
private static int MaxTokens(Size size) =>
|
||||
size switch
|
||||
{
|
||||
Size.Compact => 500,
|
||||
Size.Adequate => 1000,
|
||||
Size.Accomodating => 2000,
|
||||
_ => throw new NotImplementedException(), // %%% EXCEPTION
|
||||
};
|
||||
|
||||
private static int MaxTurns(Size size) =>
|
||||
size switch
|
||||
{
|
||||
Size.Compact => 10,
|
||||
Size.Adequate => 50,
|
||||
Size.Accomodating => 100,
|
||||
_ => throw new NotImplementedException(), // %%% EXCEPTION
|
||||
};
|
||||
}
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Executes a chain of <see cref="ChatHistoryCompactionStrategy"/> instances in order
|
||||
/// against a mutable message list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each strategy's trigger is evaluated against the metrics <em>as they stand after prior strategies</em>,
|
||||
/// so earlier strategies can bring the conversation within thresholds that cause later strategies to skip.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The pipeline is fully standalone — it can be used without any agent, session, or context provider.
|
||||
/// It also implements <see cref="IChatReducer"/> so it can be used directly anywhere a reducer is
|
||||
/// accepted (e.g., <see cref="InMemoryChatHistoryProviderOptions.ChatReducer"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public partial class ChatHistoryCompactionPipeline : IChatReducer
|
||||
{
|
||||
private readonly ChatHistoryCompactionStrategy[] _strategies;
|
||||
private readonly IChatHistoryMetricsCalculator _metricsCalculator;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryCompactionPipeline"/> class.
|
||||
/// </summary>
|
||||
/// <param name="strategies">The ordered list of compaction strategies to execute.</param>
|
||||
/// <remarks>
|
||||
/// By default, <see cref="DefaultChatHistoryMetricsCalculator"/> is used.
|
||||
/// </remarks>
|
||||
public ChatHistoryCompactionPipeline(
|
||||
params IEnumerable<ChatHistoryCompactionStrategy> strategies)
|
||||
: this(metricsCalculator: null, strategies) { }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryCompactionPipeline"/> class.
|
||||
/// </summary>
|
||||
/// <param name="metricsCalculator">
|
||||
/// An optional metrics calculator. When <see langword="null"/>, a
|
||||
/// <see cref="DefaultChatHistoryMetricsCalculator"/> is used.
|
||||
/// </param>
|
||||
/// <param name="strategies">The ordered list of compaction strategies to execute.</param>
|
||||
public ChatHistoryCompactionPipeline(
|
||||
IChatHistoryMetricsCalculator? metricsCalculator,
|
||||
params IEnumerable<ChatHistoryCompactionStrategy> strategies)
|
||||
{
|
||||
this._strategies = [.. Throw.IfNull(strategies)];
|
||||
this._metricsCalculator = metricsCalculator ?? DefaultChatHistoryMetricsCalculator.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reduces the given messages by running all strategies in sequence.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to reduce.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>The reduced set of messages.</returns>
|
||||
public virtual async Task<IEnumerable<ChatMessage>> ReduceAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<ChatMessage> messageBuffer = messages is List<ChatMessage> messageList ? messageList : [.. messages];
|
||||
await this.CompactAsync(messageBuffer, cancellationToken).ConfigureAwait(false);
|
||||
return messageBuffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run all strategies in sequence against the given messages.
|
||||
/// </summary>
|
||||
/// <param name="messages">The mutable message list to compact.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A <see cref="CompactionPipelineResult"/> with aggregate and per-strategy metrics.</returns>
|
||||
public async ValueTask<CompactionPipelineResult> CompactAsync(
|
||||
List<ChatMessage> messages,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(messages);
|
||||
|
||||
ChatHistoryMetric overallBefore = this._metricsCalculator.Calculate(messages);
|
||||
|
||||
Debug.WriteLine($"COMPACTION: BEGIN x{overallBefore.MessageCount}/#{overallBefore.UserTurnCount} ({overallBefore.TokenCount} tokens)");
|
||||
|
||||
List<CompactionResult> compactionResults = new(this._strategies.Length);
|
||||
|
||||
Stopwatch timer = new();
|
||||
TimeSpan startTime = TimeSpan.Zero;
|
||||
ChatHistoryMetric overallAfter = overallBefore;
|
||||
ChatHistoryMetric currentBefore = overallBefore;
|
||||
foreach (ChatHistoryCompactionStrategy strategy in this._strategies)
|
||||
{
|
||||
// %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} START");
|
||||
timer.Start();
|
||||
ChatHistoryCompactionStrategy.s_currentMetrics.Value = currentBefore;
|
||||
CompactionResult strategyResult = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false);
|
||||
timer.Stop();
|
||||
TimeSpan elapsedTime = timer.Elapsed - startTime;
|
||||
// %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} FINISH [{elapsedTime}]");
|
||||
compactionResults.Add(strategyResult);
|
||||
overallAfter = currentBefore = strategyResult.After;
|
||||
}
|
||||
|
||||
Debug.WriteLineIf(overallBefore.TokenCount != overallAfter.TokenCount, $"COMPACTION: TOTAL [{timer.Elapsed}] {overallBefore.TokenCount} => {overallAfter.TokenCount} tokens");
|
||||
|
||||
return new(overallBefore, overallAfter, compactionResults);
|
||||
}
|
||||
}
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A named compaction strategy with an optional conditional trigger that delegates
|
||||
/// actual message reduction to an <see cref="IChatReducer"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each strategy wraps an <see cref="IChatReducer"/> that performs the actual compaction,
|
||||
/// while the strategy adds:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A conditional trigger via <see cref="ShouldCompact"/> that decides whether compaction runs.</description></item>
|
||||
/// <item><description>Before/after <see cref="ChatHistoryMetric"/> reporting via <see cref="CompactionResult"/>.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For simple cases, construct a <see cref="ChatHistoryCompactionStrategy"/> directly with any
|
||||
/// <see cref="IChatReducer"/>. For custom trigger logic, subclass and override <see cref="ShouldCompact"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reducers <b>must</b> preserve atomic message groups: an assistant message containing
|
||||
/// tool calls and its corresponding tool result messages must be kept or removed together.
|
||||
/// Use <see cref="DefaultChatHistoryMetricsCalculator"/> to identify these groups when authoring custom reducers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class ChatHistoryCompactionStrategy
|
||||
{
|
||||
internal static readonly AsyncLocal<ChatHistoryMetric> s_currentMetrics = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="reducer">The <see cref="IChatReducer"/> that performs the actual message compaction.</param>
|
||||
protected ChatHistoryCompactionStrategy(IChatReducer reducer)
|
||||
{
|
||||
this.Reducer = Throw.IfNull(reducer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the current <see cref="ChatHistoryMetric"/> for the executing strategy, allowing <see cref="Reducer"/> to make informed decisions.
|
||||
/// </summary>
|
||||
protected static ChatHistoryMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}.");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="IChatReducer"/> that performs the actual message compaction.
|
||||
/// </summary>
|
||||
public IChatReducer Reducer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display name of this strategy, used for logging and diagnostics.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation returns the type name of the underlying <see cref="IChatReducer"/>.
|
||||
/// </remarks>
|
||||
public virtual string Name => this.Reducer.GetType().Name;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether this strategy should execute given the current conversation metrics.
|
||||
/// </summary>
|
||||
/// <param name="metrics">The current conversation metrics.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> to proceed with compaction; <see langword="false"/> to skip.
|
||||
/// </returns>
|
||||
protected abstract bool ShouldCompact(ChatHistoryMetric metrics);
|
||||
|
||||
/// <summary>
|
||||
/// Execute this strategy: check the trigger, delegate to the <see cref="IChatReducer"/>, and report metrics.
|
||||
/// </summary>
|
||||
/// <param name="history">The mutable message list to compact.</param>
|
||||
/// <param name="metricsCalculator">The calculator to use for metric snapshots.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A <see cref="CompactionResult"/> reporting the outcome.</returns>
|
||||
internal async ValueTask<CompactionResult> CompactAsync(
|
||||
List<ChatMessage> history,
|
||||
IChatHistoryMetricsCalculator metricsCalculator,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(metricsCalculator);
|
||||
Throw.IfNull(history);
|
||||
|
||||
ChatHistoryMetric beforeMetrics = CurrentMetrics;
|
||||
if (!this.ShouldCompact(beforeMetrics))
|
||||
{
|
||||
// %%% VERBOSE - Debug.WriteLine($"COMPACTION: {this.Name} - Skipped");
|
||||
return CompactionResult.Skipped(this.Name, beforeMetrics);
|
||||
}
|
||||
|
||||
Debug.WriteLine($"COMPACTION: {this.Name} - Reducing");
|
||||
|
||||
IEnumerable<ChatMessage> reducerResult = await this.Reducer.ReduceAsync(history, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ensure we have a concrete collection to avoid multiple enumerations of the reducer result, which could be costly if it's an iterator.
|
||||
ChatMessage[] reducedCopy = [.. reducerResult];
|
||||
|
||||
bool modified = reducedCopy.Length != history.Count;
|
||||
if (modified)
|
||||
{
|
||||
history.Clear();
|
||||
history.AddRange(reducedCopy);
|
||||
}
|
||||
|
||||
ChatHistoryMetric afterMetrics = modified
|
||||
? metricsCalculator.Calculate(reducedCopy)
|
||||
: beforeMetrics;
|
||||
|
||||
Debug.WriteLine($"COMPACTION: {this.Name} - Tokens {beforeMetrics.TokenCount} => {afterMetrics.TokenCount}");
|
||||
|
||||
return new(this.Name, applied: modified, beforeMetrics, afterMetrics);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot of conversation metrics used for compaction trigger evaluation and reporting.
|
||||
/// </summary>
|
||||
public sealed class ChatHistoryMetric
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the estimated token count across all messages.
|
||||
/// </summary>
|
||||
public int TokenCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total serialized byte count of all messages.
|
||||
/// </summary>
|
||||
public long ByteCount { get; init; }
|
||||
|
||||
#pragma warning disable IDE0001 // Simplify Names
|
||||
/// <summary>
|
||||
/// Gets the total number of <see cref="Microsoft.Extensions.AI.ChatMessage"/> objects.
|
||||
/// </summary>
|
||||
#pragma warning restore IDE0001 // Simplify Names
|
||||
public int MessageCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of tool/function call content items across all messages.
|
||||
/// </summary>
|
||||
public int ToolCallCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of user turns. A user turn is a user message together with the full
|
||||
/// set of agent responses (including tool calls and results) before the next user input.
|
||||
/// </summary>
|
||||
public int UserTurnCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the atomic message group index for the analyzed messages.
|
||||
/// Each group represents a contiguous range of messages that must be kept or removed together.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChatMessageGroup> Groups { get; init; } = [];
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a contiguous range of messages in a conversation that form an atomic group.
|
||||
/// Atomic groups must be kept or removed together to maintain API correctness.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For example, an assistant message containing tool calls and the subsequent tool result messages
|
||||
/// form an atomic group — removing one without the other causes API errors.
|
||||
/// </remarks>
|
||||
public readonly struct ChatMessageGroup : IEquatable<ChatMessageGroup>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatMessageGroup"/> struct.
|
||||
/// </summary>
|
||||
/// <param name="startIndex">The zero-based index of the first message in this group.</param>
|
||||
/// <param name="count">The number of messages in this group.</param>
|
||||
/// <param name="kind">The kind of this message group.</param>
|
||||
public ChatMessageGroup(int startIndex, int count, ChatMessageGroupKind kind)
|
||||
{
|
||||
this.StartIndex = startIndex;
|
||||
this.Count = count;
|
||||
this.Kind = kind;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the zero-based index of the first message in this group within the original message list.
|
||||
/// </summary>
|
||||
public int StartIndex { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of messages in this group.
|
||||
/// </summary>
|
||||
public int Count { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the kind of this message group.
|
||||
/// </summary>
|
||||
public ChatMessageGroupKind Kind { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(ChatMessageGroup other) =>
|
||||
this.StartIndex == other.StartIndex &&
|
||||
this.Count == other.Count &&
|
||||
this.Kind == other.Kind;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj) =>
|
||||
obj is ChatMessageGroup other &&
|
||||
this.Equals(other);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => HashCode.Combine(this.StartIndex, this.Count, (int)this.Kind);
|
||||
|
||||
/// <summary>Determines whether two <see cref="ChatMessageGroup"/> instances are equal.</summary>
|
||||
public static bool operator ==(ChatMessageGroup left, ChatMessageGroup right) => left.Equals(right);
|
||||
|
||||
/// <summary>Determines whether two <see cref="ChatMessageGroup"/> instances are not equal.</summary>
|
||||
public static bool operator !=(ChatMessageGroup left, ChatMessageGroup right) => !left.Equals(right);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the kind of an atomic message group in a conversation.
|
||||
/// </summary>
|
||||
public enum ChatMessageGroupKind
|
||||
{
|
||||
/// <summary>A system message.</summary>
|
||||
System,
|
||||
|
||||
/// <summary>A user message (start of a user turn).</summary>
|
||||
UserTurn,
|
||||
|
||||
/// <summary>An assistant message with tool calls and their corresponding tool result messages.</summary>
|
||||
AssistantToolGroup,
|
||||
|
||||
/// <summary>An assistant message without tool calls.</summary>
|
||||
AssistantPlain,
|
||||
|
||||
/// <summary>A tool result message that is not part of a recognized group.</summary>
|
||||
ToolResult,
|
||||
|
||||
/// <summary>A message with an unrecognized role.</summary>
|
||||
Other
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chat history compaction strategy that uses a condition function to determine when compaction should
|
||||
/// occur.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This strategy evaluates a user-provided condition against compaction metrics to decide whether to
|
||||
/// compact the chat history. It is useful for scenarios where compaction should be triggered based on custom thresholds
|
||||
/// or criteria. Inherits from ChatHistoryCompactionStrategy.
|
||||
/// </remarks>
|
||||
public class ChatReducerCompactionStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
private readonly Func<ChatHistoryMetric, bool> _condition;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatReducerCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public ChatReducerCompactionStrategy(
|
||||
IChatReducer reducer,
|
||||
Func<ChatHistoryMetric, bool> condition)
|
||||
: base(reducer)
|
||||
{
|
||||
this._condition = Throw.IfNull(condition);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._condition(metrics);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Reports the aggregate outcome of a <see cref="ChatHistoryCompactionPipeline"/> execution.
|
||||
/// </summary>
|
||||
public sealed class CompactionPipelineResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactionPipelineResult"/> class.
|
||||
/// </summary>
|
||||
/// <param name="before">Metrics of the conversation before any strategy ran.</param>
|
||||
/// <param name="after">Metrics of the conversation after all strategies ran.</param>
|
||||
/// <param name="strategyResults">Per-strategy results in execution order.</param>
|
||||
internal CompactionPipelineResult(
|
||||
ChatHistoryMetric before,
|
||||
ChatHistoryMetric after,
|
||||
IReadOnlyList<CompactionResult> strategyResults)
|
||||
{
|
||||
this.Before = Throw.IfNull(before);
|
||||
this.After = Throw.IfNull(after);
|
||||
this.StrategyResults = Throw.IfNull(strategyResults);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation metrics before any compaction strategy ran.
|
||||
/// </summary>
|
||||
public ChatHistoryMetric Before { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation metrics after all compaction strategies ran.
|
||||
/// </summary>
|
||||
public ChatHistoryMetric After { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the per-strategy results in execution order.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CompactionResult> StrategyResults { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether any strategy modified the message list.
|
||||
/// </summary>
|
||||
public bool AnyApplied => this.StrategyResults.Any(r => r.Applied);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Reports the outcome of a single <see cref="ChatHistoryCompactionStrategy"/> execution.
|
||||
/// </summary>
|
||||
public sealed class CompactionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactionResult"/> class.
|
||||
/// </summary>
|
||||
/// <param name="strategyName">The name of the strategy that produced this result.</param>
|
||||
/// <param name="applied">Whether the strategy modified the message list.</param>
|
||||
/// <param name="before">Metrics before the strategy ran.</param>
|
||||
/// <param name="after">Metrics after the strategy ran.</param>
|
||||
public CompactionResult(string strategyName, bool applied, ChatHistoryMetric before, ChatHistoryMetric after)
|
||||
{
|
||||
this.StrategyName = Throw.IfNullOrWhitespace(strategyName);
|
||||
this.Applied = applied;
|
||||
this.Before = Throw.IfNull(before);
|
||||
this.After = Throw.IfNull(after);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the strategy that produced this result.
|
||||
/// </summary>
|
||||
public string StrategyName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the strategy modified the message list.
|
||||
/// </summary>
|
||||
public bool Applied { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation metrics before the strategy executed.
|
||||
/// </summary>
|
||||
public ChatHistoryMetric Before { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation metrics after the strategy executed.
|
||||
/// </summary>
|
||||
public ChatHistoryMetric After { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="CompactionResult"/> representing a skipped strategy.
|
||||
/// </summary>
|
||||
/// <param name="strategyName">The name of the skipped strategy.</param>
|
||||
/// <param name="metrics">The current conversation metrics.</param>
|
||||
/// <returns>A result indicating no compaction was applied.</returns>
|
||||
internal static CompactionResult Skipped(string strategyName, ChatHistoryMetric metrics)
|
||||
=> new(strategyName, applied: false, metrics, metrics);
|
||||
}
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IChatHistoryMetricsCalculator"/> that uses
|
||||
/// JSON serialization length heuristics for token and byte estimation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Token estimation uses a configurable characters-per-token ratio (default ~4) since
|
||||
/// precise tokenization requires a model-specific tokenizer. For production workloads
|
||||
/// requiring accurate token counts, implement <see cref="IChatHistoryMetricsCalculator"/>
|
||||
/// with a model-appropriate tokenizer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DefaultChatHistoryMetricsCalculator : IChatHistoryMetricsCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the singleton instance of the chat history metrics calculator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="DefaultChatHistoryMetricsCalculator"/> can be safety accessed by
|
||||
/// concurrent threads.
|
||||
/// </remarks>
|
||||
public static readonly DefaultChatHistoryMetricsCalculator Instance = new();
|
||||
|
||||
private const int DefaultCharsPerToken = 4;
|
||||
private const int PerMessageOverheadTokens = 4;
|
||||
|
||||
private readonly int _charsPerToken;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultChatHistoryMetricsCalculator"/> class.
|
||||
/// </summary>
|
||||
/// <param name="charsPerToken">
|
||||
/// The approximate number of characters per token used for estimation. Default is 4.
|
||||
/// </param>
|
||||
public DefaultChatHistoryMetricsCalculator(int charsPerToken = DefaultCharsPerToken)
|
||||
{
|
||||
this._charsPerToken = charsPerToken > 0 ? charsPerToken : DefaultCharsPerToken;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ChatHistoryMetric Calculate(IReadOnlyList<ChatMessage> messages)
|
||||
{
|
||||
if (messages is null || messages.Count == 0)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
|
||||
int totalTokens = 0;
|
||||
long totalBytes = 0;
|
||||
int toolCallCount = 0;
|
||||
int userTurnCount = 0;
|
||||
bool inUserTurn = false;
|
||||
List<ChatMessageGroup> groups = [];
|
||||
int index = 0;
|
||||
|
||||
while (index < messages.Count)
|
||||
{
|
||||
ChatMessage message = messages[index];
|
||||
|
||||
// Accumulate per-message metrics
|
||||
this.AccumulateMessageMetrics(message, ref totalTokens, ref totalBytes, ref toolCallCount);
|
||||
|
||||
if (message.Role == ChatRole.User)
|
||||
{
|
||||
if (!inUserTurn)
|
||||
{
|
||||
userTurnCount++;
|
||||
inUserTurn = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
inUserTurn = false;
|
||||
}
|
||||
|
||||
// Identify the group starting at this message
|
||||
if (message.Role == ChatRole.System)
|
||||
{
|
||||
groups.Add(new(index, 1, ChatMessageGroupKind.System));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.User)
|
||||
{
|
||||
groups.Add(new(index, 1, ChatMessageGroupKind.UserTurn));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant)
|
||||
{
|
||||
bool hasToolCalls = message.Contents!.Any(c => c is FunctionCallContent);
|
||||
|
||||
if (hasToolCalls)
|
||||
{
|
||||
int groupStart = index;
|
||||
index++;
|
||||
|
||||
while (index < messages.Count && messages[index].Role == ChatRole.Tool)
|
||||
{
|
||||
this.AccumulateMessageMetrics(messages[index], ref totalTokens, ref totalBytes, ref toolCallCount);
|
||||
inUserTurn = false;
|
||||
index++;
|
||||
}
|
||||
|
||||
groups.Add(new(groupStart, index - groupStart, ChatMessageGroupKind.AssistantToolGroup));
|
||||
}
|
||||
else
|
||||
{
|
||||
groups.Add(new(index, 1, ChatMessageGroupKind.AssistantPlain));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
else if (message.Role == ChatRole.Tool)
|
||||
{
|
||||
groups.Add(new(index, 1, ChatMessageGroupKind.ToolResult));
|
||||
index++;
|
||||
}
|
||||
else
|
||||
{
|
||||
groups.Add(new(index, 1, ChatMessageGroupKind.Other));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
TokenCount = totalTokens,
|
||||
ByteCount = totalBytes,
|
||||
MessageCount = messages.Count,
|
||||
ToolCallCount = toolCallCount,
|
||||
UserTurnCount = userTurnCount,
|
||||
Groups = groups
|
||||
};
|
||||
}
|
||||
|
||||
private void AccumulateMessageMetrics(ChatMessage message, ref int totalTokens, ref long totalBytes, ref int toolCallCount)
|
||||
{
|
||||
string serialized = message.Text;
|
||||
|
||||
int charCount = serialized.Length;
|
||||
totalBytes += System.Text.Encoding.UTF8.GetByteCount(serialized);
|
||||
totalTokens += (charCount / this._charsPerToken) + PerMessageOverheadTokens;
|
||||
|
||||
if (message.Contents is not null)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent)
|
||||
{
|
||||
toolCallCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
// %%% TODO: Is this interface needed? Consider whether the default implementation is sufficient
|
||||
// and whether custom metrics calculators are a realistic extension point.
|
||||
|
||||
/// <summary>
|
||||
/// Computes <see cref="ChatHistoryMetric"/> for a list of messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Token counting is model-specific. Implementations can provide precise tokenization
|
||||
/// (e.g., using tiktoken or a model-specific tokenizer) or use estimation heuristics.
|
||||
/// </remarks>
|
||||
public interface IChatHistoryMetricsCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute metrics for the given messages.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to analyze.</param>
|
||||
/// <returns>A <see cref="ChatHistoryMetric"/> snapshot.</returns>
|
||||
ChatHistoryMetric Calculate(IReadOnlyList<ChatMessage> messages);
|
||||
}
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that keeps only the most recent user turns and their
|
||||
/// associated response groups, removing older turns to bound conversation length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy always preserves system messages. It identifies user turns in the
|
||||
/// conversation and keeps the last <c>maxTurns</c> turns along with all response groups
|
||||
/// (assistant replies, tool call groups) that follow each kept turn.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The trigger condition fires only when the number of user turns exceeds <c>maxTurns</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This strategy is more predictable than token-based truncation for bounding conversation
|
||||
/// length, since it operates on logical turn boundaries rather than estimated token counts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
private readonly int _maxTurns;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxTurns">
|
||||
/// The maximum number of user turns to keep. Older turns and their associated responses are removed.
|
||||
/// </param>
|
||||
public SlidingWindowCompactionStrategy(int maxTurns)
|
||||
: base(new SlidingWindowReducer(maxTurns))
|
||||
{
|
||||
this._maxTurns = maxTurns;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
|
||||
metrics.UserTurnCount > this._maxTurns;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that keeps system messages and the last N user turns
|
||||
/// with all their associated response groups.
|
||||
/// </summary>
|
||||
private sealed class SlidingWindowReducer(int maxTurns) : IChatReducer
|
||||
{
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<ChatMessage> messageList = [.. messages]; // %%% PERFORMANCE
|
||||
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
|
||||
|
||||
// Find the group-list indices where each user turn starts
|
||||
int[] turnGroupIndices =
|
||||
[.. CurrentMetrics.Groups
|
||||
.Select((group, index) => (group, index))
|
||||
.Where(t => t.group.Kind == ChatMessageGroupKind.UserTurn)
|
||||
.Select(t => t.index)];
|
||||
|
||||
// Keep the last maxTurns user turns and everything after the first kept turn
|
||||
int firstKeptTurnIndex = turnGroupIndices.Length - maxTurns;
|
||||
int firstKeptGroupIndex = turnGroupIndices[firstKeptTurnIndex];
|
||||
|
||||
List<ChatMessage> result = new(messageList.Count); // %%% PERFORMANCE
|
||||
for (int gi = 0; gi < groups.Count; gi++)
|
||||
{
|
||||
ChatMessageGroup group = groups[gi];
|
||||
|
||||
// Always keep system messages; keep groups at or after the window start
|
||||
if (group.Kind == ChatMessageGroupKind.System || gi >= firstKeptGroupIndex)
|
||||
{
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
result.Add(messageList[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<ChatMessage>>(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
-166
@@ -1,166 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that uses an LLM to summarize older portions of the conversation,
|
||||
/// replacing them with a concise summary message that preserves key facts and context.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy sits between tool-result clearing (gentle) and truncation (aggressive) in the
|
||||
/// compaction ladder. Unlike truncation which discards messages entirely, summarization preserves
|
||||
/// the essential information in compressed form, allowing the agent to maintain awareness of
|
||||
/// earlier context.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The strategy protects system messages and the most recent <c>preserveRecentGroups</c>
|
||||
/// non-system groups. All older groups are collected and sent to the <see cref="IChatClient"/>
|
||||
/// for summarization. The resulting summary replaces those messages as a single assistant message.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
private readonly int _maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// The default summarization prompt used when none is provided.
|
||||
/// </summary>
|
||||
public const string DefaultSummarizationPrompt =
|
||||
"""
|
||||
You are a conversation summarizer. Produce a concise summary of the conversation that preserves:
|
||||
|
||||
- Key facts, decisions, and user preferences
|
||||
- Important context needed for future turns
|
||||
- Tool call outcomes and their significance
|
||||
|
||||
Omit pleasantries and redundant exchanges. Be factual and brief.
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SummarizationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">The <see cref="IChatClient"/> to use for generating summaries. A smaller, faster model is recommended.</param>
|
||||
/// <param name="maxTokens">The maximum token budget. Summarization is triggered when the token count exceeds this value.</param>
|
||||
/// <param name="preserveRecentGroups">
|
||||
/// The number of most-recent non-system message groups to protect from summarization.
|
||||
/// Defaults to 4, preserving the current and recent exchanges.
|
||||
/// </param>
|
||||
/// <param name="summarizationPrompt">
|
||||
/// An optional custom system prompt for the summarization LLM call. When <see langword="null"/>,
|
||||
/// a default prompt that emphasizes fact-preservation is used.
|
||||
/// </param>
|
||||
public SummarizationCompactionStrategy(
|
||||
IChatClient chatClient,
|
||||
int maxTokens,
|
||||
int preserveRecentGroups = 4,
|
||||
string? summarizationPrompt = null)
|
||||
: base(new SummarizationReducer(chatClient, preserveRecentGroups, summarizationPrompt ?? DefaultSummarizationPrompt))
|
||||
{
|
||||
this._maxTokens = maxTokens;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
|
||||
metrics.TokenCount > this._maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that sends older message groups to an LLM for summarization,
|
||||
/// then replaces them with a single summary message.
|
||||
/// </summary>
|
||||
private sealed class SummarizationReducer : IChatReducer
|
||||
{
|
||||
private readonly IChatClient _chatClient;
|
||||
private readonly int _preserveRecentGroups;
|
||||
private readonly string _summarizationPrompt;
|
||||
|
||||
public SummarizationReducer(IChatClient chatClient, int preserveRecentGroups, string summarizationPrompt)
|
||||
{
|
||||
this._chatClient = Throw.IfNull(chatClient);
|
||||
this._preserveRecentGroups = preserveRecentGroups;
|
||||
this._summarizationPrompt = Throw.IfNullOrEmpty(summarizationPrompt);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ChatMessage>> ReduceAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<ChatMessage> messageList = [.. messages];
|
||||
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
|
||||
|
||||
List<ChatMessageGroup> nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
|
||||
int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - this._preserveRecentGroups);
|
||||
|
||||
if (protectedFromIndex == 0)
|
||||
{
|
||||
// Nothing to summarize — all groups are protected
|
||||
return messageList;
|
||||
}
|
||||
|
||||
// Collect messages from groups that will be summarized
|
||||
List<ChatMessage> toSummarize = [];
|
||||
for (int i = 0; i < protectedFromIndex; i++)
|
||||
{
|
||||
ChatMessageGroup group = nonSystemGroups[i];
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
toSummarize.Add(messageList[j]);
|
||||
}
|
||||
}
|
||||
|
||||
if (toSummarize.Count == 0)
|
||||
{
|
||||
return messageList;
|
||||
}
|
||||
|
||||
// Build the summarization request
|
||||
List<ChatMessage> summarizationRequest =
|
||||
[
|
||||
new(ChatRole.System, this._summarizationPrompt),
|
||||
.. toSummarize,
|
||||
new(ChatRole.User, "Summarize the conversation above concisely."),
|
||||
];
|
||||
|
||||
ChatResponse response = await this._chatClient.GetResponseAsync(summarizationRequest, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text;
|
||||
|
||||
// Build result: system groups + summary + protected groups
|
||||
List<ChatMessage> result = [];
|
||||
|
||||
// Keep system messages
|
||||
foreach (ChatMessageGroup group in groups)
|
||||
{
|
||||
if (group.Kind == ChatMessageGroupKind.System)
|
||||
{
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
result.Add(messageList[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert summary
|
||||
result.Add(new ChatMessage(ChatRole.Assistant, $"[Summary]\n{summaryText}"));
|
||||
|
||||
// Keep protected groups
|
||||
for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++)
|
||||
{
|
||||
ChatMessageGroup group = nonSystemGroups[i];
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
result.Add(messageList[j]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that collapses old assistant-tool-call groups into single
|
||||
/// concise assistant messages, removing the detailed tool results while preserving
|
||||
/// a record of which tools were called.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the gentlest compaction strategy — it does not remove any user messages or
|
||||
/// plain assistant responses. It only targets <see cref="ChatMessageGroupKind.AssistantToolGroup"/>
|
||||
/// entries outside the protected recent window, replacing each multi-message group
|
||||
/// (assistant call + tool results) with a single assistant message like
|
||||
/// <c>[Tool calls: get_weather, search_docs]</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The trigger condition fires only when token count exceeds <c>maxTokens</c> and
|
||||
/// there is at least one tool call in the conversation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default value for `preserveRecentGroups` used when constructing <see cref="ToolResultCompactionStrategy"/>.
|
||||
/// </summary>
|
||||
public const int DefaultPreserveRecentGroups = 2;
|
||||
|
||||
private readonly int _maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolResultCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxTokens">The maximum token budget. Tool groups are collapsed when the token count exceeds this value.</param>
|
||||
/// <param name="preserveRecentGroups">
|
||||
/// The number of most-recent non-system message groups to protect from collapsing.
|
||||
/// Defaults to 2, ensuring the current turn's tool interactions remain visible.
|
||||
/// </param>
|
||||
public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = DefaultPreserveRecentGroups)
|
||||
: base(new ToolResultClearingReducer(preserveRecentGroups))
|
||||
{
|
||||
this._maxTokens = maxTokens;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
|
||||
metrics.TokenCount > this._maxTokens && metrics.ToolCallCount > 0;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that collapses <see cref="ChatMessageGroupKind.AssistantToolGroup"/>
|
||||
/// entries into single summary messages, preserving the most recent groups.
|
||||
/// </summary>
|
||||
private sealed class ToolResultClearingReducer(int preserveRecentGroups) : IChatReducer
|
||||
{
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<ChatMessage> messageList = [.. messages];
|
||||
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
|
||||
|
||||
List<ChatMessageGroup> nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
|
||||
int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - preserveRecentGroups);
|
||||
HashSet<int> protectedGroupStarts = [];
|
||||
for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++)
|
||||
{
|
||||
protectedGroupStarts.Add(nonSystemGroups[i].StartIndex);
|
||||
}
|
||||
|
||||
List<ChatMessage> result = new(messageList.Count);
|
||||
bool anyCollapsed = false;
|
||||
|
||||
foreach (ChatMessageGroup group in groups)
|
||||
{
|
||||
if (group.Kind == ChatMessageGroupKind.AssistantToolGroup && !protectedGroupStarts.Contains(group.StartIndex))
|
||||
{
|
||||
// Collapse this tool group into a single summary message
|
||||
List<string> toolNames = [];
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
if (messageList[j].Contents is not null)
|
||||
{
|
||||
foreach (AIContent content in messageList[j].Contents)
|
||||
{
|
||||
if (content is FunctionCallContent fcc)
|
||||
{
|
||||
toolNames.Add(fcc.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string summary = $"[Tool calls: {string.Join(", ", toolNames)}]";
|
||||
result.Add(new ChatMessage(ChatRole.Assistant, summary));
|
||||
anyCollapsed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep this group as-is
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
result.Add(messageList[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<ChatMessage>>(anyCollapsed ? result : messageList);
|
||||
}
|
||||
}
|
||||
}
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that removes the oldest message groups until the estimated
|
||||
/// token count is within a specified budget.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy preserves system messages and removes the oldest non-system message groups first.
|
||||
/// It respects atomic group boundaries — an assistant message with tool calls and its
|
||||
/// corresponding tool result messages are always removed together.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The trigger condition fires only when the current token count exceeds <c>maxTokens</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
private readonly int _maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxTokens">The maximum token budget. Groups are removed until the token count is at or below this value.</param>
|
||||
/// <param name="preserveRecentGroups">
|
||||
/// The minimum number of most-recent non-system message groups to keep.
|
||||
/// Defaults to 1 so that at least the latest exchange is always preserved.
|
||||
/// </param>
|
||||
public TruncationCompactionStrategy(int maxTokens, int preserveRecentGroups = 1)
|
||||
: base(new TruncationReducer(preserveRecentGroups))
|
||||
{
|
||||
this._maxTokens = maxTokens;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
|
||||
metrics.TokenCount > this._maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that removes the oldest non-system message groups,
|
||||
/// keeping at least the most recent group.
|
||||
/// </summary>
|
||||
private sealed class TruncationReducer(int preserveRecentGroups) : IChatReducer
|
||||
{
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<ChatMessage> messageList = [.. messages];
|
||||
|
||||
ChatMessageGroup[] removableGroups = [.. CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
|
||||
|
||||
if (removableGroups.Length == 0)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<ChatMessage>>(messageList);
|
||||
}
|
||||
|
||||
// Remove oldest non-system groups, keeping at least preserveRecentGroups.
|
||||
int maxRemovable = removableGroups.Length - preserveRecentGroups;
|
||||
|
||||
if (maxRemovable <= 0)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<ChatMessage>>(messageList);
|
||||
}
|
||||
|
||||
HashSet<int> removedGroupStarts = [];
|
||||
for (int ri = 0; ri < maxRemovable; ri++)
|
||||
{
|
||||
removedGroupStarts.Add(removableGroups[ri].StartIndex);
|
||||
}
|
||||
|
||||
List<ChatMessage> messagesToKeep = new(messageList.Count);
|
||||
foreach (ChatMessageGroup group in CurrentMetrics.Groups)
|
||||
{
|
||||
if (removedGroupStarts.Contains(group.StartIndex))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
messagesToKeep.Add(messageList[j]);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<ChatMessage>>(messagesToKeep);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,6 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
|
||||
<PackageReference Include="Microsoft.Bcl.HashCode" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Abstractions.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -19,9 +19,10 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
/// <param name="services">The service collection to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNullOrEmpty(name);
|
||||
@@ -30,7 +31,7 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
var chatClient = sp.GetRequiredService<IChatClient>();
|
||||
var tools = sp.GetKeyedServices<AITool>(name).ToList();
|
||||
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
|
||||
});
|
||||
}, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -40,9 +41,10 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, IChatClient chatClient)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, IChatClient chatClient, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNullOrEmpty(name);
|
||||
@@ -50,7 +52,7 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
{
|
||||
var tools = sp.GetKeyedServices<AITool>(name).ToList();
|
||||
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
|
||||
});
|
||||
}, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -60,9 +62,10 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If <see langword="null"/>, a non-keyed service will be resolved.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, object? chatClientServiceKey)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNullOrEmpty(name);
|
||||
@@ -71,7 +74,7 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
|
||||
var tools = sp.GetKeyedServices<AITool>(name).ToList();
|
||||
return new ChatClientAgent(chatClient, instructions, key, tools: tools);
|
||||
});
|
||||
}, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -82,9 +85,10 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="description">A description of the agent.</param>
|
||||
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If <see langword="null"/>, a non-keyed service will be resolved.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, string? description, object? chatClientServiceKey)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, string? description, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNullOrEmpty(name);
|
||||
@@ -93,7 +97,7 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
|
||||
var tools = sp.GetKeyedServices<AITool>(name).ToList();
|
||||
return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description, tools: tools);
|
||||
});
|
||||
}, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -102,15 +106,16 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
/// <param name="services">The service collection to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns <see langword="null"/> or an agent whose <see cref="AIAgent.Name"/> does not match <paramref name="name"/>.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(services);
|
||||
Throw.IfNull(name);
|
||||
Throw.IfNull(createAgentDelegate);
|
||||
services.AddKeyedSingleton(name, (sp, key) =>
|
||||
services.AddKeyedService(name, (sp, key) =>
|
||||
{
|
||||
Throw.IfNull(key);
|
||||
var keyString = key as string;
|
||||
@@ -122,8 +127,18 @@ public static class AgentHostingServiceCollectionExtensions
|
||||
}
|
||||
|
||||
return agent;
|
||||
});
|
||||
}, lifetime);
|
||||
|
||||
return new HostedAgentBuilder(name, services);
|
||||
return new HostedAgentBuilder(name, services, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a keyed service with the specified lifetime.
|
||||
/// </summary>
|
||||
internal static void AddKeyedService<T>(this IServiceCollection services, object? serviceKey, Func<IServiceProvider, object?, T> factory, ServiceLifetime lifetime)
|
||||
where T : class
|
||||
{
|
||||
var descriptor = new ServiceDescriptor(typeof(T), serviceKey, (sp, key) => factory(sp, key), lifetime);
|
||||
services.Add(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -18,12 +19,13 @@ public static class HostApplicationBuilderAgentExtensions
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
return builder.Services.AddAIAgent(name, instructions);
|
||||
return builder.Services.AddAIAgent(name, instructions, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -33,13 +35,14 @@ public static class HostApplicationBuilderAgentExtensions
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNullOrEmpty(name);
|
||||
return builder.Services.AddAIAgent(name, instructions, chatClient);
|
||||
return builder.Services.AddAIAgent(name, instructions, chatClient, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -50,13 +53,14 @@ public static class HostApplicationBuilderAgentExtensions
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="description">A description of the agent.</param>
|
||||
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNullOrEmpty(name);
|
||||
return builder.Services.AddAIAgent(name, instructions, description, chatClientServiceKey);
|
||||
return builder.Services.AddAIAgent(name, instructions, description, chatClientServiceKey, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -66,12 +70,13 @@ public static class HostApplicationBuilderAgentExtensions
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
return builder.Services.AddAIAgent(name, instructions, chatClientServiceKey);
|
||||
return builder.Services.AddAIAgent(name, instructions, chatClientServiceKey, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -80,12 +85,13 @@ public static class HostApplicationBuilderAgentExtensions
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>The configured host application builder.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is null.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns null or an invalid AI agent instance.</exception>
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
|
||||
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
return builder.Services.AddAIAgent(name, createAgentDelegate);
|
||||
return builder.Services.AddAIAgent(name, createAgentDelegate, lifetime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,19 +19,20 @@ public static class HostApplicationBuilderWorkflowExtensions
|
||||
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
|
||||
/// <param name="name">The unique name for the workflow.</param>
|
||||
/// <param name="createWorkflowDelegate">A factory function that creates the <see cref="Workflow"/> instance. The function receives the service provider and workflow name as parameters.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the workflow registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>An <see cref="IHostedWorkflowBuilder"/> that can be used to further configure the workflow.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createWorkflowDelegate"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is empty.</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when the factory delegate returns null or a workflow with a name that doesn't match the expected name.
|
||||
/// </exception>
|
||||
public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, Workflow> createWorkflowDelegate)
|
||||
public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, Workflow> createWorkflowDelegate, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(name);
|
||||
Throw.IfNull(createWorkflowDelegate);
|
||||
|
||||
builder.Services.AddKeyedSingleton(name, (sp, key) =>
|
||||
builder.Services.AddKeyedService(name, (sp, key) =>
|
||||
{
|
||||
Throw.IfNull(key);
|
||||
var keyString = key as string;
|
||||
@@ -43,7 +44,7 @@ public static class HostApplicationBuilderWorkflowExtensions
|
||||
}
|
||||
|
||||
return workflow;
|
||||
});
|
||||
}, lifetime);
|
||||
|
||||
return new HostedWorkflowBuilder(name, builder);
|
||||
}
|
||||
|
||||
@@ -9,15 +9,17 @@ internal sealed class HostedAgentBuilder : IHostedAgentBuilder
|
||||
{
|
||||
public string Name { get; }
|
||||
public IServiceCollection ServiceCollection { get; }
|
||||
public ServiceLifetime Lifetime { get; }
|
||||
|
||||
public HostedAgentBuilder(string name, IHostApplicationBuilder builder)
|
||||
: this(name, builder.Services)
|
||||
public HostedAgentBuilder(string name, IHostApplicationBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
: this(name, builder.Services, lifetime)
|
||||
{
|
||||
}
|
||||
|
||||
public HostedAgentBuilder(string name, IServiceCollection serviceCollection)
|
||||
public HostedAgentBuilder(string name, IServiceCollection serviceCollection, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
this.Name = name;
|
||||
this.ServiceCollection = serviceCollection;
|
||||
this.Lifetime = lifetime;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,17 +42,19 @@ public static class HostedAgentBuilderExtensions
|
||||
/// <param name="builder">The host agent builder to configure.</param>
|
||||
/// <param name="createAgentSessionStore">A factory function that creates an agent session store instance using the provided service provider and agent
|
||||
/// name.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the session store registration. Defaults to <see cref="ServiceLifetime.Singleton"/>
|
||||
/// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime.</param>
|
||||
/// <returns>The same host agent builder instance, enabling further configuration.</returns>
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore)
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedSingleton(builder.Name, (sp, key) =>
|
||||
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) =>
|
||||
{
|
||||
Throw.IfNull(key);
|
||||
var keyString = key as string;
|
||||
Throw.IfNullOrEmpty(keyString);
|
||||
return createAgentSessionStore(sp, keyString) ??
|
||||
throw new InvalidOperationException($"The agent session store factory did not return a valid {nameof(AgentSessionStore)} instance for key '{keyString}'.");
|
||||
});
|
||||
}, lifetime);
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -98,13 +100,39 @@ public static class HostedAgentBuilderExtensions
|
||||
/// </summary>
|
||||
/// <param name="builder">The hosted agent builder.</param>
|
||||
/// <param name="factory">A factory function that creates a AI tool using the provided service provider.</param>
|
||||
public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, Func<IServiceProvider, AITool> factory)
|
||||
/// <param name="lifetime">The DI service lifetime for the tool registration. If <see langword="null"/>, the agent's lifetime is used.</param>
|
||||
/// <returns>The same <see cref="IHostedAgentBuilder"/> instance so that additional calls can be chained.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="factory"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when the effective tool lifetime is shorter than the agent's lifetime, which would cause a captive dependency.
|
||||
/// For example, a singleton agent cannot use scoped or transient tools.
|
||||
/// </exception>
|
||||
public static IHostedAgentBuilder WithAITool(this IHostedAgentBuilder builder, Func<IServiceProvider, AITool> factory, ServiceLifetime? lifetime = null)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(factory);
|
||||
|
||||
builder.ServiceCollection.AddKeyedSingleton(builder.Name, (sp, name) => factory(sp));
|
||||
var effectiveLifetime = lifetime ?? builder.Lifetime;
|
||||
ValidateToolLifetime(builder.Lifetime, effectiveLifetime);
|
||||
|
||||
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, name) => factory(sp), effectiveLifetime);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the tool lifetime is compatible with the agent lifetime.
|
||||
/// A tool's lifetime must be at least as long as the agent's lifetime to prevent captive dependency issues.
|
||||
/// </summary>
|
||||
internal static void ValidateToolLifetime(ServiceLifetime agentLifetime, ServiceLifetime toolLifetime)
|
||||
{
|
||||
// ServiceLifetime enum: Singleton=0, Scoped=1, Transient=2
|
||||
// A higher value means a shorter lifetime.
|
||||
if (toolLifetime > agentLifetime)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A tool with lifetime '{toolLifetime}' cannot be registered for an agent with lifetime '{agentLifetime}'. " +
|
||||
"The tool's lifetime must be at least as long as the agent's lifetime to avoid captive dependency issues.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,22 +14,24 @@ public static class HostedWorkflowBuilderExtensions
|
||||
/// Registers the workflow as an AI agent in the dependency injection container.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="IHostedWorkflowBuilder"/> instance to extend.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>An <see cref="IHostedAgentBuilder"/> that can be used to further configure the agent.</returns>
|
||||
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder)
|
||||
=> builder.AddAsAIAgent(name: null);
|
||||
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
=> builder.AddAsAIAgent(name: null, lifetime: lifetime);
|
||||
|
||||
/// <summary>
|
||||
/// Registers the workflow as an AI agent in the dependency injection container.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="IHostedWorkflowBuilder"/> instance to extend.</param>
|
||||
/// <param name="name">The optional name for the AI agent. If not specified, the workflow name is used.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the agent registration. Defaults to <see cref="ServiceLifetime.Singleton"/>.</param>
|
||||
/// <returns>An <see cref="IHostedAgentBuilder"/> that can be used to further configure the agent.</returns>
|
||||
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name)
|
||||
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
{
|
||||
var workflowName = builder.Name;
|
||||
var agentName = name ?? workflowName;
|
||||
|
||||
return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) =>
|
||||
sp.GetRequiredKeyedService<Workflow>(workflowName).AsAIAgent(name: key));
|
||||
sp.GetRequiredKeyedService<Workflow>(workflowName).AsAIAgent(name: key), lifetime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,9 @@ public interface IHostedAgentBuilder
|
||||
/// Gets the service collection for configuration.
|
||||
/// </summary>
|
||||
IServiceCollection ServiceCollection { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the DI service lifetime used for the agent registration.
|
||||
/// </summary>
|
||||
ServiceLifetime Lifetime { get; }
|
||||
}
|
||||
|
||||
@@ -350,36 +350,38 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
|
||||
string? userId = searchScope.UserId;
|
||||
string? sessionId = searchScope.SessionId;
|
||||
|
||||
Expression<Func<Dictionary<string, object?>, bool>>? filter = null;
|
||||
// Build a combined filter using a single shared parameter to avoid expression tree
|
||||
// scoping issues when multiple filters are combined with AndAlso.
|
||||
ParameterExpression parameter = Expression.Parameter(typeof(Dictionary<string, object?>), "x");
|
||||
Expression? filterBody = null;
|
||||
|
||||
if (applicationId != null)
|
||||
{
|
||||
filter = x => (string?)x[ApplicationIdField] == applicationId;
|
||||
filterBody = RebindFilterBody(x => (string?)x[ApplicationIdField] == applicationId, parameter);
|
||||
}
|
||||
|
||||
if (agentId != null)
|
||||
{
|
||||
Expression<Func<Dictionary<string, object?>, bool>> agentIdFilter = x => (string?)x[AgentIdField] == agentId;
|
||||
filter = filter == null ? agentIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
|
||||
Expression.AndAlso(filter.Body, agentIdFilter.Body),
|
||||
filter.Parameters);
|
||||
Expression body = RebindFilterBody(x => (string?)x[AgentIdField] == agentId, parameter);
|
||||
filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body);
|
||||
}
|
||||
|
||||
if (userId != null)
|
||||
{
|
||||
Expression<Func<Dictionary<string, object?>, bool>> userIdFilter = x => (string?)x[UserIdField] == userId;
|
||||
filter = filter == null ? userIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
|
||||
Expression.AndAlso(filter.Body, userIdFilter.Body),
|
||||
filter.Parameters);
|
||||
Expression body = RebindFilterBody(x => (string?)x[UserIdField] == userId, parameter);
|
||||
filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body);
|
||||
}
|
||||
|
||||
if (sessionId != null)
|
||||
{
|
||||
Expression<Func<Dictionary<string, object?>, bool>> sessionIdFilter = x => (string?)x[SessionIdField] == sessionId;
|
||||
filter = filter == null ? sessionIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
|
||||
Expression.AndAlso(filter.Body, sessionIdFilter.Body),
|
||||
filter.Parameters);
|
||||
Expression body = RebindFilterBody(x => (string?)x[SessionIdField] == sessionId, parameter);
|
||||
filterBody = filterBody == null ? body : Expression.AndAlso(filterBody, body);
|
||||
}
|
||||
|
||||
Expression<Func<Dictionary<string, object?>, bool>>? filter = filterBody != null
|
||||
? Expression.Lambda<Func<Dictionary<string, object?>, bool>>(filterBody, parameter)
|
||||
: null;
|
||||
|
||||
// Use search to find relevant messages
|
||||
var searchResults = collection.SearchAsync(
|
||||
queryText,
|
||||
@@ -467,6 +469,27 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
|
||||
|
||||
private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : "<redacted>";
|
||||
|
||||
/// <summary>
|
||||
/// Rebinds a filter expression's body to use the specified shared parameter,
|
||||
/// replacing the original lambda parameter so that multiple filters can be safely
|
||||
/// combined with <see cref="Expression.AndAlso(Expression, Expression)"/>.
|
||||
/// </summary>
|
||||
private static Expression RebindFilterBody(
|
||||
Expression<Func<Dictionary<string, object?>, bool>> filter,
|
||||
ParameterExpression sharedParameter)
|
||||
{
|
||||
return new ParameterReplacer(filter.Parameters[0], sharedParameter).Visit(filter.Body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="ExpressionVisitor"/> that replaces one <see cref="ParameterExpression"/> with another.
|
||||
/// </summary>
|
||||
private sealed class ParameterReplacer(ParameterExpression original, ParameterExpression replacement) : ExpressionVisitor
|
||||
{
|
||||
protected override Expression VisitParameter(ParameterExpression node)
|
||||
=> node == original ? replacement : base.VisitParameter(node);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a <see cref="ChatHistoryMemoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -40,9 +40,10 @@ internal sealed partial class FileAgentSkillLoader
|
||||
// "description: \"A skill\"" → (description, A skill, _)
|
||||
private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Validates skill names: lowercase letters, numbers, and hyphens only; must not start or end with a hyphen.
|
||||
// Examples: "my-skill" âś“, "skill123" âś“, "-bad" âś—, "bad-" âś—, "Bad" âś—
|
||||
private static readonly Regex s_validNameRegex = new(@"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$", RegexOptions.Compiled);
|
||||
// Validates skill names: lowercase letters, numbers, and hyphens only;
|
||||
// must not start or end with a hyphen; must not contain consecutive hyphens.
|
||||
// Examples: "my-skill" âś“, "skill123" âś“, "-bad" âś—, "bad-" âś—, "Bad" âś—, "my--skill" âś—
|
||||
private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled);
|
||||
|
||||
private readonly ILogger _logger;
|
||||
private readonly HashSet<string> _allowedResourceExtensions;
|
||||
@@ -244,7 +245,22 @@ internal sealed partial class FileAgentSkillLoader
|
||||
|
||||
if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name))
|
||||
{
|
||||
LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen.");
|
||||
LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// skillFilePath is e.g. "/skills/my-skill/SKILL.md".
|
||||
// GetDirectoryName strips the filename → "/skills/my-skill".
|
||||
// GetFileName then extracts the last segment → "my-skill".
|
||||
// This gives us the skill's parent directory name to validate against the frontmatter name.
|
||||
string directoryName = Path.GetFileName(Path.GetDirectoryName(skillFilePath)) ?? string.Empty;
|
||||
if (!string.Equals(name, directoryName, StringComparison.Ordinal))
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Error))
|
||||
{
|
||||
LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), name, SanitizePathForLog(directoryName));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -457,6 +473,9 @@ internal sealed partial class FileAgentSkillLoader
|
||||
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")]
|
||||
private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason);
|
||||
|
||||
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}': skill name '{SkillName}' does not match parent directory name '{DirectoryName}'")]
|
||||
private static partial void LogNameDirectoryMismatch(ILogger logger, string skillFilePath, string skillName, string directoryName);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")]
|
||||
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath);
|
||||
|
||||
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class ChatHistoryCompactionPipelineTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EmptyStrategies_ReturnsUnmodifiedAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatHistoryCompactionPipeline pipeline = new([]);
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.AnyApplied);
|
||||
Assert.Equal(1, result.Before.MessageCount);
|
||||
Assert.Equal(1, result.After.MessageCount);
|
||||
Assert.Empty(result.StrategyResults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChainsStrategies_InOrderAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatHistoryCompactionStrategy[] strategies =
|
||||
[
|
||||
new NeverCompactStrategy(),
|
||||
new RemoveFirstMessageStrategy(),
|
||||
];
|
||||
ChatHistoryCompactionPipeline pipeline = new(strategies);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
];
|
||||
|
||||
// Act
|
||||
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.AnyApplied);
|
||||
Assert.Equal(2, result.StrategyResults.Count);
|
||||
Assert.False(result.StrategyResults[0].Applied);
|
||||
Assert.True(result.StrategyResults[1].Applied);
|
||||
Assert.Single(messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportsOverallMetricsAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
new(ChatRole.User, "Third"),
|
||||
];
|
||||
|
||||
// Act
|
||||
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Before.MessageCount);
|
||||
Assert.Equal(2, result.After.MessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomMetricsCalculator_IsUsedAsync()
|
||||
{
|
||||
// Arrange
|
||||
Moq.Mock<IChatHistoryMetricsCalculator> calcMock = new();
|
||||
calcMock
|
||||
.Setup(c => c.Calculate(Moq.It.IsAny<IReadOnlyList<ChatMessage>>()))
|
||||
.Returns(new ChatHistoryMetric { MessageCount = 42 });
|
||||
ChatHistoryCompactionPipeline pipeline = new(calcMock.Object, []);
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(42, result.Before.MessageCount);
|
||||
calcMock.Verify(c => c.Calculate(Moq.It.IsAny<IReadOnlyList<ChatMessage>>()), Moq.Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReduceAsync_DelegatesCompactionAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
new(ChatRole.User, "Third"),
|
||||
];
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> result = await pipeline.ReduceAsync(messages, default);
|
||||
List<ChatMessage> resultList = result.ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, resultList.Count);
|
||||
Assert.Equal("Second", resultList[0].Text);
|
||||
Assert.Equal("Third", resultList[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReduceAsync_EmptyStrategies_ReturnsAllMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatHistoryCompactionPipeline pipeline = new([]);
|
||||
ChatMessage[] messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.User, "World"),
|
||||
];
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> result = await pipeline.ReduceAsync(messages, default);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count());
|
||||
}
|
||||
}
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class ChatHistoryCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ShouldCompactReturnsFalse_SkipsAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
NeverCompactStrategy strategy = new();
|
||||
|
||||
// Act
|
||||
CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Applied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldCompactReturnsTrue_RunsCompactionAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
];
|
||||
RemoveFirstMessageStrategy strategy = new();
|
||||
|
||||
// Act
|
||||
CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Applied);
|
||||
Assert.Single(messages);
|
||||
Assert.Equal("Second", messages[0].Text);
|
||||
Assert.Equal(2, result.Before.MessageCount);
|
||||
Assert.Equal(1, result.After.MessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DelegatesToReducerAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
];
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
reducerMock
|
||||
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IEnumerable<ChatMessage> messages, CancellationToken _) => messages.Skip(1));
|
||||
TestCompactionStrategy strategy = new(reducerMock.Object);
|
||||
|
||||
// Act
|
||||
CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Applied);
|
||||
Assert.Single(messages);
|
||||
Assert.Equal("Second", messages[0].Text);
|
||||
reducerMock.Verify(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReducerNoChange_ReturnsFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
];
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
reducerMock
|
||||
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs);
|
||||
TestCompactionStrategy strategy = new(reducerMock.Object, shouldCompact: false);
|
||||
|
||||
// Act
|
||||
CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Applied);
|
||||
Assert.Single(messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReducerLifecycle()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
|
||||
// Act
|
||||
TestCompactionStrategy strategy = new(reducerMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(reducerMock.Object, strategy.Reducer);
|
||||
Assert.NotNull(strategy.Name);
|
||||
Assert.NotEmpty(strategy.Name);
|
||||
Assert.Equal(reducerMock.Object.GetType().Name, strategy.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentMetrics_OutsideStrategy_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() => TestCompactionStrategy.GetCurrentMetrics());
|
||||
}
|
||||
|
||||
public static async ValueTask<CompactionResult> RunCompactionStrategyAsync(ChatHistoryCompactionStrategy strategy, List<ChatMessage> messages)
|
||||
{
|
||||
// Act
|
||||
ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages);
|
||||
return await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance);
|
||||
}
|
||||
|
||||
private sealed class TestCompactionStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
private readonly bool _shouldCompact;
|
||||
|
||||
public TestCompactionStrategy(IChatReducer reducer, bool shouldCompact = true)
|
||||
: base(reducer)
|
||||
{
|
||||
this._shouldCompact = shouldCompact;
|
||||
}
|
||||
|
||||
protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._shouldCompact;
|
||||
|
||||
public static ChatHistoryMetric GetCurrentMetrics() => CurrentMetrics;
|
||||
}
|
||||
}
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class ChatReducerCompactionStrategyTests : CompactionStrategyTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConditionFalse_SkipsAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => false);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategySkippedAsync(strategy, messages);
|
||||
|
||||
// Assert
|
||||
reducerMock.Verify(
|
||||
r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConditionTrue_RunsReducerAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
];
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
reducerMock
|
||||
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs.Skip(1));
|
||||
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 1);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Second", messages[0].Text);
|
||||
reducerMock.Verify(
|
||||
r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConditionReceivesMetricsAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi"),
|
||||
];
|
||||
ChatHistoryMetric? capturedMetrics = null;
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
reducerMock
|
||||
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs);
|
||||
ChatReducerCompactionStrategy strategy = new(
|
||||
reducerMock.Object,
|
||||
metrics =>
|
||||
{
|
||||
capturedMetrics = metrics;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategySkippedAsync(strategy, messages);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedMetrics);
|
||||
Assert.Equal(2, capturedMetrics!.MessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReducerNoChange_AppliedFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
reducerMock
|
||||
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs);
|
||||
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategySkippedAsync(strategy, messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsReducerTypeName()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
|
||||
// Act
|
||||
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(reducerMock.Object.GetType().Name, strategy.Name);
|
||||
}
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class CompactionMetricTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultValues_AreZero()
|
||||
{
|
||||
// Arrange & Act
|
||||
ChatHistoryMetric metrics = new();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, metrics.TokenCount);
|
||||
Assert.Equal(0L, metrics.ByteCount);
|
||||
Assert.Equal(0, metrics.MessageCount);
|
||||
Assert.Equal(0, metrics.ToolCallCount);
|
||||
Assert.Equal(0, metrics.UserTurnCount);
|
||||
Assert.Empty(metrics.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitProperties_SetCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
ChatHistoryMetric metrics = new()
|
||||
{
|
||||
TokenCount = 100,
|
||||
ByteCount = 500,
|
||||
MessageCount = 5,
|
||||
ToolCallCount = 2,
|
||||
UserTurnCount = 3
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(100, metrics.TokenCount);
|
||||
Assert.Equal(500L, metrics.ByteCount);
|
||||
Assert.Equal(5, metrics.MessageCount);
|
||||
Assert.Equal(2, metrics.ToolCallCount);
|
||||
Assert.Equal(3, metrics.UserTurnCount);
|
||||
}
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class CompactionPipelineResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Properties_AreReadable()
|
||||
{
|
||||
// Arrange
|
||||
ChatHistoryMetric before = new() { MessageCount = 10 };
|
||||
ChatHistoryMetric after = new() { MessageCount = 5 };
|
||||
CompactionResult strategyResult = new("Test", applied: true, before, after);
|
||||
List<CompactionResult> results = [strategyResult];
|
||||
|
||||
// Act
|
||||
CompactionPipelineResult pipelineResult = new(before, after, results);
|
||||
|
||||
// Assert
|
||||
Assert.Same(before, pipelineResult.Before);
|
||||
Assert.Same(after, pipelineResult.After);
|
||||
Assert.Single(pipelineResult.StrategyResults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnyApplied_AllFalse_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
ChatHistoryMetric metrics = new() { MessageCount = 5 };
|
||||
CompactionResult skipped = CompactionResult.Skipped("Skip", metrics);
|
||||
|
||||
// Act
|
||||
CompactionPipelineResult result = new(metrics, metrics, [skipped]);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.AnyApplied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnyApplied_SomeTrue_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
ChatHistoryMetric before = new() { MessageCount = 10 };
|
||||
ChatHistoryMetric after = new() { MessageCount = 5 };
|
||||
CompactionResult applied = new("Applied", applied: true, before, after);
|
||||
|
||||
// Act
|
||||
CompactionPipelineResult result = new(before, after, [applied]);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.AnyApplied);
|
||||
}
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class CompactionResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Skipped_HasSameBeforeAndAfter()
|
||||
{
|
||||
// Arrange
|
||||
ChatHistoryMetric metrics = new() { MessageCount = 5, TokenCount = 100 };
|
||||
|
||||
// Act
|
||||
CompactionResult result = CompactionResult.Skipped("Test", metrics);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Test", result.StrategyName);
|
||||
Assert.False(result.Applied);
|
||||
Assert.Same(metrics, result.Before);
|
||||
Assert.Same(metrics, result.After);
|
||||
}
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public abstract class CompactionStrategyTestBase
|
||||
{
|
||||
public static async ValueTask<CompactionResult> RunCompactionStrategyReducedAsync(ChatHistoryCompactionStrategy strategy, List<ChatMessage> messages, int expectedCount)
|
||||
{
|
||||
// Act
|
||||
ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages);
|
||||
CompactionResult result = await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Applied);
|
||||
Assert.NotEqual(result.Before, result.After);
|
||||
Assert.Equal(expectedCount, messages.Count);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static async ValueTask<CompactionResult> RunCompactionStrategySkippedAsync(ChatHistoryCompactionStrategy strategy, List<ChatMessage> messages)
|
||||
{
|
||||
// Act
|
||||
int initialCount = messages.Count;
|
||||
ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages);
|
||||
CompactionResult result = await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(result.Before, result.After);
|
||||
Assert.Equal(initialCount, messages.Count);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
-399
@@ -1,399 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class DefaultChatHistoryMetricsCalculatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyList_ReturnsZeroMetrics()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
// Act
|
||||
ChatHistoryMetric metrics = calculator.Calculate([]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, metrics.TokenCount);
|
||||
Assert.Equal(0L, metrics.ByteCount);
|
||||
Assert.Equal(0, metrics.MessageCount);
|
||||
Assert.Equal(0, metrics.ToolCallCount);
|
||||
Assert.Equal(0, metrics.UserTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountsMessages()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there"),
|
||||
];
|
||||
|
||||
// Act
|
||||
ChatHistoryMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, metrics.MessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountsUserTurns()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi"),
|
||||
new(ChatRole.User, "How are you?"),
|
||||
new(ChatRole.Assistant, "Good"),
|
||||
];
|
||||
|
||||
// Act
|
||||
ChatHistoryMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, metrics.UserTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountsToolCalls()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
ChatMessage assistantMsg = new(ChatRole.Assistant, [
|
||||
new FunctionCallContent("call1", "get_weather", new Dictionary<string, object?> { ["city"] = "NYC" }),
|
||||
new FunctionCallContent("call2", "get_time"),
|
||||
]);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "What's the weather?"),
|
||||
assistantMsg,
|
||||
];
|
||||
|
||||
// Act
|
||||
ChatHistoryMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, metrics.ToolCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsecutiveUserMessages_CountAsOneTurn()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
new(ChatRole.Assistant, "Reply"),
|
||||
];
|
||||
|
||||
// Act
|
||||
ChatHistoryMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, metrics.UserTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokenCount_IsPositive()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello world"),
|
||||
];
|
||||
|
||||
// Act
|
||||
ChatHistoryMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
// Assert
|
||||
Assert.True(metrics.TokenCount > 0);
|
||||
Assert.True(metrics.ByteCount > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullInput_ReturnsZeroMetrics()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
// Act
|
||||
ChatHistoryMetric metrics = calculator.Calculate(null!);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, metrics.TokenCount);
|
||||
Assert.Equal(0L, metrics.ByteCount);
|
||||
Assert.Equal(0, metrics.MessageCount);
|
||||
Assert.Empty(metrics.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidCharsPerToken_UsesDefault()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new(charsPerToken: 0);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello world"),
|
||||
];
|
||||
|
||||
// Act
|
||||
ChatHistoryMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
// Assert
|
||||
Assert.True(metrics.TokenCount > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullMessageText_HandledGracefully()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
ChatMessage msg = new() { Role = ChatRole.User };
|
||||
List<ChatMessage> messages = [msg];
|
||||
|
||||
// Act
|
||||
ChatHistoryMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, metrics.MessageCount);
|
||||
Assert.True(metrics.TokenCount > 0);
|
||||
Assert.Equal(0L, metrics.ByteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullContents_SkipsToolCounting()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
ChatMessage msg = new(ChatRole.User, "text");
|
||||
msg.Contents = null!;
|
||||
List<ChatMessage> messages = [msg];
|
||||
|
||||
// Act
|
||||
ChatHistoryMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, metrics.MessageCount);
|
||||
Assert.Equal(0, metrics.ToolCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MessageWithOnlyNonTextContent_NullTextHandled()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
ChatMessage msg = new(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("c1", "func"),
|
||||
]);
|
||||
List<ChatMessage> messages = [msg];
|
||||
|
||||
// Act
|
||||
ChatHistoryMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, metrics.MessageCount);
|
||||
Assert.Equal(1, metrics.ToolCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_PopulatesGroupIndex()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "System prompt"),
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there"),
|
||||
];
|
||||
|
||||
// Act
|
||||
ChatHistoryMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, metrics.Groups.Count);
|
||||
Assert.Equal(ChatMessageGroupKind.System, metrics.Groups[0].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.UserTurn, metrics.Groups[1].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantPlain, metrics.Groups[2].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyList_GroupIndexIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
// Act
|
||||
ChatHistoryMetric metrics = calculator.Calculate([]);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(metrics.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_SystemMessage_IdentifiedCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "You are a helpful assistant"),
|
||||
];
|
||||
|
||||
// Act
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups);
|
||||
Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind);
|
||||
Assert.Equal(0, groups[0].StartIndex);
|
||||
Assert.Equal(1, groups[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_AssistantWithToolCalls_GroupedWithResults()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
ChatMessage assistantMsg = new(ChatRole.Assistant, [
|
||||
new FunctionCallContent("call1", "get_weather", new Dictionary<string, object?> { ["city"] = "NYC" }),
|
||||
]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, [
|
||||
new FunctionResultContent("call1", "Sunny, 72°F"),
|
||||
]);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "What's the weather?"),
|
||||
assistantMsg,
|
||||
toolResult,
|
||||
];
|
||||
|
||||
// Act
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, groups.Count);
|
||||
Assert.Equal(ChatMessageGroupKind.UserTurn, groups[0].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[1].Kind);
|
||||
Assert.Equal(1, groups[1].StartIndex);
|
||||
Assert.Equal(2, groups[1].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_MultipleToolResults_GroupedTogether()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
ChatMessage assistantMsg = new(ChatRole.Assistant, [
|
||||
new FunctionCallContent("c1", "func1"),
|
||||
new FunctionCallContent("c2", "func2"),
|
||||
]);
|
||||
ChatMessage tool1 = new(ChatRole.Tool, [new FunctionResultContent("c1", "result1")]);
|
||||
ChatMessage tool2 = new(ChatRole.Tool, [new FunctionResultContent("c2", "result2")]);
|
||||
List<ChatMessage> messages = [assistantMsg, tool1, tool2];
|
||||
|
||||
// Act
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups);
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[0].Kind);
|
||||
Assert.Equal(3, groups[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_ComplexConversation_CorrectGrouping()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "You are a helper"),
|
||||
new(ChatRole.User, "Hi"),
|
||||
new(ChatRole.Assistant, "Hello!"),
|
||||
new(ChatRole.User, "Get weather"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
|
||||
new(ChatRole.Assistant, "It's sunny!"),
|
||||
];
|
||||
|
||||
// Act
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(6, groups.Count);
|
||||
Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.UserTurn, groups[1].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[2].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.UserTurn, groups[3].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[4].Kind);
|
||||
Assert.Equal(2, groups[4].Count);
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[5].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_OrphanToolResult_IdentifiedCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "orphan result")]),
|
||||
];
|
||||
|
||||
// Act
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups);
|
||||
Assert.Equal(ChatMessageGroupKind.ToolResult, groups[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_UnknownRole_IdentifiedAsOther()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(new ChatRole("custom"), "custom message"),
|
||||
];
|
||||
|
||||
// Act
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups);
|
||||
Assert.Equal(ChatMessageGroupKind.Other, groups[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_AssistantWithNullContents_ClassifiedAsPlain()
|
||||
{
|
||||
// Arrange
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
ChatMessage msg = new(ChatRole.Assistant, "reply");
|
||||
msg.Contents = null!;
|
||||
List<ChatMessage> messages = [msg];
|
||||
|
||||
// Act
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups);
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[0].Kind);
|
||||
}
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
// 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.Compaction.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a way to set <see cref="AIAgent.CurrentRunContext"/> in unit tests
|
||||
/// so that the underlying <c>AsyncLocal</c> is populated for code that reads it.
|
||||
/// </summary>
|
||||
internal static class AgentRunContextHarness
|
||||
{
|
||||
private static readonly ContextAgentShim s_instance = new();
|
||||
|
||||
/// <summary>
|
||||
/// Sets <see cref="AIAgent.CurrentRunContext"/> and invokes the provided action.
|
||||
/// </summary>
|
||||
public static void ExecuteWithRunContext(AgentRunContext context, Action action)
|
||||
{
|
||||
Assert.NotNull(context);
|
||||
Assert.NotNull(action);
|
||||
//AgentRunContext context = new(agent, session, messages ?? [], options); // %%% TODO
|
||||
s_instance.Set(context);
|
||||
action.Invoke();
|
||||
}
|
||||
|
||||
// Derived class that exposes the protected setter.
|
||||
private sealed class ContextAgentShim : AIAgent
|
||||
{
|
||||
public void Set(AgentRunContext? value) => CurrentRunContext = value;
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
|
||||
|
||||
internal sealed class NeverCompactStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
public NeverCompactStrategy()
|
||||
: base(new NoOpReducer())
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "NeverCompact";
|
||||
|
||||
protected override bool ShouldCompact(ChatHistoryMetric metrics) => false;
|
||||
|
||||
private sealed class NoOpReducer : IChatReducer
|
||||
{
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(messages);
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
|
||||
|
||||
internal sealed class RemoveFirstMessageStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
public RemoveFirstMessageStrategy()
|
||||
: base(new RemoveFirstReducer())
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "RemoveFirst";
|
||||
|
||||
protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.MessageCount > 0;
|
||||
|
||||
private sealed class RemoveFirstReducer : IChatReducer
|
||||
{
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<ChatMessage> list = messages.ToList();
|
||||
if (list.Count > 1)
|
||||
{
|
||||
list.RemoveAt(0);
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<ChatMessage>>(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class MessageGroupTests
|
||||
{
|
||||
[Fact]
|
||||
public void Equality_Works()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
ChatMessageGroup c = new(1, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(a, b);
|
||||
Assert.True(a == b);
|
||||
Assert.NotEqual(a, c);
|
||||
Assert.True(a != c);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_Object_NullReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System);
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(group.Equals(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_Object_BoxedMessageGroupReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessageGroup group = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
object boxed = new ChatMessageGroup(0, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(group.Equals(boxed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_Object_WrongTypeReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System);
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(group.Equals("not a MessageGroup"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHashCode_ConsistentForEqualInstances()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
}
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class SlidingWindowCompactionStrategyTests : CompactionStrategyTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task UnderLimit_NoChangeAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi"),
|
||||
];
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 10);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategySkippedAsync(strategy, messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KeepsLastNTurnsAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Turn 1"),
|
||||
new(ChatRole.Assistant, "Reply 1"),
|
||||
new(ChatRole.User, "Turn 2"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
new(ChatRole.User, "Turn 3"),
|
||||
new(ChatRole.Assistant, "Reply 3"),
|
||||
];
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 2);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Turn 2", messages[0].Text);
|
||||
Assert.Equal("Reply 2", messages[1].Text);
|
||||
Assert.Equal("Turn 3", messages[2].Text);
|
||||
Assert.Equal("Reply 3", messages[3].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "You are a helper"),
|
||||
new(ChatRole.User, "Turn 1"),
|
||||
new(ChatRole.Assistant, "Reply 1"),
|
||||
new(ChatRole.User, "Turn 2"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
];
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ChatRole.System, messages[0].Role);
|
||||
Assert.Equal("You are a helper", messages[0].Text);
|
||||
Assert.Equal("Turn 2", messages[1].Text);
|
||||
Assert.Equal("Reply 2", messages[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreservesToolGroupsWithinKeptTurnsAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Turn 1"),
|
||||
new(ChatRole.Assistant, "Reply 1"),
|
||||
new(ChatRole.User, "Get weather"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
|
||||
new(ChatRole.Assistant, "It's sunny!"),
|
||||
];
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Get weather", messages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleTurn_AtLimit_NoChangeAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi"),
|
||||
];
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategySkippedAsync(strategy, messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DropsResponseGroupsFromOldTurnsAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Turn 1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "search")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "result")]),
|
||||
new(ChatRole.Assistant, "Here's what I found"),
|
||||
new(ChatRole.User, "Turn 2"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
];
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Turn 2", messages[0].Text);
|
||||
Assert.Equal("Reply 2", messages[1].Text);
|
||||
}
|
||||
}
|
||||
-164
@@ -1,164 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class SummarizationCompactionStrategyTests : CompactionStrategyTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task UnderLimit_NoChangeAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi"),
|
||||
];
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategySkippedAsync(strategy, messages);
|
||||
|
||||
// Assert
|
||||
chatClientMock.Verify(
|
||||
c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SummarizesOldGroupsAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "What's the weather?"),
|
||||
new(ChatRole.Assistant, "The weather is sunny and 72°F."),
|
||||
new(ChatRole.User, "How about tomorrow?"),
|
||||
new(ChatRole.Assistant, "Tomorrow will be cloudy."),
|
||||
new(ChatRole.User, "Thanks!"),
|
||||
new(ChatRole.Assistant, "You're welcome!"),
|
||||
];
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "User asked about weather. It was sunny.")));
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 2);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("[Summary]", messages[0].Text);
|
||||
Assert.Contains("sunny", messages[0].Text);
|
||||
Assert.Equal("Thanks!", messages[1].Text);
|
||||
Assert.Equal("You're welcome!", messages[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "You are a helper"),
|
||||
new(ChatRole.User, "Turn 1"),
|
||||
new(ChatRole.Assistant, "Reply 1"),
|
||||
new(ChatRole.User, "Turn 2"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
];
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of earlier discussion.")));
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ChatRole.System, messages[0].Role);
|
||||
Assert.Equal("You are a helper", messages[0].Text);
|
||||
Assert.Contains("[Summary]", messages[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllGroupsProtected_NoChangeAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi"),
|
||||
];
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategySkippedAsync(strategy, messages);
|
||||
|
||||
// Assert
|
||||
chatClientMock.Verify(
|
||||
c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomPrompt_UsedInRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string CustomPrompt = "Summarize briefly.";
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.Assistant, "Reply"),
|
||||
new(ChatRole.User, "Second"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
];
|
||||
List<ChatMessage>? capturedMessages = null;
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, _, _) => capturedMessages = [.. msgs])
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Brief summary.")));
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1, summarizationPrompt: CustomPrompt);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedMessages);
|
||||
Assert.Equal(ChatRole.System, capturedMessages![0].Role);
|
||||
Assert.Equal(CustomPrompt, capturedMessages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullResponseText_UsesFallbackAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.Assistant, "Reply"),
|
||||
new(ChatRole.User, "Second"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
];
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, (string?)null)));
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("[Summary unavailable]", messages[0].Text);
|
||||
}
|
||||
}
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class ToolResultCompactionStrategyTests : CompactionStrategyTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task UnderLimit_NoChangeAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
|
||||
];
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 100000);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategySkippedAsync(strategy, messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CollapsesOldToolGroupAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Check weather"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny, 72°F")]),
|
||||
new(ChatRole.User, "Thanks"),
|
||||
new(ChatRole.Assistant, "You're welcome!"),
|
||||
];
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("[Tool calls: get_weather]", messages[1].Text);
|
||||
Assert.Equal(ChatRole.Assistant, messages[1].Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProtectsRecentGroupsAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Check weather"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
|
||||
new(ChatRole.User, "Thanks"),
|
||||
new(ChatRole.Assistant, "You're welcome!"),
|
||||
];
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 10);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategySkippedAsync(strategy, messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "You are a helper"),
|
||||
new(ChatRole.User, "Check weather"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
|
||||
new(ChatRole.User, "Thanks"),
|
||||
new(ChatRole.Assistant, "You're welcome!"),
|
||||
];
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 5);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ChatRole.System, messages[0].Role);
|
||||
Assert.Equal("You are a helper", messages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleToolCalls_ListedInSummaryAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Do research"),
|
||||
new ChatMessage(ChatRole.Assistant, [
|
||||
new FunctionCallContent("c1", "search"),
|
||||
new FunctionCallContent("c2", "fetch_page"),
|
||||
]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "results...")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "page content...")]),
|
||||
new(ChatRole.User, "Summarize"),
|
||||
new(ChatRole.Assistant, "Here's the summary."),
|
||||
];
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("search", messages[1].Text);
|
||||
Assert.Contains("fetch_page", messages[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoToolGroups_NoChangeAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there"),
|
||||
];
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 1);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategySkippedAsync(strategy, messages);
|
||||
}
|
||||
}
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class TruncationCompactionStrategyTests : CompactionStrategyTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task UnderLimit_NoChangeAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
];
|
||||
TruncationCompactionStrategy strategy = new(maxTokens: 100000);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategySkippedAsync(strategy, messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OverLimit_RemovesOldestGroupsAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First message"),
|
||||
new(ChatRole.Assistant, "First reply"),
|
||||
new(ChatRole.User, "Second message"),
|
||||
new(ChatRole.Assistant, "Second reply"),
|
||||
];
|
||||
TruncationCompactionStrategy strategy = new(maxTokens: 1);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SystemOnlyMessages_NoChangeAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "You are a helper"),
|
||||
];
|
||||
TruncationCompactionStrategy strategy = new(maxTokens: 1);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategySkippedAsync(strategy, messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleNonSystemGroup_NoChangeAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "System prompt"),
|
||||
new(ChatRole.User, "Only user message"),
|
||||
];
|
||||
TruncationCompactionStrategy strategy = new(maxTokens: 1);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategySkippedAsync(strategy, messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreserveRecentGroups_KeepsMultipleGroupsAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Turn 1"),
|
||||
new(ChatRole.Assistant, "Reply 1"),
|
||||
new(ChatRole.User, "Turn 2"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
new(ChatRole.User, "Turn 3"),
|
||||
new(ChatRole.Assistant, "Reply 3"),
|
||||
];
|
||||
TruncationCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 2);
|
||||
|
||||
// Act & Assert
|
||||
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Reply 3", messages[^1].Text);
|
||||
}
|
||||
}
|
||||
+91
-1
@@ -105,7 +105,7 @@ public class AgentHostingServiceCollectionExtensionsTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers the agent as a keyed singleton service.
|
||||
/// Verifies that AddAIAgent registers the agent as a keyed singleton service by default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_RegistersKeyedSingleton()
|
||||
@@ -203,4 +203,94 @@ public class AgentHostingServiceCollectionExtensionsTests
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(descriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers with the specified scoped lifetime.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_WithScopedLifetime_RegistersKeyedScoped()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
const string AgentName = "scopedAgent";
|
||||
|
||||
// Act
|
||||
var result = services.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Scoped);
|
||||
|
||||
// Assert
|
||||
var descriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == AgentName &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
Assert.Equal(ServiceLifetime.Scoped, result.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers with the specified transient lifetime.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_WithTransientLifetime_RegistersKeyedTransient()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
const string AgentName = "transientAgent";
|
||||
|
||||
// Act
|
||||
var result = services.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Transient);
|
||||
|
||||
// Assert
|
||||
var descriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == AgentName &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
|
||||
Assert.Equal(ServiceLifetime.Transient, result.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the builder exposes the correct lifetime for default registration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_DefaultLifetime_BuilderExposesSingleton()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
|
||||
// Act
|
||||
var result = services.AddAIAgent("agentName", (sp, key) => mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ServiceLifetime.Singleton, result.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with instructions overload respects the lifetime parameter.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient)]
|
||||
public void AddAIAgent_InstructionsOverload_RespectsLifetime(ServiceLifetime lifetime)
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act
|
||||
var result = services.AddAIAgent("agent", "instructions", lifetime);
|
||||
|
||||
// Assert
|
||||
var descriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "agent" &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(lifetime, descriptor.Lifetime);
|
||||
Assert.Equal(lifetime, result.Lifetime);
|
||||
}
|
||||
}
|
||||
|
||||
+74
-1
@@ -127,7 +127,7 @@ public class HostApplicationBuilderAgentExtensionsTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers the agent as a keyed singleton service.
|
||||
/// Verifies that AddAIAgent registers the agent as a keyed singleton service by default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_RegistersKeyedSingleton()
|
||||
@@ -235,4 +235,77 @@ public class HostApplicationBuilderAgentExtensionsTests
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
Assert.NotNull(descriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers with the specified scoped lifetime via the host builder.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_WithScopedLifetime_RegistersKeyedScoped()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
const string AgentName = "scopedAgent";
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Scoped);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == AgentName &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
Assert.Equal(ServiceLifetime.Scoped, result.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent registers with the specified transient lifetime via the host builder.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddAIAgent_WithTransientLifetime_RegistersKeyedTransient()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
const string AgentName = "transientAgent";
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent(AgentName, (sp, key) => mockAgent.Object, ServiceLifetime.Transient);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == AgentName &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
|
||||
Assert.Equal(ServiceLifetime.Transient, result.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAIAgent with instructions overload respects the lifetime parameter via the host builder.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient)]
|
||||
public void AddAIAgent_InstructionsOverload_RespectsLifetime(ServiceLifetime lifetime)
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
|
||||
// Act
|
||||
var result = builder.AddAIAgent("agent", "instructions", lifetime);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "agent" &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(lifetime, descriptor.Lifetime);
|
||||
Assert.Equal(lifetime, result.Lifetime);
|
||||
}
|
||||
}
|
||||
|
||||
+72
-1
@@ -63,7 +63,7 @@ public class HostApplicationBuilderWorkflowExtensionsTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow registers the workflow as a keyed singleton service.
|
||||
/// Verifies that AddWorkflow registers the workflow as a keyed singleton service by default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_RegistersKeyedSingleton()
|
||||
@@ -328,6 +328,77 @@ public class HostApplicationBuilderWorkflowExtensionsTests
|
||||
Assert.NotNull(agentDescriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow registers with the specified scoped lifetime.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_WithScopedLifetime_RegistersKeyedScoped()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "scopedWorkflow";
|
||||
|
||||
// Act
|
||||
builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key), ServiceLifetime.Scoped);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == WorkflowName &&
|
||||
d.ServiceType == typeof(Workflow));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddWorkflow registers with the specified transient lifetime.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddWorkflow_WithTransientLifetime_RegistersKeyedTransient()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "transientWorkflow";
|
||||
|
||||
// Act
|
||||
builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key), ServiceLifetime.Transient);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == WorkflowName &&
|
||||
d.ServiceType == typeof(Workflow));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddAsAIAgent respects the lifetime parameter.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient)]
|
||||
public void AddAsAIAgent_RespectsLifetime(ServiceLifetime lifetime)
|
||||
{
|
||||
// Arrange
|
||||
var builder = new HostApplicationBuilder();
|
||||
const string WorkflowName = "testWorkflow";
|
||||
var workflowBuilder = builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
|
||||
|
||||
// Act
|
||||
var agentBuilder = workflowBuilder.AddAsAIAgent("agent", lifetime);
|
||||
|
||||
// Assert
|
||||
var descriptor = builder.Services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "agent" &&
|
||||
d.ServiceType == typeof(AIAgent));
|
||||
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(lifetime, descriptor.Lifetime);
|
||||
Assert.Equal(lifetime, agentBuilder.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to create a simple test workflow with a given name.
|
||||
/// </summary>
|
||||
|
||||
+174
@@ -7,6 +7,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
@@ -250,6 +251,179 @@ public sealed class HostedAgentBuilderToolsExtensionsTests
|
||||
Assert.Contains(factoryTool, agentTools);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAITool factory method defaults to the agent's lifetime when no explicit lifetime is specified.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient)]
|
||||
public void WithAIToolFactory_DefaultsToAgentLifetime(ServiceLifetime agentLifetime)
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, agentLifetime);
|
||||
|
||||
// Act
|
||||
builder.WithAITool(_ => new DummyAITool());
|
||||
|
||||
// Assert
|
||||
var toolDescriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "test-agent" &&
|
||||
d.ServiceType == typeof(AITool));
|
||||
|
||||
Assert.NotNull(toolDescriptor);
|
||||
Assert.Equal(agentLifetime, toolDescriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAITool factory method accepts an explicit lifetime override.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAIToolFactory_ExplicitLifetimeOverridesDefault()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Transient);
|
||||
|
||||
// Act - Transient agent with Singleton tool is valid (longer-lived dependency)
|
||||
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Singleton);
|
||||
|
||||
// Assert
|
||||
var toolDescriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "test-agent" &&
|
||||
d.ServiceType == typeof(AITool));
|
||||
|
||||
Assert.NotNull(toolDescriptor);
|
||||
Assert.Equal(ServiceLifetime.Singleton, toolDescriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAITool factory throws for singleton agent with scoped tool (captive dependency).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAIToolFactory_SingletonAgentWithScopedTool_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Singleton);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Scoped));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAITool factory throws for singleton agent with transient tool (captive dependency).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAIToolFactory_SingletonAgentWithTransientTool_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Singleton);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Transient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAITool factory throws for scoped agent with transient tool (captive dependency).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAIToolFactory_ScopedAgentWithTransientTool_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Scoped);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
builder.WithAITool(_ => new DummyAITool(), ServiceLifetime.Transient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies all valid tool lifetime combinations do not throw.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton, ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped, ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped, ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient, ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Transient, ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient, ServiceLifetime.Transient)]
|
||||
public void WithAIToolFactory_ValidLifetimeCombinations_DoNotThrow(ServiceLifetime agentLifetime, ServiceLifetime toolLifetime)
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, agentLifetime);
|
||||
|
||||
// Act & Assert - should not throw
|
||||
builder.WithAITool(_ => new DummyAITool(), toolLifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ValidateToolLifetime correctly identifies all invalid combinations.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton, ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Singleton, ServiceLifetime.Transient)]
|
||||
[InlineData(ServiceLifetime.Scoped, ServiceLifetime.Transient)]
|
||||
public void ValidateToolLifetime_InvalidCombinations_Throw(ServiceLifetime agentLifetime, ServiceLifetime toolLifetime)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
HostedAgentBuilderExtensions.ValidateToolLifetime(agentLifetime, toolLifetime));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the WithSessionStore factory method defaults to Singleton regardless of agent lifetime.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ServiceLifetime.Singleton)]
|
||||
[InlineData(ServiceLifetime.Scoped)]
|
||||
[InlineData(ServiceLifetime.Transient)]
|
||||
public void WithSessionStoreFactory_DefaultsToSingleton(ServiceLifetime agentLifetime)
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, agentLifetime);
|
||||
|
||||
// Act
|
||||
builder.WithSessionStore((sp, name) => new InMemoryAgentSessionStore());
|
||||
|
||||
// Assert
|
||||
var storeDescriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "test-agent" &&
|
||||
d.ServiceType == typeof(AgentSessionStore));
|
||||
|
||||
Assert.NotNull(storeDescriptor);
|
||||
Assert.Equal(ServiceLifetime.Singleton, storeDescriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the WithSessionStore factory method accepts an explicit lifetime override.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithSessionStoreFactory_ExplicitLifetimeOverridesDefault()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var builder = services.AddAIAgent("test-agent", (sp, key) => new Mock<AIAgent>().Object, ServiceLifetime.Transient);
|
||||
|
||||
// Act
|
||||
builder.WithSessionStore((sp, name) => new InMemoryAgentSessionStore(), ServiceLifetime.Singleton);
|
||||
|
||||
// Assert
|
||||
var storeDescriptor = services.FirstOrDefault(
|
||||
d => (d.ServiceKey as string) == "test-agent" &&
|
||||
d.ServiceType == typeof(AgentSessionStore));
|
||||
|
||||
Assert.NotNull(storeDescriptor);
|
||||
Assert.Equal(ServiceLifetime.Singleton, storeDescriptor.Lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dummy AITool implementation for testing.
|
||||
/// </summary>
|
||||
|
||||
+24
-4
@@ -122,10 +122,11 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
[InlineData("-leading-hyphen")]
|
||||
[InlineData("trailing-hyphen-")]
|
||||
[InlineData("has spaces")]
|
||||
[InlineData("consecutive--hyphens")]
|
||||
public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName)
|
||||
{
|
||||
// Arrange
|
||||
string skillDir = Path.Combine(this._testRoot, "invalid-name-test");
|
||||
string skillDir = Path.Combine(this._testRoot, invalidName);
|
||||
if (Directory.Exists(skillDir))
|
||||
{
|
||||
Directory.Delete(skillDir, recursive: true);
|
||||
@@ -147,15 +148,19 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly()
|
||||
{
|
||||
// Arrange
|
||||
string dir1 = Path.Combine(this._testRoot, "skill-a");
|
||||
string dir2 = Path.Combine(this._testRoot, "skill-b");
|
||||
string dir1 = Path.Combine(this._testRoot, "dupe");
|
||||
string dir2 = Path.Combine(this._testRoot, "subdir");
|
||||
Directory.CreateDirectory(dir1);
|
||||
Directory.CreateDirectory(dir2);
|
||||
|
||||
// Create a nested duplicate: subdir/dupe/SKILL.md
|
||||
string nestedDir = Path.Combine(dir2, "dupe");
|
||||
Directory.CreateDirectory(nestedDir);
|
||||
File.WriteAllText(
|
||||
Path.Combine(dir1, "SKILL.md"),
|
||||
"---\nname: dupe\ndescription: First\n---\nFirst body.");
|
||||
File.WriteAllText(
|
||||
Path.Combine(dir2, "SKILL.md"),
|
||||
Path.Combine(nestedDir, "SKILL.md"),
|
||||
"---\nname: dupe\ndescription: Second\n---\nSecond body.");
|
||||
|
||||
// Act
|
||||
@@ -168,6 +173,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverAndLoadSkills_NameMismatchesDirectory_ExcludesSkill()
|
||||
{
|
||||
// Arrange — directory name differs from the frontmatter name
|
||||
_ = this.CreateSkillDirectoryWithRawContent(
|
||||
"wrong-dir-name",
|
||||
"---\nname: actual-skill-name\ndescription: A skill\n---\nBody.");
|
||||
|
||||
// Act
|
||||
var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot });
|
||||
|
||||
// Assert
|
||||
Assert.Empty(skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverAndLoadSkills_FilesWithMatchingExtensions_DiscoveredAsResources()
|
||||
{
|
||||
|
||||
@@ -454,6 +454,77 @@ public class ChatHistoryMemoryProviderTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsync_CombinedFilterCanBeCompiled_WhenMultipleScopeFiltersProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
// This test reproduces a bug where combining multiple scope filters
|
||||
// (e.g. userId + sessionId) produces an expression tree with dangling
|
||||
// ParameterExpression references that fails at compile time.
|
||||
ChatHistoryMemoryProviderOptions providerOptions = new()
|
||||
{
|
||||
SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke,
|
||||
MaxResults = 2,
|
||||
ContextPrompt = "Here is the relevant chat history:\n"
|
||||
};
|
||||
|
||||
ChatHistoryMemoryProviderScope searchScope = new()
|
||||
{
|
||||
ApplicationId = "app1",
|
||||
AgentId = "agent1",
|
||||
SessionId = "session1",
|
||||
UserId = "user1"
|
||||
};
|
||||
|
||||
System.Linq.Expressions.Expression<Func<Dictionary<string, object?>, bool>>? capturedFilter = null;
|
||||
|
||||
this._vectorStoreCollectionMock
|
||||
.Setup(c => c.SearchAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<VectorSearchOptions<Dictionary<string, object?>>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback((string query, int maxResults, VectorSearchOptions<Dictionary<string, object?>> options, CancellationToken ct) =>
|
||||
capturedFilter = options.Filter)
|
||||
.Returns(ToAsyncEnumerableAsync(new List<VectorSearchResult<Dictionary<string, object?>>>()));
|
||||
|
||||
ChatHistoryMemoryProvider provider = new(
|
||||
this._vectorStoreMock.Object,
|
||||
TestCollectionName,
|
||||
1,
|
||||
_ => new ChatHistoryMemoryProvider.State(searchScope, searchScope),
|
||||
options: providerOptions);
|
||||
|
||||
ChatMessage requestMsg = new(ChatRole.User, "requesting relevant history");
|
||||
AIContextProvider.InvokingContext invokingContext = new(s_mockAgent, new TestAgentSession(), new AIContext { Messages = new List<ChatMessage> { requestMsg } });
|
||||
|
||||
// Act
|
||||
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert - The filter must be compilable and executable without expression tree scoping errors
|
||||
Assert.NotNull(capturedFilter);
|
||||
Func<Dictionary<string, object?>, bool> compiledFilter = capturedFilter!.Compile();
|
||||
|
||||
Dictionary<string, object?> matchingRecord = new()
|
||||
{
|
||||
["ApplicationId"] = "app1",
|
||||
["AgentId"] = "agent1",
|
||||
["SessionId"] = "session1",
|
||||
["UserId"] = "user1"
|
||||
};
|
||||
|
||||
Dictionary<string, object?> nonMatchingRecord = new()
|
||||
{
|
||||
["ApplicationId"] = "app1",
|
||||
["AgentId"] = "agent1",
|
||||
["SessionId"] = "other-session",
|
||||
["UserId"] = "user1"
|
||||
};
|
||||
|
||||
Assert.True(compiledFilter(matchingRecord));
|
||||
Assert.False(compiledFilter(nonMatchingRecord));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false, 2)]
|
||||
[InlineData(true, false, 2)]
|
||||
|
||||
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **agent-framework-redis**: Fix `RedisContextProvider` compatibility with redisvl 0.14.0 by using `AggregateHybridQuery` ([#3954](https://github.com/microsoft/agent-framework/pull/3954))
|
||||
|
||||
## [1.0.0rc3] - 2026-03-04
|
||||
|
||||
### Added
|
||||
|
||||
@@ -44,7 +44,7 @@ packages = ["agent_framework_ag_ui", "agent_framework_ag_ui_examples"]
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests/ag_ui"]
|
||||
pythonpath = ["."]
|
||||
pythonpath = [".", "tests/ag_ui"]
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Mapping, MutableSequence, Sequence
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Generic, Literal, cast, overload
|
||||
|
||||
@@ -36,6 +37,13 @@ StreamFn = Callable[..., AsyncIterable[ChatResponseUpdate]]
|
||||
ResponseFn = Callable[..., Awaitable[ChatResponse]]
|
||||
|
||||
|
||||
def pytest_configure() -> None:
|
||||
"""Ensure this test directory is on sys.path so helper modules can be imported by name."""
|
||||
test_dir = str(Path(__file__).resolve().parent)
|
||||
if test_dir not in sys.path:
|
||||
sys.path.insert(0, test_dir)
|
||||
|
||||
|
||||
class StreamingChatClientStub(
|
||||
ChatMiddlewareLayer[OptionsCoT],
|
||||
FunctionInvocationLayer[OptionsCoT],
|
||||
@@ -241,3 +249,83 @@ def stream_from_updates_fixture() -> Callable[[list[ChatResponseUpdate]], Stream
|
||||
def stub_agent() -> type[SupportsAgentRun]:
|
||||
"""Return the StubAgent class for creating test instances."""
|
||||
return StubAgent # type: ignore[return-value]
|
||||
|
||||
|
||||
# ── Fixtures for golden / integration tests ──
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collect_events() -> Callable[..., Any]:
|
||||
"""Return an async helper that collects all events from an async generator."""
|
||||
|
||||
async def _collect(async_gen: AsyncIterable[Any]) -> list[Any]:
|
||||
return [event async for event in async_gen]
|
||||
|
||||
return _collect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_agent_wrapper() -> Callable[..., Any]:
|
||||
"""Factory that builds an AgentFrameworkAgent from a stream function.
|
||||
|
||||
Usage::
|
||||
|
||||
agent = make_agent_wrapper(
|
||||
stream_fn=stream_from_updates(updates),
|
||||
state_schema=...,
|
||||
)
|
||||
events = [e async for e in agent.run(payload)]
|
||||
"""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
def _factory(
|
||||
stream_fn: StreamFn,
|
||||
*,
|
||||
state_schema: Any | None = None,
|
||||
predict_state_config: dict[str, dict[str, str]] | None = None,
|
||||
require_confirmation: bool = True,
|
||||
) -> Any:
|
||||
client = StreamingChatClientStub(stream_fn)
|
||||
stub = StubAgent(client=client)
|
||||
return AgentFrameworkAgent(
|
||||
agent=stub,
|
||||
state_schema=state_schema,
|
||||
predict_state_config=predict_state_config,
|
||||
require_confirmation=require_confirmation,
|
||||
)
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_app() -> Callable[..., Any]:
|
||||
"""Factory that builds a FastAPI app with an AG-UI endpoint.
|
||||
|
||||
Usage::
|
||||
|
||||
app = make_app(agent_or_wrapper, path="/test")
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
|
||||
def _factory(
|
||||
agent: Any,
|
||||
*,
|
||||
path: str = "/",
|
||||
state_schema: Any | None = None,
|
||||
predict_state_config: dict[str, dict[str, str]] | None = None,
|
||||
default_state: dict[str, Any] | None = None,
|
||||
) -> FastAPI:
|
||||
app = FastAPI()
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app,
|
||||
agent,
|
||||
path=path,
|
||||
state_schema=state_schema,
|
||||
predict_state_config=predict_state_config,
|
||||
default_state=default_state,
|
||||
)
|
||||
return app
|
||||
|
||||
return _factory
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""EventStream assertion helper for AG-UI regression tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class EventStream:
|
||||
"""Wraps a list of AG-UI events with structured assertion methods.
|
||||
|
||||
Usage:
|
||||
events = [event async for event in agent.run(payload)]
|
||||
stream = EventStream(events)
|
||||
stream.assert_bookends()
|
||||
stream.assert_text_messages_balanced()
|
||||
"""
|
||||
|
||||
def __init__(self, events: list[Any]) -> None:
|
||||
self.events = events
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.events)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.events)
|
||||
|
||||
def types(self) -> list[str]:
|
||||
"""Return ordered list of event type strings."""
|
||||
return [self._type_str(e) for e in self.events]
|
||||
|
||||
def get(self, event_type: str) -> list[Any]:
|
||||
"""Filter events matching the given type string."""
|
||||
return [e for e in self.events if self._type_str(e) == event_type]
|
||||
|
||||
def first(self, event_type: str) -> Any:
|
||||
"""Return the first event matching the given type, or raise."""
|
||||
matches = self.get(event_type)
|
||||
if not matches:
|
||||
raise ValueError(f"No event of type {event_type!r} found. Available: {self.types()}")
|
||||
return matches[0]
|
||||
|
||||
def last(self, event_type: str) -> Any:
|
||||
"""Return the last event matching the given type, or raise."""
|
||||
matches = self.get(event_type)
|
||||
if not matches:
|
||||
raise ValueError(f"No event of type {event_type!r} found. Available: {self.types()}")
|
||||
return matches[-1]
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
"""Return the latest StateSnapshotEvent snapshot dict."""
|
||||
return self.last("STATE_SNAPSHOT").snapshot
|
||||
|
||||
def messages_snapshot(self) -> list[Any]:
|
||||
"""Return the latest MessagesSnapshotEvent messages list."""
|
||||
return self.last("MESSAGES_SNAPSHOT").messages
|
||||
|
||||
# ── Structural assertions ──
|
||||
|
||||
def assert_bookends(self) -> None:
|
||||
"""Assert first event is RUN_STARTED and last is RUN_FINISHED."""
|
||||
types = self.types()
|
||||
assert types, "Event stream is empty"
|
||||
assert types[0] == "RUN_STARTED", f"Expected RUN_STARTED first, got {types[0]}"
|
||||
assert types[-1] == "RUN_FINISHED", f"Expected RUN_FINISHED last, got {types[-1]}"
|
||||
|
||||
def assert_has_run_lifecycle(self) -> None:
|
||||
"""Assert RUN_STARTED is first and RUN_FINISHED exists (may not be last).
|
||||
|
||||
Use this instead of assert_bookends() for workflow resume streams where
|
||||
_drain_open_message() can emit TEXT_MESSAGE_END after RUN_FINISHED.
|
||||
"""
|
||||
types = self.types()
|
||||
assert types, "Event stream is empty"
|
||||
assert types[0] == "RUN_STARTED", f"Expected RUN_STARTED first, got {types[0]}"
|
||||
assert "RUN_FINISHED" in types, f"Expected RUN_FINISHED in stream. Types: {types}"
|
||||
|
||||
def assert_strict_types(self, expected: list[str]) -> None:
|
||||
"""Assert exact type sequence match."""
|
||||
actual = self.types()
|
||||
assert actual == expected, f"Event type mismatch.\nExpected: {expected}\nActual: {actual}"
|
||||
|
||||
def assert_ordered_types(self, expected: list[str]) -> None:
|
||||
"""Assert expected types appear as a subsequence (in order, not necessarily contiguous)."""
|
||||
actual = self.types()
|
||||
actual_idx = 0
|
||||
for expected_type in expected:
|
||||
found = False
|
||||
while actual_idx < len(actual):
|
||||
if actual[actual_idx] == expected_type:
|
||||
actual_idx += 1
|
||||
found = True
|
||||
break
|
||||
actual_idx += 1
|
||||
if not found:
|
||||
raise AssertionError(
|
||||
f"Expected subsequence type {expected_type!r} not found after index {actual_idx}.\n"
|
||||
f"Expected subsequence: {expected}\n"
|
||||
f"Actual types: {actual}"
|
||||
)
|
||||
|
||||
def assert_text_messages_balanced(self) -> None:
|
||||
"""Assert every TEXT_MESSAGE_START has a matching TEXT_MESSAGE_END with the same message_id."""
|
||||
starts: dict[str, int] = {}
|
||||
ends: set[str] = set()
|
||||
for i, event in enumerate(self.events):
|
||||
t = self._type_str(event)
|
||||
if t == "TEXT_MESSAGE_START":
|
||||
mid = event.message_id
|
||||
assert mid not in starts, f"Duplicate TEXT_MESSAGE_START for message_id={mid}"
|
||||
starts[mid] = i
|
||||
elif t == "TEXT_MESSAGE_END":
|
||||
mid = event.message_id
|
||||
assert mid in starts, f"TEXT_MESSAGE_END for unknown message_id={mid}"
|
||||
assert mid not in ends, f"Duplicate TEXT_MESSAGE_END for message_id={mid}"
|
||||
ends.add(mid)
|
||||
|
||||
unclosed = set(starts.keys()) - ends
|
||||
assert not unclosed, f"Unclosed text messages: {unclosed}"
|
||||
|
||||
def assert_tool_calls_balanced(self) -> None:
|
||||
"""Assert every TOOL_CALL_START has a matching TOOL_CALL_END with the same tool_call_id."""
|
||||
starts: dict[str, int] = {}
|
||||
ends: set[str] = set()
|
||||
for i, event in enumerate(self.events):
|
||||
t = self._type_str(event)
|
||||
if t == "TOOL_CALL_START":
|
||||
tid = event.tool_call_id
|
||||
assert tid not in starts, f"Duplicate TOOL_CALL_START for tool_call_id={tid}"
|
||||
starts[tid] = i
|
||||
elif t == "TOOL_CALL_END":
|
||||
tid = event.tool_call_id
|
||||
assert tid in starts, f"TOOL_CALL_END for unknown tool_call_id={tid}"
|
||||
assert tid not in ends, f"Duplicate TOOL_CALL_END for tool_call_id={tid}"
|
||||
ends.add(tid)
|
||||
|
||||
unclosed = set(starts.keys()) - ends
|
||||
assert not unclosed, f"Unclosed tool calls: {unclosed}"
|
||||
|
||||
def assert_no_run_error(self) -> None:
|
||||
"""Assert no RUN_ERROR events exist."""
|
||||
errors = self.get("RUN_ERROR")
|
||||
if errors:
|
||||
messages = [getattr(e, "message", str(e)) for e in errors]
|
||||
raise AssertionError(f"Found {len(errors)} RUN_ERROR event(s): {messages}")
|
||||
|
||||
def assert_has_type(self, event_type: str) -> None:
|
||||
"""Assert at least one event of the given type exists."""
|
||||
assert event_type in self.types(), f"Expected {event_type!r} in stream. Available: {self.types()}"
|
||||
|
||||
def assert_message_ids_consistent(self) -> None:
|
||||
"""Assert TEXT_MESSAGE_CONTENT events reference valid, open message_ids."""
|
||||
open_messages: set[str] = set()
|
||||
for event in self.events:
|
||||
t = self._type_str(event)
|
||||
if t == "TEXT_MESSAGE_START":
|
||||
open_messages.add(event.message_id)
|
||||
elif t == "TEXT_MESSAGE_END":
|
||||
open_messages.discard(event.message_id)
|
||||
elif t == "TEXT_MESSAGE_CONTENT":
|
||||
mid = event.message_id
|
||||
assert mid in open_messages, f"TEXT_MESSAGE_CONTENT references message_id={mid} which is not open"
|
||||
|
||||
# ── Internal ──
|
||||
|
||||
@staticmethod
|
||||
def _type_str(event: Any) -> str:
|
||||
"""Extract event type as a plain string."""
|
||||
t = getattr(event, "type", None)
|
||||
if t is None:
|
||||
return type(event).__name__
|
||||
if isinstance(t, str):
|
||||
return t
|
||||
return getattr(t, "value", str(t))
|
||||
@@ -0,0 +1 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Conftest for golden tests — ensures parent test dir is importable."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def pytest_configure() -> None:
|
||||
"""Ensure parent test directory is on sys.path for helper module imports."""
|
||||
parent_test_dir = str(Path(__file__).resolve().parent.parent)
|
||||
if parent_test_dir not in sys.path:
|
||||
sys.path.insert(0, parent_test_dir)
|
||||
@@ -0,0 +1,140 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Golden event-stream tests for the basic agentic chat scenario."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
from conftest import StubAgent
|
||||
from event_stream import EventStream
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
|
||||
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
|
||||
stub = StubAgent(updates=updates)
|
||||
return AgentFrameworkAgent(agent=stub, **kwargs)
|
||||
|
||||
|
||||
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
|
||||
return EventStream([event async for event in agent.run(payload)])
|
||||
|
||||
|
||||
BASIC_PAYLOAD: dict[str, Any] = {
|
||||
"thread_id": "thread-chat",
|
||||
"run_id": "run-chat",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
}
|
||||
|
||||
|
||||
def _text_update(text: str) -> AgentResponseUpdate:
|
||||
return AgentResponseUpdate(contents=[Content.from_text(text=text)], role="assistant")
|
||||
|
||||
|
||||
def _snapshot_role(msg: Any) -> str:
|
||||
"""Extract role string from a snapshot message (Pydantic model or dict)."""
|
||||
role = getattr(msg, "role", None) or (msg.get("role") if isinstance(msg, dict) else None)
|
||||
if role is None:
|
||||
return ""
|
||||
return str(getattr(role, "value", role))
|
||||
|
||||
|
||||
def _snapshot_content(msg: Any) -> str:
|
||||
"""Extract content string from a snapshot message."""
|
||||
content = getattr(msg, "content", None) or (msg.get("content") if isinstance(msg, dict) else "")
|
||||
return str(content) if content else ""
|
||||
|
||||
|
||||
# ── Golden stream tests ──
|
||||
|
||||
|
||||
async def test_basic_chat_golden_event_sequence() -> None:
|
||||
"""Assert the exact event type sequence for a single text response."""
|
||||
agent = _build_agent([_text_update("Hi there!")])
|
||||
stream = await _run(agent, BASIC_PAYLOAD)
|
||||
|
||||
stream.assert_strict_types(
|
||||
[
|
||||
"RUN_STARTED",
|
||||
"TEXT_MESSAGE_START",
|
||||
"TEXT_MESSAGE_CONTENT",
|
||||
"TEXT_MESSAGE_END",
|
||||
"MESSAGES_SNAPSHOT",
|
||||
"RUN_FINISHED",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def test_basic_chat_bookends() -> None:
|
||||
"""RUN_STARTED is first, RUN_FINISHED is last."""
|
||||
agent = _build_agent([_text_update("reply")])
|
||||
stream = await _run(agent, BASIC_PAYLOAD)
|
||||
stream.assert_bookends()
|
||||
|
||||
|
||||
async def test_basic_chat_text_messages_balanced() -> None:
|
||||
"""Every TEXT_MESSAGE_START has a matching TEXT_MESSAGE_END."""
|
||||
agent = _build_agent([_text_update("reply")])
|
||||
stream = await _run(agent, BASIC_PAYLOAD)
|
||||
stream.assert_text_messages_balanced()
|
||||
|
||||
|
||||
async def test_basic_chat_no_errors() -> None:
|
||||
"""No RUN_ERROR events in a normal flow."""
|
||||
agent = _build_agent([_text_update("reply")])
|
||||
stream = await _run(agent, BASIC_PAYLOAD)
|
||||
stream.assert_no_run_error()
|
||||
|
||||
|
||||
async def test_basic_chat_message_id_consistency() -> None:
|
||||
"""All text events reference the same message_id."""
|
||||
agent = _build_agent([_text_update("reply")])
|
||||
stream = await _run(agent, BASIC_PAYLOAD)
|
||||
|
||||
start = stream.first("TEXT_MESSAGE_START")
|
||||
content = stream.first("TEXT_MESSAGE_CONTENT")
|
||||
end = stream.first("TEXT_MESSAGE_END")
|
||||
assert start.message_id == content.message_id == end.message_id
|
||||
|
||||
|
||||
async def test_multi_chunk_text_golden_sequence() -> None:
|
||||
"""Streaming multiple chunks produces START + multiple CONTENT + END."""
|
||||
agent = _build_agent([_text_update("Hello "), _text_update("world!")])
|
||||
stream = await _run(agent, BASIC_PAYLOAD)
|
||||
|
||||
stream.assert_strict_types(
|
||||
[
|
||||
"RUN_STARTED",
|
||||
"TEXT_MESSAGE_START",
|
||||
"TEXT_MESSAGE_CONTENT",
|
||||
"TEXT_MESSAGE_CONTENT",
|
||||
"TEXT_MESSAGE_END",
|
||||
"MESSAGES_SNAPSHOT",
|
||||
"RUN_FINISHED",
|
||||
]
|
||||
)
|
||||
stream.assert_text_messages_balanced()
|
||||
stream.assert_message_ids_consistent()
|
||||
|
||||
|
||||
async def test_messages_snapshot_contains_assistant_reply() -> None:
|
||||
"""MessagesSnapshotEvent includes the assistant's accumulated text."""
|
||||
agent = _build_agent([_text_update("Hello there")])
|
||||
stream = await _run(agent, BASIC_PAYLOAD)
|
||||
|
||||
snapshot = stream.messages_snapshot()
|
||||
assistant_msgs = [m for m in snapshot if _snapshot_role(m) == "assistant"]
|
||||
assert assistant_msgs, "No assistant message in snapshot"
|
||||
assert any("Hello there" in _snapshot_content(m) for m in assistant_msgs)
|
||||
|
||||
|
||||
async def test_empty_messages_produces_start_and_finish() -> None:
|
||||
"""Empty message list still produces RUN_STARTED and RUN_FINISHED."""
|
||||
agent = _build_agent([_text_update("reply")])
|
||||
payload = {"thread_id": "t1", "run_id": "r1", "messages": []}
|
||||
stream = await _run(agent, payload)
|
||||
|
||||
stream.assert_bookends()
|
||||
assert "TEXT_MESSAGE_START" not in stream.types()
|
||||
@@ -0,0 +1,236 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Golden event-stream tests for the backend (server-side) tools scenario."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
from conftest import StubAgent
|
||||
from event_stream import EventStream
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
|
||||
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
|
||||
stub = StubAgent(updates=updates)
|
||||
return AgentFrameworkAgent(agent=stub, **kwargs)
|
||||
|
||||
|
||||
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
|
||||
return EventStream([event async for event in agent.run(payload)])
|
||||
|
||||
|
||||
PAYLOAD: dict[str, Any] = {
|
||||
"thread_id": "thread-tools",
|
||||
"run_id": "run-tools",
|
||||
"messages": [{"role": "user", "content": "What's the weather?"}],
|
||||
}
|
||||
|
||||
|
||||
# ── Golden stream tests ──
|
||||
|
||||
|
||||
async def test_tool_call_lifecycle_golden_sequence() -> None:
|
||||
"""Assert the full event sequence for a tool call → result → text response."""
|
||||
updates = [
|
||||
# LLM calls the tool
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city": "SF"}')],
|
||||
role="assistant",
|
||||
),
|
||||
# Tool result comes back
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call-1", result="72°F and sunny")],
|
||||
role="assistant",
|
||||
),
|
||||
# LLM responds with text
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="It's 72°F and sunny in SF!")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_ordered_types(
|
||||
[
|
||||
"RUN_STARTED",
|
||||
"TEXT_MESSAGE_START", # Synthetic start for tool-only message
|
||||
"TOOL_CALL_START",
|
||||
"TOOL_CALL_ARGS",
|
||||
"TOOL_CALL_END",
|
||||
"TOOL_CALL_RESULT",
|
||||
"TEXT_MESSAGE_END", # End of synthetic message
|
||||
"TEXT_MESSAGE_START", # New message for text response
|
||||
"TEXT_MESSAGE_CONTENT",
|
||||
"TEXT_MESSAGE_END",
|
||||
"MESSAGES_SNAPSHOT",
|
||||
"RUN_FINISHED",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def test_tool_calls_balanced() -> None:
|
||||
"""Every TOOL_CALL_START has a matching TOOL_CALL_END."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city": "SF"}')],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="It's 72°F!")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_tool_calls_balanced()
|
||||
|
||||
|
||||
async def test_text_messages_balanced_with_tools() -> None:
|
||||
"""Text messages are properly balanced even around tool calls."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city": "SF"}')],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="It's 72°F!")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_text_messages_balanced()
|
||||
|
||||
|
||||
async def test_tool_call_id_matches_result() -> None:
|
||||
"""TOOL_CALL_START and TOOL_CALL_RESULT reference the same tool_call_id."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments="{}")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
start = stream.first("TOOL_CALL_START")
|
||||
result = stream.first("TOOL_CALL_RESULT")
|
||||
assert start.tool_call_id == result.tool_call_id == "call-1"
|
||||
|
||||
|
||||
async def test_tool_result_content_preserved() -> None:
|
||||
"""TOOL_CALL_RESULT event carries the tool's result content."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments="{}")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call-1", result="72°F and sunny")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
result = stream.first("TOOL_CALL_RESULT")
|
||||
assert result.content == "72°F and sunny"
|
||||
|
||||
|
||||
async def test_no_run_error_on_tool_flow() -> None:
|
||||
"""Tool call flow doesn't produce RUN_ERROR."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments="{}")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_no_run_error()
|
||||
stream.assert_bookends()
|
||||
|
||||
|
||||
async def test_multiple_sequential_tool_calls() -> None:
|
||||
"""Multiple sequential tool calls each produce balanced START/END pairs."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="tool_a", call_id="call-a", arguments="{}")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call-a", result="result-a")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="tool_b", call_id="call-b", arguments="{}")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call-b", result="result-b")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="Done!")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_tool_calls_balanced()
|
||||
stream.assert_text_messages_balanced()
|
||||
stream.assert_bookends()
|
||||
|
||||
# Both tool calls should appear
|
||||
starts = stream.get("TOOL_CALL_START")
|
||||
assert len(starts) == 2
|
||||
assert {s.tool_call_name for s in starts} == {"tool_a", "tool_b"}
|
||||
|
||||
|
||||
async def test_messages_snapshot_includes_tool_calls() -> None:
|
||||
"""MessagesSnapshotEvent includes tool call and result messages."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city":"SF"}')],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="It's warm!")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_has_type("MESSAGES_SNAPSHOT")
|
||||
snapshot = stream.messages_snapshot()
|
||||
# Should have: user message, assistant with tool_calls, tool result, assistant text
|
||||
assert len(snapshot) >= 3
|
||||
@@ -0,0 +1,91 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Golden event-stream tests for the generative UI (workflow-as-agent) scenario."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import WorkflowBuilder, WorkflowContext, executor
|
||||
from event_stream import EventStream
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkWorkflow
|
||||
|
||||
|
||||
async def _run(wrapper: AgentFrameworkWorkflow, payload: dict[str, Any]) -> EventStream:
|
||||
return EventStream([event async for event in wrapper.run(payload)])
|
||||
|
||||
|
||||
PAYLOAD: dict[str, Any] = {
|
||||
"thread_id": "thread-gen-ui-agent",
|
||||
"run_id": "run-gen-ui-agent",
|
||||
"messages": [{"role": "user", "content": "Generate a UI"}],
|
||||
}
|
||||
|
||||
|
||||
# ── Golden stream tests ──
|
||||
|
||||
|
||||
async def test_workflow_agent_golden_sequence() -> None:
|
||||
"""Workflow-as-agent: emits step events and text content."""
|
||||
|
||||
@executor(id="generator")
|
||||
async def generator(message: Any, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("Here is your generated UI content!")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=generator).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, PAYLOAD)
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
stream.assert_text_messages_balanced()
|
||||
|
||||
# Should have step events for the executor
|
||||
stream.assert_has_type("STEP_STARTED")
|
||||
stream.assert_has_type("STEP_FINISHED")
|
||||
|
||||
# Should have text message content
|
||||
stream.assert_has_type("TEXT_MESSAGE_CONTENT")
|
||||
|
||||
|
||||
async def test_workflow_agent_step_names_match() -> None:
|
||||
"""Step started/finished events reference the executor name."""
|
||||
|
||||
@executor(id="my_executor")
|
||||
async def my_executor(message: Any, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("Done!")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=my_executor).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, PAYLOAD)
|
||||
|
||||
started = [e for e in stream.get("STEP_STARTED") if getattr(e, "step_name", "") == "my_executor"]
|
||||
finished = [e for e in stream.get("STEP_FINISHED") if getattr(e, "step_name", "") == "my_executor"]
|
||||
assert started, "Expected STEP_STARTED for 'my_executor'"
|
||||
assert finished, "Expected STEP_FINISHED for 'my_executor'"
|
||||
|
||||
|
||||
async def test_workflow_agent_ordered_events() -> None:
|
||||
"""Workflow events follow expected ordering: RUN_STARTED → STEP_STARTED → content → STEP_FINISHED → RUN_FINISHED."""
|
||||
|
||||
@executor(id="my_step")
|
||||
async def my_step(message: Any, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("Generated content")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=my_step).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, PAYLOAD)
|
||||
|
||||
stream.assert_ordered_types(
|
||||
[
|
||||
"RUN_STARTED",
|
||||
"STEP_STARTED",
|
||||
"TEXT_MESSAGE_START",
|
||||
"TEXT_MESSAGE_CONTENT",
|
||||
"STEP_FINISHED",
|
||||
"TEXT_MESSAGE_END",
|
||||
"RUN_FINISHED",
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Golden event-stream tests for the client-side (declaration-only) tools scenario."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
from conftest import StubAgent
|
||||
from event_stream import EventStream
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
|
||||
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
|
||||
stub = StubAgent(updates=updates)
|
||||
return AgentFrameworkAgent(agent=stub, **kwargs)
|
||||
|
||||
|
||||
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
|
||||
return EventStream([event async for event in agent.run(payload)])
|
||||
|
||||
|
||||
PAYLOAD: dict[str, Any] = {
|
||||
"thread_id": "thread-gen-ui-tool",
|
||||
"run_id": "run-gen-ui-tool",
|
||||
"messages": [{"role": "user", "content": "Show me a chart"}],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "render_chart",
|
||||
"description": "Render a chart in the UI",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"data": {"type": "array"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Golden stream tests ──
|
||||
|
||||
|
||||
async def test_declaration_only_tool_golden_sequence() -> None:
|
||||
"""Declaration-only tool: TOOL_CALL_START/ARGS emitted, TOOL_CALL_END at stream end."""
|
||||
# The LLM calls a client-side tool (no server-side execution)
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="render_chart",
|
||||
call_id="call-chart",
|
||||
arguments='{"data": [1, 2, 3]}',
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
|
||||
# Tool call start and args should be present
|
||||
stream.assert_has_type("TOOL_CALL_START")
|
||||
stream.assert_has_type("TOOL_CALL_ARGS")
|
||||
|
||||
# TOOL_CALL_END should be emitted (via get_pending_without_end)
|
||||
stream.assert_has_type("TOOL_CALL_END")
|
||||
stream.assert_tool_calls_balanced()
|
||||
|
||||
|
||||
async def test_declaration_only_tool_no_tool_call_result() -> None:
|
||||
"""Declaration-only tools should NOT produce TOOL_CALL_RESULT events."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="render_chart",
|
||||
call_id="call-chart",
|
||||
arguments='{"data": [1, 2, 3]}',
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
assert "TOOL_CALL_RESULT" not in stream.types(), "Declaration-only tools should not have TOOL_CALL_RESULT"
|
||||
|
||||
|
||||
async def test_declaration_only_tool_text_messages_balanced() -> None:
|
||||
"""Text messages remain balanced even with declaration-only tools."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="render_chart",
|
||||
call_id="call-chart",
|
||||
arguments='{"data": [1, 2, 3]}',
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_text_messages_balanced()
|
||||
|
||||
|
||||
async def test_declaration_only_tool_messages_snapshot() -> None:
|
||||
"""MessagesSnapshotEvent includes the tool call for declaration-only tools."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="render_chart",
|
||||
call_id="call-chart",
|
||||
arguments='{"data": [1, 2, 3]}',
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_has_type("MESSAGES_SNAPSHOT")
|
||||
@@ -0,0 +1,196 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Golden event-stream tests for the HITL (human-in-the-loop) approval scenario."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
from conftest import StubAgent
|
||||
from event_stream import EventStream
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
PREDICT_CONFIG = {
|
||||
"tasks": {
|
||||
"tool": "generate_task_steps",
|
||||
"tool_argument": "steps",
|
||||
}
|
||||
}
|
||||
|
||||
STATE_SCHEMA = {
|
||||
"tasks": {"type": "array", "items": {"type": "object"}},
|
||||
}
|
||||
|
||||
|
||||
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
|
||||
stub = StubAgent(updates=updates)
|
||||
return AgentFrameworkAgent(
|
||||
agent=stub,
|
||||
state_schema=STATE_SCHEMA,
|
||||
predict_state_config=PREDICT_CONFIG,
|
||||
require_confirmation=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
|
||||
return EventStream([event async for event in agent.run(payload)])
|
||||
|
||||
|
||||
STEPS = [
|
||||
{"description": "Step 1: Plan", "status": "enabled"},
|
||||
{"description": "Step 2: Execute", "status": "enabled"},
|
||||
]
|
||||
|
||||
|
||||
PAYLOAD: dict[str, Any] = {
|
||||
"thread_id": "thread-hitl",
|
||||
"run_id": "run-hitl",
|
||||
"messages": [{"role": "user", "content": "Plan my tasks"}],
|
||||
"state": {"tasks": []},
|
||||
}
|
||||
|
||||
|
||||
# ── Turn 1: Tool call → confirm_changes → interrupt ──
|
||||
|
||||
|
||||
async def test_hitl_turn1_golden_sequence() -> None:
|
||||
"""Turn 1 emits tool call, confirm_changes, and finishes with interrupt."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="generate_task_steps",
|
||||
call_id="call-steps",
|
||||
arguments=json.dumps({"steps": STEPS}),
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
# Should have: tool call start/args/end for the primary tool,
|
||||
# then TOOL_CALL_END, STATE_SNAPSHOT, confirm_changes cycle
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
|
||||
# confirm_changes tool call should be present
|
||||
tool_starts = stream.get("TOOL_CALL_START")
|
||||
tool_names = [getattr(s, "tool_call_name", None) for s in tool_starts]
|
||||
assert "generate_task_steps" in tool_names
|
||||
assert "confirm_changes" in tool_names
|
||||
|
||||
# RUN_FINISHED should have interrupt metadata
|
||||
finished = stream.last("RUN_FINISHED")
|
||||
interrupt = getattr(finished, "interrupt", None)
|
||||
assert interrupt is not None, "Expected interrupt in RUN_FINISHED"
|
||||
assert len(interrupt) > 0
|
||||
|
||||
|
||||
async def test_hitl_turn1_tool_calls_balanced() -> None:
|
||||
"""All tool calls in turn 1 (primary + confirm_changes) are balanced."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="generate_task_steps",
|
||||
call_id="call-steps",
|
||||
arguments=json.dumps({"steps": STEPS}),
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_tool_calls_balanced()
|
||||
|
||||
|
||||
async def test_hitl_turn1_text_messages_balanced() -> None:
|
||||
"""Text messages are balanced even in the approval flow."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="generate_task_steps",
|
||||
call_id="call-steps",
|
||||
arguments=json.dumps({"steps": STEPS}),
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_text_messages_balanced()
|
||||
|
||||
|
||||
# ── Turn 2: Resume with approval → confirmation message → no interrupt ──
|
||||
|
||||
|
||||
async def test_hitl_turn2_resume_with_approval() -> None:
|
||||
"""Resuming with confirm_changes result emits confirmation text and finishes cleanly."""
|
||||
# Turn 2: user sends confirm_changes result as resume
|
||||
# The agent wrapper sees a confirm_changes response and emits a confirmation message
|
||||
confirm_result = json.dumps(
|
||||
{
|
||||
"accepted": True,
|
||||
"steps": STEPS,
|
||||
}
|
||||
)
|
||||
|
||||
# Build payload with resume containing the approval
|
||||
# For confirm_changes, the messages should include the tool result
|
||||
payload: dict[str, Any] = {
|
||||
"thread_id": "thread-hitl",
|
||||
"run_id": "run-hitl-2",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Plan my tasks"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "confirm-id-1",
|
||||
"type": "function",
|
||||
"function": {"name": "confirm_changes", "arguments": json.dumps({"steps": STEPS})},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"toolCallId": "confirm-id-1",
|
||||
"content": confirm_result,
|
||||
},
|
||||
],
|
||||
"state": {"tasks": []},
|
||||
}
|
||||
|
||||
# In turn 2, the agent sees the confirm_changes result and emits a confirmation text
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="Tasks confirmed!")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, payload)
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_text_messages_balanced()
|
||||
stream.assert_no_run_error()
|
||||
|
||||
# Should have text message content (the confirmation message)
|
||||
text_events = stream.get("TEXT_MESSAGE_CONTENT")
|
||||
assert text_events, "Expected confirmation text message"
|
||||
|
||||
# RUN_FINISHED should NOT have interrupt (approval completed)
|
||||
finished = stream.last("RUN_FINISHED")
|
||||
interrupt = getattr(finished, "interrupt", None)
|
||||
assert not interrupt, f"Expected no interrupt after approval, got {interrupt}"
|
||||
@@ -0,0 +1,130 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Golden event-stream tests for the predictive state scenario."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
from conftest import StubAgent
|
||||
from event_stream import EventStream
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
PREDICT_CONFIG = {
|
||||
"document": {
|
||||
"tool": "update_document",
|
||||
"tool_argument": "content",
|
||||
}
|
||||
}
|
||||
|
||||
STATE_SCHEMA = {
|
||||
"document": {"type": "string"},
|
||||
}
|
||||
|
||||
|
||||
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
|
||||
stub = StubAgent(updates=updates)
|
||||
return AgentFrameworkAgent(
|
||||
agent=stub,
|
||||
state_schema=STATE_SCHEMA,
|
||||
predict_state_config=PREDICT_CONFIG,
|
||||
require_confirmation=False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
|
||||
return EventStream([event async for event in agent.run(payload)])
|
||||
|
||||
|
||||
PAYLOAD: dict[str, Any] = {
|
||||
"thread_id": "thread-predict",
|
||||
"run_id": "run-predict",
|
||||
"messages": [{"role": "user", "content": "Write a document"}],
|
||||
"state": {"document": ""},
|
||||
}
|
||||
|
||||
|
||||
# ── Golden stream tests ──
|
||||
|
||||
|
||||
async def test_predictive_state_emits_deltas_during_tool_args() -> None:
|
||||
"""STATE_DELTA events are emitted as tool arguments stream in."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="update_document", call_id="call-1", arguments="")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(name="update_document", call_id="call-1", arguments='{"content": "Hello')
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="update_document", call_id="call-1", arguments=' world"}')],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
|
||||
# PredictState custom event should be present
|
||||
custom_events = stream.get("CUSTOM")
|
||||
predict_events = [e for e in custom_events if getattr(e, "name", None) == "PredictState"]
|
||||
assert predict_events, "Expected PredictState custom event"
|
||||
|
||||
# STATE_DELTA events should be emitted during tool arg streaming
|
||||
assert "STATE_DELTA" in stream.types(), "Expected STATE_DELTA events during predictive streaming"
|
||||
|
||||
|
||||
async def test_predictive_state_snapshot_after_tool_end() -> None:
|
||||
"""STATE_SNAPSHOT is emitted when a predictive tool completes (no confirmation)."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="update_document", call_id="call-1", arguments='{"content": "Final text"}'
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_bookends()
|
||||
|
||||
# Should have initial state snapshot + updated snapshot after tool completion
|
||||
snapshots = stream.get("STATE_SNAPSHOT")
|
||||
assert len(snapshots) >= 1, "Expected at least one STATE_SNAPSHOT"
|
||||
|
||||
|
||||
async def test_predictive_state_ordered_events() -> None:
|
||||
"""Event ordering: RUN_STARTED → PredictState → STATE_SNAPSHOT → TOOL_CALL_* → STATE_SNAPSHOT → RUN_FINISHED."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(name="update_document", call_id="call-1", arguments='{"content": "doc"}')
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_ordered_types(
|
||||
[
|
||||
"RUN_STARTED",
|
||||
"CUSTOM", # PredictState
|
||||
"STATE_SNAPSHOT", # Initial state
|
||||
"TOOL_CALL_START",
|
||||
"TOOL_CALL_ARGS",
|
||||
"RUN_FINISHED",
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Golden event-stream tests for the shared state (structured output) scenario."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
from conftest import StubAgent
|
||||
from event_stream import EventStream
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
|
||||
class RecipeState(BaseModel):
|
||||
recipe_title: str = ""
|
||||
ingredients: list[str] = []
|
||||
message: str = ""
|
||||
|
||||
|
||||
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
|
||||
stub = StubAgent(
|
||||
updates=updates,
|
||||
default_options={"tools": None, "response_format": RecipeState},
|
||||
)
|
||||
return AgentFrameworkAgent(
|
||||
agent=stub,
|
||||
state_schema={
|
||||
"recipe_title": {"type": "string"},
|
||||
"ingredients": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
|
||||
return EventStream([event async for event in agent.run(payload)])
|
||||
|
||||
|
||||
PAYLOAD: dict[str, Any] = {
|
||||
"thread_id": "thread-state",
|
||||
"run_id": "run-state",
|
||||
"messages": [{"role": "user", "content": "Give me a pasta recipe"}],
|
||||
"state": {"recipe_title": "", "ingredients": []},
|
||||
}
|
||||
|
||||
|
||||
# ── Golden stream tests ──
|
||||
|
||||
|
||||
async def test_shared_state_emits_state_snapshot() -> None:
|
||||
"""Structured output agent emits STATE_SNAPSHOT with parsed model fields."""
|
||||
# The structured output agent gets a response that the framework parses as RecipeState
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_text(
|
||||
text='{"recipe_title": "Pasta Carbonara", "ingredients": ["pasta", "eggs", "cheese"], "message": "Here is your recipe!"}'
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
|
||||
# Should have STATE_SNAPSHOT with the initial state at minimum
|
||||
stream.assert_has_type("STATE_SNAPSHOT")
|
||||
|
||||
|
||||
async def test_shared_state_initial_snapshot_on_first_update() -> None:
|
||||
"""When state_schema and state are provided, initial STATE_SNAPSHOT is emitted after RUN_STARTED."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text='{"recipe_title": "Test", "ingredients": [], "message": "hi"}')],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
# RUN_STARTED should be followed by STATE_SNAPSHOT (initial state)
|
||||
stream.assert_ordered_types(["RUN_STARTED", "STATE_SNAPSHOT"])
|
||||
|
||||
|
||||
async def test_shared_state_text_emitted_from_message_field() -> None:
|
||||
"""Structured output's 'message' field is emitted as text message events."""
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_text(
|
||||
text='{"recipe_title": "Pasta", "ingredients": ["pasta"], "message": "Enjoy your pasta!"}'
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
agent = _build_agent(updates)
|
||||
stream = await _run(agent, PAYLOAD)
|
||||
|
||||
# Text should be emitted from the message field
|
||||
text_contents = stream.get("TEXT_MESSAGE_CONTENT")
|
||||
if text_contents:
|
||||
combined = "".join(getattr(e, "delta", "") for e in text_contents)
|
||||
assert "Enjoy your pasta!" in combined
|
||||
@@ -0,0 +1,211 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Golden event-stream tests for the workflow HITL (subgraphs) scenario.
|
||||
|
||||
Extends the existing test_subgraphs_example_agent.py with EventStream assertions
|
||||
on full event ordering, balancing, and interrupt structure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from event_stream import EventStream
|
||||
|
||||
from agent_framework_ag_ui_examples.agents.subgraphs_agent import subgraphs_agent
|
||||
|
||||
|
||||
async def _run(agent: Any, payload: dict[str, Any]) -> EventStream:
|
||||
return EventStream([event async for event in agent.run(payload)])
|
||||
|
||||
|
||||
# ── Turn 1: Initial request → flight interrupt ──
|
||||
|
||||
|
||||
async def test_subgraphs_turn1_golden_bookends() -> None:
|
||||
"""Turn 1 starts with RUN_STARTED and ends with RUN_FINISHED."""
|
||||
agent = subgraphs_agent()
|
||||
stream = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": "thread-sub-golden-1",
|
||||
"run_id": "run-1",
|
||||
"messages": [{"role": "user", "content": "Plan a trip to San Francisco"}],
|
||||
},
|
||||
)
|
||||
stream.assert_bookends()
|
||||
|
||||
|
||||
async def test_subgraphs_turn1_no_errors() -> None:
|
||||
"""Turn 1 completes without errors."""
|
||||
agent = subgraphs_agent()
|
||||
stream = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": "thread-sub-golden-2",
|
||||
"run_id": "run-1",
|
||||
"messages": [{"role": "user", "content": "Plan a trip"}],
|
||||
},
|
||||
)
|
||||
stream.assert_no_run_error()
|
||||
|
||||
|
||||
async def test_subgraphs_turn1_has_step_events() -> None:
|
||||
"""Turn 1 emits STEP_STARTED and STEP_FINISHED for workflow executors."""
|
||||
agent = subgraphs_agent()
|
||||
stream = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": "thread-sub-golden-3",
|
||||
"run_id": "run-1",
|
||||
"messages": [{"role": "user", "content": "Plan a trip"}],
|
||||
},
|
||||
)
|
||||
stream.assert_has_type("STEP_STARTED")
|
||||
stream.assert_has_type("STEP_FINISHED")
|
||||
|
||||
|
||||
async def test_subgraphs_turn1_interrupt_structure() -> None:
|
||||
"""Turn 1 RUN_FINISHED carries flight interrupt with correct structure."""
|
||||
agent = subgraphs_agent()
|
||||
stream = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": "thread-sub-golden-4",
|
||||
"run_id": "run-1",
|
||||
"messages": [{"role": "user", "content": "Plan a trip to SF"}],
|
||||
},
|
||||
)
|
||||
|
||||
finished = stream.last("RUN_FINISHED")
|
||||
interrupt = getattr(finished, "interrupt", None)
|
||||
assert interrupt is not None, "Expected interrupt in RUN_FINISHED"
|
||||
assert isinstance(interrupt, list)
|
||||
assert len(interrupt) > 0
|
||||
assert interrupt[0]["value"]["agent"] == "flights"
|
||||
assert len(interrupt[0]["value"]["options"]) == 2
|
||||
|
||||
|
||||
async def test_subgraphs_turn1_text_messages_balanced() -> None:
|
||||
"""All text messages in turn 1 are properly balanced."""
|
||||
agent = subgraphs_agent()
|
||||
stream = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": "thread-sub-golden-5",
|
||||
"run_id": "run-1",
|
||||
"messages": [{"role": "user", "content": "Plan a trip"}],
|
||||
},
|
||||
)
|
||||
stream.assert_text_messages_balanced()
|
||||
|
||||
|
||||
async def test_subgraphs_turn1_ordered_flow() -> None:
|
||||
"""Turn 1 event ordering: RUN_STARTED → STATE_SNAPSHOT → STEP_* → TOOL_CALL_* → RUN_FINISHED."""
|
||||
agent = subgraphs_agent()
|
||||
stream = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": "thread-sub-golden-6",
|
||||
"run_id": "run-1",
|
||||
"messages": [{"role": "user", "content": "Plan a trip"}],
|
||||
},
|
||||
)
|
||||
stream.assert_ordered_types(
|
||||
[
|
||||
"RUN_STARTED",
|
||||
"STATE_SNAPSHOT",
|
||||
"STEP_STARTED",
|
||||
"RUN_FINISHED",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ── Multi-turn: Flight selection → hotel interrupt → completion ──
|
||||
|
||||
|
||||
async def test_subgraphs_full_flow_event_ordering() -> None:
|
||||
"""Complete 3-turn flow maintains proper event ordering throughout."""
|
||||
agent = subgraphs_agent()
|
||||
thread_id = "thread-sub-golden-full"
|
||||
|
||||
# Turn 1
|
||||
stream1 = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"run_id": "run-1",
|
||||
"messages": [{"role": "user", "content": "Plan a trip to SF from Amsterdam"}],
|
||||
},
|
||||
)
|
||||
stream1.assert_bookends()
|
||||
stream1.assert_no_run_error()
|
||||
|
||||
# Extract flight interrupt
|
||||
finished1 = stream1.last("RUN_FINISHED")
|
||||
interrupt1 = finished1.model_dump()["interrupt"][0]
|
||||
|
||||
# Turn 2: Select flight
|
||||
stream2 = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"run_id": "run-2",
|
||||
"resume": {
|
||||
"interrupts": [
|
||||
{
|
||||
"id": interrupt1["id"],
|
||||
"value": json.dumps(
|
||||
{
|
||||
"airline": "United",
|
||||
"departure": "Amsterdam (AMS)",
|
||||
"arrival": "San Francisco (SFO)",
|
||||
"price": "$720",
|
||||
"duration": "12h 15m",
|
||||
}
|
||||
),
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
stream2.assert_bookends()
|
||||
stream2.assert_no_run_error()
|
||||
|
||||
# Should now have hotel interrupt
|
||||
finished2 = stream2.last("RUN_FINISHED")
|
||||
interrupt2 = finished2.model_dump()["interrupt"]
|
||||
assert interrupt2[0]["value"]["agent"] == "hotels"
|
||||
|
||||
# Turn 3: Select hotel
|
||||
stream3 = await _run(
|
||||
agent,
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"run_id": "run-3",
|
||||
"resume": {
|
||||
"interrupts": [
|
||||
{
|
||||
"id": interrupt2[0]["id"],
|
||||
"value": json.dumps(
|
||||
{
|
||||
"name": "The Ritz-Carlton",
|
||||
"location": "Nob Hill",
|
||||
"price_per_night": "$550/night",
|
||||
"rating": "4.8 stars",
|
||||
}
|
||||
),
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
stream3.assert_bookends()
|
||||
stream3.assert_no_run_error()
|
||||
stream3.assert_text_messages_balanced()
|
||||
|
||||
# Final turn should not have interrupt
|
||||
finished3 = stream3.last("RUN_FINISHED")
|
||||
final_interrupt = getattr(finished3, "interrupt", None)
|
||||
assert not final_interrupt, f"Expected no interrupt after completion, got {final_interrupt}"
|
||||
@@ -0,0 +1,962 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Comprehensive golden event-stream tests for AgentFrameworkWorkflow.
|
||||
|
||||
Covers the full matrix of workflow-specific AG-UI patterns:
|
||||
- request_info → TOOL_CALL lifecycle and balancing
|
||||
- Executor step events and activity snapshots
|
||||
- Text output, dict output, BaseEvent passthrough, AgentResponse output
|
||||
- Text deduplication across workflow outputs
|
||||
- Workflow error handling → RUN_ERROR
|
||||
- Multi-turn interrupt/resume round-trips
|
||||
- Empty turns with pending requests
|
||||
- Custom workflow events
|
||||
- Text message draining on request_info and executor boundaries
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from ag_ui.core import EventType, StateSnapshotEvent
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
Content,
|
||||
Executor,
|
||||
Message,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
executor,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from event_stream import EventStream
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkWorkflow
|
||||
|
||||
|
||||
async def _run(wrapper: AgentFrameworkWorkflow, payload: dict[str, Any]) -> EventStream:
|
||||
return EventStream([event async for event in wrapper.run(payload)])
|
||||
|
||||
|
||||
def _payload(
|
||||
msg: str = "go",
|
||||
*,
|
||||
thread_id: str = "thread-wf",
|
||||
run_id: str = "run-wf",
|
||||
**extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
return {"thread_id": thread_id, "run_id": run_id, "messages": [{"role": "user", "content": msg}], **extra}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 1. Basic workflow text output
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_text_output_golden_sequence() -> None:
|
||||
"""Simple text output: RUN_STARTED → STEP_STARTED → TEXT_* → STEP_FINISHED → TEXT_MESSAGE_END → RUN_FINISHED."""
|
||||
|
||||
@executor(id="greeter")
|
||||
async def greeter(message: Any, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("Hello from workflow!")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=greeter).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
stream.assert_text_messages_balanced()
|
||||
stream.assert_has_type("TEXT_MESSAGE_START")
|
||||
stream.assert_has_type("TEXT_MESSAGE_CONTENT")
|
||||
stream.assert_has_type("TEXT_MESSAGE_END")
|
||||
|
||||
# Verify actual content
|
||||
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
|
||||
assert "Hello from workflow!" in deltas
|
||||
|
||||
|
||||
async def test_workflow_text_output_message_id_consistency() -> None:
|
||||
"""All text events for a single output share the same message_id."""
|
||||
|
||||
@executor(id="echo")
|
||||
async def echo(message: Any, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("echo reply")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=echo).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
stream.assert_message_ids_consistent()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 2. Executor step events and activity snapshots
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_executor_lifecycle_events() -> None:
|
||||
"""Executor invocation produces STEP_STARTED, ACTIVITY_SNAPSHOT, STEP_FINISHED."""
|
||||
|
||||
@executor(id="worker")
|
||||
async def worker(message: Any, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("done")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=worker).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
# Step events with executor ID
|
||||
started = [e for e in stream.get("STEP_STARTED") if getattr(e, "step_name", "") == "worker"]
|
||||
finished = [e for e in stream.get("STEP_FINISHED") if getattr(e, "step_name", "") == "worker"]
|
||||
assert started, "Expected STEP_STARTED for 'worker'"
|
||||
assert finished, "Expected STEP_FINISHED for 'worker'"
|
||||
|
||||
# Activity snapshots
|
||||
activities = stream.get("ACTIVITY_SNAPSHOT")
|
||||
assert activities, "Expected ACTIVITY_SNAPSHOT events"
|
||||
# Check one of them has executor payload
|
||||
executor_activities = [a for a in activities if getattr(a, "activity_type", None) == "executor"]
|
||||
assert executor_activities, "Expected executor-type activity snapshots"
|
||||
|
||||
|
||||
async def test_workflow_executor_step_ordering() -> None:
|
||||
"""STEP_STARTED comes before content, STEP_FINISHED comes after."""
|
||||
|
||||
@executor(id="orderer")
|
||||
async def orderer(message: Any, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("ordered output")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=orderer).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
stream.assert_ordered_types(
|
||||
[
|
||||
"RUN_STARTED",
|
||||
"STEP_STARTED",
|
||||
"TEXT_MESSAGE_START",
|
||||
"TEXT_MESSAGE_CONTENT",
|
||||
"STEP_FINISHED",
|
||||
"RUN_FINISHED",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 3. Dict output → CUSTOM workflow_output
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_dict_output_maps_to_custom_event() -> None:
|
||||
"""Non-chat dict output is emitted as CUSTOM workflow_output event."""
|
||||
|
||||
@executor(id="structured")
|
||||
async def structured(message: Any, ctx: WorkflowContext[Never, dict[str, int]]) -> None:
|
||||
await ctx.yield_output({"count": 42, "status": 1})
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=structured).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
|
||||
customs = [e for e in stream.get("CUSTOM") if getattr(e, "name", None) == "workflow_output"]
|
||||
assert len(customs) == 1
|
||||
assert customs[0].value == {"count": 42, "status": 1}
|
||||
|
||||
# Should NOT have TEXT_MESSAGE events for dict output
|
||||
assert "TEXT_MESSAGE_CONTENT" not in stream.types()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 4. BaseEvent passthrough
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_base_event_passthrough() -> None:
|
||||
"""AG-UI BaseEvent outputs are yielded directly, not wrapped."""
|
||||
|
||||
@executor(id="stateful")
|
||||
async def stateful(message: Any, ctx: WorkflowContext[Never, StateSnapshotEvent]) -> None:
|
||||
await ctx.yield_output(StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot={"active_agent": "flights"}))
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=stateful).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
stream.assert_bookends()
|
||||
snapshots = stream.get("STATE_SNAPSHOT")
|
||||
assert len(snapshots) == 1
|
||||
assert snapshots[0].snapshot["active_agent"] == "flights"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 5. AgentResponse output (conversation payload)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_agent_response_output_extracts_latest_assistant() -> None:
|
||||
"""AgentResponse output uses only the latest assistant message, not full history."""
|
||||
|
||||
@executor(id="responder")
|
||||
async def responder(message: Any, ctx: WorkflowContext[Never, AgentResponse]) -> None:
|
||||
response = AgentResponse(
|
||||
messages=[
|
||||
Message(role="user", contents=[Content.from_text("My order is damaged")]),
|
||||
Message(role="assistant", contents=[Content.from_text("I'll process your replacement.")]),
|
||||
]
|
||||
)
|
||||
await ctx.yield_output(response)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=responder).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_text_messages_balanced()
|
||||
|
||||
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
|
||||
assert deltas == ["I'll process your replacement."]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 6. Custom workflow events
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ProgressEvent(WorkflowEvent):
|
||||
"""Custom workflow event for testing CUSTOM event mapping."""
|
||||
|
||||
def __init__(self, progress: int) -> None:
|
||||
super().__init__("custom_progress", data={"progress": progress})
|
||||
|
||||
|
||||
async def test_workflow_custom_events() -> None:
|
||||
"""Custom workflow events are mapped to CUSTOM AG-UI events."""
|
||||
|
||||
@executor(id="progress_tracker")
|
||||
async def progress_tracker(message: Any, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.add_event(ProgressEvent(25))
|
||||
await ctx.yield_output("In progress...")
|
||||
await ctx.add_event(ProgressEvent(100))
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=progress_tracker).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
|
||||
progress_events = [e for e in stream.get("CUSTOM") if getattr(e, "name", None) == "custom_progress"]
|
||||
assert len(progress_events) == 2
|
||||
assert progress_events[0].value == {"progress": 25}
|
||||
assert progress_events[1].value == {"progress": 100}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 7. request_info → TOOL_CALL lifecycle
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_request_info_tool_call_lifecycle() -> None:
|
||||
"""request_info emits TOOL_CALL_START/ARGS/END cycle plus CUSTOM request_info."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info("Need approval", str, request_id="req-1")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=requester).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
|
||||
# Tool call lifecycle
|
||||
stream.assert_ordered_types(
|
||||
[
|
||||
"RUN_STARTED",
|
||||
"TOOL_CALL_START",
|
||||
"TOOL_CALL_ARGS",
|
||||
"TOOL_CALL_END",
|
||||
"CUSTOM", # request_info
|
||||
"RUN_FINISHED",
|
||||
]
|
||||
)
|
||||
|
||||
# Verify tool call details
|
||||
start = stream.first("TOOL_CALL_START")
|
||||
assert start.tool_call_id == "req-1"
|
||||
assert start.tool_call_name == "request_info"
|
||||
|
||||
# TOOL_CALL_ARGS should contain the request payload
|
||||
args = stream.first("TOOL_CALL_ARGS")
|
||||
assert args.tool_call_id == "req-1"
|
||||
parsed_args = json.loads(args.delta)
|
||||
assert parsed_args["request_id"] == "req-1"
|
||||
|
||||
# Tool calls should be balanced
|
||||
stream.assert_tool_calls_balanced()
|
||||
|
||||
|
||||
async def test_workflow_request_info_interrupt_in_run_finished() -> None:
|
||||
"""request_info populates RUN_FINISHED.interrupt with the request metadata."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info(
|
||||
{"message": "Choose a flight", "options": [{"airline": "KLM"}], "agent": "flights"},
|
||||
dict,
|
||||
request_id="flights-choice",
|
||||
)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=requester).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
finished = stream.last("RUN_FINISHED")
|
||||
interrupt = finished.model_dump().get("interrupt")
|
||||
assert isinstance(interrupt, list)
|
||||
assert len(interrupt) == 1
|
||||
assert interrupt[0]["id"] == "flights-choice"
|
||||
assert interrupt[0]["value"]["agent"] == "flights"
|
||||
|
||||
|
||||
async def test_workflow_request_info_emits_interrupt_card_event() -> None:
|
||||
"""request_info with dict data emits a WorkflowInterruptEvent custom event."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info(
|
||||
{"message": "Pick one", "options": ["A", "B"]},
|
||||
dict,
|
||||
request_id="pick-1",
|
||||
)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=requester).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
interrupt_cards = [e for e in stream.get("CUSTOM") if getattr(e, "name", None) == "WorkflowInterruptEvent"]
|
||||
assert interrupt_cards, "Expected WorkflowInterruptEvent custom event"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 8. Text message draining on request_info boundary
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_text_drained_before_request_info() -> None:
|
||||
"""Open text message is closed (TEXT_MESSAGE_END) before request_info tool calls begin."""
|
||||
|
||||
@executor(id="text_then_request")
|
||||
async def text_then_request(message: Any, ctx: WorkflowContext) -> None:
|
||||
await ctx.yield_output("Please confirm this action.")
|
||||
await ctx.request_info("Need approval", str, request_id="approval-1")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=text_then_request).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
stream.assert_text_messages_balanced()
|
||||
stream.assert_tool_calls_balanced()
|
||||
|
||||
# TEXT_MESSAGE_END must appear before TOOL_CALL_START
|
||||
types = stream.types()
|
||||
text_end_idx = types.index("TEXT_MESSAGE_END")
|
||||
tool_start_idx = types.index("TOOL_CALL_START")
|
||||
assert text_end_idx < tool_start_idx, (
|
||||
f"TEXT_MESSAGE_END (idx={text_end_idx}) must come before TOOL_CALL_START (idx={tool_start_idx})"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 9. Text deduplication
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_skips_duplicate_text_from_snapshot() -> None:
|
||||
"""Duplicate text from AgentResponse snapshot is not re-emitted."""
|
||||
|
||||
@executor(id="deduper")
|
||||
async def deduper(message: Any, ctx: WorkflowContext[Never, Any]) -> None:
|
||||
text = "Order processed successfully."
|
||||
await ctx.yield_output(text)
|
||||
# Snapshot repeats the same text
|
||||
await ctx.yield_output(
|
||||
AgentResponse(
|
||||
messages=[
|
||||
Message(role="user", contents=[Content.from_text("process order")]),
|
||||
Message(role="assistant", contents=[Content.from_text(text)]),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=deduper).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
stream.assert_text_messages_balanced()
|
||||
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
|
||||
# Text should appear only once
|
||||
assert deltas == ["Order processed successfully."]
|
||||
|
||||
|
||||
async def test_workflow_skips_consecutive_duplicate_outputs() -> None:
|
||||
"""Consecutive identical text outputs are deduplicated."""
|
||||
|
||||
@executor(id="repeater")
|
||||
async def repeater(message: Any, ctx: WorkflowContext[Never, Any]) -> None:
|
||||
text = "Done!"
|
||||
await ctx.yield_output(text)
|
||||
await ctx.yield_output(text)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=repeater).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
stream.assert_text_messages_balanced()
|
||||
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
|
||||
assert deltas == ["Done!"]
|
||||
|
||||
|
||||
async def test_workflow_emits_distinct_consecutive_outputs() -> None:
|
||||
"""Distinct text outputs are all emitted, not incorrectly deduplicated."""
|
||||
|
||||
@executor(id="multisayer")
|
||||
async def multisayer(message: Any, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("First part. ")
|
||||
await ctx.yield_output("Second part.")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=multisayer).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
stream.assert_text_messages_balanced()
|
||||
deltas = [e.delta for e in stream.get("TEXT_MESSAGE_CONTENT")]
|
||||
assert deltas == ["First part. ", "Second part."]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 10. Workflow error handling → RUN_ERROR
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_error_emits_run_error_event() -> None:
|
||||
"""Exceptions during workflow streaming produce RUN_ERROR events."""
|
||||
|
||||
class FailingWorkflow:
|
||||
def run(self, **kwargs: Any):
|
||||
async def _stream():
|
||||
raise RuntimeError("workflow exploded")
|
||||
yield # pragma: no cover
|
||||
|
||||
return _stream()
|
||||
|
||||
wrapper = AgentFrameworkWorkflow(workflow=cast(Any, FailingWorkflow()))
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
# Should still have RUN_STARTED
|
||||
stream.assert_has_type("RUN_STARTED")
|
||||
# Should have RUN_ERROR
|
||||
stream.assert_has_type("RUN_ERROR")
|
||||
error = stream.first("RUN_ERROR")
|
||||
assert "workflow exploded" in error.message
|
||||
|
||||
|
||||
async def test_workflow_error_preserves_bookend_structure() -> None:
|
||||
"""Even on error, RUN_STARTED is the first event."""
|
||||
|
||||
class FailingWorkflow:
|
||||
def run(self, **kwargs: Any):
|
||||
async def _stream():
|
||||
raise ValueError("bad input")
|
||||
yield # pragma: no cover
|
||||
|
||||
return _stream()
|
||||
|
||||
wrapper = AgentFrameworkWorkflow(workflow=cast(Any, FailingWorkflow()))
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
types = stream.types()
|
||||
assert types[0] == "RUN_STARTED"
|
||||
assert "RUN_ERROR" in types
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 11. Multi-turn request_info interrupt/resume
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_interrupt_resume_round_trip() -> None:
|
||||
"""Turn 1: request_info → interrupt. Turn 2: resume → completion."""
|
||||
|
||||
class RequesterExecutor(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="requester")
|
||||
|
||||
@handler
|
||||
async def start(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info("Choose an option", str, request_id="choice-1")
|
||||
|
||||
@response_handler
|
||||
async def handle_choice(self, original: str, response: str, ctx: WorkflowContext) -> None:
|
||||
await ctx.yield_output(f"You chose: {response}")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=RequesterExecutor()).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
|
||||
# Turn 1
|
||||
stream1 = await _run(wrapper, _payload(thread_id="thread-resume", run_id="run-1"))
|
||||
stream1.assert_bookends()
|
||||
stream1.assert_no_run_error()
|
||||
stream1.assert_tool_calls_balanced()
|
||||
|
||||
finished1 = stream1.last("RUN_FINISHED")
|
||||
interrupt1 = finished1.model_dump().get("interrupt")
|
||||
assert interrupt1, "Expected interrupt"
|
||||
assert interrupt1[0]["id"] == "choice-1"
|
||||
|
||||
# Turn 2: resume
|
||||
stream2 = await _run(
|
||||
wrapper,
|
||||
{
|
||||
"thread_id": "thread-resume",
|
||||
"run_id": "run-2",
|
||||
"messages": [],
|
||||
"resume": {"interrupts": [{"id": "choice-1", "value": "Option A"}]},
|
||||
},
|
||||
)
|
||||
stream2.assert_has_run_lifecycle()
|
||||
stream2.assert_no_run_error()
|
||||
stream2.assert_text_messages_balanced()
|
||||
|
||||
# Should have the response text
|
||||
deltas = [e.delta for e in stream2.get("TEXT_MESSAGE_CONTENT")]
|
||||
assert any("Option A" in d for d in deltas), f"Expected 'Option A' in deltas: {deltas}"
|
||||
|
||||
# No interrupt after resume
|
||||
finished2 = stream2.last("RUN_FINISHED")
|
||||
interrupt2 = finished2.model_dump().get("interrupt")
|
||||
assert not interrupt2
|
||||
|
||||
|
||||
async def test_workflow_forwarded_props_resume() -> None:
|
||||
"""CopilotKit-style forwarded_props.command.resume should resume a pending request."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info({"options": [{"name": "A"}]}, dict, request_id="pick")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=requester).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
|
||||
# Turn 1
|
||||
await _run(wrapper, _payload(thread_id="thread-fwd", run_id="run-1"))
|
||||
|
||||
# Turn 2 via forwarded_props
|
||||
stream2 = await _run(
|
||||
wrapper,
|
||||
{
|
||||
"thread_id": "thread-fwd",
|
||||
"run_id": "run-2",
|
||||
"messages": [],
|
||||
"forwarded_props": {"command": {"resume": json.dumps({"name": "A"})}},
|
||||
},
|
||||
)
|
||||
stream2.assert_bookends()
|
||||
stream2.assert_no_run_error()
|
||||
|
||||
finished = stream2.last("RUN_FINISHED")
|
||||
assert not finished.model_dump().get("interrupt")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 12. Empty turns with pending requests
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_empty_turn_preserves_interrupts() -> None:
|
||||
"""An empty turn with a pending request still returns the interrupt without errors."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info({"prompt": "choose"}, dict, request_id="pick-one")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=requester).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
|
||||
# Turn 1: trigger the request
|
||||
await _run(wrapper, _payload(thread_id="thread-empty", run_id="run-1"))
|
||||
|
||||
# Turn 2: empty messages, no resume
|
||||
stream2 = await _run(
|
||||
wrapper,
|
||||
{
|
||||
"thread_id": "thread-empty",
|
||||
"run_id": "run-2",
|
||||
"messages": [],
|
||||
},
|
||||
)
|
||||
stream2.assert_bookends()
|
||||
stream2.assert_no_run_error()
|
||||
stream2.assert_tool_calls_balanced()
|
||||
|
||||
# Should re-emit the pending interrupt
|
||||
finished = stream2.last("RUN_FINISHED")
|
||||
interrupts = finished.model_dump().get("interrupt")
|
||||
assert isinstance(interrupts, list)
|
||||
assert interrupts[0]["id"] == "pick-one"
|
||||
|
||||
# Should have TOOL_CALL events for the pending request
|
||||
stream2.assert_has_type("TOOL_CALL_START")
|
||||
|
||||
|
||||
async def test_workflow_empty_turn_no_pending_requests() -> None:
|
||||
"""Empty turn with no pending requests produces clean bookends."""
|
||||
|
||||
@executor(id="noop")
|
||||
async def noop(message: Any, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("done")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=noop).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
|
||||
# Run once to completion
|
||||
await _run(wrapper, _payload(thread_id="thread-empty-clean", run_id="run-1"))
|
||||
|
||||
# Empty turn
|
||||
stream2 = await _run(
|
||||
wrapper,
|
||||
{
|
||||
"thread_id": "thread-empty-clean",
|
||||
"run_id": "run-2",
|
||||
"messages": [],
|
||||
},
|
||||
)
|
||||
stream2.assert_bookends()
|
||||
stream2.assert_no_run_error()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 13. Usage content as CUSTOM event
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_usage_output_maps_to_custom_event() -> None:
|
||||
"""Usage Content outputs are surfaced as custom usage events."""
|
||||
|
||||
@executor(id="usage_reporter")
|
||||
async def usage_reporter(message: Any, ctx: WorkflowContext[Never, Content]) -> None:
|
||||
await ctx.yield_output(
|
||||
Content.from_usage({"input_token_count": 100, "output_token_count": 50, "total_token_count": 150})
|
||||
)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=usage_reporter).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
stream = await _run(wrapper, _payload())
|
||||
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
|
||||
usage_events = [e for e in stream.get("CUSTOM") if getattr(e, "name", None) == "usage"]
|
||||
assert len(usage_events) == 1
|
||||
assert usage_events[0].value["input_token_count"] == 100
|
||||
assert usage_events[0].value["total_token_count"] == 150
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 14. Approval flow (Content-based request_info)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_approval_flow_round_trip() -> None:
|
||||
"""function_approval_request via request_info, then resume with approval response."""
|
||||
|
||||
class ApprovalExecutor(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="approval_exec")
|
||||
|
||||
@handler
|
||||
async def start(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
function_call = Content.from_function_call(
|
||||
call_id="refund-call",
|
||||
name="submit_refund",
|
||||
arguments={"order_id": "12345", "amount": "$89.99"},
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(id="approval-1", function_call=function_call)
|
||||
await ctx.request_info(approval_request, Content, request_id="approval-1")
|
||||
|
||||
@response_handler
|
||||
async def handle_approval(self, original_request: Content, response: Content, ctx: WorkflowContext) -> None:
|
||||
status = "approved" if bool(response.approved) else "rejected"
|
||||
await ctx.yield_output(f"Refund {status}.")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=ApprovalExecutor()).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
|
||||
# Turn 1: request approval
|
||||
stream1 = await _run(wrapper, _payload(thread_id="thread-approval", run_id="run-1"))
|
||||
stream1.assert_bookends()
|
||||
stream1.assert_no_run_error()
|
||||
|
||||
finished1 = stream1.last("RUN_FINISHED")
|
||||
interrupt1 = finished1.model_dump().get("interrupt")
|
||||
assert interrupt1, "Expected approval interrupt"
|
||||
interrupt_value = interrupt1[0]["value"]
|
||||
|
||||
# Turn 2: approve
|
||||
stream2 = await _run(
|
||||
wrapper,
|
||||
{
|
||||
"thread_id": "thread-approval",
|
||||
"run_id": "run-2",
|
||||
"messages": [],
|
||||
"resume": {
|
||||
"interrupts": [
|
||||
{
|
||||
"id": "approval-1",
|
||||
"value": {
|
||||
"type": "function_approval_response",
|
||||
"approved": True,
|
||||
"id": interrupt_value.get("id", "approval-1"),
|
||||
"function_call": interrupt_value.get("function_call"),
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
stream2.assert_has_run_lifecycle()
|
||||
stream2.assert_no_run_error()
|
||||
stream2.assert_text_messages_balanced()
|
||||
|
||||
deltas = [e.delta for e in stream2.get("TEXT_MESSAGE_CONTENT")]
|
||||
assert any("approved" in d for d in deltas)
|
||||
|
||||
# No more interrupt
|
||||
finished2 = stream2.last("RUN_FINISHED")
|
||||
assert not finished2.model_dump().get("interrupt")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 15. Message list request/response coercion
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_message_list_resume() -> None:
|
||||
"""Resume with list[Message] payload coerces correctly into workflow response."""
|
||||
|
||||
class MessageRequestExecutor(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="msg_request")
|
||||
|
||||
@handler
|
||||
async def start(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info({"prompt": "Need follow-up"}, list[Message], request_id="handoff")
|
||||
|
||||
@response_handler
|
||||
async def handle_input(self, original: dict, response: list[Message], ctx: WorkflowContext) -> None:
|
||||
user_text = response[0].text if response else ""
|
||||
await ctx.yield_output(f"Got: {user_text}")
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=MessageRequestExecutor()).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
|
||||
# Turn 1
|
||||
await _run(wrapper, _payload(thread_id="thread-msg", run_id="run-1"))
|
||||
|
||||
# Turn 2: resume with message list
|
||||
stream2 = await _run(
|
||||
wrapper,
|
||||
{
|
||||
"thread_id": "thread-msg",
|
||||
"run_id": "run-2",
|
||||
"messages": [],
|
||||
"resume": {
|
||||
"interrupts": [
|
||||
{
|
||||
"id": "handoff",
|
||||
"value": [
|
||||
{"role": "user", "contents": [{"type": "text", "text": "Ship a replacement"}]},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
stream2.assert_has_run_lifecycle()
|
||||
stream2.assert_no_run_error()
|
||||
stream2.assert_text_messages_balanced()
|
||||
|
||||
deltas = [e.delta for e in stream2.get("TEXT_MESSAGE_CONTENT")]
|
||||
assert any("replacement" in d for d in deltas)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 16. Plain text follow-up does NOT infer interrupt response
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_plain_text_does_not_resume_pending_dict_request() -> None:
|
||||
"""Plain text user follow-up should NOT be coerced into a dict response."""
|
||||
|
||||
@executor(id="requester")
|
||||
async def requester(message: Any, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info(
|
||||
{"message": "Choose a flight", "options": [{"airline": "KLM"}], "agent": "flights"},
|
||||
dict,
|
||||
request_id="flights-choice",
|
||||
)
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=requester).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
|
||||
# Turn 1
|
||||
await _run(wrapper, _payload(thread_id="thread-nocoerce", run_id="run-1"))
|
||||
|
||||
# Turn 2: plain text follow-up with request_info tool call in history
|
||||
stream2 = await _run(
|
||||
wrapper,
|
||||
{
|
||||
"thread_id": "thread-nocoerce",
|
||||
"run_id": "run-2",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "flights-choice",
|
||||
"type": "function",
|
||||
"function": {"name": "request_info", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "I prefer KLM please"},
|
||||
],
|
||||
},
|
||||
)
|
||||
stream2.assert_bookends()
|
||||
stream2.assert_no_run_error()
|
||||
|
||||
# Should still have the interrupt (text was not accepted as dict response)
|
||||
finished = stream2.last("RUN_FINISHED")
|
||||
interrupts = finished.model_dump().get("interrupt")
|
||||
assert isinstance(interrupts, list)
|
||||
assert interrupts[0]["id"] == "flights-choice"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 17. Workflow factory (thread-scoped workflows)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_factory_thread_scoping() -> None:
|
||||
"""workflow_factory creates separate workflow instances per thread_id."""
|
||||
|
||||
def make_workflow(thread_id: str):
|
||||
@executor(id="echo")
|
||||
async def echo(message: Any, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output(f"Thread: {thread_id}")
|
||||
|
||||
return WorkflowBuilder(start_executor=echo).build()
|
||||
|
||||
wrapper = AgentFrameworkWorkflow(workflow_factory=make_workflow)
|
||||
|
||||
stream_a = await _run(wrapper, _payload(thread_id="thread-a", run_id="run-a"))
|
||||
stream_b = await _run(wrapper, _payload(thread_id="thread-b", run_id="run-b"))
|
||||
|
||||
stream_a.assert_bookends()
|
||||
stream_b.assert_bookends()
|
||||
|
||||
deltas_a = [e.delta for e in stream_a.get("TEXT_MESSAGE_CONTENT")]
|
||||
deltas_b = [e.delta for e in stream_b.get("TEXT_MESSAGE_CONTENT")]
|
||||
assert any("thread-a" in d for d in deltas_a)
|
||||
assert any("thread-b" in d for d in deltas_b)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 18. Multiple request_info calls in sequence
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_workflow_sequential_request_info_interrupts() -> None:
|
||||
"""Two chained executors each requesting info: first triggers interrupt, resume, then second triggers interrupt.
|
||||
|
||||
This mirrors the subgraphs_agent pattern where separate executors handle sequential interactions.
|
||||
"""
|
||||
|
||||
class NameRequester(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="name_requester")
|
||||
|
||||
@handler
|
||||
async def start(self, message: Any, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.request_info("What's your name?", str, request_id="name-req")
|
||||
|
||||
@response_handler
|
||||
async def handle_name(self, original: str, response: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message(response)
|
||||
|
||||
class DestRequester(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="dest_requester")
|
||||
|
||||
@handler
|
||||
async def start(self, message: str, ctx: WorkflowContext[str]) -> None:
|
||||
self._name = message
|
||||
await ctx.request_info("Where to?", str, request_id="dest-req")
|
||||
|
||||
@response_handler
|
||||
async def handle_dest(self, original: str, response: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.yield_output(f"Booking for {self._name} to {response}")
|
||||
|
||||
name_requester = NameRequester()
|
||||
dest_requester = DestRequester()
|
||||
workflow = WorkflowBuilder(start_executor=name_requester).add_chain([name_requester, dest_requester]).build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
|
||||
# Turn 1
|
||||
stream1 = await _run(wrapper, _payload(thread_id="thread-seq", run_id="run-1"))
|
||||
stream1.assert_bookends()
|
||||
stream1.assert_tool_calls_balanced()
|
||||
interrupt1 = stream1.last("RUN_FINISHED").model_dump().get("interrupt")
|
||||
assert interrupt1[0]["id"] == "name-req"
|
||||
|
||||
# Turn 2: answer name → triggers second executor's request_info
|
||||
stream2 = await _run(
|
||||
wrapper,
|
||||
{
|
||||
"thread_id": "thread-seq",
|
||||
"run_id": "run-2",
|
||||
"messages": [],
|
||||
"resume": {"interrupts": [{"id": "name-req", "value": "Alice"}]},
|
||||
},
|
||||
)
|
||||
stream2.assert_has_run_lifecycle()
|
||||
stream2.assert_tool_calls_balanced()
|
||||
interrupt2 = stream2.last("RUN_FINISHED").model_dump().get("interrupt")
|
||||
assert interrupt2[0]["id"] == "dest-req"
|
||||
|
||||
# Turn 3: answer destination → completion
|
||||
stream3 = await _run(
|
||||
wrapper,
|
||||
{
|
||||
"thread_id": "thread-seq",
|
||||
"run_id": "run-3",
|
||||
"messages": [],
|
||||
"resume": {"interrupts": [{"id": "dest-req", "value": "Paris"}]},
|
||||
},
|
||||
)
|
||||
stream3.assert_has_run_lifecycle()
|
||||
stream3.assert_no_run_error()
|
||||
stream3.assert_text_messages_balanced()
|
||||
|
||||
deltas = [e.delta for e in stream3.get("TEXT_MESSAGE_CONTENT")]
|
||||
assert any("Alice" in d and "Paris" in d for d in deltas)
|
||||
assert not stream3.last("RUN_FINISHED").model_dump().get("interrupt")
|
||||
@@ -0,0 +1,72 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""SSE parsing helpers for AG-UI HTTP round-trip tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from event_stream import EventStream
|
||||
|
||||
|
||||
def parse_sse_response(response_content: bytes) -> list[dict[str, Any]]:
|
||||
"""Parse raw SSE bytes from TestClient into a list of event dicts.
|
||||
|
||||
Each SSE event is a ``data: {...}`` line followed by a blank line.
|
||||
"""
|
||||
text = response_content.decode("utf-8")
|
||||
events: list[dict[str, Any]] = []
|
||||
decode_errors: list[str] = []
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data: "):
|
||||
payload = line[6:]
|
||||
try:
|
||||
events.append(json.loads(payload))
|
||||
except json.JSONDecodeError as exc:
|
||||
decode_errors.append(f"payload={payload!r}, error={exc}")
|
||||
continue
|
||||
if decode_errors:
|
||||
joined = "; ".join(decode_errors)
|
||||
raise AssertionError(f"Failed to decode one or more SSE data lines: {joined}")
|
||||
return events
|
||||
|
||||
|
||||
def parse_sse_to_event_stream(response_content: bytes) -> EventStream:
|
||||
"""Parse SSE bytes and wrap in EventStream for structured assertions.
|
||||
|
||||
Returns an EventStream over lightweight SimpleNamespace objects that
|
||||
mirror AG-UI event attributes (type, message_id, tool_call_id, etc.)
|
||||
so that EventStream assertion methods work.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
raw_events = parse_sse_response(response_content)
|
||||
events: list[Any] = []
|
||||
for raw in raw_events:
|
||||
# Normalize camelCase keys to snake_case attributes that EventStream expects
|
||||
ns = SimpleNamespace()
|
||||
ns.type = raw.get("type", "")
|
||||
ns.raw = raw
|
||||
# Map common camelCase fields
|
||||
for camel, snake in _FIELD_MAP.items():
|
||||
if camel in raw:
|
||||
setattr(ns, snake, raw[camel])
|
||||
# Also keep camelCase as attributes for direct access
|
||||
for key, value in raw.items():
|
||||
if not hasattr(ns, key):
|
||||
setattr(ns, key, value)
|
||||
events.append(ns)
|
||||
return EventStream(events)
|
||||
|
||||
|
||||
_FIELD_MAP: dict[str, str] = {
|
||||
"messageId": "message_id",
|
||||
"runId": "run_id",
|
||||
"threadId": "thread_id",
|
||||
"toolCallId": "tool_call_id",
|
||||
"toolCallName": "tool_call_name",
|
||||
"toolName": "tool_call_name",
|
||||
"parentMessageId": "parent_message_id",
|
||||
"stepName": "step_name",
|
||||
}
|
||||
@@ -21,7 +21,7 @@ from agent_framework_ag_ui._client import AGUIChatClient
|
||||
from agent_framework_ag_ui._http_service import AGUIHttpService
|
||||
|
||||
|
||||
class TestableAGUIChatClient(AGUIChatClient):
|
||||
class StubAGUIChatClient(AGUIChatClient):
|
||||
"""Testable wrapper exposing protected helpers."""
|
||||
|
||||
@property
|
||||
@@ -53,19 +53,19 @@ class TestAGUIChatClient:
|
||||
|
||||
async def test_client_initialization(self) -> None:
|
||||
"""Test client initialization."""
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
|
||||
assert client.http_service is not None
|
||||
assert client.http_service.endpoint.startswith("http://localhost:8888")
|
||||
|
||||
async def test_client_context_manager(self) -> None:
|
||||
"""Test client as async context manager."""
|
||||
async with TestableAGUIChatClient(endpoint="http://localhost:8888/") as client:
|
||||
async with StubAGUIChatClient(endpoint="http://localhost:8888/") as client:
|
||||
assert client is not None
|
||||
|
||||
async def test_extract_state_from_messages_no_state(self) -> None:
|
||||
"""Test state extraction when no state is present."""
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
messages = [
|
||||
Message(role="user", text="Hello"),
|
||||
Message(role="assistant", text="Hi there"),
|
||||
@@ -80,7 +80,7 @@ class TestAGUIChatClient:
|
||||
"""Test state extraction from last message."""
|
||||
import base64
|
||||
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
|
||||
state_data = {"key": "value", "count": 42}
|
||||
state_json = json.dumps(state_data)
|
||||
@@ -104,7 +104,7 @@ class TestAGUIChatClient:
|
||||
"""Test state extraction with invalid JSON."""
|
||||
import base64
|
||||
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
|
||||
invalid_json = "not valid json"
|
||||
state_b64 = base64.b64encode(invalid_json.encode("utf-8")).decode("utf-8")
|
||||
@@ -123,7 +123,7 @@ class TestAGUIChatClient:
|
||||
|
||||
async def test_convert_messages_to_agui_format(self) -> None:
|
||||
"""Test message conversion to AG-UI format."""
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
messages = [
|
||||
Message(role="user", text="What is the weather?"),
|
||||
Message(role="assistant", text="Let me check.", message_id="msg_123"),
|
||||
@@ -140,7 +140,7 @@ class TestAGUIChatClient:
|
||||
|
||||
async def test_get_thread_id_from_metadata(self) -> None:
|
||||
"""Test thread ID extraction from metadata."""
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
chat_options = ChatOptions(metadata={"thread_id": "existing_thread_123"})
|
||||
|
||||
thread_id = client.get_thread_id(chat_options)
|
||||
@@ -149,7 +149,7 @@ class TestAGUIChatClient:
|
||||
|
||||
async def test_get_thread_id_generation(self) -> None:
|
||||
"""Test automatic thread ID generation."""
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
chat_options = ChatOptions()
|
||||
|
||||
thread_id = client.get_thread_id(chat_options)
|
||||
@@ -170,7 +170,7 @@ class TestAGUIChatClient:
|
||||
for event in mock_events:
|
||||
yield event
|
||||
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
|
||||
|
||||
messages = [Message(role="user", text="Test message")]
|
||||
@@ -203,7 +203,7 @@ class TestAGUIChatClient:
|
||||
for event in mock_events:
|
||||
yield event
|
||||
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
|
||||
|
||||
messages = [Message(role="user", text="Test message")]
|
||||
@@ -246,7 +246,7 @@ class TestAGUIChatClient:
|
||||
for event in mock_events:
|
||||
yield event
|
||||
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
|
||||
|
||||
messages = [Message(role="user", text="Test with tools")]
|
||||
@@ -270,7 +270,7 @@ class TestAGUIChatClient:
|
||||
for event in mock_events:
|
||||
yield event
|
||||
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
|
||||
|
||||
messages = [Message(role="user", text="Test server tool execution")]
|
||||
@@ -312,7 +312,7 @@ class TestAGUIChatClient:
|
||||
|
||||
monkeypatch.setattr("agent_framework._tools._auto_invoke_function", fake_auto_invoke)
|
||||
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
|
||||
|
||||
messages = [Message(role="user", text="Test server tool execution")]
|
||||
@@ -348,7 +348,7 @@ class TestAGUIChatClient:
|
||||
for event in mock_events:
|
||||
yield event
|
||||
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
|
||||
|
||||
chat_options = ChatOptions()
|
||||
@@ -357,6 +357,81 @@ class TestAGUIChatClient:
|
||||
|
||||
assert response is not None
|
||||
|
||||
async def test_extract_state_from_empty_messages(self) -> None:
|
||||
"""Empty messages list returns empty list and None state."""
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
result_messages, state = client.extract_state_from_messages([])
|
||||
assert result_messages == []
|
||||
assert state is None
|
||||
|
||||
async def test_register_server_tool_non_dict_config(self) -> None:
|
||||
"""Non-dict function_invocation_configuration is a no-op."""
|
||||
client = StubAGUIChatClient(
|
||||
endpoint="http://localhost:8888/",
|
||||
function_invocation_configuration=None, # type: ignore[arg-type]
|
||||
)
|
||||
# Should not raise
|
||||
client._register_server_tool_placeholder("some_tool")
|
||||
|
||||
async def test_non_streaming_response(self, monkeypatch: MonkeyPatch) -> None:
|
||||
"""Non-streaming path collects updates into ChatResponse."""
|
||||
mock_events = [
|
||||
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
|
||||
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "Hello"},
|
||||
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
|
||||
]
|
||||
|
||||
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
|
||||
for event in mock_events:
|
||||
yield event
|
||||
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
|
||||
|
||||
messages = [Message(role="user", text="Test")]
|
||||
response = await client.inner_get_response(messages=messages, options={}, stream=False)
|
||||
|
||||
assert response is not None
|
||||
assert len(response.messages) > 0
|
||||
|
||||
async def test_client_tool_sets_additional_properties(self, monkeypatch: MonkeyPatch) -> None:
|
||||
"""Client tool content gets agui_thread_id additional property."""
|
||||
|
||||
@tool
|
||||
def my_tool(param: str) -> str:
|
||||
"""My tool."""
|
||||
return "result"
|
||||
|
||||
mock_events = [
|
||||
{"type": "RUN_STARTED", "threadId": "thread_1", "runId": "run_1"},
|
||||
{"type": "TOOL_CALL_START", "toolCallId": "call_1", "toolName": "my_tool"},
|
||||
{"type": "TOOL_CALL_ARGS", "toolCallId": "call_1", "delta": '{"param": "test"}'},
|
||||
{"type": "RUN_FINISHED", "threadId": "thread_1", "runId": "run_1"},
|
||||
]
|
||||
|
||||
async def mock_post_run(*args: object, **kwargs: Any) -> AsyncGenerator[dict[str, Any], None]:
|
||||
for event in mock_events:
|
||||
yield event
|
||||
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
|
||||
|
||||
messages = [Message(role="user", text="Test")]
|
||||
updates: list[ChatResponseUpdate] = []
|
||||
async for update in client._inner_get_response(messages=messages, stream=True, options={"tools": [my_tool]}):
|
||||
updates.append(update)
|
||||
|
||||
# Find the function_call content - it should have agui_thread_id
|
||||
found = False
|
||||
for update in updates:
|
||||
for content in update.contents:
|
||||
if content.type == "function_call" and content.name == "my_tool":
|
||||
assert content.additional_properties is not None
|
||||
assert "agui_thread_id" in content.additional_properties
|
||||
found = True
|
||||
break
|
||||
assert found, "Expected to find function_call content for my_tool"
|
||||
|
||||
async def test_interrupt_options_transmission(self, monkeypatch: MonkeyPatch) -> None:
|
||||
"""Interrupt option fields are forwarded to the HTTP service."""
|
||||
available_interrupts = [{"id": "req_1", "type": "request_info"}]
|
||||
@@ -373,7 +448,7 @@ class TestAGUIChatClient:
|
||||
for event in mock_events:
|
||||
yield event
|
||||
|
||||
client = TestableAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
client = StubAGUIChatClient(endpoint="http://localhost:8888/")
|
||||
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
|
||||
|
||||
messages = [Message(role="user", text="continue")]
|
||||
|
||||
@@ -550,3 +550,56 @@ async def test_endpoint_without_dependencies_is_accessible(build_chat_client):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
|
||||
|
||||
async def test_endpoint_invalid_agent_type_raises_typeerror():
|
||||
"""Passing an invalid agent type raises TypeError."""
|
||||
app = FastAPI()
|
||||
|
||||
with pytest.raises(TypeError, match="must be SupportsAgentRun"):
|
||||
add_agent_framework_fastapi_endpoint(app, agent="not_an_agent") # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_endpoint_encoding_failure_emits_run_error():
|
||||
"""Event encoding failure emits RUN_ERROR event in the SSE stream."""
|
||||
from unittest.mock import patch
|
||||
|
||||
class SimpleWorkflow(AgentFrameworkWorkflow):
|
||||
async def run(self, input_data: dict[str, Any]):
|
||||
del input_data
|
||||
yield RunStartedEvent(run_id="run-1", thread_id="thread-1")
|
||||
|
||||
app = FastAPI()
|
||||
add_agent_framework_fastapi_endpoint(app, SimpleWorkflow(), path="/encode-fail")
|
||||
client = TestClient(app)
|
||||
|
||||
with patch("ag_ui.encoder.EventEncoder.encode") as mock_encode:
|
||||
# First call fails (the RUN_STARTED event), second call succeeds (the error event)
|
||||
mock_encode.side_effect = [ValueError("encode boom"), 'data: {"type":"RUN_ERROR"}\n\n']
|
||||
response = client.post("/encode-fail", json={"messages": [{"role": "user", "content": "go"}]})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
assert "RUN_ERROR" in content
|
||||
|
||||
|
||||
async def test_endpoint_double_encoding_failure_terminates():
|
||||
"""When both event and error encoding fail, stream terminates gracefully."""
|
||||
from unittest.mock import patch
|
||||
|
||||
class SimpleWorkflow(AgentFrameworkWorkflow):
|
||||
async def run(self, input_data: dict[str, Any]):
|
||||
del input_data
|
||||
yield RunStartedEvent(run_id="run-1", thread_id="thread-1")
|
||||
|
||||
app = FastAPI()
|
||||
add_agent_framework_fastapi_endpoint(app, SimpleWorkflow(), path="/double-fail")
|
||||
client = TestClient(app)
|
||||
|
||||
with patch("ag_ui.encoder.EventEncoder.encode") as mock_encode:
|
||||
# Both calls fail - event encode and error event encode
|
||||
mock_encode.side_effect = ValueError("always fails")
|
||||
response = client.post("/double-fail", json={"messages": [{"role": "user", "content": "go"}]})
|
||||
|
||||
# Should still get 200 (SSE stream), just with no events
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""HTTP round-trip tests: POST → SSE bytes → parse → validate event sequence.
|
||||
|
||||
These tests exercise the full HTTP pipeline using FastAPI TestClient,
|
||||
parsing the raw SSE byte stream and validating through EventStream assertions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponseUpdate, Content, WorkflowBuilder, WorkflowContext, executor
|
||||
from conftest import StubAgent
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sse_helpers import parse_sse_response, parse_sse_to_event_stream
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent, AgentFrameworkWorkflow, add_agent_framework_fastapi_endpoint
|
||||
|
||||
|
||||
def _build_app_with_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> FastAPI:
|
||||
stub = StubAgent(updates=updates)
|
||||
agent = AgentFrameworkAgent(agent=stub, **kwargs)
|
||||
app = FastAPI()
|
||||
add_agent_framework_fastapi_endpoint(app, agent)
|
||||
return app
|
||||
|
||||
|
||||
def _build_app_with_workflow(workflow_builder: WorkflowBuilder) -> FastAPI:
|
||||
workflow = workflow_builder.build()
|
||||
wrapper = AgentFrameworkWorkflow(workflow=workflow)
|
||||
app = FastAPI()
|
||||
add_agent_framework_fastapi_endpoint(app, wrapper)
|
||||
return app
|
||||
|
||||
|
||||
USER_PAYLOAD: dict[str, Any] = {
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"threadId": "thread-http",
|
||||
"runId": "run-http",
|
||||
}
|
||||
|
||||
|
||||
# ── Agentic chat SSE round-trip ──
|
||||
|
||||
|
||||
def test_agentic_chat_sse_round_trip() -> None:
|
||||
"""Full HTTP round-trip: POST → SSE bytes → parse → validate event sequence."""
|
||||
app = _build_app_with_agent(
|
||||
[
|
||||
AgentResponseUpdate(contents=[Content.from_text(text="Hi there!")], role="assistant"),
|
||||
]
|
||||
)
|
||||
client = TestClient(app)
|
||||
response = client.post("/", json=USER_PAYLOAD)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "text/event-stream" in response.headers["content-type"]
|
||||
|
||||
stream = parse_sse_to_event_stream(response.content)
|
||||
stream.assert_bookends()
|
||||
stream.assert_text_messages_balanced()
|
||||
stream.assert_no_run_error()
|
||||
stream.assert_ordered_types(
|
||||
[
|
||||
"RUN_STARTED",
|
||||
"TEXT_MESSAGE_START",
|
||||
"TEXT_MESSAGE_CONTENT",
|
||||
"TEXT_MESSAGE_END",
|
||||
"MESSAGES_SNAPSHOT",
|
||||
"RUN_FINISHED",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ── Tool call SSE round-trip ──
|
||||
|
||||
|
||||
def test_tool_call_sse_round_trip() -> None:
|
||||
"""Tool call events survive SSE encoding/parsing round-trip."""
|
||||
app = _build_app_with_agent(
|
||||
[
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city": "SF"}')],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="It's warm!")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
)
|
||||
client = TestClient(app)
|
||||
response = client.post("/", json=USER_PAYLOAD)
|
||||
|
||||
stream = parse_sse_to_event_stream(response.content)
|
||||
stream.assert_bookends()
|
||||
stream.assert_tool_calls_balanced()
|
||||
stream.assert_text_messages_balanced()
|
||||
|
||||
# Verify tool call details survive SSE encoding
|
||||
start = stream.first("TOOL_CALL_START")
|
||||
assert start.tool_call_name == "get_weather"
|
||||
assert start.tool_call_id == "call-1"
|
||||
|
||||
|
||||
# ── SSE encoding fidelity ──
|
||||
|
||||
|
||||
def test_sse_event_encoding_fidelity() -> None:
|
||||
"""Every event from agent.run() produces a valid SSE data: line that round-trips."""
|
||||
app = _build_app_with_agent(
|
||||
[
|
||||
AgentResponseUpdate(contents=[Content.from_text(text="Hello world")], role="assistant"),
|
||||
]
|
||||
)
|
||||
client = TestClient(app)
|
||||
response = client.post("/", json=USER_PAYLOAD)
|
||||
|
||||
raw_events = parse_sse_response(response.content)
|
||||
assert len(raw_events) > 0, "No SSE events parsed"
|
||||
|
||||
# Every event should have a 'type' field
|
||||
for event in raw_events:
|
||||
assert "type" in event, f"Event missing 'type': {event}"
|
||||
|
||||
# Event types should include the expected ones
|
||||
event_types = [e["type"] for e in raw_events]
|
||||
assert "RUN_STARTED" in event_types
|
||||
assert "RUN_FINISHED" in event_types
|
||||
|
||||
|
||||
# ── camelCase request field acceptance ──
|
||||
|
||||
|
||||
def test_camel_case_request_fields_accepted() -> None:
|
||||
"""Request with camelCase fields (runId, threadId) is correctly parsed."""
|
||||
app = _build_app_with_agent(
|
||||
[
|
||||
AgentResponseUpdate(contents=[Content.from_text(text="ok")], role="assistant"),
|
||||
]
|
||||
)
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/",
|
||||
json={
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"runId": "camel-run",
|
||||
"threadId": "camel-thread",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
stream = parse_sse_to_event_stream(response.content)
|
||||
stream.assert_bookends()
|
||||
|
||||
|
||||
# ── Workflow SSE round-trip ──
|
||||
|
||||
|
||||
def test_workflow_sse_round_trip() -> None:
|
||||
"""Workflow events survive SSE encoding/parsing."""
|
||||
|
||||
@executor(id="greeter")
|
||||
async def greeter(message: Any, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output("Hello from workflow!")
|
||||
|
||||
app = _build_app_with_workflow(WorkflowBuilder(start_executor=greeter))
|
||||
client = TestClient(app)
|
||||
response = client.post("/", json=USER_PAYLOAD)
|
||||
|
||||
assert response.status_code == 200
|
||||
stream = parse_sse_to_event_stream(response.content)
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
stream.assert_text_messages_balanced()
|
||||
stream.assert_has_type("STEP_STARTED")
|
||||
|
||||
|
||||
# ── Error handling ──
|
||||
|
||||
|
||||
def test_empty_messages_returns_valid_sse() -> None:
|
||||
"""Empty messages list still returns a valid SSE stream with bookends."""
|
||||
app = _build_app_with_agent(
|
||||
[
|
||||
AgentResponseUpdate(contents=[Content.from_text(text="ok")], role="assistant"),
|
||||
]
|
||||
)
|
||||
client = TestClient(app)
|
||||
response = client.post("/", json={"messages": []})
|
||||
|
||||
assert response.status_code == 200
|
||||
stream = parse_sse_to_event_stream(response.content)
|
||||
stream.assert_bookends()
|
||||
|
||||
|
||||
def test_sse_response_headers() -> None:
|
||||
"""SSE response has correct headers for event streaming."""
|
||||
app = _build_app_with_agent(
|
||||
[
|
||||
AgentResponseUpdate(contents=[Content.from_text(text="ok")], role="assistant"),
|
||||
]
|
||||
)
|
||||
client = TestClient(app)
|
||||
response = client.post("/", json=USER_PAYLOAD)
|
||||
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
assert response.headers.get("cache-control") == "no-cache"
|
||||
@@ -868,6 +868,648 @@ def test_agui_messages_to_snapshot_format_basic():
|
||||
assert result[1]["content"] == "Hi there"
|
||||
|
||||
|
||||
# ── Tool history sanitization edge cases ──
|
||||
|
||||
|
||||
def test_sanitize_multiple_approvals_and_logic():
|
||||
"""Two function_approval_response contents: True + False → False overall."""
|
||||
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
|
||||
|
||||
assistant_msg = Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
|
||||
Content.from_function_call(call_id="c2", name="confirm_changes", arguments='{"function_call_id":"c1"}'),
|
||||
],
|
||||
)
|
||||
user_msg = Message(
|
||||
role="user",
|
||||
contents=[
|
||||
Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="a1",
|
||||
function_call=Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
|
||||
),
|
||||
Content.from_function_approval_response(
|
||||
approved=False,
|
||||
id="a2",
|
||||
function_call=Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
result = _sanitize_tool_history([assistant_msg, user_msg])
|
||||
# Both approvals should be preserved in user message
|
||||
assert any(msg.role == "user" for msg in result)
|
||||
|
||||
|
||||
def test_sanitize_pending_tool_skip_on_user_followup():
|
||||
"""User text message after assistant tool call injects synthetic skipped results."""
|
||||
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
|
||||
|
||||
assistant_msg = Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(call_id="c1", name="get_weather", arguments="{}")],
|
||||
)
|
||||
user_msg = Message(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Actually, never mind")],
|
||||
)
|
||||
|
||||
result = _sanitize_tool_history([assistant_msg, user_msg])
|
||||
# Should have: assistant, synthetic tool result, user
|
||||
tool_results = [m for m in result if m.role == "tool"]
|
||||
assert len(tool_results) == 1
|
||||
assert "skipped" in str(tool_results[0].contents[0].result).lower()
|
||||
|
||||
|
||||
def test_sanitize_tool_result_clears_pending_confirm():
|
||||
"""Tool result for pending confirm_changes call_id clears pending state."""
|
||||
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
|
||||
|
||||
assistant_msg = Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
|
||||
],
|
||||
)
|
||||
tool_msg = Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="c1", result="done")],
|
||||
)
|
||||
|
||||
result = _sanitize_tool_history([assistant_msg, tool_msg])
|
||||
assert len(result) == 2
|
||||
assert result[1].role == "tool"
|
||||
|
||||
|
||||
def test_sanitize_non_standard_role_resets_state():
|
||||
"""System message between assistant+user resets pending tool state."""
|
||||
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
|
||||
|
||||
assistant_msg = Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(call_id="c1", name="get_weather", arguments="{}")],
|
||||
)
|
||||
system_msg = Message(role="system", contents=[Content.from_text(text="System update")])
|
||||
user_msg = Message(role="user", contents=[Content.from_text(text="Continue")])
|
||||
|
||||
result = _sanitize_tool_history([assistant_msg, system_msg, user_msg])
|
||||
# System message should reset pending state, so no synthetic tool results
|
||||
tool_results = [m for m in result if m.role == "tool"]
|
||||
assert len(tool_results) == 0
|
||||
|
||||
|
||||
def test_sanitize_json_confirm_changes_response():
|
||||
"""User sends JSON text with 'accepted' after confirm_changes."""
|
||||
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
|
||||
|
||||
assistant_msg = Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
|
||||
Content.from_function_call(call_id="c2", name="confirm_changes", arguments='{"function_call_id":"c1"}'),
|
||||
],
|
||||
)
|
||||
# Note: confirm_changes is filtered, so c2 won't be in pending_tool_call_ids
|
||||
# But c1 will remain pending. User message with JSON accepted text doesn't match
|
||||
# confirm_changes path since pending_confirm_changes_id was reset.
|
||||
user_msg = Message(
|
||||
role="user",
|
||||
contents=[Content.from_text(text=json.dumps({"accepted": True}))],
|
||||
)
|
||||
|
||||
result = _sanitize_tool_history([assistant_msg, user_msg])
|
||||
# Should still process without errors
|
||||
assert len(result) >= 1
|
||||
|
||||
|
||||
# ── Deduplication edge cases ──
|
||||
|
||||
|
||||
def test_deduplicate_tool_results():
|
||||
"""Duplicate tool results for same call_id are deduplicated."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
msg1 = Message(role="tool", contents=[Content.from_function_result(call_id="c1", result="first")])
|
||||
msg2 = Message(role="tool", contents=[Content.from_function_result(call_id="c1", result="second")])
|
||||
|
||||
result = _deduplicate_messages([msg1, msg2])
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_deduplicate_assistant_tool_calls():
|
||||
"""Duplicate assistant messages with same tool_calls are deduplicated."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
msg1 = Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(call_id="c1", name="fn", arguments="{}")],
|
||||
)
|
||||
msg2 = Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(call_id="c1", name="fn", arguments="{}")],
|
||||
)
|
||||
|
||||
result = _deduplicate_messages([msg1, msg2])
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_deduplicate_general_messages():
|
||||
"""Duplicate general user messages are deduplicated."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
|
||||
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")])
|
||||
|
||||
result = _deduplicate_messages([msg1, msg2])
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_deduplicate_replaces_empty_tool_result():
|
||||
"""Empty tool result is replaced by later non-empty result."""
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
|
||||
|
||||
msg1 = Message(role="tool", contents=[Content.from_function_result(call_id="c1", result="")])
|
||||
msg2 = Message(role="tool", contents=[Content.from_function_result(call_id="c1", result="actual result")])
|
||||
|
||||
result = _deduplicate_messages([msg1, msg2])
|
||||
assert len(result) == 1
|
||||
assert result[0].contents[0].result == "actual result"
|
||||
|
||||
|
||||
# ── Multimodal & content conversion edge cases ──
|
||||
|
||||
|
||||
def test_convert_agui_content_unknown_source_type_fallback():
|
||||
"""Unknown source type falls back to url/data/id fields."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
part = {
|
||||
"type": "image",
|
||||
"source": {"type": "custom", "url": "https://example.com/img.png"},
|
||||
}
|
||||
result = _parse_multimodal_media_part(part)
|
||||
assert result is not None
|
||||
assert result.uri == "https://example.com/img.png"
|
||||
|
||||
|
||||
def test_convert_agui_content_data_uri_prefix():
|
||||
"""base64 data starting with 'data:' is treated as data URI."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
part = {
|
||||
"type": "image",
|
||||
"source": {"type": "base64", "data": "data:image/png;base64,abc", "mimeType": "image/png"},
|
||||
}
|
||||
result = _parse_multimodal_media_part(part)
|
||||
assert result is not None
|
||||
assert result.uri == "data:image/png;base64,abc"
|
||||
|
||||
|
||||
def test_convert_agui_content_binary_id():
|
||||
"""Source with 'id' field creates ag-ui:// URI."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
part = {
|
||||
"type": "image",
|
||||
"source": {"type": "id", "id": "file123"},
|
||||
}
|
||||
result = _parse_multimodal_media_part(part)
|
||||
assert result is not None
|
||||
assert result.uri == "ag-ui://binary/file123"
|
||||
|
||||
|
||||
def test_convert_agui_content_string_items_in_list():
|
||||
"""String items in content list create text Content."""
|
||||
from agent_framework_ag_ui._message_adapters import _convert_agui_content_to_framework
|
||||
|
||||
result = _convert_agui_content_to_framework(["hello", "world"])
|
||||
assert len(result) == 2
|
||||
assert result[0].text == "hello"
|
||||
assert result[1].text == "world"
|
||||
|
||||
|
||||
def test_convert_agui_content_non_dict_non_str_items():
|
||||
"""Non-dict/non-str items in list are stringified."""
|
||||
from agent_framework_ag_ui._message_adapters import _convert_agui_content_to_framework
|
||||
|
||||
result = _convert_agui_content_to_framework([123, None])
|
||||
assert len(result) == 2
|
||||
assert result[0].text == "123"
|
||||
assert result[1].text == "None"
|
||||
|
||||
|
||||
def test_convert_agui_content_unknown_part_type_with_text():
|
||||
"""Unknown part type with 'text' key extracts the text."""
|
||||
from agent_framework_ag_ui._message_adapters import _convert_agui_content_to_framework
|
||||
|
||||
result = _convert_agui_content_to_framework([{"type": "widget", "text": "hi"}])
|
||||
assert len(result) == 1
|
||||
assert result[0].text == "hi"
|
||||
|
||||
|
||||
def test_convert_agui_content_unknown_part_type_without_text():
|
||||
"""Unknown part type without 'text' key stringifies the dict."""
|
||||
from agent_framework_ag_ui._message_adapters import _convert_agui_content_to_framework
|
||||
|
||||
result = _convert_agui_content_to_framework([{"type": "widget", "data": 42}])
|
||||
assert len(result) == 1
|
||||
assert "widget" in result[0].text
|
||||
|
||||
|
||||
def test_convert_agui_content_none():
|
||||
"""None content returns empty list."""
|
||||
from agent_framework_ag_ui._message_adapters import _convert_agui_content_to_framework
|
||||
|
||||
result = _convert_agui_content_to_framework(None)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_convert_agui_content_non_str_non_list_non_none():
|
||||
"""Non-string, non-list, non-None content is stringified."""
|
||||
from agent_framework_ag_ui._message_adapters import _convert_agui_content_to_framework
|
||||
|
||||
result = _convert_agui_content_to_framework(42)
|
||||
assert len(result) == 1
|
||||
assert result[0].text == "42"
|
||||
|
||||
|
||||
# ── Snapshot normalization edge cases ──
|
||||
|
||||
|
||||
def test_snapshot_input_image_to_binary():
|
||||
"""input_image type is normalized to binary in snapshot."""
|
||||
result = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_image", "source": {"type": "url", "url": "https://example.com/img.png"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert isinstance(result[0]["content"], list)
|
||||
assert result[0]["content"][0]["type"] == "binary"
|
||||
|
||||
|
||||
def test_snapshot_mime_type_snake_case():
|
||||
"""mime_type (snake_case) is normalized to mimeType."""
|
||||
result = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Caption", "mime_type": "text/plain"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"type": "url", "url": "https://x.com/a.png", "mime_type": "image/png"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
content = result[0]["content"]
|
||||
assert isinstance(content, list)
|
||||
# The text part should have mimeType added
|
||||
text_part = content[0]
|
||||
assert text_part.get("mimeType") == "text/plain"
|
||||
|
||||
|
||||
def test_snapshot_text_only_list_collapsed():
|
||||
"""List of only text parts is collapsed to string."""
|
||||
result = agui_messages_to_snapshot_format(
|
||||
[{"role": "user", "content": [{"type": "text", "text": "Hello"}, {"type": "text", "text": " World"}]}]
|
||||
)
|
||||
assert result[0]["content"] == "Hello World"
|
||||
|
||||
|
||||
def test_snapshot_legacy_binary_data_and_id():
|
||||
"""Legacy binary part with data and id fields."""
|
||||
result = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Caption"},
|
||||
{"type": "binary", "data": "base64data", "id": "file1", "mimeType": "image/png"},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
content = result[0]["content"]
|
||||
assert isinstance(content, list)
|
||||
binary_part = content[1]
|
||||
assert binary_part["type"] == "binary"
|
||||
assert binary_part["data"] == "base64data"
|
||||
assert binary_part["id"] == "file1"
|
||||
|
||||
|
||||
# ── Message conversion edge cases ──
|
||||
|
||||
|
||||
def test_agui_tool_message_action_execution_id_fallback():
|
||||
"""Tool message with actionExecutionId but no tool_call_id."""
|
||||
messages = agui_messages_to_agent_framework(
|
||||
[
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "result data",
|
||||
"actionExecutionId": "action_1",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert len(messages) == 1
|
||||
assert messages[0].contents[0].type == "function_result"
|
||||
assert messages[0].contents[0].call_id == "action_1"
|
||||
|
||||
|
||||
def test_agui_tool_message_result_key_instead_of_content():
|
||||
"""Tool message with 'result' key instead of 'content'."""
|
||||
messages = agui_messages_to_agent_framework(
|
||||
[
|
||||
{
|
||||
"role": "tool",
|
||||
"result": "the result",
|
||||
"toolCallId": "c1",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert len(messages) == 1
|
||||
assert messages[0].contents[0].result == "the result"
|
||||
|
||||
|
||||
def test_agui_tool_message_dict_content():
|
||||
"""Tool message with dict content."""
|
||||
messages = agui_messages_to_agent_framework(
|
||||
[
|
||||
{
|
||||
"role": "tool",
|
||||
"content": {"key": "value"},
|
||||
"toolCallId": "c1",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert len(messages) == 1
|
||||
# Dict content as approval check: no 'accepted' key, so it's a regular tool result
|
||||
assert messages[0].contents[0].type == "function_result"
|
||||
|
||||
|
||||
def test_agui_tool_message_list_content():
|
||||
"""Tool message with list content."""
|
||||
messages = agui_messages_to_agent_framework(
|
||||
[
|
||||
{
|
||||
"role": "tool",
|
||||
"content": ["item1", "item2"],
|
||||
"toolCallId": "c1",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert len(messages) == 1
|
||||
assert messages[0].contents[0].type == "function_result"
|
||||
|
||||
|
||||
def test_agui_action_execution_id_without_role():
|
||||
"""Message with actionExecutionId but no role maps to tool."""
|
||||
messages = agui_messages_to_agent_framework(
|
||||
[
|
||||
{
|
||||
"actionExecutionId": "action_1",
|
||||
"result": "tool result",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == "tool"
|
||||
assert messages[0].contents[0].call_id == "action_1"
|
||||
|
||||
|
||||
def test_agui_non_dict_tool_call_skipped():
|
||||
"""Non-dict tool_call entries in tool_calls array are skipped."""
|
||||
messages = agui_messages_to_agent_framework(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
"not_a_dict",
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "fn", "arguments": "{}"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert len(messages) == 1
|
||||
func_calls = [c for c in messages[0].contents if c.type == "function_call"]
|
||||
assert len(func_calls) == 1
|
||||
|
||||
|
||||
def test_agui_empty_content_default():
|
||||
"""Message with empty/null content gets default empty text."""
|
||||
messages = agui_messages_to_agent_framework([{"role": "user"}])
|
||||
assert len(messages) == 1
|
||||
assert len(messages[0].contents) == 1
|
||||
assert messages[0].contents[0].text == ""
|
||||
|
||||
|
||||
def test_agui_dict_tool_msg_without_tool_call_id():
|
||||
"""Dict tool message missing toolCallId gets empty string."""
|
||||
result = agui_messages_to_snapshot_format([{"role": "tool", "content": "result"}])
|
||||
assert len(result) == 1
|
||||
assert result[0].get("toolCallId") == ""
|
||||
|
||||
|
||||
def test_snapshot_argument_serialization_none():
|
||||
"""None arguments in tool_calls are serialized to empty string."""
|
||||
result = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "c1", "type": "function", "function": {"name": "fn", "arguments": None}},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
tc = result[0]["tool_calls"][0]
|
||||
assert tc["function"]["arguments"] == ""
|
||||
|
||||
|
||||
def test_snapshot_argument_serialization_object():
|
||||
"""Object arguments in tool_calls are JSON-serialized."""
|
||||
result = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "c1", "type": "function", "function": {"name": "fn", "arguments": {"key": "val"}}},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
tc = result[0]["tool_calls"][0]
|
||||
assert tc["function"]["arguments"] == '{"key": "val"}'
|
||||
|
||||
|
||||
def test_snapshot_tool_call_id_normalization():
|
||||
"""tool_call_id is normalized to toolCallId in snapshot."""
|
||||
result = agui_messages_to_snapshot_format([{"role": "tool", "content": "result", "tool_call_id": "c1"}])
|
||||
assert result[0].get("toolCallId") == "c1"
|
||||
assert "tool_call_id" not in result[0]
|
||||
|
||||
|
||||
def test_agui_to_framework_dict_tool_msg_without_tool_call_id():
|
||||
"""Dict tool message in agent_framework_messages_to_agui without toolCallId."""
|
||||
result = agent_framework_messages_to_agui(
|
||||
[{"role": "tool", "content": "result"}] # type: ignore[list-item]
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].get("toolCallId") == ""
|
||||
|
||||
|
||||
def test_snapshot_none_content():
|
||||
"""None content is normalized to empty string."""
|
||||
result = agui_messages_to_snapshot_format([{"role": "user", "content": None}])
|
||||
assert result[0]["content"] == ""
|
||||
|
||||
|
||||
def test_sanitize_confirm_changes_with_approval_accepted():
|
||||
"""Approval for pending confirm_changes creates synthetic result."""
|
||||
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
|
||||
|
||||
# Create assistant with both a real tool and confirm_changes
|
||||
assistant_msg = Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
|
||||
Content.from_function_call(call_id="c2", name="confirm_changes", arguments='{"function_call_id":"c1"}'),
|
||||
],
|
||||
)
|
||||
# Note: confirm_changes gets filtered out, so pending_confirm_changes_id becomes None.
|
||||
# The test verifies the filtering path works without error.
|
||||
user_msg = Message(
|
||||
role="user",
|
||||
contents=[
|
||||
Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id="a1",
|
||||
function_call=Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
result = _sanitize_tool_history([assistant_msg, user_msg])
|
||||
# Should process without errors; confirm_changes is filtered from assistant msg
|
||||
assert len(result) >= 1
|
||||
|
||||
|
||||
def test_sanitize_json_accepted_text_for_pending_confirm():
|
||||
"""JSON text with 'accepted' field for non-filtered confirm_changes path."""
|
||||
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
|
||||
|
||||
# Create an assistant with a tool call that requires a result
|
||||
assistant_msg = Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(call_id="c1", name="tool_a", arguments="{}"),
|
||||
],
|
||||
)
|
||||
# A tool result arrives, then a user message
|
||||
tool_msg = Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="c1", result="done")],
|
||||
)
|
||||
user_msg = Message(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Continue please")],
|
||||
)
|
||||
|
||||
result = _sanitize_tool_history([assistant_msg, tool_msg, user_msg])
|
||||
# Should have: assistant, tool result, user
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
def test_parse_multimodal_media_part_no_data_no_url():
|
||||
"""Part with no url, data, or id returns None."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
result = _parse_multimodal_media_part({"type": "image"})
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_parse_multimodal_media_part_binary_source_type():
|
||||
"""Source with type='binary' extracts data field."""
|
||||
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
|
||||
|
||||
result = _parse_multimodal_media_part(
|
||||
{"type": "image", "source": {"type": "binary", "data": "data:image/png;base64,abc"}}
|
||||
)
|
||||
assert result is not None
|
||||
assert result.uri == "data:image/png;base64,abc"
|
||||
|
||||
|
||||
def test_snapshot_non_dict_item_in_content_list():
|
||||
"""Non-dict items in content list are stringified."""
|
||||
result = agui_messages_to_snapshot_format([{"role": "user", "content": [42, "text"]}])
|
||||
# Text-only after stringification means collapsed to string
|
||||
assert isinstance(result[0]["content"], str)
|
||||
|
||||
|
||||
def test_snapshot_non_dict_tool_call_skipped():
|
||||
"""Non-dict entries in tool_calls are skipped during argument serialization."""
|
||||
result = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
"not_a_dict",
|
||||
{"id": "c1", "type": "function", "function": {"name": "fn", "arguments": "{}"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
# Should not error
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_snapshot_tool_call_without_function_payload():
|
||||
"""tool_call dict without function payload is skipped."""
|
||||
result = agui_messages_to_snapshot_format(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": "c1", "type": "function"}],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_agui_to_framework_action_name_without_role():
|
||||
"""Message with actionName but no explicit role maps to tool."""
|
||||
messages = agui_messages_to_agent_framework([{"actionName": "get_weather", "result": "Sunny", "toolCallId": "c1"}])
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == "tool"
|
||||
|
||||
|
||||
def test_agui_to_framework_tool_message_content_none():
|
||||
"""Tool message with content=None uses result field fallback."""
|
||||
messages = agui_messages_to_agent_framework(
|
||||
[{"role": "tool", "content": None, "result": "fallback_result", "toolCallId": "c1"}]
|
||||
)
|
||||
assert len(messages) == 1
|
||||
assert messages[0].contents[0].result == "fallback_result"
|
||||
|
||||
|
||||
def test_agui_fresh_approval_is_still_processed():
|
||||
"""A fresh approval (no assistant response after it) must still produce function_approval_response.
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Multi-turn conversation tests: POST → collect events → extract snapshot → POST again.
|
||||
|
||||
These tests catch round-trip fidelity bugs: if MessagesSnapshotEvent produces a
|
||||
malformed message list, the second turn will fail during normalize_agui_input_messages()
|
||||
or produce incorrect behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
from conftest import StubAgent
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sse_helpers import parse_sse_response, parse_sse_to_event_stream
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent, add_agent_framework_fastapi_endpoint
|
||||
|
||||
|
||||
def _build_app_with_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> FastAPI:
|
||||
stub = StubAgent(updates=updates)
|
||||
agent = AgentFrameworkAgent(agent=stub, **kwargs)
|
||||
app = FastAPI()
|
||||
add_agent_framework_fastapi_endpoint(app, agent)
|
||||
return app
|
||||
|
||||
|
||||
def _extract_snapshot_messages(response_content: bytes) -> list[dict[str, Any]]:
|
||||
"""Extract the latest MessagesSnapshotEvent.messages from SSE response bytes."""
|
||||
raw_events = parse_sse_response(response_content)
|
||||
snapshot_msgs: list[dict[str, Any]] | None = None
|
||||
for event in raw_events:
|
||||
if event.get("type") == "MESSAGES_SNAPSHOT":
|
||||
snapshot_msgs = event.get("messages", [])
|
||||
assert snapshot_msgs is not None, "No MESSAGES_SNAPSHOT event found"
|
||||
return snapshot_msgs
|
||||
|
||||
|
||||
# ── Basic multi-turn chat ──
|
||||
|
||||
|
||||
def test_basic_multi_turn_chat() -> None:
|
||||
"""Turn 1: user→assistant. Turn 2: user→assistant with prior history from snapshot."""
|
||||
app = _build_app_with_agent(
|
||||
[
|
||||
AgentResponseUpdate(contents=[Content.from_text(text="Hello! How can I help?")], role="assistant"),
|
||||
]
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
# Turn 1
|
||||
resp1 = client.post(
|
||||
"/",
|
||||
json={
|
||||
"messages": [{"role": "user", "content": "Hi there"}],
|
||||
"threadId": "thread-multi",
|
||||
"runId": "run-1",
|
||||
},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
stream1 = parse_sse_to_event_stream(resp1.content)
|
||||
stream1.assert_bookends()
|
||||
stream1.assert_text_messages_balanced()
|
||||
|
||||
# Extract snapshot messages from turn 1
|
||||
snapshot_messages = _extract_snapshot_messages(resp1.content)
|
||||
|
||||
# Turn 2: send snapshot messages + new user message
|
||||
turn2_messages = list(snapshot_messages) + [{"role": "user", "content": "Tell me more"}]
|
||||
resp2 = client.post(
|
||||
"/",
|
||||
json={
|
||||
"messages": turn2_messages,
|
||||
"threadId": "thread-multi",
|
||||
"runId": "run-2",
|
||||
},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
stream2 = parse_sse_to_event_stream(resp2.content)
|
||||
stream2.assert_bookends()
|
||||
stream2.assert_text_messages_balanced()
|
||||
stream2.assert_no_run_error()
|
||||
|
||||
|
||||
# ── Tool call history round-trip ──
|
||||
|
||||
|
||||
def test_tool_call_history_round_trips() -> None:
|
||||
"""Turn 1: tool call + result. Turn 2: snapshot messages correctly reconstruct tool history."""
|
||||
app = _build_app_with_agent(
|
||||
[
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="get_weather", call_id="call-1", arguments='{"city": "SF"}')],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_function_result(call_id="call-1", result="72°F")],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="It's warm!")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
# Turn 1
|
||||
resp1 = client.post(
|
||||
"/",
|
||||
json={
|
||||
"messages": [{"role": "user", "content": "What's the weather?"}],
|
||||
"threadId": "thread-tool-multi",
|
||||
"runId": "run-1",
|
||||
},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
stream1 = parse_sse_to_event_stream(resp1.content)
|
||||
stream1.assert_tool_calls_balanced()
|
||||
|
||||
# Extract snapshot and verify it has tool history
|
||||
snapshot_messages = _extract_snapshot_messages(resp1.content)
|
||||
roles = [m.get("role") for m in snapshot_messages]
|
||||
assert "tool" in roles or "assistant" in roles, f"Expected tool/assistant messages in snapshot, got: {roles}"
|
||||
|
||||
# Turn 2: send snapshot + new question
|
||||
turn2_messages = list(snapshot_messages) + [{"role": "user", "content": "What about tomorrow?"}]
|
||||
resp2 = client.post(
|
||||
"/",
|
||||
json={
|
||||
"messages": turn2_messages,
|
||||
"threadId": "thread-tool-multi",
|
||||
"runId": "run-2",
|
||||
},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
stream2 = parse_sse_to_event_stream(resp2.content)
|
||||
stream2.assert_bookends()
|
||||
stream2.assert_no_run_error()
|
||||
|
||||
|
||||
# ── Approval interrupt/resume round-trip ──
|
||||
|
||||
|
||||
async def test_approval_interrupt_resume_round_trip() -> None:
|
||||
"""Turn 1: approval request → interrupt with confirm_changes. Turn 2: confirm_changes result → confirmation text.
|
||||
|
||||
The confirm_changes flow uses a specific message format that bypasses the agent
|
||||
and directly emits a confirmation text message.
|
||||
"""
|
||||
from event_stream import EventStream
|
||||
|
||||
steps = [{"description": "Execute task", "status": "enabled"}]
|
||||
|
||||
# Build agent with predictive state and confirmation
|
||||
stub = StubAgent(
|
||||
updates=[
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="generate_task_steps",
|
||||
call_id="call-steps",
|
||||
arguments=json.dumps({"steps": steps}),
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
)
|
||||
agent = AgentFrameworkAgent(
|
||||
agent=stub,
|
||||
state_schema={"tasks": {"type": "array"}},
|
||||
predict_state_config={"tasks": {"tool": "generate_task_steps", "tool_argument": "steps"}},
|
||||
require_confirmation=True,
|
||||
)
|
||||
|
||||
# Turn 1
|
||||
events1 = [
|
||||
e
|
||||
async for e in agent.run(
|
||||
{
|
||||
"thread_id": "thread-approval-multi",
|
||||
"run_id": "run-1",
|
||||
"messages": [{"role": "user", "content": "Plan my tasks"}],
|
||||
"state": {"tasks": []},
|
||||
}
|
||||
)
|
||||
]
|
||||
stream1 = EventStream(events1)
|
||||
stream1.assert_bookends()
|
||||
stream1.assert_tool_calls_balanced()
|
||||
|
||||
# Should have interrupt with function_approval_request
|
||||
finished1 = stream1.last("RUN_FINISHED")
|
||||
interrupt1 = finished1.model_dump().get("interrupt")
|
||||
assert interrupt1, "Expected interrupt in RUN_FINISHED"
|
||||
|
||||
# Verify confirm_changes tool call was emitted
|
||||
tool_starts = stream1.get("TOOL_CALL_START")
|
||||
tool_names = [getattr(s, "tool_call_name", None) for s in tool_starts]
|
||||
assert "confirm_changes" in tool_names, f"Expected confirm_changes in tool calls, got {tool_names}"
|
||||
|
||||
# Turn 2: Direct confirm_changes response (the way CopilotKit sends it)
|
||||
# Construct the messages as CopilotKit would - with the confirm_changes tool call
|
||||
# and a tool result
|
||||
confirm_tool = [s for s in tool_starts if getattr(s, "tool_call_name", None) == "confirm_changes"][0]
|
||||
confirm_id = confirm_tool.tool_call_id
|
||||
confirm_args = None
|
||||
for e in stream1.get("TOOL_CALL_ARGS"):
|
||||
if e.tool_call_id == confirm_id:
|
||||
confirm_args = e.delta
|
||||
break
|
||||
|
||||
turn2_messages = [
|
||||
{"role": "user", "content": "Plan my tasks"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": confirm_id,
|
||||
"type": "function",
|
||||
"function": {"name": "confirm_changes", "arguments": confirm_args or "{}"},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"toolCallId": confirm_id,
|
||||
"content": json.dumps({"accepted": True, "steps": steps}),
|
||||
},
|
||||
]
|
||||
|
||||
events2 = [
|
||||
e
|
||||
async for e in agent.run(
|
||||
{
|
||||
"thread_id": "thread-approval-multi",
|
||||
"run_id": "run-2",
|
||||
"messages": turn2_messages,
|
||||
"state": {"tasks": []},
|
||||
}
|
||||
)
|
||||
]
|
||||
stream2 = EventStream(events2)
|
||||
stream2.assert_bookends()
|
||||
stream2.assert_text_messages_balanced()
|
||||
stream2.assert_no_run_error()
|
||||
|
||||
# Turn 2 should have confirmation text (the approval handler generates it)
|
||||
text_events = stream2.get("TEXT_MESSAGE_CONTENT")
|
||||
assert text_events, "Expected confirmation text message in turn 2"
|
||||
|
||||
# Turn 2 should NOT have interrupt (approval completed)
|
||||
finished2 = stream2.last("RUN_FINISHED")
|
||||
interrupt2 = finished2.model_dump().get("interrupt")
|
||||
assert not interrupt2, f"Expected no interrupt after approval, got {interrupt2}"
|
||||
|
||||
|
||||
# ── Workflow interrupt/resume round-trip ──
|
||||
# Note: Workflow tests use async agent.run() directly instead of HTTP TestClient
|
||||
# because the sync TestClient runs in a different event loop, which conflicts
|
||||
# with the workflow's asyncio Queue.
|
||||
|
||||
|
||||
async def test_workflow_interrupt_resume_round_trip() -> None:
|
||||
"""Turn 1: workflow request_info → interrupt. Turn 2: resume → completion."""
|
||||
from event_stream import EventStream
|
||||
|
||||
from agent_framework_ag_ui_examples.agents.subgraphs_agent import subgraphs_agent
|
||||
|
||||
agent = subgraphs_agent()
|
||||
|
||||
# Turn 1: initial request → flight interrupt
|
||||
events1 = [
|
||||
event
|
||||
async for event in agent.run(
|
||||
{
|
||||
"messages": [{"role": "user", "content": "Plan a trip to SF"}],
|
||||
"thread_id": "thread-wf-multi",
|
||||
"run_id": "run-1",
|
||||
}
|
||||
)
|
||||
]
|
||||
stream1 = EventStream(events1)
|
||||
stream1.assert_bookends()
|
||||
stream1.assert_no_run_error()
|
||||
|
||||
finished1 = stream1.last("RUN_FINISHED")
|
||||
interrupt1 = finished1.model_dump().get("interrupt")
|
||||
assert interrupt1, "Expected flight interrupt"
|
||||
assert interrupt1[0]["value"]["agent"] == "flights"
|
||||
|
||||
# Turn 2: resume with flight selection
|
||||
events2 = [
|
||||
event
|
||||
async for event in agent.run(
|
||||
{
|
||||
"messages": [],
|
||||
"thread_id": "thread-wf-multi",
|
||||
"run_id": "run-2",
|
||||
"resume": {
|
||||
"interrupts": [
|
||||
{
|
||||
"id": interrupt1[0]["id"],
|
||||
"value": json.dumps(
|
||||
{
|
||||
"airline": "United",
|
||||
"departure": "Amsterdam (AMS)",
|
||||
"arrival": "San Francisco (SFO)",
|
||||
"price": "$720",
|
||||
"duration": "12h 15m",
|
||||
}
|
||||
),
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
]
|
||||
stream2 = EventStream(events2)
|
||||
stream2.assert_bookends()
|
||||
stream2.assert_no_run_error()
|
||||
|
||||
# Should now have hotel interrupt
|
||||
finished2 = stream2.last("RUN_FINISHED")
|
||||
interrupt2 = finished2.model_dump().get("interrupt")
|
||||
assert interrupt2, "Expected hotel interrupt"
|
||||
assert interrupt2[0]["value"]["agent"] == "hotels"
|
||||
@@ -0,0 +1,122 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for _run_common.py edge cases."""
|
||||
|
||||
from agent_framework import Content
|
||||
|
||||
from agent_framework_ag_ui._run_common import (
|
||||
FlowState,
|
||||
_emit_tool_result,
|
||||
_extract_resume_payload,
|
||||
_normalize_resume_interrupts,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeResumeInterrupts:
|
||||
"""Tests for _normalize_resume_interrupts edge cases."""
|
||||
|
||||
def test_plain_list_of_dicts(self):
|
||||
"""Resume payload as a plain list of interrupt dicts."""
|
||||
result = _normalize_resume_interrupts([{"id": "x", "value": "y"}])
|
||||
assert result == [{"id": "x", "value": "y"}]
|
||||
|
||||
def test_dict_with_singular_interrupt_key(self):
|
||||
"""Resume dict using 'interrupt' (singular) instead of 'interrupts'."""
|
||||
result = _normalize_resume_interrupts({"interrupt": [{"id": "x", "value": "y"}]})
|
||||
assert result == [{"id": "x", "value": "y"}]
|
||||
|
||||
def test_dict_without_interrupts_key_wraps_as_candidate(self):
|
||||
"""Resume dict without interrupts/interrupt key wraps the dict itself."""
|
||||
result = _normalize_resume_interrupts({"id": "x", "value": "y"})
|
||||
assert result == [{"id": "x", "value": "y"}]
|
||||
|
||||
def test_non_dict_items_in_list_are_skipped(self):
|
||||
"""Non-dict items in candidate list are silently skipped."""
|
||||
result = _normalize_resume_interrupts([None, "string", {"id": "x", "value": "y"}])
|
||||
assert result == [{"id": "x", "value": "y"}]
|
||||
|
||||
def test_items_missing_id_are_skipped(self):
|
||||
"""Dict items without any id field are skipped."""
|
||||
result = _normalize_resume_interrupts([{"name": "test"}])
|
||||
assert result == []
|
||||
|
||||
def test_response_key_used_as_value(self):
|
||||
"""'response' key is used as value when 'value' is absent."""
|
||||
result = _normalize_resume_interrupts([{"id": "x", "response": "approved"}])
|
||||
assert result == [{"id": "x", "value": "approved"}]
|
||||
|
||||
def test_neither_value_nor_response_uses_remaining_fields(self):
|
||||
"""When neither 'value' nor 'response' key exists, remaining fields become value."""
|
||||
result = _normalize_resume_interrupts([{"id": "x", "extra": "data", "more": 42}])
|
||||
assert result == [{"id": "x", "value": {"extra": "data", "more": 42}}]
|
||||
|
||||
def test_none_payload_returns_empty(self):
|
||||
"""None resume payload returns empty list."""
|
||||
assert _normalize_resume_interrupts(None) == []
|
||||
|
||||
def test_non_dict_non_list_returns_empty(self):
|
||||
"""Non-dict, non-list payload returns empty list."""
|
||||
assert _normalize_resume_interrupts(42) == []
|
||||
|
||||
def test_interrupt_id_key_used_as_id(self):
|
||||
"""interruptId key is accepted as identifier."""
|
||||
result = _normalize_resume_interrupts([{"interruptId": "abc", "value": "yes"}])
|
||||
assert result == [{"id": "abc", "value": "yes"}]
|
||||
|
||||
def test_tool_call_id_key_used_as_id(self):
|
||||
"""toolCallId key is accepted as identifier."""
|
||||
result = _normalize_resume_interrupts([{"toolCallId": "tc1", "value": "done"}])
|
||||
assert result == [{"id": "tc1", "value": "done"}]
|
||||
|
||||
|
||||
class TestExtractResumePayload:
|
||||
"""Tests for _extract_resume_payload edge cases."""
|
||||
|
||||
def test_forwarded_props_resume_not_nested_in_command(self):
|
||||
"""forwarded_props.resume (not nested in command) is extracted."""
|
||||
result = _extract_resume_payload({"forwarded_props": {"resume": "data"}})
|
||||
assert result == "data"
|
||||
|
||||
def test_forwarded_props_not_dict_returns_none(self):
|
||||
"""Non-dict forwarded_props returns None."""
|
||||
result = _extract_resume_payload({"forwarded_props": "string"})
|
||||
assert result is None
|
||||
|
||||
def test_resume_key_has_priority(self):
|
||||
"""Direct resume key takes priority over forwarded_props."""
|
||||
result = _extract_resume_payload({"resume": "direct", "forwarded_props": {"resume": "fp"}})
|
||||
assert result == "direct"
|
||||
|
||||
def test_no_resume_at_all(self):
|
||||
"""No resume key anywhere returns None."""
|
||||
result = _extract_resume_payload({"messages": []})
|
||||
assert result is None
|
||||
|
||||
def test_forwarded_props_camelcase(self):
|
||||
"""camelCase forwardedProps is also supported."""
|
||||
result = _extract_resume_payload({"forwardedProps": {"resume": "camel"}})
|
||||
assert result == "camel"
|
||||
|
||||
|
||||
class TestEmitToolResult:
|
||||
"""Tests for _emit_tool_result edge cases."""
|
||||
|
||||
def test_tool_result_without_call_id_returns_empty(self):
|
||||
"""Tool result Content without call_id returns empty event list."""
|
||||
content = Content.from_function_result(call_id=None, result="some result")
|
||||
flow = FlowState()
|
||||
events = _emit_tool_result(content, flow)
|
||||
assert events == []
|
||||
|
||||
def test_tool_result_closes_open_text_message(self):
|
||||
"""Tool result closes any open text message (issue #3568 fix)."""
|
||||
content = Content.from_function_result(call_id="call_1", result="done")
|
||||
flow = FlowState(message_id="msg_1", accumulated_text="Hello")
|
||||
events = _emit_tool_result(content, flow)
|
||||
|
||||
event_types = [e.type for e in events]
|
||||
assert "TOOL_CALL_END" in event_types
|
||||
assert "TOOL_CALL_RESULT" in event_types
|
||||
assert "TEXT_MESSAGE_END" in event_types
|
||||
assert flow.message_id is None
|
||||
assert flow.accumulated_text == ""
|
||||
@@ -3,12 +3,14 @@
|
||||
"""Tests for native workflow AG-UI runner."""
|
||||
|
||||
import json
|
||||
from enum import Enum
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
from ag_ui.core import EventType, StateSnapshotEvent
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
Content,
|
||||
Executor,
|
||||
Message,
|
||||
@@ -22,8 +24,25 @@ from agent_framework import (
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework_ag_ui._workflow_run import (
|
||||
_coerce_content,
|
||||
_coerce_json_value,
|
||||
_coerce_message,
|
||||
_coerce_message_content,
|
||||
_coerce_response_for_request,
|
||||
_coerce_responses_for_pending_requests,
|
||||
_custom_event_value,
|
||||
_details_code,
|
||||
_details_message,
|
||||
_interrupt_entry_for_request_event,
|
||||
_latest_assistant_contents,
|
||||
_latest_user_text,
|
||||
_message_role_value,
|
||||
_pending_request_events,
|
||||
_request_payload_from_request_event,
|
||||
_single_pending_response_from_value,
|
||||
_text_from_contents,
|
||||
_workflow_interrupt_event_value,
|
||||
_workflow_payload_to_contents,
|
||||
run_workflow_stream,
|
||||
)
|
||||
|
||||
@@ -677,3 +696,734 @@ async def test_workflow_run_emits_run_error_when_stream_raises() -> None:
|
||||
assert "RUN_ERROR" in event_types
|
||||
run_error = next(event for event in events if event.type == "RUN_ERROR")
|
||||
assert "workflow stream exploded" in run_error.message
|
||||
|
||||
|
||||
# ── Helper function unit tests ──
|
||||
|
||||
|
||||
class TestPendingRequestEvents:
|
||||
"""Tests for _pending_request_events helper."""
|
||||
|
||||
async def test_no_runner_context(self):
|
||||
"""Workflow without _runner_context returns empty dict."""
|
||||
workflow = SimpleNamespace()
|
||||
result = await _pending_request_events(cast(Any, workflow))
|
||||
assert result == {}
|
||||
|
||||
async def test_runner_context_missing_get_pending(self):
|
||||
"""Runner context without get_pending_request_info_events returns empty."""
|
||||
workflow = SimpleNamespace(_runner_context=SimpleNamespace())
|
||||
result = await _pending_request_events(cast(Any, workflow))
|
||||
assert result == {}
|
||||
|
||||
async def test_get_pending_returns_non_dict(self):
|
||||
"""get_pending returning non-dict returns empty dict."""
|
||||
|
||||
async def get_pending():
|
||||
return ["not", "a", "dict"]
|
||||
|
||||
workflow = SimpleNamespace(_runner_context=SimpleNamespace(get_pending_request_info_events=get_pending))
|
||||
result = await _pending_request_events(cast(Any, workflow))
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestInterruptEntryForRequestEvent:
|
||||
"""Tests for _interrupt_entry_for_request_event helper."""
|
||||
|
||||
def test_request_id_none(self):
|
||||
"""request_id=None returns None."""
|
||||
event = SimpleNamespace(request_id=None)
|
||||
assert _interrupt_entry_for_request_event(event) is None
|
||||
|
||||
def test_dict_data_used_directly(self):
|
||||
"""Dict data is used as interrupt value."""
|
||||
event = SimpleNamespace(request_id="r1", data={"key": "val"})
|
||||
result = _interrupt_entry_for_request_event(event)
|
||||
assert result == {"id": "r1", "value": {"key": "val"}}
|
||||
|
||||
def test_non_dict_data_wrapped(self):
|
||||
"""Non-dict data is wrapped in {data: ...}."""
|
||||
event = SimpleNamespace(request_id="r1", data="text")
|
||||
result = _interrupt_entry_for_request_event(event)
|
||||
assert result == {"id": "r1", "value": {"data": "text"}}
|
||||
|
||||
|
||||
class TestRequestPayloadFromRequestEvent:
|
||||
"""Tests for _request_payload_from_request_event helper."""
|
||||
|
||||
def test_falsy_request_id_returns_none(self):
|
||||
"""Empty string request_id returns None."""
|
||||
event = SimpleNamespace(request_id="", request_type=None, response_type=None, data=None)
|
||||
assert _request_payload_from_request_event(event) is None
|
||||
|
||||
|
||||
class TestCoerceJsonValue:
|
||||
"""Tests for _coerce_json_value helper."""
|
||||
|
||||
def test_empty_string(self):
|
||||
"""Empty string returns original value."""
|
||||
assert _coerce_json_value("") == ""
|
||||
|
||||
def test_whitespace_string(self):
|
||||
"""Whitespace-only string returns original value."""
|
||||
assert _coerce_json_value(" ") == " "
|
||||
|
||||
def test_valid_json_parsed(self):
|
||||
"""Valid JSON string is parsed."""
|
||||
assert _coerce_json_value('{"a": 1}') == {"a": 1}
|
||||
|
||||
def test_invalid_json_returned_as_is(self):
|
||||
"""Invalid JSON string returned as-is."""
|
||||
assert _coerce_json_value("not json") == "not json"
|
||||
|
||||
def test_non_string_returned_as_is(self):
|
||||
"""Non-string values returned as-is."""
|
||||
assert _coerce_json_value(42) == 42
|
||||
assert _coerce_json_value(None) is None
|
||||
|
||||
|
||||
class TestCoerceContent:
|
||||
"""Tests for _coerce_content helper."""
|
||||
|
||||
def test_already_content(self):
|
||||
"""Content object returned as-is."""
|
||||
content = Content.from_text(text="hello")
|
||||
assert _coerce_content(content) is content
|
||||
|
||||
def test_non_dict_returns_none(self):
|
||||
"""Non-dict value (after JSON parse) returns None."""
|
||||
assert _coerce_content([1, 2, 3]) is None
|
||||
assert _coerce_content(42) is None
|
||||
|
||||
def test_auto_function_approval_response_type_attempted(self):
|
||||
"""Dict with approved+id+function_call triggers the auto-type detection path."""
|
||||
# The function injects type="function_approval_response" into a copy,
|
||||
# but Content.from_dict may fail for complex nested types - returns None.
|
||||
value = {
|
||||
"approved": True,
|
||||
"id": "a1",
|
||||
"function_call": {"call_id": "c1", "name": "fn", "arguments": "{}"},
|
||||
}
|
||||
# Exercises the auto-detection code path even though result is None
|
||||
result = _coerce_content(value)
|
||||
assert result is None # from_dict fails for this shape
|
||||
|
||||
def test_valid_text_content_dict(self):
|
||||
"""Dict with type=text converts successfully."""
|
||||
result = _coerce_content({"type": "text", "text": "hello"})
|
||||
assert result is not None
|
||||
assert result.type == "text"
|
||||
assert result.text == "hello"
|
||||
|
||||
|
||||
class TestCoerceMessageContent:
|
||||
"""Tests for _coerce_message_content helper."""
|
||||
|
||||
def test_string_content(self):
|
||||
"""String content creates text Content."""
|
||||
result = _coerce_message_content("hello")
|
||||
assert result is not None
|
||||
assert result.type == "text"
|
||||
assert result.text == "hello"
|
||||
|
||||
def test_already_content_object(self):
|
||||
"""Content object returned as-is."""
|
||||
content = Content.from_text(text="test")
|
||||
assert _coerce_message_content(content) is content
|
||||
|
||||
def test_none_input_returns_none(self):
|
||||
"""None input returns None."""
|
||||
assert _coerce_message_content(None) is None
|
||||
|
||||
|
||||
class TestCoerceMessage:
|
||||
"""Tests for _coerce_message helper."""
|
||||
|
||||
def test_already_message(self):
|
||||
"""Message object returned as-is."""
|
||||
msg = Message(role="user", contents=[Content.from_text(text="hi")])
|
||||
assert _coerce_message(msg) is msg
|
||||
|
||||
def test_non_dict_non_str_returns_none(self):
|
||||
"""Non-dict/str (e.g. int) returns None."""
|
||||
assert _coerce_message(123) is None
|
||||
|
||||
def test_empty_contents(self):
|
||||
"""Dict with no contents key gets empty text content."""
|
||||
msg = _coerce_message({"role": "user"})
|
||||
assert msg is not None
|
||||
assert len(msg.contents) == 1
|
||||
assert msg.contents[0].text == ""
|
||||
|
||||
def test_dict_with_content_key_variant(self):
|
||||
"""'content' key maps to contents."""
|
||||
msg = _coerce_message({"role": "assistant", "content": "Done"})
|
||||
assert msg is not None
|
||||
assert msg.role == "assistant"
|
||||
assert len(msg.contents) == 1
|
||||
|
||||
|
||||
class TestCoerceResponseForRequest:
|
||||
"""Tests for _coerce_response_for_request helper."""
|
||||
|
||||
def test_response_type_none(self):
|
||||
"""None response_type returns candidate as-is."""
|
||||
event = SimpleNamespace(response_type=None)
|
||||
assert _coerce_response_for_request(event, "hello") == "hello"
|
||||
|
||||
def test_response_type_any(self):
|
||||
"""Any response_type returns candidate as-is."""
|
||||
event = SimpleNamespace(response_type=Any)
|
||||
assert _coerce_response_for_request(event, {"a": 1}) == {"a": 1}
|
||||
|
||||
def test_list_coercion_bare_list(self):
|
||||
"""list without type args passes through."""
|
||||
event = SimpleNamespace(response_type=list)
|
||||
assert _coerce_response_for_request(event, [1, 2]) == [1, 2]
|
||||
|
||||
def test_list_content_coercion(self):
|
||||
"""list[Content] coerces dicts to Content objects."""
|
||||
event = SimpleNamespace(response_type=list[Content])
|
||||
result = _coerce_response_for_request(event, [{"type": "text", "text": "hi"}])
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], Content)
|
||||
|
||||
def test_list_message_coercion(self):
|
||||
"""list[Message] coerces dicts to Message objects."""
|
||||
event = SimpleNamespace(response_type=list[Message])
|
||||
result = _coerce_response_for_request(event, [{"role": "user", "contents": [{"type": "text", "text": "hi"}]}])
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], Message)
|
||||
|
||||
def test_list_coercion_fails_returns_none(self):
|
||||
"""list coercion returns None when items can't be converted."""
|
||||
event = SimpleNamespace(response_type=list[Content])
|
||||
result = _coerce_response_for_request(event, [None])
|
||||
assert result is None
|
||||
|
||||
def test_str_coercion_from_dict(self):
|
||||
"""str type coerces dict to JSON string."""
|
||||
event = SimpleNamespace(response_type=str)
|
||||
result = _coerce_response_for_request(event, {"a": 1})
|
||||
assert isinstance(result, str)
|
||||
assert '"a"' in result
|
||||
|
||||
def test_unknown_type_mismatch(self):
|
||||
"""Custom class type returns None for non-instance."""
|
||||
|
||||
class Custom:
|
||||
pass
|
||||
|
||||
event = SimpleNamespace(response_type=Custom)
|
||||
assert _coerce_response_for_request(event, "not_custom") is None
|
||||
|
||||
def test_unknown_type_match(self):
|
||||
"""Custom class type returns object if isinstance matches."""
|
||||
|
||||
class Custom:
|
||||
pass
|
||||
|
||||
obj = Custom()
|
||||
event = SimpleNamespace(response_type=Custom)
|
||||
assert _coerce_response_for_request(event, obj) is obj
|
||||
|
||||
|
||||
class TestSinglePendingResponseFromValue:
|
||||
"""Tests for _single_pending_response_from_value helper."""
|
||||
|
||||
def test_missing_request_id(self):
|
||||
"""Event with no request_id returns empty dict."""
|
||||
event = SimpleNamespace(response_type=str)
|
||||
pending = {"key": event}
|
||||
result = _single_pending_response_from_value(pending, "value")
|
||||
assert result == {}
|
||||
|
||||
def test_multiple_pending_returns_empty(self):
|
||||
"""Multiple pending events returns empty dict (ambiguous)."""
|
||||
e1 = SimpleNamespace(request_id="r1", response_type=str)
|
||||
e2 = SimpleNamespace(request_id="r2", response_type=str)
|
||||
result = _single_pending_response_from_value({"r1": e1, "r2": e2}, "val")
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestCoerceResponsesForPendingRequests:
|
||||
"""Tests for _coerce_responses_for_pending_requests helper."""
|
||||
|
||||
def test_failed_coercion_skipped(self):
|
||||
"""Incompatible type causes response to be skipped."""
|
||||
event = SimpleNamespace(response_type=bool)
|
||||
responses = {"r1": "not_a_bool"}
|
||||
pending = {"r1": event}
|
||||
result = _coerce_responses_for_pending_requests(responses, pending)
|
||||
assert "r1" not in result
|
||||
|
||||
def test_unknown_request_id_preserved(self):
|
||||
"""Responses for unknown request IDs are preserved as-is."""
|
||||
responses = {"unknown_id": "value"}
|
||||
pending = {}
|
||||
result = _coerce_responses_for_pending_requests(responses, pending)
|
||||
assert result == {"unknown_id": "value"}
|
||||
|
||||
def test_empty_responses(self):
|
||||
"""Empty responses dict returns responses unchanged."""
|
||||
result = _coerce_responses_for_pending_requests({}, {"r1": SimpleNamespace()})
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestMessageRoleValue:
|
||||
"""Tests for _message_role_value helper."""
|
||||
|
||||
def test_string_role(self):
|
||||
"""String role returned directly."""
|
||||
msg = Message(role="user", contents=[])
|
||||
assert _message_role_value(msg) == "user"
|
||||
|
||||
def test_enum_role(self):
|
||||
"""Enum-like role gets .value."""
|
||||
|
||||
class Role(Enum):
|
||||
USER = "user"
|
||||
|
||||
msg = SimpleNamespace(role=Role.USER)
|
||||
assert _message_role_value(cast(Any, msg)) == "user"
|
||||
|
||||
|
||||
class TestLatestUserText:
|
||||
"""Tests for _latest_user_text helper."""
|
||||
|
||||
def test_only_assistant_messages(self):
|
||||
"""Only assistant messages returns None."""
|
||||
messages = [Message(role="assistant", contents=[Content.from_text(text="hi")])]
|
||||
assert _latest_user_text(messages) is None
|
||||
|
||||
def test_user_with_non_text_content(self):
|
||||
"""User message with only non-text content returns None."""
|
||||
messages = [
|
||||
Message(role="user", contents=[Content.from_function_call(call_id="c1", name="fn", arguments="{}")])
|
||||
]
|
||||
assert _latest_user_text(messages) is None
|
||||
|
||||
def test_user_with_empty_text(self):
|
||||
"""User message with empty/whitespace text returns None."""
|
||||
messages = [Message(role="user", contents=[Content.from_text(text=" ")])]
|
||||
assert _latest_user_text(messages) is None
|
||||
|
||||
|
||||
class TestLatestAssistantContents:
|
||||
"""Tests for _latest_assistant_contents helper."""
|
||||
|
||||
def test_no_assistant_messages(self):
|
||||
"""Only user messages returns None."""
|
||||
messages = [Message(role="user", contents=[Content.from_text(text="hi")])]
|
||||
assert _latest_assistant_contents(messages) is None
|
||||
|
||||
def test_assistant_with_empty_contents(self):
|
||||
"""Assistant message with empty contents returns None."""
|
||||
messages = [Message(role="assistant", contents=[])]
|
||||
assert _latest_assistant_contents(messages) is None
|
||||
|
||||
|
||||
class TestTextFromContents:
|
||||
"""Tests for _text_from_contents helper."""
|
||||
|
||||
def test_empty_text_skipped(self):
|
||||
"""Empty string text content is skipped."""
|
||||
contents = [Content.from_text(text="")]
|
||||
assert _text_from_contents(contents) is None
|
||||
|
||||
def test_non_text_content_skipped(self):
|
||||
"""Non-text content types are skipped."""
|
||||
contents = [Content.from_function_call(call_id="c1", name="fn", arguments="{}")]
|
||||
assert _text_from_contents(contents) is None
|
||||
|
||||
|
||||
class TestWorkflowInterruptEventValue:
|
||||
"""Tests for _workflow_interrupt_event_value helper."""
|
||||
|
||||
def test_none_data(self):
|
||||
"""None data returns None."""
|
||||
assert _workflow_interrupt_event_value({"data": None}) is None
|
||||
|
||||
def test_string_data(self):
|
||||
"""String data returned directly."""
|
||||
assert _workflow_interrupt_event_value({"data": "text"}) == "text"
|
||||
|
||||
def test_dict_data_serialized(self):
|
||||
"""Dict data is JSON-serialized."""
|
||||
result = _workflow_interrupt_event_value({"data": {"key": "val"}})
|
||||
assert json.loads(result) == {"key": "val"}
|
||||
|
||||
|
||||
class TestWorkflowPayloadToContents:
|
||||
"""Tests for _workflow_payload_to_contents helper."""
|
||||
|
||||
def test_none_payload(self):
|
||||
"""None payload returns None."""
|
||||
assert _workflow_payload_to_contents(None) is None
|
||||
|
||||
def test_non_assistant_message(self):
|
||||
"""User Message returns None."""
|
||||
msg = Message(role="user", contents=[Content.from_text(text="hi")])
|
||||
assert _workflow_payload_to_contents(msg) is None
|
||||
|
||||
def test_agent_response_update_non_assistant(self):
|
||||
"""AgentResponseUpdate with user role returns None."""
|
||||
update = AgentResponseUpdate(contents=[Content.from_text(text="hi")], role="user")
|
||||
assert _workflow_payload_to_contents(update) is None
|
||||
|
||||
def test_agent_response_update_none_role(self):
|
||||
"""AgentResponseUpdate with None role returns None."""
|
||||
update = AgentResponseUpdate(contents=[Content.from_text(text="hi")], role=None)
|
||||
assert _workflow_payload_to_contents(update) is None
|
||||
|
||||
def test_list_with_none_item(self):
|
||||
"""List containing None causes None return."""
|
||||
result = _workflow_payload_to_contents([Content.from_text(text="hi"), None])
|
||||
assert result is None
|
||||
|
||||
def test_empty_list(self):
|
||||
"""Empty list returns None."""
|
||||
assert _workflow_payload_to_contents([]) is None
|
||||
|
||||
def test_string_payload(self):
|
||||
"""String payload creates text content."""
|
||||
result = _workflow_payload_to_contents("hello")
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
|
||||
def test_content_payload(self):
|
||||
"""Single Content returned as list."""
|
||||
content = Content.from_text(text="test")
|
||||
result = _workflow_payload_to_contents(content)
|
||||
assert result == [content]
|
||||
|
||||
def test_unknown_type_returns_none(self):
|
||||
"""Unknown types return None."""
|
||||
assert _workflow_payload_to_contents(42) is None
|
||||
|
||||
|
||||
class TestCustomEventValue:
|
||||
"""Tests for _custom_event_value helper."""
|
||||
|
||||
def test_event_with_data(self):
|
||||
"""Event with .data attribute returns data."""
|
||||
event = SimpleNamespace(type="custom", data={"progress": 50})
|
||||
assert _custom_event_value(event) == {"progress": 50}
|
||||
|
||||
def test_event_without_data(self):
|
||||
"""Event without .data returns filtered custom fields."""
|
||||
event = SimpleNamespace(type="custom", data=None, custom_field="value")
|
||||
result = _custom_event_value(event)
|
||||
assert result == {"custom_field": "value"}
|
||||
|
||||
def test_event_with_no_custom_fields(self):
|
||||
"""Event with only base fields returns None."""
|
||||
event = SimpleNamespace(type="custom", data=None)
|
||||
result = _custom_event_value(event)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestDetailsMessage:
|
||||
"""Tests for _details_message helper."""
|
||||
|
||||
def test_none_details(self):
|
||||
"""None details returns default message."""
|
||||
assert _details_message(None) == "Workflow execution failed."
|
||||
|
||||
def test_details_with_message(self):
|
||||
"""Details with .message attribute uses it."""
|
||||
details = SimpleNamespace(message="Custom error")
|
||||
assert _details_message(details) == "Custom error"
|
||||
|
||||
def test_details_with_empty_message(self):
|
||||
"""Details with empty .message falls back to str()."""
|
||||
details = SimpleNamespace(message="")
|
||||
result = _details_message(details)
|
||||
assert "message=" in result or result == str(details)
|
||||
|
||||
def test_details_without_message(self):
|
||||
"""Details without .message uses str()."""
|
||||
assert _details_message("plain string") == "plain string"
|
||||
|
||||
|
||||
class TestDetailsCode:
|
||||
"""Tests for _details_code helper."""
|
||||
|
||||
def test_none_details(self):
|
||||
"""None details returns None."""
|
||||
assert _details_code(None) is None
|
||||
|
||||
def test_details_with_error_type(self):
|
||||
"""Details with .error_type returns it."""
|
||||
details = SimpleNamespace(error_type="ValueError")
|
||||
assert _details_code(details) == "ValueError"
|
||||
|
||||
def test_details_with_empty_error_type(self):
|
||||
"""Details with empty .error_type returns None."""
|
||||
details = SimpleNamespace(error_type="")
|
||||
assert _details_code(details) is None
|
||||
|
||||
def test_details_without_error_type(self):
|
||||
"""Details without .error_type returns None."""
|
||||
details = SimpleNamespace(message="err")
|
||||
assert _details_code(details) is None
|
||||
|
||||
|
||||
# ── Stream integration tests ──
|
||||
|
||||
|
||||
async def test_workflow_run_available_interrupts_logged():
|
||||
"""available_interrupts in input data should be logged without errors."""
|
||||
|
||||
@executor(id="noop")
|
||||
async def noop(message: Any, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=noop).build()
|
||||
input_data = {
|
||||
"messages": [{"role": "user", "content": "go"}],
|
||||
"available_interrupts": [{"id": "req_1", "type": "request_info"}],
|
||||
}
|
||||
|
||||
events = [event async for event in run_workflow_stream(input_data, workflow)]
|
||||
event_types = [event.type for event in events]
|
||||
assert "RUN_STARTED" in event_types
|
||||
assert "RUN_FINISHED" in event_types
|
||||
assert "RUN_ERROR" not in event_types
|
||||
|
||||
|
||||
async def test_workflow_run_failed_event():
|
||||
"""Workflow 'failed' event should produce RUN_ERROR."""
|
||||
|
||||
class FailingWorkflow:
|
||||
def run(self, **kwargs: Any):
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
yield SimpleNamespace(
|
||||
type="failed", details=SimpleNamespace(message="it broke", error_type="TestError")
|
||||
)
|
||||
|
||||
return _stream()
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, FailingWorkflow())
|
||||
)
|
||||
]
|
||||
|
||||
event_types = [event.type for event in events]
|
||||
assert "RUN_STARTED" in event_types
|
||||
assert "RUN_ERROR" in event_types
|
||||
error_event = next(e for e in events if e.type == "RUN_ERROR")
|
||||
assert error_event.message == "it broke"
|
||||
assert error_event.code == "TestError"
|
||||
|
||||
|
||||
async def test_workflow_run_status_enum_state():
|
||||
"""Status events with enum-like state should be handled."""
|
||||
|
||||
class WorkflowState(Enum):
|
||||
IDLE = "idle"
|
||||
|
||||
class StatusWorkflow:
|
||||
def run(self, **kwargs: Any):
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
yield SimpleNamespace(type="status", state=WorkflowState.IDLE)
|
||||
|
||||
return _stream()
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, StatusWorkflow())
|
||||
)
|
||||
]
|
||||
|
||||
event_types = [event.type for event in events]
|
||||
assert "RUN_STARTED" in event_types
|
||||
assert "RUN_FINISHED" in event_types
|
||||
|
||||
|
||||
async def test_workflow_run_executor_invoked_drains_text():
|
||||
"""executor_invoked should drain any open text message."""
|
||||
|
||||
class ExecutorWorkflow:
|
||||
def run(self, **kwargs: Any):
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
yield SimpleNamespace(type="output", data="Hello world")
|
||||
yield SimpleNamespace(type="executor_invoked", executor_id="agent_1", data=None)
|
||||
yield SimpleNamespace(type="executor_completed", executor_id="agent_1", data=None)
|
||||
|
||||
return _stream()
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, ExecutorWorkflow())
|
||||
)
|
||||
]
|
||||
|
||||
# Text should end before executor step starts
|
||||
text_end_idx = next(i for i, e in enumerate(events) if e.type == "TEXT_MESSAGE_END")
|
||||
step_start_idx = next(i for i, e in enumerate(events) if e.type == "STEP_STARTED")
|
||||
assert text_end_idx < step_start_idx
|
||||
|
||||
|
||||
async def test_workflow_run_executor_failed_event():
|
||||
"""executor_failed event should emit activity snapshot with failed status."""
|
||||
|
||||
class ExecutorFailWorkflow:
|
||||
def run(self, **kwargs: Any):
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
yield SimpleNamespace(
|
||||
type="executor_failed",
|
||||
executor_id="agent_1",
|
||||
details=SimpleNamespace(message="agent crashed"),
|
||||
)
|
||||
|
||||
return _stream()
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, ExecutorFailWorkflow())
|
||||
)
|
||||
]
|
||||
|
||||
activity = [e for e in events if e.type == "ACTIVITY_SNAPSHOT"]
|
||||
assert len(activity) == 1
|
||||
assert activity[0].content["status"] == "failed"
|
||||
assert activity[0].content["details"]["message"] == "agent crashed"
|
||||
|
||||
|
||||
async def test_workflow_run_list_base_event_output():
|
||||
"""Workflow yielding list of BaseEvent objects should emit each."""
|
||||
|
||||
class ListEventWorkflow:
|
||||
def run(self, **kwargs: Any):
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
yield SimpleNamespace(
|
||||
type="output",
|
||||
data=[
|
||||
StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot={"a": 1}),
|
||||
StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot={"b": 2}),
|
||||
],
|
||||
)
|
||||
|
||||
return _stream()
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, ListEventWorkflow())
|
||||
)
|
||||
]
|
||||
|
||||
snapshots = [e for e in events if e.type == "STATE_SNAPSHOT"]
|
||||
assert len(snapshots) == 2
|
||||
assert snapshots[0].snapshot == {"a": 1}
|
||||
assert snapshots[1].snapshot == {"b": 2}
|
||||
|
||||
|
||||
async def test_workflow_run_late_run_started():
|
||||
"""If no events emitted, RUN_STARTED still emitted at end."""
|
||||
|
||||
class EmptyWorkflow:
|
||||
def run(self, **kwargs: Any):
|
||||
async def _stream():
|
||||
return
|
||||
yield # pragma: no cover
|
||||
|
||||
return _stream()
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, EmptyWorkflow())
|
||||
)
|
||||
]
|
||||
|
||||
assert events[0].type == "RUN_STARTED"
|
||||
assert events[-1].type == "RUN_FINISHED"
|
||||
|
||||
|
||||
async def test_workflow_run_last_assistant_text_update():
|
||||
"""Text outputs update last_assistant_text for dedup tracking."""
|
||||
|
||||
class DualTextWorkflow:
|
||||
def run(self, **kwargs: Any):
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
yield SimpleNamespace(type="output", data="First text")
|
||||
yield SimpleNamespace(type="output", data="Second text")
|
||||
|
||||
return _stream()
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, DualTextWorkflow())
|
||||
)
|
||||
]
|
||||
|
||||
text_deltas = [e.delta for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
|
||||
assert "First text" in text_deltas
|
||||
assert "Second text" in text_deltas
|
||||
|
||||
|
||||
async def test_workflow_run_superstep_events():
|
||||
"""superstep_started/completed emit Step events with iteration."""
|
||||
|
||||
class SuperstepWorkflow:
|
||||
def run(self, **kwargs: Any):
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
yield SimpleNamespace(type="superstep_started", iteration=1)
|
||||
yield SimpleNamespace(type="superstep_completed", iteration=1)
|
||||
|
||||
return _stream()
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, SuperstepWorkflow())
|
||||
)
|
||||
]
|
||||
|
||||
step_started = [e for e in events if e.type == "STEP_STARTED"]
|
||||
step_finished = [e for e in events if e.type == "STEP_FINISHED"]
|
||||
assert len(step_started) == 1
|
||||
assert step_started[0].step_name == "superstep:1"
|
||||
assert len(step_finished) == 1
|
||||
assert step_finished[0].step_name == "superstep:1"
|
||||
|
||||
|
||||
async def test_workflow_run_non_terminal_status_emits_custom():
|
||||
"""Non-terminal status events emit custom events."""
|
||||
|
||||
class StatusWorkflow:
|
||||
def run(self, **kwargs: Any):
|
||||
async def _stream():
|
||||
yield SimpleNamespace(type="started")
|
||||
yield SimpleNamespace(type="status", state="running")
|
||||
|
||||
return _stream()
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in run_workflow_stream(
|
||||
{"messages": [{"role": "user", "content": "go"}]}, cast(Any, StatusWorkflow())
|
||||
)
|
||||
]
|
||||
|
||||
custom = [e for e in events if e.type == "CUSTOM" and e.name == "status"]
|
||||
assert len(custom) == 1
|
||||
assert custom[0].value == {"state": "running"}
|
||||
|
||||
@@ -1461,8 +1461,9 @@ class AzureAIAgentClient(
|
||||
|
||||
Keyword Args:
|
||||
id: The unique identifier for the agent. Will be created automatically if not provided.
|
||||
name: The name of the agent.
|
||||
description: A brief description of the agent's purpose.
|
||||
name: The name of the agent. Defaults to the client's ``agent_name`` when None.
|
||||
description: A brief description of the agent's purpose. Defaults to the client's
|
||||
``agent_description`` when None.
|
||||
instructions: Optional instructions for the agent.
|
||||
tools: The tools to use for the request.
|
||||
default_options: A TypedDict containing chat options.
|
||||
@@ -1475,8 +1476,8 @@ class AzureAIAgentClient(
|
||||
"""
|
||||
return super().as_agent(
|
||||
id=id,
|
||||
name=name,
|
||||
description=description,
|
||||
name=self.agent_name if name is None else name,
|
||||
description=self.agent_description if description is None else description,
|
||||
instructions=instructions,
|
||||
tools=tools,
|
||||
default_options=default_options,
|
||||
|
||||
@@ -1189,8 +1189,9 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
|
||||
Keyword Args:
|
||||
id: The unique identifier for the agent. Will be created automatically if not provided.
|
||||
name: The name of the agent.
|
||||
description: A brief description of the agent's purpose.
|
||||
name: The name of the agent. Defaults to the client's ``agent_name`` when None.
|
||||
description: A brief description of the agent's purpose. Defaults to the client's
|
||||
``agent_description`` when None.
|
||||
instructions: Optional instructions for the agent.
|
||||
tools: The tools to use for the request.
|
||||
default_options: A TypedDict containing chat options.
|
||||
@@ -1203,8 +1204,8 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
"""
|
||||
return super().as_agent(
|
||||
id=id,
|
||||
name=name,
|
||||
description=description,
|
||||
name=self.agent_name if name is None else name,
|
||||
description=self.agent_description if description is None else description,
|
||||
instructions=instructions,
|
||||
tools=tools,
|
||||
default_options=default_options,
|
||||
|
||||
@@ -509,6 +509,48 @@ async def test_azure_ai_chat_client_prepare_options_merges_instructions_from_mes
|
||||
assert "concise" in instructions_text.lower()
|
||||
|
||||
|
||||
def test_as_agent_uses_client_agent_name_as_default(mock_agents_client: MagicMock) -> None:
|
||||
"""Test that as_agent() defaults Agent.name to client.agent_name when name is not provided."""
|
||||
client = create_test_azure_ai_chat_client(mock_agents_client, agent_name="my_agent")
|
||||
client.agent_description = "my description"
|
||||
|
||||
agent = client.as_agent(instructions="You are helpful.")
|
||||
|
||||
assert agent.name == "my_agent"
|
||||
assert agent.description == "my description"
|
||||
|
||||
|
||||
def test_as_agent_explicit_name_overrides_client_agent_name(mock_agents_client: MagicMock) -> None:
|
||||
"""Test that an explicit name passed to as_agent() takes precedence over client.agent_name."""
|
||||
client = create_test_azure_ai_chat_client(mock_agents_client, agent_name="client_name")
|
||||
client.agent_description = "client description"
|
||||
|
||||
agent = client.as_agent(name="explicit_name", description="explicit description", instructions="You are helpful.")
|
||||
|
||||
assert agent.name == "explicit_name"
|
||||
assert agent.description == "explicit description"
|
||||
|
||||
|
||||
def test_as_agent_no_name_anywhere(mock_agents_client: MagicMock) -> None:
|
||||
"""Test that Agent.name is None when neither as_agent name nor client.agent_name is provided."""
|
||||
client = create_test_azure_ai_chat_client(mock_agents_client)
|
||||
|
||||
agent = client.as_agent(instructions="You are helpful.")
|
||||
|
||||
assert agent.name is None
|
||||
|
||||
|
||||
def test_as_agent_empty_string_preserves_explicit_value(mock_agents_client: MagicMock) -> None:
|
||||
"""Test that empty-string name/description are preserved and do not fall back to client defaults."""
|
||||
client = create_test_azure_ai_chat_client(mock_agents_client, agent_name="client_name")
|
||||
client.agent_description = "client description"
|
||||
|
||||
agent = client.as_agent(name="", description="", instructions="You are helpful.")
|
||||
|
||||
assert agent.name == ""
|
||||
assert agent.description == ""
|
||||
|
||||
|
||||
async def test_azure_ai_chat_client_inner_get_response(mock_agents_client: MagicMock) -> None:
|
||||
"""Test _inner_get_response method."""
|
||||
client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
@@ -546,6 +546,48 @@ def test_update_agent_name_and_description(mock_project_client: MagicMock) -> No
|
||||
mock_update.assert_called_once_with(None)
|
||||
|
||||
|
||||
def test_as_agent_uses_client_agent_name_as_default(mock_project_client: MagicMock) -> None:
|
||||
"""Test that as_agent() defaults Agent.name to client.agent_name when name is not provided."""
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="my_agent")
|
||||
client.agent_description = "my description"
|
||||
|
||||
agent = client.as_agent(instructions="You are helpful.")
|
||||
|
||||
assert agent.name == "my_agent"
|
||||
assert agent.description == "my description"
|
||||
|
||||
|
||||
def test_as_agent_explicit_name_overrides_client_agent_name(mock_project_client: MagicMock) -> None:
|
||||
"""Test that an explicit name passed to as_agent() takes precedence over client.agent_name."""
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="client_name")
|
||||
client.agent_description = "client description"
|
||||
|
||||
agent = client.as_agent(name="explicit_name", description="explicit description", instructions="You are helpful.")
|
||||
|
||||
assert agent.name == "explicit_name"
|
||||
assert agent.description == "explicit description"
|
||||
|
||||
|
||||
def test_as_agent_no_name_anywhere(mock_project_client: MagicMock) -> None:
|
||||
"""Test that Agent.name is None when neither as_agent name nor client.agent_name is provided."""
|
||||
client = create_test_azure_ai_client(mock_project_client)
|
||||
|
||||
agent = client.as_agent(instructions="You are helpful.")
|
||||
|
||||
assert agent.name is None
|
||||
|
||||
|
||||
def test_as_agent_empty_string_preserves_explicit_value(mock_project_client: MagicMock) -> None:
|
||||
"""Test that empty-string name/description are preserved and do not fall back to client defaults."""
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="client_name")
|
||||
client.agent_description = "client description"
|
||||
|
||||
agent = client.as_agent(name="", description="", instructions="You are helpful.")
|
||||
|
||||
assert agent.name == ""
|
||||
assert agent.description == ""
|
||||
|
||||
|
||||
async def test_async_context_manager(mock_project_client: MagicMock) -> None:
|
||||
"""Test async context manager functionality."""
|
||||
client = create_test_azure_ai_client(mock_project_client, should_close_client=True)
|
||||
|
||||
@@ -901,7 +901,11 @@ class MCPTool:
|
||||
for attempt in range(2):
|
||||
try:
|
||||
result = await self.session.call_tool(tool_name, arguments=filtered_kwargs, meta=otel_meta) # type: ignore
|
||||
if result.isError:
|
||||
raise ToolExecutionException(parser(result))
|
||||
return parser(result)
|
||||
except ToolExecutionException:
|
||||
raise
|
||||
except ClosedResourceError as cl_ex:
|
||||
if attempt == 0:
|
||||
# First attempt failed, try reconnecting
|
||||
|
||||
@@ -563,10 +563,10 @@ class SkillsProvider(BaseContextProvider):
|
||||
try:
|
||||
if inspect.iscoroutinefunction(resource.function):
|
||||
result = (
|
||||
await resource.function(**kwargs) if resource._accepts_kwargs else await resource.function()
|
||||
await resource.function(**kwargs) if resource._accepts_kwargs else await resource.function() # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
else:
|
||||
result = resource.function(**kwargs) if resource._accepts_kwargs else resource.function()
|
||||
result = resource.function(**kwargs) if resource._accepts_kwargs else resource.function() # pyright: ignore[reportPrivateUsage]
|
||||
return str(result)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to read resource '%s' from skill '%s'", resource_name, skill_name)
|
||||
|
||||
@@ -14,6 +14,8 @@ from pydantic import AnyUrl, BaseModel
|
||||
|
||||
from agent_framework import (
|
||||
Content,
|
||||
FunctionInvocationContext,
|
||||
FunctionMiddleware,
|
||||
MCPStdioTool,
|
||||
MCPStreamableHTTPTool,
|
||||
MCPWebsocketTool,
|
||||
@@ -30,6 +32,7 @@ from agent_framework._mcp import (
|
||||
_prepare_message_for_mcp,
|
||||
logger,
|
||||
)
|
||||
from agent_framework._middleware import FunctionMiddlewarePipeline
|
||||
from agent_framework.exceptions import ToolException, ToolExecutionException
|
||||
|
||||
# Integration test skip condition
|
||||
@@ -898,6 +901,147 @@ async def test_local_mcp_server_function_execution_error():
|
||||
await func.invoke(param="test_value")
|
||||
|
||||
|
||||
async def test_mcp_tool_call_tool_raises_on_is_error():
|
||||
"""Test that call_tool raises ToolExecutionException when MCP returns isError=True."""
|
||||
|
||||
class TestServer(MCPTool):
|
||||
async def connect(self):
|
||||
self.session = Mock(spec=ClientSession)
|
||||
self.session.list_tools = AsyncMock(
|
||||
return_value=types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {"param": {"type": "string"}},
|
||||
"required": ["param"],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
self.session.call_tool = AsyncMock(
|
||||
return_value=types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Something went wrong")],
|
||||
isError=True,
|
||||
)
|
||||
)
|
||||
|
||||
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
|
||||
return None
|
||||
|
||||
server = TestServer(name="test_server")
|
||||
async with server:
|
||||
await server.load_tools()
|
||||
func = server.functions[0]
|
||||
|
||||
with pytest.raises(ToolExecutionException, match="Something went wrong"):
|
||||
await func.invoke(param="test_value")
|
||||
|
||||
|
||||
async def test_mcp_tool_call_tool_succeeds_when_is_error_false():
|
||||
"""Test that call_tool returns normally when MCP returns isError=False."""
|
||||
|
||||
class TestServer(MCPTool):
|
||||
async def connect(self):
|
||||
self.session = Mock(spec=ClientSession)
|
||||
self.session.list_tools = AsyncMock(
|
||||
return_value=types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {"param": {"type": "string"}},
|
||||
"required": ["param"],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
self.session.call_tool = AsyncMock(
|
||||
return_value=types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Success")],
|
||||
isError=False,
|
||||
)
|
||||
)
|
||||
|
||||
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
|
||||
return None
|
||||
|
||||
server = TestServer(name="test_server")
|
||||
async with server:
|
||||
await server.load_tools()
|
||||
func = server.functions[0]
|
||||
result = await func.invoke(param="test_value")
|
||||
assert result == "Success"
|
||||
|
||||
|
||||
async def test_mcp_tool_is_error_propagates_through_function_middleware():
|
||||
"""Test that MCP isError=True propagates as ToolExecutionException through function middleware."""
|
||||
error_seen_in_middleware = False
|
||||
|
||||
class ErrorCheckMiddleware(FunctionMiddleware):
|
||||
async def process(self, context: FunctionInvocationContext, call_next):
|
||||
nonlocal error_seen_in_middleware
|
||||
try:
|
||||
await call_next()
|
||||
except ToolExecutionException:
|
||||
error_seen_in_middleware = True
|
||||
raise
|
||||
|
||||
class TestServer(MCPTool):
|
||||
async def connect(self):
|
||||
self.session = Mock(spec=ClientSession)
|
||||
self.session.list_tools = AsyncMock(
|
||||
return_value=types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {"param": {"type": "string"}},
|
||||
"required": ["param"],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
self.session.call_tool = AsyncMock(
|
||||
return_value=types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="MCP error occurred")],
|
||||
isError=True,
|
||||
)
|
||||
)
|
||||
|
||||
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
|
||||
return None
|
||||
|
||||
server = TestServer(name="test_server")
|
||||
async with server:
|
||||
await server.load_tools()
|
||||
func = server.functions[0]
|
||||
|
||||
middleware_pipeline = FunctionMiddlewarePipeline(ErrorCheckMiddleware())
|
||||
|
||||
middleware_context = FunctionInvocationContext(
|
||||
function=func,
|
||||
arguments={"param": "test_value"},
|
||||
)
|
||||
|
||||
with pytest.raises(ToolExecutionException, match="MCP error occurred"):
|
||||
await middleware_pipeline.execute(
|
||||
middleware_context,
|
||||
lambda ctx: func.invoke(arguments=ctx.arguments),
|
||||
)
|
||||
|
||||
assert error_seen_in_middleware, "Middleware should have seen the ToolExecutionException"
|
||||
|
||||
|
||||
async def test_local_mcp_server_prompt_execution():
|
||||
"""Test prompt execution through MCP server."""
|
||||
|
||||
@@ -2098,7 +2242,7 @@ async def test_mcp_tool_connection_properly_invalidated_after_closed_resource_er
|
||||
tool._tools_loaded = True
|
||||
|
||||
# First call should work - connection is valid
|
||||
mock_session.call_tool.return_value = MagicMock(content=[])
|
||||
mock_session.call_tool.return_value = types.CallToolResult(content=[])
|
||||
result = await tool.call_tool("test_tool", arg1="value1")
|
||||
assert result is not None
|
||||
|
||||
@@ -2111,7 +2255,7 @@ async def test_mcp_tool_connection_properly_invalidated_after_closed_resource_er
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise ClosedResourceError
|
||||
return MagicMock(content=[])
|
||||
return types.CallToolResult(content=[])
|
||||
|
||||
mock_session.call_tool = call_tool_with_error
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from agent_framework.exceptions import (
|
||||
IntegrationInvalidRequestException,
|
||||
)
|
||||
from redisvl.index import AsyncSearchIndex
|
||||
from redisvl.query import HybridQuery, TextQuery
|
||||
from redisvl.query import AggregateHybridQuery, TextQuery
|
||||
from redisvl.query.filter import FilterExpression, Tag
|
||||
from redisvl.utils.token_escaper import TokenEscaper
|
||||
from redisvl.utils.vectorize import BaseVectorizer
|
||||
@@ -341,7 +341,7 @@ class RedisContextProvider(BaseContextProvider):
|
||||
filter_expression: Any | None = None,
|
||||
return_fields: list[str] | None = None,
|
||||
num_results: int = 10,
|
||||
linear_alpha: float = 0.7,
|
||||
alpha: float = 0.7,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Runs a text or hybrid vector-text search with optional filters."""
|
||||
await self._ensure_index()
|
||||
@@ -371,14 +371,14 @@ class RedisContextProvider(BaseContextProvider):
|
||||
try:
|
||||
if self.redis_vectorizer and self.vector_field_name:
|
||||
vector = await self.redis_vectorizer.aembed(q) # pyright: ignore[reportUnknownMemberType]
|
||||
query = HybridQuery(
|
||||
query = AggregateHybridQuery(
|
||||
text=q,
|
||||
text_field_name="content",
|
||||
vector=vector,
|
||||
vector_field_name=self.vector_field_name,
|
||||
text_scorer=text_scorer,
|
||||
filter_expression=combined_filter,
|
||||
linear_alpha=linear_alpha,
|
||||
alpha=alpha,
|
||||
dtype=self.redis_vectorizer.dtype, # pyright: ignore[reportUnknownMemberType]
|
||||
num_results=num_results,
|
||||
return_fields=return_fields,
|
||||
|
||||
@@ -271,6 +271,44 @@ class TestRedisContextProviderContextManager:
|
||||
assert p is provider
|
||||
|
||||
|
||||
class TestRedisContextProviderHybridQuery:
|
||||
"""Test for AggregateHybridQuery parameter compatibility with redisvl 0.14.0."""
|
||||
|
||||
async def test_aggregate_hybrid_query_uses_alpha(
|
||||
self,
|
||||
mock_index: AsyncMock,
|
||||
patch_index_from_dict: MagicMock, # noqa: ARG002 - fixture modifies behavior via side effects
|
||||
):
|
||||
"""Ensure AggregateHybridQuery is called with alpha parameter."""
|
||||
from redisvl.utils.vectorize import BaseVectorizer
|
||||
|
||||
# Create a mock vectorizer that inherits from BaseVectorizer
|
||||
mock_vectorizer = MagicMock(spec=BaseVectorizer)
|
||||
mock_vectorizer.dims = 128
|
||||
mock_vectorizer.dtype = "float32"
|
||||
mock_vectorizer.aembed = AsyncMock(return_value=[0.1] * 128)
|
||||
|
||||
mock_index.query = AsyncMock(return_value=[{"content": "test result"}])
|
||||
|
||||
provider = RedisContextProvider(
|
||||
source_id="ctx",
|
||||
user_id="u1",
|
||||
redis_vectorizer=mock_vectorizer,
|
||||
vector_field_name="embedding",
|
||||
)
|
||||
|
||||
# Call _redis_search with custom alpha
|
||||
with patch("agent_framework_redis._context_provider.AggregateHybridQuery") as mock_hybrid_query:
|
||||
mock_hybrid_query.return_value = MagicMock()
|
||||
await provider._redis_search(text="test query", alpha=0.5)
|
||||
|
||||
# Verify AggregateHybridQuery was called with alpha parameter
|
||||
mock_hybrid_query.assert_called_once()
|
||||
call_kwargs = mock_hybrid_query.call_args.kwargs
|
||||
assert "alpha" in call_kwargs
|
||||
assert call_kwargs["alpha"] == 0.5
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RedisHistoryProvider tests
|
||||
# ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user