mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-featurecollections-messagestore
This commit is contained in:
@@ -124,6 +124,16 @@ jobs:
|
||||
popd
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
# Start Cosmos DB Emulator for Cosmos-based unit tests (only on Windows)
|
||||
- name: Start Azure Cosmos DB Emulator
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Launching Azure Cosmos DB Emulator"
|
||||
Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator"
|
||||
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
|
||||
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Run Unit Tests
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -143,6 +153,10 @@ jobs:
|
||||
echo "Skipping $project - does not support target framework ${{ matrix.targetFramework }} (supports: $target_frameworks)"
|
||||
fi
|
||||
done
|
||||
env:
|
||||
# Cosmos DB Emulator connection settings
|
||||
COSMOSDB_ENDPOINT: https://localhost:8081
|
||||
COSMOSDB_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==
|
||||
|
||||
- name: Log event name and matrix integration-tests
|
||||
shell: bash
|
||||
@@ -181,6 +195,9 @@ jobs:
|
||||
fi
|
||||
done
|
||||
env:
|
||||
# Cosmos DB Emulator connection settings
|
||||
COSMOSDB_ENDPOINT: https://localhost:8081
|
||||
COSMOSDB_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==
|
||||
# OpenAI Models
|
||||
OpenAI__ApiKey: ${{ secrets.OPENAI__APIKEY }}
|
||||
OpenAI__ChatModelId: ${{ vars.OPENAI__CHATMODELID }}
|
||||
@@ -200,7 +217,7 @@ jobs:
|
||||
# Generate test reports and check coverage
|
||||
- name: Generate test reports
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@5.4.18
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.0
|
||||
with:
|
||||
reports: "./TestResults/Coverage/**/coverage.cobertura.xml"
|
||||
targetdir: "./TestResults/Reports"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoWarn>$(NoWarn);NU5128</NoWarn>
|
||||
<NoWarn>$(NoWarn);NU5128;CS8002</NoWarn>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<TargetFrameworksCore>net10.0;net9.0;net8.0</TargetFrameworksCore>
|
||||
<TargetFrameworks>$(TargetFrameworksCore);netstandard2.0;net472</TargetFrameworks>
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.5.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.17.0" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
|
||||
<!-- Microsoft.Azure.* -->
|
||||
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.54.0" />
|
||||
<!-- Newtonsoft.Json -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- System.* -->
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
@@ -129,6 +133,7 @@
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.abstractions" Version="2.0.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3" />
|
||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
|
||||
<PackageVersion Include="xretry" Version="1.9.0" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<!-- Symbols -->
|
||||
|
||||
@@ -343,6 +343,7 @@
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.CosmosNoSql/Microsoft.Agents.AI.CosmosNoSql.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Anthropic/Microsoft.Agents.AI.Anthropic.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
@@ -383,6 +384,7 @@
|
||||
<Folder Name="/Tests/UnitTests/">
|
||||
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Anthropic.UnitTests/Microsoft.Agents.AI.Anthropic.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj" />
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.0.0</VersionPrefix>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251114.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251114.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.251114.1</GitTag>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251125.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251125.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.251125.1</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+10
@@ -1,3 +1,13 @@
|
||||
# Classic Foundry Agents
|
||||
|
||||
This sample demonstrates how to create an agent using the classic Foundry Agents experience.
|
||||
|
||||
# Classic vs New Foundry Agents
|
||||
|
||||
Below is a comparison between the classic and new Foundry Agents approaches:
|
||||
|
||||
[Migration Guide](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/migrate?view=foundry)
|
||||
|
||||
# Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
@@ -10,35 +10,34 @@ using Microsoft.Agents.AI;
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
const string JokerInstructions = "You are good at telling jokes.";
|
||||
const string JokerName = "JokerAgent";
|
||||
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
var aiProjectClient = new AIProjectClient(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
// Define the agent you want to create. (Prompt Agent in this case)
|
||||
var agentVersionCreationOptions = new AgentVersionCreationOptions(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions });
|
||||
var agentVersionCreationOptions = new AgentVersionCreationOptions(new PromptAgentDefinition(model: deploymentName) { Instructions = "You are good at telling jokes." });
|
||||
// Azure.AI.Agents SDK creates and manages agent by name and versions.
|
||||
// You can create a server side agent version with the Azure.AI.Agents SDK client below.
|
||||
var agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options: agentVersionCreationOptions);
|
||||
var createdAgentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options: agentVersionCreationOptions);
|
||||
|
||||
// Note:
|
||||
// agentVersion.Id = "<agentName>:<versionNumber>",
|
||||
// agentVersion.Version = <versionNumber>,
|
||||
// agentVersion.Name = <agentName>
|
||||
|
||||
// You can retrieve an AIAgent for a already created server side agent version.
|
||||
AIAgent jokerAgentV1 = aiProjectClient.GetAIAgent(agentVersion);
|
||||
// You can retrieve an AIAgent for an already created server side agent version.
|
||||
AIAgent existingJokerAgent = aiProjectClient.GetAIAgent(createdAgentVersion);
|
||||
|
||||
// You can also create another AIAgent version (V2) by providing the same name with a different definition.
|
||||
AIAgent jokerAgentV2 = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructions + "V2");
|
||||
// You can also create another AIAgent version by providing the same name with a different definition.
|
||||
AIAgent newJokerAgent = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes.");
|
||||
|
||||
// You can also get the AIAgent latest version just providing its name.
|
||||
AIAgent jokerAgentLatest = aiProjectClient.GetAIAgent(name: JokerName);
|
||||
var latestVersion = jokerAgentLatest.GetService<AgentVersion>()!;
|
||||
var latestAgentVersion = jokerAgentLatest.GetService<AgentVersion>()!;
|
||||
|
||||
// The AIAgent version can be accessed via the GetService method.
|
||||
Console.WriteLine($"Latest agent version id: {latestVersion.Id}");
|
||||
Console.WriteLine($"Latest agent version id: {latestAgentVersion.Id}");
|
||||
|
||||
// Once you have the AIAgent, you can invoke it like any other AIAgent.
|
||||
AgentThread thread = jokerAgentLatest.GetNewThread();
|
||||
@@ -47,5 +46,5 @@ Console.WriteLine(await jokerAgentLatest.RunAsync("Tell me a joke about a pirate
|
||||
// This will use the same thread to continue the conversation.
|
||||
Console.WriteLine(await jokerAgentLatest.RunAsync("Now tell me a joke about a cat and a dog using last joke as the anchor.", thread));
|
||||
|
||||
// Cleanup by agent name removes both agent versions created (jokerAgentV1 + jokerAgentV2).
|
||||
aiProjectClient.Agents.DeleteAgent(jokerAgentV1.Name);
|
||||
// Cleanup by agent name removes both agent versions created.
|
||||
aiProjectClient.Agents.DeleteAgent(existingJokerAgent.Name);
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
# New Foundry Agents
|
||||
|
||||
This sample demonstrates how to create an agent using the new Foundry Agents experience.
|
||||
|
||||
# Classic vs New Foundry Agents
|
||||
|
||||
Below is a comparison between the classic and new Foundry Agents approaches:
|
||||
|
||||
[Migration Guide](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/migrate?view=foundry)
|
||||
|
||||
# Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
+9
-11
@@ -11,19 +11,17 @@ using Microsoft.Extensions.AI;
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
const string JokerInstructionsV1 = "You are good at telling jokes.";
|
||||
const string JokerInstructionsV2 = "You are extremely hilarious at telling jokes.";
|
||||
const string JokerName = "JokerAgent";
|
||||
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
// Define the agent you want to create. (Prompt Agent in this case)
|
||||
AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructionsV1 });
|
||||
AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = "You are good at telling jokes." });
|
||||
|
||||
// Azure.AI.Agents SDK creates and manages agent by name and versions.
|
||||
// You can create a server side agent version with the Azure.AI.Agents SDK client below.
|
||||
AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options);
|
||||
AgentVersion createdAgentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options);
|
||||
|
||||
// Note:
|
||||
// agentVersion.Id = "<agentName>:<versionNumber>",
|
||||
@@ -31,20 +29,20 @@ AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName:
|
||||
// agentVersion.Name = <agentName>
|
||||
|
||||
// You can retrieve an AIAgent for an already created server side agent version.
|
||||
AIAgent jokerAgentV1 = aiProjectClient.GetAIAgent(agentVersion);
|
||||
AIAgent existingJokerAgent = aiProjectClient.GetAIAgent(createdAgentVersion);
|
||||
|
||||
// You can also create another AIAgent version (V2) by providing the same name with a different definition/instruction.
|
||||
AIAgent jokerAgentV2 = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructionsV2);
|
||||
// You can also create another AIAgent version by providing the same name with a different definition/instruction.
|
||||
AIAgent newJokerAgent = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes.");
|
||||
|
||||
// You can also get the AIAgent latest version by just providing its name.
|
||||
AIAgent jokerAgentLatest = aiProjectClient.GetAIAgent(name: JokerName);
|
||||
AgentVersion latestVersion = jokerAgentLatest.GetService<AgentVersion>()!;
|
||||
AgentVersion latestAgentVersion = jokerAgentLatest.GetService<AgentVersion>()!;
|
||||
|
||||
// The AIAgent version can be accessed via the GetService method.
|
||||
Console.WriteLine($"Latest agent version id: {latestVersion.Id}");
|
||||
Console.WriteLine($"Latest agent version id: {latestAgentVersion.Id}");
|
||||
|
||||
// Once you have the AIAgent, you can invoke it like any other AIAgent.
|
||||
Console.WriteLine(await jokerAgentLatest.RunAsync("Tell me a joke about a pirate."));
|
||||
|
||||
// Cleanup by agent name removes both agent versions created (jokerAgentV1 + jokerAgentV2).
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(jokerAgentV1.Name);
|
||||
// Cleanup by agent name removes both agent versions created.
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(existingJokerAgent.Name);
|
||||
|
||||
@@ -6,6 +6,15 @@ of Azure Foundry Agents and can be used with Azure Foundry as the AI provider.
|
||||
These samples showcase how to work with agents managed through Azure Foundry, including agent creation,
|
||||
versioning, multi-turn conversations, and advanced features like code interpretation and computer use.
|
||||
|
||||
## Classic vs New Foundry Agents
|
||||
|
||||
> [!NOTE]
|
||||
> Recently, Azure Foundry introduced a new and improved experience for creating and managing AI agents, which is the target of these samples.
|
||||
|
||||
For more information about the previous classic agents and for what's new in Foundry Agents, see the [Foundry Agents migration documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/migrate?view=foundry).
|
||||
|
||||
For a sample demonstrating how to use classic Foundry Agents, see the following: [Agent with Azure AI Persistent](../AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md).
|
||||
|
||||
## Getting started with Foundry Agents prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
@@ -0,0 +1,688 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a Cosmos DB implementation of the <see cref="ChatMessageStore"/> abstract class.
|
||||
/// </summary>
|
||||
[RequiresUnreferencedCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public sealed class CosmosChatMessageStore : ChatMessageStore, IDisposable
|
||||
{
|
||||
private readonly CosmosClient _cosmosClient;
|
||||
private readonly Container _container;
|
||||
private readonly bool _ownsClient;
|
||||
private bool _disposed;
|
||||
|
||||
// Hierarchical partition key support
|
||||
private readonly string? _tenantId;
|
||||
private readonly string? _userId;
|
||||
private readonly PartitionKey _partitionKey;
|
||||
private readonly bool _useHierarchicalPartitioning;
|
||||
|
||||
/// <summary>
|
||||
/// Cached JSON serializer options for .NET 9.0 compatibility.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions s_defaultJsonOptions = CreateDefaultJsonOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateDefaultJsonOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions();
|
||||
#if NET9_0_OR_GREATER
|
||||
// Configure TypeInfoResolver for .NET 9.0 to enable JSON serialization
|
||||
options.TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver();
|
||||
#endif
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of messages to return in a single query batch.
|
||||
/// Default is 100 for optimal performance.
|
||||
/// </summary>
|
||||
public int MaxItemCount { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of items per transactional batch operation.
|
||||
/// Default is 100, maximum allowed by Cosmos DB is 100.
|
||||
/// </summary>
|
||||
public int MaxBatchSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of messages to retrieve from the store.
|
||||
/// This helps prevent exceeding LLM context windows in long conversations.
|
||||
/// Default is null (no limit). When set, only the most recent messages are returned.
|
||||
/// </summary>
|
||||
public int? MaxMessagesToRetrieve { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Time-To-Live (TTL) in seconds for messages.
|
||||
/// Default is 86400 seconds (24 hours). Set to null to disable TTL.
|
||||
/// </summary>
|
||||
public int? MessageTtlSeconds { get; set; } = 86400;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation ID associated with this message store.
|
||||
/// </summary>
|
||||
public string ConversationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the database ID associated with this message store.
|
||||
/// </summary>
|
||||
public string DatabaseId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the container ID associated with this message store.
|
||||
/// </summary>
|
||||
public string ContainerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Internal primary constructor used by all public constructors.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="conversationId">The unique identifier for this conversation thread.</param>
|
||||
/// <param name="ownsClient">Whether this instance owns the CosmosClient and should dispose it.</param>
|
||||
/// <param name="tenantId">Optional tenant identifier for hierarchical partitioning.</param>
|
||||
/// <param name="userId">Optional user identifier for hierarchical partitioning.</param>
|
||||
internal CosmosChatMessageStore(CosmosClient cosmosClient, string databaseId, string containerId, string conversationId, bool ownsClient, string? tenantId = null, string? userId = null)
|
||||
{
|
||||
this._cosmosClient = Throw.IfNull(cosmosClient);
|
||||
this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId));
|
||||
this.ConversationId = Throw.IfNullOrWhitespace(conversationId);
|
||||
this.DatabaseId = databaseId;
|
||||
this.ContainerId = containerId;
|
||||
this._ownsClient = ownsClient;
|
||||
|
||||
// Initialize partitioning mode
|
||||
this._tenantId = tenantId;
|
||||
this._userId = userId;
|
||||
this._useHierarchicalPartitioning = tenantId != null && userId != null;
|
||||
|
||||
this._partitionKey = this._useHierarchicalPartitioning
|
||||
? new PartitionKeyBuilder()
|
||||
.Add(tenantId!)
|
||||
.Add(userId!)
|
||||
.Add(conversationId)
|
||||
.Build()
|
||||
: new PartitionKey(conversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using a connection string.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(string connectionString, string databaseId, string containerId)
|
||||
: this(connectionString, databaseId, containerId, Guid.NewGuid().ToString("N"))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using a connection string.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="conversationId">The unique identifier for this conversation thread.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(string connectionString, string databaseId, string containerId, string conversationId)
|
||||
: this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, conversationId, ownsClient: true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using TokenCredential for authentication.
|
||||
/// </summary>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId)
|
||||
: this(accountEndpoint, tokenCredential, databaseId, containerId, Guid.NewGuid().ToString("N"))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using a TokenCredential for authentication.
|
||||
/// </summary>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="conversationId">The unique identifier for this conversation thread.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId, string conversationId)
|
||||
: this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, conversationId, ownsClient: true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(CosmosClient cosmosClient, string databaseId, string containerId)
|
||||
: this(cosmosClient, databaseId, containerId, Guid.NewGuid().ToString("N"))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="conversationId">The unique identifier for this conversation thread.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(CosmosClient cosmosClient, string databaseId, string containerId, string conversationId)
|
||||
: this(cosmosClient, databaseId, containerId, conversationId, ownsClient: false)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using a connection string with hierarchical partition keys.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="tenantId">The tenant identifier for hierarchical partitioning.</param>
|
||||
/// <param name="userId">The user identifier for hierarchical partitioning.</param>
|
||||
/// <param name="sessionId">The session identifier for hierarchical partitioning.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(string connectionString, string databaseId, string containerId, string tenantId, string userId, string sessionId)
|
||||
: this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, Throw.IfNullOrWhitespace(sessionId), ownsClient: true, Throw.IfNullOrWhitespace(tenantId), Throw.IfNullOrWhitespace(userId))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using a TokenCredential for authentication with hierarchical partition keys.
|
||||
/// </summary>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="tenantId">The tenant identifier for hierarchical partitioning.</param>
|
||||
/// <param name="userId">The user identifier for hierarchical partitioning.</param>
|
||||
/// <param name="sessionId">The session identifier for hierarchical partitioning.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId, string tenantId, string userId, string sessionId)
|
||||
: this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, Throw.IfNullOrWhitespace(sessionId), ownsClient: true, Throw.IfNullOrWhitespace(tenantId), Throw.IfNullOrWhitespace(userId))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosChatMessageStore"/> class using an existing <see cref="CosmosClient"/> with hierarchical partition keys.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="tenantId">The tenant identifier for hierarchical partitioning.</param>
|
||||
/// <param name="userId">The user identifier for hierarchical partitioning.</param>
|
||||
/// <param name="sessionId">The session identifier for hierarchical partitioning.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosChatMessageStore(CosmosClient cosmosClient, string databaseId, string containerId, string tenantId, string userId, string sessionId)
|
||||
: this(cosmosClient, databaseId, containerId, Throw.IfNullOrWhitespace(sessionId), ownsClient: false, Throw.IfNullOrWhitespace(tenantId), Throw.IfNullOrWhitespace(userId))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the <see cref="CosmosChatMessageStore"/> class from previously serialized state.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="serializedStoreState">A <see cref="JsonElement"/> representing the serialized state of the message store.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosChatMessageStore"/> initialized from the serialized state.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the serialized state cannot be deserialized.</exception>
|
||||
public static CosmosChatMessageStore CreateFromSerializedState(CosmosClient cosmosClient, JsonElement serializedStoreState, string databaseId, string containerId, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
Throw.IfNull(cosmosClient);
|
||||
Throw.IfNullOrWhitespace(databaseId);
|
||||
Throw.IfNullOrWhitespace(containerId);
|
||||
|
||||
if (serializedStoreState.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("Invalid serialized state", nameof(serializedStoreState));
|
||||
}
|
||||
|
||||
var state = JsonSerializer.Deserialize<StoreState>(serializedStoreState, jsonSerializerOptions);
|
||||
if (state?.ConversationIdentifier is not { } conversationId)
|
||||
{
|
||||
throw new ArgumentException("Invalid serialized state", nameof(serializedStoreState));
|
||||
}
|
||||
|
||||
// Use the internal constructor with all parameters to ensure partition key logic is centralized
|
||||
return state.UseHierarchicalPartitioning && state.TenantId != null && state.UserId != null
|
||||
? new CosmosChatMessageStore(cosmosClient, databaseId, containerId, conversationId, ownsClient: false, state.TenantId, state.UserId)
|
||||
: new CosmosChatMessageStore(cosmosClient, databaseId, containerId, conversationId, ownsClient: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<IEnumerable<ChatMessage>> GetMessagesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
// Fetch most recent messages in descending order when limit is set, then reverse to ascending
|
||||
var orderDirection = this.MaxMessagesToRetrieve.HasValue ? "DESC" : "ASC";
|
||||
var query = new QueryDefinition($"SELECT * FROM c WHERE c.conversationId = @conversationId AND c.type = @type ORDER BY c.timestamp {orderDirection}")
|
||||
.WithParameter("@conversationId", this.ConversationId)
|
||||
.WithParameter("@type", "ChatMessage");
|
||||
|
||||
var iterator = this._container.GetItemQueryIterator<CosmosMessageDocument>(query, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = this._partitionKey,
|
||||
MaxItemCount = this.MaxItemCount // Configurable query performance
|
||||
});
|
||||
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
while (iterator.HasMoreResults)
|
||||
{
|
||||
var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var document in response)
|
||||
{
|
||||
if (this.MaxMessagesToRetrieve.HasValue && messages.Count >= this.MaxMessagesToRetrieve.Value)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(document.Message))
|
||||
{
|
||||
var message = JsonSerializer.Deserialize<ChatMessage>(document.Message, s_defaultJsonOptions);
|
||||
if (message != null)
|
||||
{
|
||||
messages.Add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.MaxMessagesToRetrieve.HasValue && messages.Count >= this.MaxMessagesToRetrieve.Value)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we fetched in descending order (most recent first), reverse to ascending order
|
||||
if (this.MaxMessagesToRetrieve.HasValue)
|
||||
{
|
||||
messages.Reverse();
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task AddMessagesAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (messages is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(messages));
|
||||
}
|
||||
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
var messageList = messages as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
|
||||
if (messageList.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Use transactional batch for atomic operations
|
||||
if (messageList.Count > 1)
|
||||
{
|
||||
await this.AddMessagesInBatchAsync(messageList, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.AddSingleMessageAsync(messageList.First(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple messages using transactional batch operations for atomicity.
|
||||
/// </summary>
|
||||
private async Task AddMessagesInBatchAsync(IReadOnlyCollection<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
// Process messages in optimal batch sizes
|
||||
for (int i = 0; i < messages.Count; i += this.MaxBatchSize)
|
||||
{
|
||||
var batchMessages = messages.Skip(i).Take(this.MaxBatchSize).ToList();
|
||||
await this.ExecuteBatchOperationAsync(batchMessages, currentTimestamp, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a single batch operation with enhanced error handling.
|
||||
/// Cosmos SDK handles throttling (429) retries automatically.
|
||||
/// </summary>
|
||||
private async Task ExecuteBatchOperationAsync(List<ChatMessage> messages, long timestamp, CancellationToken cancellationToken)
|
||||
{
|
||||
// Create all documents upfront for validation and batch operation
|
||||
var documents = new List<CosmosMessageDocument>(messages.Count);
|
||||
foreach (var message in messages)
|
||||
{
|
||||
documents.Add(this.CreateMessageDocument(message, timestamp));
|
||||
}
|
||||
|
||||
// Defensive check: Verify all messages share the same partition key values
|
||||
// In hierarchical partitioning, this means same tenantId, userId, and sessionId
|
||||
// In simple partitioning, this means same conversationId
|
||||
if (documents.Count > 0)
|
||||
{
|
||||
if (this._useHierarchicalPartitioning)
|
||||
{
|
||||
// Verify all documents have matching hierarchical partition key components
|
||||
var firstDoc = documents[0];
|
||||
if (!documents.All(d => d.TenantId == firstDoc.TenantId && d.UserId == firstDoc.UserId && d.SessionId == firstDoc.SessionId))
|
||||
{
|
||||
throw new InvalidOperationException("All messages in a batch must share the same partition key values (tenantId, userId, sessionId).");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Verify all documents have matching conversationId
|
||||
var firstConversationId = documents[0].ConversationId;
|
||||
if (!documents.All(d => d.ConversationId == firstConversationId))
|
||||
{
|
||||
throw new InvalidOperationException("All messages in a batch must share the same partition key value (conversationId).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All messages in this store share the same partition key by design
|
||||
// Transactional batches require all items to share the same partition key
|
||||
var batch = this._container.CreateTransactionalBatch(this._partitionKey);
|
||||
|
||||
foreach (var document in documents)
|
||||
{
|
||||
batch.CreateItem(document);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await batch.ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Batch operation failed with status: {response.StatusCode}. Details: {response.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.RequestEntityTooLarge)
|
||||
{
|
||||
// If batch is too large, split into smaller batches
|
||||
if (messages.Count == 1)
|
||||
{
|
||||
// Can't split further, use single operation
|
||||
await this.AddSingleMessageAsync(messages[0], cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split the batch in half and retry
|
||||
var midpoint = messages.Count / 2;
|
||||
var firstHalf = messages.Take(midpoint).ToList();
|
||||
var secondHalf = messages.Skip(midpoint).ToList();
|
||||
|
||||
await this.ExecuteBatchOperationAsync(firstHalf, timestamp, cancellationToken).ConfigureAwait(false);
|
||||
await this.ExecuteBatchOperationAsync(secondHalf, timestamp, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a single message to the store.
|
||||
/// </summary>
|
||||
private async Task AddSingleMessageAsync(ChatMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = this.CreateMessageDocument(message, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
|
||||
try
|
||||
{
|
||||
await this._container.CreateItemAsync(document, this._partitionKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.RequestEntityTooLarge)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Message exceeds Cosmos DB's maximum item size limit of 2MB. " +
|
||||
"Message ID: " + message.MessageId + ", Serialized size is too large. " +
|
||||
"Consider reducing message content or splitting into smaller messages.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a message document with enhanced metadata.
|
||||
/// </summary>
|
||||
private CosmosMessageDocument CreateMessageDocument(ChatMessage message, long timestamp)
|
||||
{
|
||||
return new CosmosMessageDocument
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ConversationId = this.ConversationId,
|
||||
Timestamp = timestamp,
|
||||
MessageId = message.MessageId,
|
||||
Role = message.Role.Value,
|
||||
Message = JsonSerializer.Serialize(message, s_defaultJsonOptions),
|
||||
Type = "ChatMessage", // Type discriminator
|
||||
Ttl = this.MessageTtlSeconds, // Configurable TTL
|
||||
// Include hierarchical metadata when using hierarchical partitioning
|
||||
TenantId = this._useHierarchicalPartitioning ? this._tenantId : null,
|
||||
UserId = this._useHierarchicalPartitioning ? this._userId : null,
|
||||
SessionId = this._useHierarchicalPartitioning ? this.ConversationId : null
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
var state = new StoreState
|
||||
{
|
||||
ConversationIdentifier = this.ConversationId,
|
||||
TenantId = this._tenantId,
|
||||
UserId = this._userId,
|
||||
UseHierarchicalPartitioning = this._useHierarchicalPartitioning
|
||||
};
|
||||
|
||||
var options = jsonSerializerOptions ?? s_defaultJsonOptions;
|
||||
return JsonSerializer.SerializeToElement(state, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of messages in this conversation.
|
||||
/// This is an additional utility method beyond the base contract.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The number of messages in the conversation.</returns>
|
||||
public async Task<int> GetMessageCountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
// Efficient count query
|
||||
var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId AND c.Type = @type")
|
||||
.WithParameter("@conversationId", this.ConversationId)
|
||||
.WithParameter("@type", "ChatMessage");
|
||||
|
||||
var iterator = this._container.GetItemQueryIterator<int>(query, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = this._partitionKey
|
||||
});
|
||||
|
||||
// COUNT queries always return a result
|
||||
var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false);
|
||||
return response.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all messages in this conversation.
|
||||
/// This is an additional utility method beyond the base contract.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The number of messages deleted.</returns>
|
||||
public async Task<int> ClearMessagesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
// Batch delete for efficiency
|
||||
var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE c.conversationId = @conversationId AND c.Type = @type")
|
||||
.WithParameter("@conversationId", this.ConversationId)
|
||||
.WithParameter("@type", "ChatMessage");
|
||||
|
||||
var iterator = this._container.GetItemQueryIterator<string>(query, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = this._partitionKey,
|
||||
MaxItemCount = this.MaxItemCount
|
||||
});
|
||||
|
||||
var deletedCount = 0;
|
||||
|
||||
while (iterator.HasMoreResults)
|
||||
{
|
||||
var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false);
|
||||
var batch = this._container.CreateTransactionalBatch(this._partitionKey);
|
||||
var batchItemCount = 0;
|
||||
|
||||
foreach (var itemId in response)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
batch.DeleteItem(itemId);
|
||||
batchItemCount++;
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (batchItemCount > 0)
|
||||
{
|
||||
await batch.ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (!this._disposed)
|
||||
{
|
||||
if (this._ownsClient)
|
||||
{
|
||||
this._cosmosClient?.Dispose();
|
||||
}
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StoreState
|
||||
{
|
||||
public string ConversationIdentifier { get; set; } = string.Empty;
|
||||
public string? TenantId { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public bool UseHierarchicalPartitioning { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a document stored in Cosmos DB for chat messages.
|
||||
/// </summary>
|
||||
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Cosmos DB operations")]
|
||||
private sealed class CosmosMessageDocument
|
||||
{
|
||||
[Newtonsoft.Json.JsonProperty("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("conversationId")]
|
||||
public string ConversationId { get; set; } = string.Empty;
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("timestamp")]
|
||||
public long Timestamp { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("messageId")]
|
||||
public string? MessageId { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("role")]
|
||||
public string? Role { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("ttl")]
|
||||
public int? Ttl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for hierarchical partitioning scenarios (optional).
|
||||
/// </summary>
|
||||
[Newtonsoft.Json.JsonProperty("tenantId")]
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User ID for hierarchical partitioning scenarios (optional).
|
||||
/// </summary>
|
||||
[Newtonsoft.Json.JsonProperty("userId")]
|
||||
public string? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Session ID for hierarchical partitioning scenarios (same as ConversationId for compatibility).
|
||||
/// </summary>
|
||||
[Newtonsoft.Json.JsonProperty("sessionId")]
|
||||
public string? SessionId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a Cosmos DB implementation of the <see cref="JsonCheckpointStore"/> abstract class.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of objects to store as checkpoint values.</typeparam>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
|
||||
{
|
||||
private readonly CosmosClient _cosmosClient;
|
||||
private readonly Container _container;
|
||||
private readonly bool _ownsClient;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosCheckpointStore{T}"/> class using a connection string.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosCheckpointStore(string connectionString, string databaseId, string containerId)
|
||||
{
|
||||
var cosmosClientOptions = new CosmosClientOptions();
|
||||
|
||||
this._cosmosClient = new CosmosClient(Throw.IfNullOrWhitespace(connectionString), cosmosClientOptions);
|
||||
this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId));
|
||||
this._ownsClient = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosCheckpointStore{T}"/> class using a TokenCredential for authentication.
|
||||
/// </summary>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosCheckpointStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId)
|
||||
{
|
||||
var cosmosClientOptions = new CosmosClientOptions
|
||||
{
|
||||
SerializerOptions = new CosmosSerializationOptions
|
||||
{
|
||||
PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
|
||||
}
|
||||
};
|
||||
|
||||
this._cosmosClient = new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential), cosmosClientOptions);
|
||||
this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId));
|
||||
this._ownsClient = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CosmosCheckpointStore{T}"/> class using an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
public CosmosCheckpointStore(CosmosClient cosmosClient, string databaseId, string containerId)
|
||||
{
|
||||
this._cosmosClient = Throw.IfNull(cosmosClient);
|
||||
|
||||
this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId));
|
||||
this._ownsClient = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the identifier of the Cosmos DB database.
|
||||
/// </summary>
|
||||
public string DatabaseId => this._container.Database.Id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the identifier of the Cosmos DB container.
|
||||
/// </summary>
|
||||
public string ContainerId => this._container.Id;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<CheckpointInfo> CreateCheckpointAsync(string runId, JsonElement value, CheckpointInfo? parent = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(runId));
|
||||
}
|
||||
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
var checkpointId = Guid.NewGuid().ToString("N");
|
||||
var checkpointInfo = new CheckpointInfo(runId, checkpointId);
|
||||
|
||||
var document = new CosmosCheckpointDocument
|
||||
{
|
||||
Id = $"{runId}_{checkpointId}",
|
||||
RunId = runId,
|
||||
CheckpointId = checkpointId,
|
||||
Value = JToken.Parse(value.GetRawText()),
|
||||
ParentCheckpointId = parent?.CheckpointId,
|
||||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
|
||||
};
|
||||
|
||||
await this._container.CreateItemAsync(document, new PartitionKey(runId)).ConfigureAwait(false);
|
||||
return checkpointInfo;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<JsonElement> RetrieveCheckpointAsync(string runId, CheckpointInfo key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(runId));
|
||||
}
|
||||
|
||||
if (key is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(key));
|
||||
}
|
||||
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
var id = $"{runId}_{key.CheckpointId}";
|
||||
|
||||
try
|
||||
{
|
||||
var response = await this._container.ReadItemAsync<CosmosCheckpointDocument>(id, new PartitionKey(runId)).ConfigureAwait(false);
|
||||
using var document = JsonDocument.Parse(response.Resource.Value.ToString());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
throw new InvalidOperationException($"Checkpoint with ID '{key.CheckpointId}' for run '{runId}' not found.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string runId, CheckpointInfo? withParent = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(runId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(runId));
|
||||
}
|
||||
|
||||
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
|
||||
if (this._disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(this.GetType().FullName);
|
||||
}
|
||||
#pragma warning restore CA1513
|
||||
|
||||
QueryDefinition query = withParent == null
|
||||
? new QueryDefinition("SELECT c.runId, c.checkpointId FROM c WHERE c.runId = @runId ORDER BY c.timestamp ASC")
|
||||
.WithParameter("@runId", runId)
|
||||
: new QueryDefinition("SELECT c.runId, c.checkpointId FROM c WHERE c.runId = @runId AND c.parentCheckpointId = @parentCheckpointId ORDER BY c.timestamp ASC")
|
||||
.WithParameter("@runId", runId)
|
||||
.WithParameter("@parentCheckpointId", withParent.CheckpointId);
|
||||
|
||||
var iterator = this._container.GetItemQueryIterator<CheckpointQueryResult>(query);
|
||||
var checkpoints = new List<CheckpointInfo>();
|
||||
|
||||
while (iterator.HasMoreResults)
|
||||
{
|
||||
var response = await iterator.ReadNextAsync().ConfigureAwait(false);
|
||||
checkpoints.AddRange(response.Select(r => new CheckpointInfo(r.RunId, r.CheckpointId)));
|
||||
}
|
||||
|
||||
return checkpoints;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
this.Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the unmanaged resources used by the <see cref="CosmosCheckpointStore{T}"/> and optionally releases the managed resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!this._disposed)
|
||||
{
|
||||
if (disposing && this._ownsClient)
|
||||
{
|
||||
this._cosmosClient?.Dispose();
|
||||
}
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a checkpoint document stored in Cosmos DB.
|
||||
/// </summary>
|
||||
internal sealed class CosmosCheckpointDocument
|
||||
{
|
||||
[JsonProperty("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("runId")]
|
||||
public string RunId { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("checkpointId")]
|
||||
public string CheckpointId { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("value")]
|
||||
public JToken Value { get; set; } = JValue.CreateNull();
|
||||
|
||||
[JsonProperty("parentCheckpointId")]
|
||||
public string? ParentCheckpointId { get; set; }
|
||||
|
||||
[JsonProperty("timestamp")]
|
||||
public long Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a checkpoint query.
|
||||
/// </summary>
|
||||
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Cosmos DB query deserialization")]
|
||||
private sealed class CheckpointQueryResult
|
||||
{
|
||||
public string RunId { get; set; } = string.Empty;
|
||||
public string CheckpointId { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides a non-generic Cosmos DB implementation of the <see cref="JsonCheckpointStore"/> abstract class.
|
||||
/// </summary>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public sealed class CosmosCheckpointStore : CosmosCheckpointStore<JsonElement>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public CosmosCheckpointStore(string connectionString, string databaseId, string containerId)
|
||||
: base(connectionString, databaseId, containerId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CosmosCheckpointStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId)
|
||||
: base(accountEndpoint, tokenCredential, databaseId, containerId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CosmosCheckpointStore(CosmosClient cosmosClient, string databaseId, string containerId)
|
||||
: base(cosmosClient, databaseId, containerId)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for integrating Cosmos DB chat message storage with the Agent Framework.
|
||||
/// </summary>
|
||||
public static class CosmosDBChatExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the agent to use Cosmos DB for message storage with connection string authentication.
|
||||
/// </summary>
|
||||
/// <param name="options">The chat client agent options to configure.</param>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>The configured <see cref="ChatClientAgentOptions"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static ChatClientAgentOptions WithCosmosDBMessageStore(
|
||||
this ChatClientAgentOptions options,
|
||||
string connectionString,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
options.ChatMessageStoreFactory = context => new CosmosChatMessageStore(connectionString, databaseId, containerId);
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the agent to use Cosmos DB for message storage with managed identity authentication.
|
||||
/// </summary>
|
||||
/// <param name="options">The chat client agent options to configure.</param>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>The configured <see cref="ChatClientAgentOptions"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static ChatClientAgentOptions WithCosmosDBMessageStoreUsingManagedIdentity(
|
||||
this ChatClientAgentOptions options,
|
||||
string accountEndpoint,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
options.ChatMessageStoreFactory = context => new CosmosChatMessageStore(accountEndpoint, new DefaultAzureCredential(), databaseId, containerId);
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the agent to use Cosmos DB for message storage with an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">The chat client agent options to configure.</param>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>The configured <see cref="ChatClientAgentOptions"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosChatMessageStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static ChatClientAgentOptions WithCosmosDBMessageStore(
|
||||
this ChatClientAgentOptions options,
|
||||
CosmosClient cosmosClient,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
options.ChatMessageStoreFactory = context => new CosmosChatMessageStore(cosmosClient, databaseId, containerId);
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for integrating Cosmos DB checkpoint storage with the Agent Framework.
|
||||
/// </summary>
|
||||
public static class CosmosDBWorkflowExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a Cosmos DB checkpoint store using connection string authentication.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosCheckpointStore"/>.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static CosmosCheckpointStore CreateCheckpointStore(
|
||||
string connectionString,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(connectionString));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
|
||||
}
|
||||
|
||||
return new CosmosCheckpointStore(connectionString, databaseId, containerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Cosmos DB checkpoint store using managed identity authentication.
|
||||
/// </summary>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosCheckpointStore"/>.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static CosmosCheckpointStore CreateCheckpointStoreUsingManagedIdentity(
|
||||
string accountEndpoint,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountEndpoint))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(accountEndpoint));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
|
||||
}
|
||||
|
||||
return new CosmosCheckpointStore(accountEndpoint, new DefaultAzureCredential(), databaseId, containerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Cosmos DB checkpoint store using an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosCheckpointStore"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static CosmosCheckpointStore CreateCheckpointStore(
|
||||
CosmosClient cosmosClient,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (cosmosClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(cosmosClient));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
|
||||
}
|
||||
|
||||
return new CosmosCheckpointStore(cosmosClient, databaseId, containerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a generic Cosmos DB checkpoint store using connection string authentication.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of objects to store as checkpoint values.</typeparam>
|
||||
/// <param name="connectionString">The Cosmos DB connection string.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosCheckpointStore{T}"/>.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static CosmosCheckpointStore<T> CreateCheckpointStore<T>(
|
||||
string connectionString,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(connectionString));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
|
||||
}
|
||||
|
||||
return new CosmosCheckpointStore<T>(connectionString, databaseId, containerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a generic Cosmos DB checkpoint store using managed identity authentication.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of objects to store as checkpoint values.</typeparam>
|
||||
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosCheckpointStore{T}"/>.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static CosmosCheckpointStore<T> CreateCheckpointStoreUsingManagedIdentity<T>(
|
||||
string accountEndpoint,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountEndpoint))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(accountEndpoint));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
|
||||
}
|
||||
|
||||
return new CosmosCheckpointStore<T>(accountEndpoint, new DefaultAzureCredential(), databaseId, containerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a generic Cosmos DB checkpoint store using an existing <see cref="CosmosClient"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of objects to store as checkpoint values.</typeparam>
|
||||
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
|
||||
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
|
||||
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
|
||||
/// <returns>A new instance of <see cref="CosmosCheckpointStore{T}"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
|
||||
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
|
||||
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
|
||||
public static CosmosCheckpointStore<T> CreateCheckpointStore<T>(
|
||||
CosmosClient cosmosClient,
|
||||
string databaseId,
|
||||
string containerId)
|
||||
{
|
||||
if (cosmosClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(cosmosClient));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(containerId))
|
||||
{
|
||||
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
|
||||
}
|
||||
|
||||
return new CosmosCheckpointStore<T>(cosmosClient, databaseId, containerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI</RootNamespace>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectRequiredMemberOnLegacy>true</InjectRequiredMemberOnLegacy>
|
||||
<InjectCompilerFeatureRequiredOnLegacy>true</InjectCompilerFeatureRequiredOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Cosmos DB NoSQL Integration</Title>
|
||||
<Description>Provides Cosmos DB NoSQL implementations for Microsoft Agent Framework storage abstractions including ChatMessageStore and CheckpointStore.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Azure.Cosmos" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Newtonsoft.Json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.CosmosNoSql.UnitTests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
# EditorConfig overrides for Cosmos DB Unit Tests
|
||||
# Multi-targeting (net472 + net9.0) causes false positives for IDE0005 (unnecessary using directives)
|
||||
|
||||
root = false
|
||||
|
||||
[*.cs]
|
||||
# Suppress IDE0005 for this project - multi-targeting causes false positives
|
||||
# These using directives ARE necessary but appear unnecessary in one target framework
|
||||
dotnet_diagnostic.IDE0005.severity = none
|
||||
+760
@@ -0,0 +1,760 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="CosmosChatMessageStore"/>.
|
||||
///
|
||||
/// Test Modes:
|
||||
/// - Default Mode: Cleans up all test data after each test run (deletes database)
|
||||
/// - Preserve Mode: Keeps containers and data for inspection in Cosmos DB Emulator Data Explorer
|
||||
///
|
||||
/// To enable Preserve Mode, set environment variable: COSMOS_PRESERVE_CONTAINERS=true
|
||||
/// Example: $env:COSMOS_PRESERVE_CONTAINERS="true"; dotnet test
|
||||
///
|
||||
/// In Preserve Mode, you can view the data in Cosmos DB Emulator Data Explorer at:
|
||||
/// https://localhost:8081/_explorer/index.html
|
||||
/// Database: AgentFrameworkTests
|
||||
/// Container: ChatMessages
|
||||
///
|
||||
/// Environment Variable Reference:
|
||||
/// | Variable | Values | Description |
|
||||
/// |----------|--------|-------------|
|
||||
/// | COSMOS_PRESERVE_CONTAINERS | true / false | Controls whether to preserve test data after completion |
|
||||
///
|
||||
/// Usage Examples:
|
||||
/// - Run all tests in preserve mode: $env:COSMOS_PRESERVE_CONTAINERS="true"; dotnet test tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/
|
||||
/// - Run specific test category in preserve mode: $env:COSMOS_PRESERVE_CONTAINERS="true"; dotnet test tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/ --filter "Category=CosmosDB"
|
||||
/// - Reset to cleanup mode: $env:COSMOS_PRESERVE_CONTAINERS=""; dotnet test tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/
|
||||
/// </summary>
|
||||
[Collection("CosmosDB")]
|
||||
public sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
|
||||
{
|
||||
// Cosmos DB Emulator connection settings
|
||||
private const string EmulatorEndpoint = "https://localhost:8081";
|
||||
private const string EmulatorKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
|
||||
private const string TestContainerId = "ChatMessages";
|
||||
private const string HierarchicalTestContainerId = "HierarchicalChatMessages";
|
||||
// Use unique database ID per test class instance to avoid conflicts
|
||||
#pragma warning disable CA1802 // Use literals where appropriate
|
||||
private static readonly string s_testDatabaseId = $"AgentFrameworkTests-ChatStore-{Guid.NewGuid():N}";
|
||||
#pragma warning restore CA1802
|
||||
|
||||
private string _connectionString = string.Empty;
|
||||
private bool _emulatorAvailable;
|
||||
private bool _preserveContainer;
|
||||
private CosmosClient? _setupClient; // Only used for test setup/cleanup
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Check environment variable to determine if we should preserve containers
|
||||
// Set COSMOS_PRESERVE_CONTAINERS=true to keep containers and data for inspection
|
||||
this._preserveContainer = string.Equals(Environment.GetEnvironmentVariable("COSMOS_PRESERVE_CONTAINERS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
this._connectionString = $"AccountEndpoint={EmulatorEndpoint};AccountKey={EmulatorKey}";
|
||||
|
||||
try
|
||||
{
|
||||
// Only create CosmosClient for test setup - the actual tests will use connection string constructors
|
||||
this._setupClient = new CosmosClient(EmulatorEndpoint, EmulatorKey);
|
||||
|
||||
// Test connection by attempting to create database
|
||||
var databaseResponse = await this._setupClient.CreateDatabaseIfNotExistsAsync(s_testDatabaseId);
|
||||
|
||||
// Create container for simple partitioning tests
|
||||
await databaseResponse.Database.CreateContainerIfNotExistsAsync(
|
||||
TestContainerId,
|
||||
"/conversationId",
|
||||
throughput: 400);
|
||||
|
||||
// Create container for hierarchical partitioning tests with hierarchical partition key
|
||||
var hierarchicalContainerProperties = new ContainerProperties(HierarchicalTestContainerId, new List<string> { "/tenantId", "/userId", "/sessionId" });
|
||||
await databaseResponse.Database.CreateContainerIfNotExistsAsync(
|
||||
hierarchicalContainerProperties,
|
||||
throughput: 400);
|
||||
|
||||
this._emulatorAvailable = true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Emulator not available, tests will be skipped
|
||||
this._emulatorAvailable = false;
|
||||
this._setupClient?.Dispose();
|
||||
this._setupClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (this._setupClient != null && this._emulatorAvailable)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (this._preserveContainer)
|
||||
{
|
||||
// Preserve mode: Don't delete the database/container, keep data for inspection
|
||||
// This allows viewing data in the Cosmos DB Emulator Data Explorer
|
||||
// No cleanup needed - data persists for debugging
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clean mode: Delete the test database and all data
|
||||
var database = this._setupClient.GetDatabase(s_testDatabaseId);
|
||||
await database.DeleteAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ignore cleanup errors during test teardown
|
||||
Console.WriteLine($"Warning: Cleanup failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._setupClient.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._setupClient?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void SkipIfEmulatorNotAvailable()
|
||||
{
|
||||
// In CI: Skip if COSMOS_EMULATOR_AVAILABLE is not set to "true"
|
||||
// Locally: Skip if emulator connection check failed
|
||||
var ciEmulatorAvailable = string.Equals(Environment.GetEnvironmentVariable("COSMOS_EMULATOR_AVAILABLE"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Xunit.Skip.If(!ciEmulatorAvailable && !this._emulatorAvailable, "Cosmos DB Emulator is not available");
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithConnectionString_ShouldCreateInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Act
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, "test-conversation");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(store);
|
||||
Assert.Equal("test-conversation", store.ConversationId);
|
||||
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
||||
Assert.Equal(TestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithConnectionStringNoConversationId_ShouldCreateInstance()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Act
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(store);
|
||||
Assert.NotNull(store.ConversationId);
|
||||
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
||||
Assert.Equal(TestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithNullConnectionString_ShouldThrowArgumentException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new CosmosChatMessageStore((string)null!, s_testDatabaseId, TestContainerId, "test-conversation"));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithEmptyConversationId_ShouldThrowArgumentException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, ""));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AddMessagesAsync Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task AddMessagesAsync_WithSingleMessage_ShouldAddMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var conversationId = Guid.NewGuid().ToString();
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId);
|
||||
var message = new ChatMessage(ChatRole.User, "Hello, world!");
|
||||
|
||||
// Act
|
||||
await store.AddMessagesAsync([message]);
|
||||
|
||||
// Wait a moment for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
var messages = await store.GetMessagesAsync();
|
||||
var messageList = messages.ToList();
|
||||
|
||||
// Simple assertion - if this fails, we know the deserialization is the issue
|
||||
if (messageList.Count == 0)
|
||||
{
|
||||
// Let's check if we can find ANY items in the container for this conversation
|
||||
var directQuery = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId")
|
||||
.WithParameter("@conversationId", conversationId);
|
||||
var countIterator = this._setupClient!.GetDatabase(s_testDatabaseId).GetContainer(TestContainerId)
|
||||
.GetItemQueryIterator<int>(directQuery, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = new PartitionKey(conversationId)
|
||||
});
|
||||
|
||||
var countResponse = await countIterator.ReadNextAsync();
|
||||
var count = countResponse.FirstOrDefault();
|
||||
|
||||
// Debug: Let's see what the raw query returns
|
||||
var rawQuery = new QueryDefinition("SELECT * FROM c WHERE c.conversationId = @conversationId")
|
||||
.WithParameter("@conversationId", conversationId);
|
||||
var rawIterator = this._setupClient!.GetDatabase(s_testDatabaseId).GetContainer(TestContainerId)
|
||||
.GetItemQueryIterator<dynamic>(rawQuery, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = new PartitionKey(conversationId)
|
||||
});
|
||||
|
||||
List<dynamic> rawResults = new();
|
||||
while (rawIterator.HasMoreResults)
|
||||
{
|
||||
var rawResponse = await rawIterator.ReadNextAsync();
|
||||
rawResults.AddRange(rawResponse);
|
||||
}
|
||||
|
||||
string rawJson = rawResults.Count > 0 ? Newtonsoft.Json.JsonConvert.SerializeObject(rawResults[0], Newtonsoft.Json.Formatting.Indented) : "null";
|
||||
Assert.Fail($"GetMessagesAsync returned 0 messages, but direct count query found {count} items for conversation {conversationId}. Raw document: {rawJson}");
|
||||
}
|
||||
|
||||
Assert.Single(messageList);
|
||||
Assert.Equal("Hello, world!", messageList[0].Text);
|
||||
Assert.Equal(ChatRole.User, messageList[0].Role);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task AddMessagesAsync_WithMultipleMessages_ShouldAddAllMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var conversationId = Guid.NewGuid().ToString();
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId);
|
||||
var messages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "First message"),
|
||||
new ChatMessage(ChatRole.Assistant, "Second message"),
|
||||
new ChatMessage(ChatRole.User, "Third message")
|
||||
};
|
||||
|
||||
// Act
|
||||
await store.AddMessagesAsync(messages);
|
||||
|
||||
// Assert
|
||||
var retrievedMessages = await store.GetMessagesAsync();
|
||||
var messageList = retrievedMessages.ToList();
|
||||
Assert.Equal(3, messageList.Count);
|
||||
Assert.Equal("First message", messageList[0].Text);
|
||||
Assert.Equal("Second message", messageList[1].Text);
|
||||
Assert.Equal("Third message", messageList[2].Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetMessagesAsync Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task GetMessagesAsync_WithNoMessages_ShouldReturnEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, Guid.NewGuid().ToString());
|
||||
|
||||
// Act
|
||||
var messages = await store.GetMessagesAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(messages);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task GetMessagesAsync_WithConversationIsolation_ShouldOnlyReturnMessagesForConversationAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var conversation1 = Guid.NewGuid().ToString();
|
||||
var conversation2 = Guid.NewGuid().ToString();
|
||||
|
||||
using var store1 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversation1);
|
||||
using var store2 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversation2);
|
||||
|
||||
await store1.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message for conversation 1")]);
|
||||
await store2.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message for conversation 2")]);
|
||||
|
||||
// Act
|
||||
var messages1 = await store1.GetMessagesAsync();
|
||||
var messages2 = await store2.GetMessagesAsync();
|
||||
|
||||
// Assert
|
||||
var messageList1 = messages1.ToList();
|
||||
var messageList2 = messages2.ToList();
|
||||
Assert.Single(messageList1);
|
||||
Assert.Single(messageList2);
|
||||
Assert.Equal("Message for conversation 1", messageList1[0].Text);
|
||||
Assert.Equal("Message for conversation 2", messageList2[0].Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task FullWorkflow_AddAndGet_ShouldWorkCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var conversationId = $"test-conversation-{Guid.NewGuid():N}"; // Use unique conversation ID
|
||||
using var originalStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId);
|
||||
|
||||
var messages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.System, "You are a helpful assistant."),
|
||||
new ChatMessage(ChatRole.User, "Hello!"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi there! How can I help you today?"),
|
||||
new ChatMessage(ChatRole.User, "What's the weather like?"),
|
||||
new ChatMessage(ChatRole.Assistant, "I'm sorry, I don't have access to current weather data.")
|
||||
};
|
||||
|
||||
// Act 1: Add messages
|
||||
await originalStore.AddMessagesAsync(messages);
|
||||
|
||||
// Act 2: Verify messages were added
|
||||
var retrievedMessages = await originalStore.GetMessagesAsync();
|
||||
var retrievedList = retrievedMessages.ToList();
|
||||
Assert.Equal(5, retrievedList.Count);
|
||||
|
||||
// Act 3: Create new store instance for same conversation (test persistence)
|
||||
using var newStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, conversationId);
|
||||
var persistedMessages = await newStore.GetMessagesAsync();
|
||||
var persistedList = persistedMessages.ToList();
|
||||
|
||||
// Assert final state
|
||||
Assert.Equal(5, persistedList.Count);
|
||||
Assert.Equal("You are a helpful assistant.", persistedList[0].Text);
|
||||
Assert.Equal("Hello!", persistedList[1].Text);
|
||||
Assert.Equal("Hi there! How can I help you today?", persistedList[2].Text);
|
||||
Assert.Equal("What's the weather like?", persistedList[3].Text);
|
||||
Assert.Equal("I'm sorry, I don't have access to current weather data.", persistedList[4].Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Disposal Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Dispose_AfterUse_ShouldNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, Guid.NewGuid().ToString());
|
||||
|
||||
// Act & Assert
|
||||
store.Dispose(); // Should not throw
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Dispose_MultipleCalls_ShouldNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, Guid.NewGuid().ToString());
|
||||
|
||||
// Act & Assert
|
||||
store.Dispose(); // First call
|
||||
store.Dispose(); // Second call - should not throw
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Hierarchical Partitioning Tests
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalConnectionString_ShouldCreateInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Act
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "user-456", "session-789");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(store);
|
||||
Assert.Equal("session-789", store.ConversationId);
|
||||
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
||||
Assert.Equal(HierarchicalTestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalEndpoint_ShouldCreateInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Act
|
||||
TokenCredential credential = new DefaultAzureCredential();
|
||||
using var store = new CosmosChatMessageStore(EmulatorEndpoint, credential, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "user-456", "session-789");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(store);
|
||||
Assert.Equal("session-789", store.ConversationId);
|
||||
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
||||
Assert.Equal(HierarchicalTestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalCosmosClient_ShouldCreateInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
using var cosmosClient = new CosmosClient(EmulatorEndpoint, EmulatorKey);
|
||||
using var store = new CosmosChatMessageStore(cosmosClient, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "user-456", "session-789");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(store);
|
||||
Assert.Equal("session-789", store.ConversationId);
|
||||
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
||||
Assert.Equal(HierarchicalTestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalNullTenantId_ShouldThrowArgumentException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, null!, "user-456", "session-789"));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalEmptyUserId_ShouldThrowArgumentException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "", "session-789"));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public void Constructor_WithHierarchicalWhitespaceSessionId_ShouldThrowArgumentException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-123", "user-456", " "));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task AddMessagesAsync_WithHierarchicalPartitioning_ShouldAddMessageWithMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string TenantId = "tenant-123";
|
||||
const string UserId = "user-456";
|
||||
const string SessionId = "session-789";
|
||||
// Test hierarchical partitioning constructor with connection string
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId);
|
||||
var message = new ChatMessage(ChatRole.User, "Hello from hierarchical partitioning!");
|
||||
|
||||
// Act
|
||||
await store.AddMessagesAsync([message]);
|
||||
|
||||
// Wait a moment for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
var messages = await store.GetMessagesAsync();
|
||||
var messageList = messages.ToList();
|
||||
|
||||
Assert.Single(messageList);
|
||||
Assert.Equal("Hello from hierarchical partitioning!", messageList[0].Text);
|
||||
Assert.Equal(ChatRole.User, messageList[0].Role);
|
||||
|
||||
// Verify that the document is stored with hierarchical partitioning metadata
|
||||
var directQuery = new QueryDefinition("SELECT * FROM c WHERE c.conversationId = @conversationId AND c.type = @type")
|
||||
.WithParameter("@conversationId", SessionId)
|
||||
.WithParameter("@type", "ChatMessage");
|
||||
|
||||
var iterator = this._setupClient!.GetDatabase(s_testDatabaseId).GetContainer(HierarchicalTestContainerId)
|
||||
.GetItemQueryIterator<dynamic>(directQuery, requestOptions: new QueryRequestOptions
|
||||
{
|
||||
PartitionKey = new PartitionKeyBuilder().Add(TenantId).Add(UserId).Add(SessionId).Build()
|
||||
});
|
||||
|
||||
var response = await iterator.ReadNextAsync();
|
||||
var document = response.FirstOrDefault();
|
||||
|
||||
Assert.NotNull(document);
|
||||
// The document should have hierarchical metadata
|
||||
Assert.Equal(SessionId, (string)document!.conversationId);
|
||||
Assert.Equal(TenantId, (string)document!.tenantId);
|
||||
Assert.Equal(UserId, (string)document!.userId);
|
||||
Assert.Equal(SessionId, (string)document!.sessionId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task AddMessagesAsync_WithHierarchicalMultipleMessages_ShouldAddAllMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string TenantId = "tenant-batch";
|
||||
const string UserId = "user-batch";
|
||||
const string SessionId = "session-batch";
|
||||
// Test hierarchical partitioning constructor with connection string
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId);
|
||||
var messages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "First hierarchical message"),
|
||||
new ChatMessage(ChatRole.Assistant, "Second hierarchical message"),
|
||||
new ChatMessage(ChatRole.User, "Third hierarchical message")
|
||||
};
|
||||
|
||||
// Act
|
||||
await store.AddMessagesAsync(messages);
|
||||
|
||||
// Wait a moment for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
var retrievedMessages = await store.GetMessagesAsync();
|
||||
var messageList = retrievedMessages.ToList();
|
||||
|
||||
Assert.Equal(3, messageList.Count);
|
||||
Assert.Equal("First hierarchical message", messageList[0].Text);
|
||||
Assert.Equal("Second hierarchical message", messageList[1].Text);
|
||||
Assert.Equal("Third hierarchical message", messageList[2].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task GetMessagesAsync_WithHierarchicalPartitionIsolation_ShouldIsolateMessagesByUserIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string TenantId = "tenant-isolation";
|
||||
const string UserId1 = "user-1";
|
||||
const string UserId2 = "user-2";
|
||||
const string SessionId = "session-isolation";
|
||||
|
||||
// Different userIds create different hierarchical partitions, providing proper isolation
|
||||
using var store1 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId1, SessionId);
|
||||
using var store2 = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId2, SessionId);
|
||||
|
||||
// Add messages to both stores
|
||||
await store1.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message from user 1")]);
|
||||
await store2.AddMessagesAsync([new ChatMessage(ChatRole.User, "Message from user 2")]);
|
||||
|
||||
// Wait a moment for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act & Assert
|
||||
var messages1 = await store1.GetMessagesAsync();
|
||||
var messageList1 = messages1.ToList();
|
||||
|
||||
var messages2 = await store2.GetMessagesAsync();
|
||||
var messageList2 = messages2.ToList();
|
||||
|
||||
// With true hierarchical partitioning, each user sees only their own messages
|
||||
Assert.Single(messageList1);
|
||||
Assert.Single(messageList2);
|
||||
Assert.Equal("Message from user 1", messageList1[0].Text);
|
||||
Assert.Equal("Message from user 2", messageList2[0].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task SerializeDeserialize_WithHierarchicalPartitioning_ShouldPreserveStateAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string TenantId = "tenant-serialize";
|
||||
const string UserId = "user-serialize";
|
||||
const string SessionId = "session-serialize";
|
||||
|
||||
using var originalStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, TenantId, UserId, SessionId);
|
||||
await originalStore.AddMessagesAsync([new ChatMessage(ChatRole.User, "Test serialization message")]);
|
||||
|
||||
// Act - Serialize the store state
|
||||
var serializedState = originalStore.Serialize();
|
||||
|
||||
// Create a new store from the serialized state
|
||||
using var cosmosClient = new CosmosClient(EmulatorEndpoint, EmulatorKey);
|
||||
var serializerOptions = new JsonSerializerOptions
|
||||
{
|
||||
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
|
||||
};
|
||||
using var deserializedStore = CosmosChatMessageStore.CreateFromSerializedState(cosmosClient, serializedState, s_testDatabaseId, HierarchicalTestContainerId, serializerOptions);
|
||||
|
||||
// Wait a moment for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert - The deserialized store should have the same functionality
|
||||
var messages = await deserializedStore.GetMessagesAsync();
|
||||
var messageList = messages.ToList();
|
||||
|
||||
Assert.Single(messageList);
|
||||
Assert.Equal("Test serialization message", messageList[0].Text);
|
||||
Assert.Equal(SessionId, deserializedStore.ConversationId);
|
||||
Assert.Equal(s_testDatabaseId, deserializedStore.DatabaseId);
|
||||
Assert.Equal(HierarchicalTestContainerId, deserializedStore.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task HierarchicalAndSimplePartitioning_ShouldCoexistAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string SessionId = "coexist-session";
|
||||
|
||||
// Create simple store using simple partitioning container and hierarchical store using hierarchical container
|
||||
using var simpleStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, SessionId);
|
||||
using var hierarchicalStore = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, HierarchicalTestContainerId, "tenant-coexist", "user-coexist", SessionId);
|
||||
|
||||
// Add messages to both
|
||||
await simpleStore.AddMessagesAsync([new ChatMessage(ChatRole.User, "Simple partitioning message")]);
|
||||
await hierarchicalStore.AddMessagesAsync([new ChatMessage(ChatRole.User, "Hierarchical partitioning message")]);
|
||||
|
||||
// Wait a moment for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act & Assert
|
||||
var simpleMessages = await simpleStore.GetMessagesAsync();
|
||||
var simpleMessageList = simpleMessages.ToList();
|
||||
|
||||
var hierarchicalMessages = await hierarchicalStore.GetMessagesAsync();
|
||||
var hierarchicalMessageList = hierarchicalMessages.ToList();
|
||||
|
||||
// Each should only see its own messages since they use different containers
|
||||
Assert.Single(simpleMessageList);
|
||||
Assert.Single(hierarchicalMessageList);
|
||||
Assert.Equal("Simple partitioning message", simpleMessageList[0].Text);
|
||||
Assert.Equal("Hierarchical partitioning message", hierarchicalMessageList[0].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task MaxMessagesToRetrieve_ShouldLimitAndReturnMostRecentAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string ConversationId = "max-messages-test";
|
||||
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, ConversationId);
|
||||
|
||||
// Add 10 messages
|
||||
var messages = new List<ChatMessage>();
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
messages.Add(new ChatMessage(ChatRole.User, $"Message {i}"));
|
||||
await Task.Delay(10); // Small delay to ensure different timestamps
|
||||
}
|
||||
await store.AddMessagesAsync(messages);
|
||||
|
||||
// Wait for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - Set max to 5 and retrieve
|
||||
store.MaxMessagesToRetrieve = 5;
|
||||
var retrievedMessages = await store.GetMessagesAsync();
|
||||
var messageList = retrievedMessages.ToList();
|
||||
|
||||
// Assert - Should get the 5 most recent messages (6-10) in ascending order
|
||||
Assert.Equal(5, messageList.Count);
|
||||
Assert.Equal("Message 6", messageList[0].Text);
|
||||
Assert.Equal("Message 7", messageList[1].Text);
|
||||
Assert.Equal("Message 8", messageList[2].Text);
|
||||
Assert.Equal("Message 9", messageList[3].Text);
|
||||
Assert.Equal("Message 10", messageList[4].Text);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Trait("Category", "CosmosDB")]
|
||||
public async Task MaxMessagesToRetrieve_Null_ShouldReturnAllMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
const string ConversationId = "max-messages-null-test";
|
||||
|
||||
using var store = new CosmosChatMessageStore(this._connectionString, s_testDatabaseId, TestContainerId, ConversationId);
|
||||
|
||||
// Add 10 messages
|
||||
var messages = new List<ChatMessage>();
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
messages.Add(new ChatMessage(ChatRole.User, $"Message {i}"));
|
||||
}
|
||||
await store.AddMessagesAsync(messages);
|
||||
|
||||
// Wait for eventual consistency
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - No limit set (default null)
|
||||
var retrievedMessages = await store.GetMessagesAsync();
|
||||
var messageList = retrievedMessages.ToList();
|
||||
|
||||
// Assert - Should get all 10 messages
|
||||
Assert.Equal(10, messageList.Count);
|
||||
Assert.Equal("Message 1", messageList[0].Text);
|
||||
Assert.Equal("Message 10", messageList[9].Text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Azure.Cosmos;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="CosmosCheckpointStore"/>.
|
||||
///
|
||||
/// Test Modes:
|
||||
/// - Default Mode: Cleans up all test data after each test run (deletes database)
|
||||
/// - Preserve Mode: Keeps containers and data for inspection in Cosmos DB Emulator Data Explorer
|
||||
///
|
||||
/// To enable Preserve Mode, set environment variable: COSMOS_PRESERVE_CONTAINERS=true
|
||||
/// Example: $env:COSMOS_PRESERVE_CONTAINERS="true"; dotnet test
|
||||
///
|
||||
/// In Preserve Mode, you can view the data in Cosmos DB Emulator Data Explorer at:
|
||||
/// https://localhost:8081/_explorer/index.html
|
||||
/// Database: AgentFrameworkTests
|
||||
/// Container: Checkpoints
|
||||
/// </summary>
|
||||
[Collection("CosmosDB")]
|
||||
public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
|
||||
{
|
||||
// Cosmos DB Emulator connection settings
|
||||
private const string EmulatorEndpoint = "https://localhost:8081";
|
||||
private const string EmulatorKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
|
||||
private const string TestContainerId = "Checkpoints";
|
||||
// Use unique database ID per test class instance to avoid conflicts
|
||||
#pragma warning disable CA1802 // Use literals where appropriate
|
||||
private static readonly string s_testDatabaseId = $"AgentFrameworkTests-CheckpointStore-{Guid.NewGuid():N}";
|
||||
#pragma warning restore CA1802
|
||||
|
||||
private string _connectionString = string.Empty;
|
||||
private CosmosClient? _cosmosClient;
|
||||
private Database? _database;
|
||||
private bool _emulatorAvailable;
|
||||
private bool _preserveContainer;
|
||||
|
||||
// JsonSerializerOptions configured for .NET 9+ compatibility
|
||||
private static readonly JsonSerializerOptions s_jsonOptions = CreateJsonOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions();
|
||||
#if NET9_0_OR_GREATER
|
||||
options.TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver();
|
||||
#endif
|
||||
return options;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Check environment variable to determine if we should preserve containers
|
||||
// Set COSMOS_PRESERVE_CONTAINERS=true to keep containers and data for inspection
|
||||
this._preserveContainer = string.Equals(Environment.GetEnvironmentVariable("COSMOS_PRESERVE_CONTAINERS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
this._connectionString = $"AccountEndpoint={EmulatorEndpoint};AccountKey={EmulatorKey}";
|
||||
|
||||
try
|
||||
{
|
||||
this._cosmosClient = new CosmosClient(EmulatorEndpoint, EmulatorKey);
|
||||
|
||||
// Test connection by attempting to create database
|
||||
this._database = await this._cosmosClient.CreateDatabaseIfNotExistsAsync(s_testDatabaseId);
|
||||
await this._database.CreateContainerIfNotExistsAsync(
|
||||
TestContainerId,
|
||||
"/runId",
|
||||
throughput: 400);
|
||||
|
||||
this._emulatorAvailable = true;
|
||||
}
|
||||
catch (Exception ex) when (!(ex is OutOfMemoryException || ex is StackOverflowException || ex is AccessViolationException))
|
||||
{
|
||||
// Emulator not available, tests will be skipped
|
||||
this._emulatorAvailable = false;
|
||||
this._cosmosClient?.Dispose();
|
||||
this._cosmosClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (this._cosmosClient != null && this._emulatorAvailable)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (this._preserveContainer)
|
||||
{
|
||||
// Preserve mode: Don't delete the database/container, keep data for inspection
|
||||
// This allows viewing data in the Cosmos DB Emulator Data Explorer
|
||||
// No cleanup needed - data persists for debugging
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clean mode: Delete the test database and all data
|
||||
await this._database!.DeleteAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ignore cleanup errors, but log for diagnostics
|
||||
Console.WriteLine($"[DisposeAsync] Cleanup error: {ex.Message}\n{ex.StackTrace}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._cosmosClient.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SkipIfEmulatorNotAvailable()
|
||||
{
|
||||
// In CI: Skip if COSMOS_EMULATOR_AVAILABLE is not set to "true"
|
||||
// Locally: Skip if emulator connection check failed
|
||||
var ciEmulatorAvailable = string.Equals(Environment.GetEnvironmentVariable("COSMOS_EMULATOR_AVAILABLE"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Xunit.Skip.If(!ciEmulatorAvailable && !this._emulatorAvailable, "Cosmos DB Emulator is not available");
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[SkippableFact]
|
||||
public void Constructor_WithCosmosClient_SetsProperties()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Act
|
||||
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
||||
Assert.Equal(TestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public void Constructor_WithConnectionString_SetsProperties()
|
||||
{
|
||||
// Arrange
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Act
|
||||
using var store = new CosmosCheckpointStore(this._connectionString, s_testDatabaseId, TestContainerId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(s_testDatabaseId, store.DatabaseId);
|
||||
Assert.Equal(TestContainerId, store.ContainerId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public void Constructor_WithNullCosmosClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new CosmosCheckpointStore((CosmosClient)null!, s_testDatabaseId, TestContainerId));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public void Constructor_WithNullConnectionString_ThrowsArgumentException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new CosmosCheckpointStore((string)null!, s_testDatabaseId, TestContainerId));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Checkpoint Operations Tests
|
||||
|
||||
[SkippableFact]
|
||||
public async Task CreateCheckpointAsync_NewCheckpoint_CreatesSuccessfullyAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Arrange
|
||||
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
var runId = Guid.NewGuid().ToString();
|
||||
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test checkpoint" }, s_jsonOptions);
|
||||
|
||||
// Act
|
||||
var checkpointInfo = await store.CreateCheckpointAsync(runId, checkpointValue);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(checkpointInfo);
|
||||
Assert.Equal(runId, checkpointInfo.RunId);
|
||||
Assert.NotNull(checkpointInfo.CheckpointId);
|
||||
Assert.NotEmpty(checkpointInfo.CheckpointId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task RetrieveCheckpointAsync_ExistingCheckpoint_ReturnsCorrectValueAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Arrange
|
||||
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
var runId = Guid.NewGuid().ToString();
|
||||
var originalData = new { message = "Hello, World!", timestamp = DateTimeOffset.UtcNow };
|
||||
var checkpointValue = JsonSerializer.SerializeToElement(originalData, s_jsonOptions);
|
||||
|
||||
// Act
|
||||
var checkpointInfo = await store.CreateCheckpointAsync(runId, checkpointValue);
|
||||
var retrievedValue = await store.RetrieveCheckpointAsync(runId, checkpointInfo);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(JsonValueKind.Object, retrievedValue.ValueKind);
|
||||
Assert.True(retrievedValue.TryGetProperty("message", out var messageProp));
|
||||
Assert.Equal("Hello, World!", messageProp.GetString());
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task RetrieveCheckpointAsync_NonExistentCheckpoint_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Arrange
|
||||
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
var runId = Guid.NewGuid().ToString();
|
||||
var fakeCheckpointInfo = new CheckpointInfo(runId, "nonexistent-checkpoint");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
store.RetrieveCheckpointAsync(runId, fakeCheckpointInfo).AsTask());
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task RetrieveIndexAsync_EmptyStore_ReturnsEmptyCollectionAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Arrange
|
||||
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
var runId = Guid.NewGuid().ToString();
|
||||
|
||||
// Act
|
||||
var index = await store.RetrieveIndexAsync(runId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(index);
|
||||
Assert.Empty(index);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task RetrieveIndexAsync_WithCheckpoints_ReturnsAllCheckpointsAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Arrange
|
||||
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
var runId = Guid.NewGuid().ToString();
|
||||
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
||||
|
||||
// Create multiple checkpoints
|
||||
var checkpoint1 = await store.CreateCheckpointAsync(runId, checkpointValue);
|
||||
var checkpoint2 = await store.CreateCheckpointAsync(runId, checkpointValue);
|
||||
var checkpoint3 = await store.CreateCheckpointAsync(runId, checkpointValue);
|
||||
|
||||
// Act
|
||||
var index = (await store.RetrieveIndexAsync(runId)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, index.Count);
|
||||
Assert.Contains(index, c => c.CheckpointId == checkpoint1.CheckpointId);
|
||||
Assert.Contains(index, c => c.CheckpointId == checkpoint2.CheckpointId);
|
||||
Assert.Contains(index, c => c.CheckpointId == checkpoint3.CheckpointId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task CreateCheckpointAsync_WithParent_CreatesHierarchyAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Arrange
|
||||
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
var runId = Guid.NewGuid().ToString();
|
||||
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
||||
|
||||
// Act
|
||||
var parentCheckpoint = await store.CreateCheckpointAsync(runId, checkpointValue);
|
||||
var childCheckpoint = await store.CreateCheckpointAsync(runId, checkpointValue, parentCheckpoint);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(parentCheckpoint.CheckpointId, childCheckpoint.CheckpointId);
|
||||
Assert.Equal(runId, parentCheckpoint.RunId);
|
||||
Assert.Equal(runId, childCheckpoint.RunId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task RetrieveIndexAsync_WithParentFilter_ReturnsFilteredResultsAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Arrange
|
||||
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
var runId = Guid.NewGuid().ToString();
|
||||
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
||||
|
||||
// Create parent and child checkpoints
|
||||
var parent = await store.CreateCheckpointAsync(runId, checkpointValue);
|
||||
var child1 = await store.CreateCheckpointAsync(runId, checkpointValue, parent);
|
||||
var child2 = await store.CreateCheckpointAsync(runId, checkpointValue, parent);
|
||||
|
||||
// Create an orphan checkpoint
|
||||
var orphan = await store.CreateCheckpointAsync(runId, checkpointValue);
|
||||
|
||||
// Act
|
||||
var allCheckpoints = (await store.RetrieveIndexAsync(runId)).ToList();
|
||||
var childrenOfParent = (await store.RetrieveIndexAsync(runId, parent)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(4, allCheckpoints.Count); // parent + 2 children + orphan
|
||||
Assert.Equal(2, childrenOfParent.Count); // only children
|
||||
|
||||
Assert.Contains(childrenOfParent, c => c.CheckpointId == child1.CheckpointId);
|
||||
Assert.Contains(childrenOfParent, c => c.CheckpointId == child2.CheckpointId);
|
||||
Assert.DoesNotContain(childrenOfParent, c => c.CheckpointId == parent.CheckpointId);
|
||||
Assert.DoesNotContain(childrenOfParent, c => c.CheckpointId == orphan.CheckpointId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Run Isolation Tests
|
||||
|
||||
[SkippableFact]
|
||||
public async Task CheckpointOperations_DifferentRuns_IsolatesDataAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Arrange
|
||||
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
var runId1 = Guid.NewGuid().ToString();
|
||||
var runId2 = Guid.NewGuid().ToString();
|
||||
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
||||
|
||||
// Act
|
||||
var checkpoint1 = await store.CreateCheckpointAsync(runId1, checkpointValue);
|
||||
var checkpoint2 = await store.CreateCheckpointAsync(runId2, checkpointValue);
|
||||
|
||||
var index1 = (await store.RetrieveIndexAsync(runId1)).ToList();
|
||||
var index2 = (await store.RetrieveIndexAsync(runId2)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Single(index1);
|
||||
Assert.Single(index2);
|
||||
Assert.Equal(checkpoint1.CheckpointId, index1[0].CheckpointId);
|
||||
Assert.Equal(checkpoint2.CheckpointId, index2[0].CheckpointId);
|
||||
Assert.NotEqual(checkpoint1.CheckpointId, checkpoint2.CheckpointId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[SkippableFact]
|
||||
public async Task CreateCheckpointAsync_WithNullRunId_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Arrange
|
||||
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
store.CreateCheckpointAsync(null!, checkpointValue).AsTask());
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task CreateCheckpointAsync_WithEmptyRunId_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Arrange
|
||||
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
store.CreateCheckpointAsync("", checkpointValue).AsTask());
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task RetrieveCheckpointAsync_WithNullCheckpointInfo_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Arrange
|
||||
using var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
var runId = Guid.NewGuid().ToString();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
store.RetrieveCheckpointAsync(runId, null!).AsTask());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Disposal Tests
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Dispose_AfterDisposal_ThrowsObjectDisposedExceptionAsync()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Arrange
|
||||
var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
var checkpointValue = JsonSerializer.SerializeToElement(new { data = "test" }, s_jsonOptions);
|
||||
|
||||
// Act
|
||||
store.Dispose();
|
||||
|
||||
// Assert
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(() =>
|
||||
store.CreateCheckpointAsync("test-run", checkpointValue).AsTask());
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public void Dispose_MultipleCalls_DoesNotThrow()
|
||||
{
|
||||
this.SkipIfEmulatorNotAvailable();
|
||||
|
||||
// Arrange
|
||||
var store = new CosmosCheckpointStore(this._cosmosClient!, s_testDatabaseId, TestContainerId);
|
||||
|
||||
// Act & Assert (should not throw)
|
||||
store.Dispose();
|
||||
store.Dispose();
|
||||
store.Dispose();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this.Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
this._cosmosClient?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a collection fixture for Cosmos DB tests to ensure they run sequentially.
|
||||
/// This prevents race conditions and resource conflicts when tests create and delete
|
||||
/// databases in the Cosmos DB Emulator.
|
||||
/// </summary>
|
||||
[CollectionDefinition("CosmosDB", DisableParallelization = true)]
|
||||
public sealed class CosmosDBCollectionFixture
|
||||
{
|
||||
// This class has no code, and is never created. Its purpose is simply
|
||||
// to be the place to apply [CollectionDefinition] and all the
|
||||
// ICollectionFixture<> interfaces.
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0;net9.0</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.CosmosNoSql\Microsoft.Agents.AI.CosmosNoSql.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Azure.Cosmos" />
|
||||
<PackageReference Include="Xunit.SkippableFact" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -155,7 +155,11 @@ class AzureAIClient(OpenAIBaseResponsesClient):
|
||||
self.credential = async_credential
|
||||
self.model_id = azure_ai_settings.model_deployment_name
|
||||
self.conversation_id = conversation_id
|
||||
self._should_close_client = should_close_client # Track whether we should close client connection
|
||||
|
||||
# Track whether the application endpoint is used
|
||||
self._is_application_endpoint = "/applications/" in project_client._config.endpoint # type: ignore
|
||||
# Track whether we should close client connection
|
||||
self._should_close_client = should_close_client
|
||||
|
||||
async def setup_azure_ai_observability(self, enable_sensitive_data: bool | None = None) -> None:
|
||||
"""Use this method to setup tracing in your Azure AI Project.
|
||||
@@ -308,15 +312,19 @@ class AzureAIClient(OpenAIBaseResponsesClient):
|
||||
return result, instructions
|
||||
|
||||
async def prepare_options(
|
||||
self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions
|
||||
self,
|
||||
messages: MutableSequence[ChatMessage],
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Take ChatOptions and create the specific options for Azure AI."""
|
||||
chat_options.store = bool(chat_options.store or chat_options.store is None)
|
||||
prepared_messages, instructions = self._prepare_input(messages)
|
||||
run_options = await super().prepare_options(prepared_messages, chat_options)
|
||||
agent_reference = await self._get_agent_reference_or_create(run_options, instructions)
|
||||
run_options = await super().prepare_options(prepared_messages, chat_options, **kwargs)
|
||||
|
||||
run_options["extra_body"] = {"agent": agent_reference}
|
||||
if not self._is_application_endpoint:
|
||||
# Application-scoped response APIs do not support "agent" property.
|
||||
agent_reference = await self._get_agent_reference_or_create(run_options, instructions)
|
||||
run_options["extra_body"] = {"agent": agent_reference}
|
||||
|
||||
conversation_id = chat_options.conversation_id or self.conversation_id
|
||||
|
||||
@@ -378,12 +386,12 @@ class AzureAIClient(OpenAIBaseResponsesClient):
|
||||
self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None
|
||||
) -> str | None:
|
||||
"""Get the conversation ID from the response if store is True."""
|
||||
if store:
|
||||
# If conversation ID exists, it means that we operate with conversation
|
||||
# so we use conversation ID as input and output.
|
||||
if response.conversation and response.conversation.id:
|
||||
return response.conversation.id
|
||||
# If conversation ID doesn't exist, we operate with responses
|
||||
# so we use response ID as input and output.
|
||||
return response.id
|
||||
return None
|
||||
if store is False:
|
||||
return None
|
||||
# If conversation ID exists, it means that we operate with conversation
|
||||
# so we use conversation ID as input and output.
|
||||
if response.conversation and response.conversation.id:
|
||||
return response.conversation.id
|
||||
# If conversation ID doesn't exist, we operate with responses
|
||||
# so we use response ID as input and output.
|
||||
return response.id
|
||||
|
||||
@@ -87,6 +87,7 @@ def create_test_azure_ai_client(
|
||||
client.use_latest_version = use_latest_version
|
||||
client.model_id = azure_ai_settings.model_deployment_name
|
||||
client.conversation_id = conversation_id
|
||||
client._is_application_endpoint = False # type: ignore
|
||||
client._should_close_client = should_close_client # type: ignore
|
||||
client.additional_properties = {}
|
||||
client.middleware = None
|
||||
@@ -305,6 +306,84 @@ async def test_azure_ai_client_prepare_options_basic(mock_project_client: MagicM
|
||||
assert run_options["extra_body"]["agent"]["name"] == "test-agent"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint,expects_agent",
|
||||
[
|
||||
("https://example.com/api/projects/my-project/applications/my-application/protocols", False),
|
||||
("https://example.com/api/projects/my-project", True),
|
||||
],
|
||||
)
|
||||
async def test_azure_ai_client_prepare_options_with_application_endpoint(
|
||||
mock_azure_credential: MagicMock, endpoint: str, expects_agent: bool
|
||||
) -> None:
|
||||
client = AzureAIClient(
|
||||
project_endpoint=endpoint,
|
||||
model_deployment_name="test-model",
|
||||
async_credential=mock_azure_credential,
|
||||
agent_name="test-agent",
|
||||
agent_version="1",
|
||||
)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
|
||||
chat_options = ChatOptions()
|
||||
|
||||
with (
|
||||
patch.object(client.__class__.__bases__[0], "prepare_options", return_value={"model": "test-model"}),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_agent_reference_or_create",
|
||||
return_value={"name": "test-agent", "version": "1", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
|
||||
if expects_agent:
|
||||
assert "extra_body" in run_options
|
||||
assert run_options["extra_body"]["agent"]["name"] == "test-agent"
|
||||
else:
|
||||
assert "extra_body" not in run_options
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint,expects_agent",
|
||||
[
|
||||
("https://example.com/api/projects/my-project/applications/my-application/protocols", False),
|
||||
("https://example.com/api/projects/my-project", True),
|
||||
],
|
||||
)
|
||||
async def test_azure_ai_client_prepare_options_with_application_project_client(
|
||||
mock_project_client: MagicMock, endpoint: str, expects_agent: bool
|
||||
) -> None:
|
||||
mock_project_client._config = MagicMock()
|
||||
mock_project_client._config.endpoint = endpoint
|
||||
|
||||
client = AzureAIClient(
|
||||
project_client=mock_project_client,
|
||||
model_deployment_name="test-model",
|
||||
agent_name="test-agent",
|
||||
agent_version="1",
|
||||
)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
|
||||
chat_options = ChatOptions()
|
||||
|
||||
with (
|
||||
patch.object(client.__class__.__bases__[0], "prepare_options", return_value={"model": "test-model"}),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_agent_reference_or_create",
|
||||
return_value={"name": "test-agent", "version": "1", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
|
||||
if expects_agent:
|
||||
assert "extra_body" in run_options
|
||||
assert run_options["extra_body"]["agent"]["name"] == "test-agent"
|
||||
else:
|
||||
assert "extra_body" not in run_options
|
||||
|
||||
|
||||
async def test_azure_ai_client_initialize_client(mock_project_client: MagicMock) -> None:
|
||||
"""Test initialize_client method."""
|
||||
client = create_test_azure_ai_client(mock_project_client)
|
||||
|
||||
@@ -9,6 +9,7 @@ with Azure Durable Entities, enabling stateful and durable AI agent execution.
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
|
||||
import azure.durable_functions as df
|
||||
@@ -39,6 +40,22 @@ logger = get_logger("agent_framework.azurefunctions")
|
||||
EntityHandler = Callable[[df.DurableEntityContext], None]
|
||||
HandlerT = TypeVar("HandlerT", bound=Callable[..., Any])
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentMetadata:
|
||||
"""Metadata for a registered agent.
|
||||
|
||||
Attributes:
|
||||
agent: The agent instance implementing AgentProtocol
|
||||
http_endpoint_enabled: Whether HTTP endpoint is enabled for this agent
|
||||
mcp_tool_enabled: Whether MCP tool endpoint is enabled for this agent
|
||||
"""
|
||||
|
||||
agent: AgentProtocol
|
||||
http_endpoint_enabled: bool
|
||||
mcp_tool_enabled: bool
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
class DFAppBase:
|
||||
@@ -56,6 +73,15 @@ if TYPE_CHECKING:
|
||||
|
||||
def activity_trigger(self, input_name: str) -> Callable[[HandlerT], HandlerT]: ...
|
||||
|
||||
def mcp_tool_trigger(
|
||||
self,
|
||||
arg_name: str,
|
||||
tool_name: str,
|
||||
description: str,
|
||||
tool_properties: str,
|
||||
data_type: func.DataType,
|
||||
) -> Callable[[HandlerT], HandlerT]: ...
|
||||
|
||||
else:
|
||||
DFAppBase = df.DFApp # type: ignore[assignment]
|
||||
|
||||
@@ -117,14 +143,15 @@ class AgentFunctionApp(DFAppBase):
|
||||
agents: Dictionary of agent name to AgentProtocol instance
|
||||
enable_health_check: Whether health check endpoint is enabled
|
||||
enable_http_endpoints: Whether HTTP endpoints are created for agents
|
||||
enable_mcp_tool_trigger: Whether MCP tool triggers are created for agents
|
||||
max_poll_retries: Maximum polling attempts when waiting for responses
|
||||
poll_interval_seconds: Delay (seconds) between polling attempts
|
||||
"""
|
||||
|
||||
agents: dict[str, AgentProtocol]
|
||||
_agent_metadata: dict[str, AgentMetadata]
|
||||
enable_health_check: bool
|
||||
enable_http_endpoints: bool
|
||||
agent_http_endpoint_flags: dict[str, bool]
|
||||
enable_mcp_tool_trigger: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -134,6 +161,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
enable_http_endpoints: bool = True,
|
||||
max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES,
|
||||
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
enable_mcp_tool_trigger: bool = False,
|
||||
default_callback: AgentResponseCallbackProtocol | None = None,
|
||||
):
|
||||
"""Initialize the AgentFunctionApp.
|
||||
@@ -142,6 +170,8 @@ class AgentFunctionApp(DFAppBase):
|
||||
:param http_auth_level: HTTP authentication level (default: ``func.AuthLevel.FUNCTION``).
|
||||
:param enable_health_check: Enable the built-in health check endpoint (default: ``True``).
|
||||
:param enable_http_endpoints: Enable HTTP endpoints for agents (default: ``True``).
|
||||
:param enable_mcp_tool_trigger: Enable MCP tool triggers for agents (default: ``False``).
|
||||
When enabled, agents will be exposed as MCP tools that can be invoked by MCP-compatible clients.
|
||||
:param max_poll_retries: Maximum polling attempts when waiting for a response.
|
||||
Defaults to ``DEFAULT_MAX_POLL_RETRIES``.
|
||||
:param poll_interval_seconds: Delay in seconds between polling attempts.
|
||||
@@ -155,11 +185,11 @@ class AgentFunctionApp(DFAppBase):
|
||||
# Initialize parent DFApp
|
||||
super().__init__(http_auth_level=http_auth_level)
|
||||
|
||||
# Initialize agents dictionary
|
||||
self.agents = {}
|
||||
self.agent_http_endpoint_flags = {}
|
||||
# Initialize agent metadata dictionary
|
||||
self._agent_metadata = {}
|
||||
self.enable_health_check = enable_health_check
|
||||
self.enable_http_endpoints = enable_http_endpoints
|
||||
self.enable_mcp_tool_trigger = enable_mcp_tool_trigger
|
||||
self.default_callback = default_callback
|
||||
|
||||
try:
|
||||
@@ -186,11 +216,21 @@ class AgentFunctionApp(DFAppBase):
|
||||
|
||||
logger.debug("[AgentFunctionApp] Initialization complete")
|
||||
|
||||
@property
|
||||
def agents(self) -> dict[str, AgentProtocol]:
|
||||
"""Returns dict of agent names to agent instances.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to their AgentProtocol instances.
|
||||
"""
|
||||
return {name: metadata.agent for name, metadata in self._agent_metadata.items()}
|
||||
|
||||
def add_agent(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
callback: AgentResponseCallbackProtocol | None = None,
|
||||
enable_http_endpoint: bool | None = None,
|
||||
enable_mcp_tool_trigger: bool | None = None,
|
||||
) -> None:
|
||||
"""Add an agent to the function app after initialization.
|
||||
|
||||
@@ -198,8 +238,10 @@ class AgentFunctionApp(DFAppBase):
|
||||
agent: The Microsoft Agent Framework agent instance (must implement AgentProtocol)
|
||||
The agent must have a 'name' attribute.
|
||||
callback: Optional callback invoked during agent execution
|
||||
enable_http_endpoint: Optional flag that overrides the app-level
|
||||
HTTP endpoint setting for this agent
|
||||
enable_http_endpoint: Optional flag to enable/disable HTTP endpoint for this agent.
|
||||
The app level enable_http_endpoints setting will override this setting.
|
||||
enable_mcp_tool_trigger: Optional flag to enable/disable MCP tool trigger for this agent.
|
||||
The app level enable_mcp_tool_trigger setting will override this setting.
|
||||
|
||||
Raises:
|
||||
ValueError: If the agent doesn't have a 'name' attribute or if an agent
|
||||
@@ -210,12 +252,17 @@ class AgentFunctionApp(DFAppBase):
|
||||
if name is None:
|
||||
raise ValueError("Agent does not have a 'name' attribute. All agents must have a 'name' attribute.")
|
||||
|
||||
if name in self.agents:
|
||||
if name in self._agent_metadata:
|
||||
raise ValueError(f"Agent with name '{name}' is already registered. Each agent must have a unique name.")
|
||||
|
||||
effective_enable_http_endpoint = (
|
||||
self.enable_http_endpoints if enable_http_endpoint is None else self._coerce_to_bool(enable_http_endpoint)
|
||||
)
|
||||
effective_enable_mcp_endpoint = (
|
||||
self.enable_mcp_tool_trigger
|
||||
if enable_mcp_tool_trigger is None
|
||||
else self._coerce_to_bool(enable_mcp_tool_trigger)
|
||||
)
|
||||
|
||||
logger.debug(f"[AgentFunctionApp] Adding agent: {name}")
|
||||
logger.debug(f"[AgentFunctionApp] Route: /api/agents/{name}")
|
||||
@@ -224,17 +271,21 @@ class AgentFunctionApp(DFAppBase):
|
||||
"enabled" if effective_enable_http_endpoint else "disabled",
|
||||
name,
|
||||
)
|
||||
logger.debug(
|
||||
f"[AgentFunctionApp] MCP tool trigger: {'enabled' if effective_enable_mcp_endpoint else 'disabled'}"
|
||||
)
|
||||
|
||||
self.agents[name] = agent
|
||||
self.agent_http_endpoint_flags[name] = effective_enable_http_endpoint
|
||||
# Store agent metadata
|
||||
self._agent_metadata[name] = AgentMetadata(
|
||||
agent=agent,
|
||||
http_endpoint_enabled=effective_enable_http_endpoint,
|
||||
mcp_tool_enabled=effective_enable_mcp_endpoint,
|
||||
)
|
||||
|
||||
effective_callback = callback or self.default_callback
|
||||
|
||||
self._setup_agent_functions(
|
||||
agent,
|
||||
name,
|
||||
effective_callback,
|
||||
effective_enable_http_endpoint,
|
||||
agent, name, effective_callback, effective_enable_http_endpoint, effective_enable_mcp_endpoint
|
||||
)
|
||||
|
||||
logger.debug(f"[AgentFunctionApp] Agent '{name}' added successfully")
|
||||
@@ -258,7 +309,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
"""
|
||||
normalized_name = str(agent_name)
|
||||
|
||||
if normalized_name not in self.agents:
|
||||
if normalized_name not in self._agent_metadata:
|
||||
raise ValueError(f"Agent '{normalized_name}' is not registered with this app.")
|
||||
|
||||
return DurableAIAgent(context, normalized_name)
|
||||
@@ -269,15 +320,16 @@ class AgentFunctionApp(DFAppBase):
|
||||
agent_name: str,
|
||||
callback: AgentResponseCallbackProtocol | None,
|
||||
enable_http_endpoint: bool,
|
||||
enable_mcp_tool_trigger: bool,
|
||||
) -> None:
|
||||
"""Set up the HTTP trigger and entity for a specific agent.
|
||||
"""Set up the HTTP trigger, entity, and MCP tool trigger for a specific agent.
|
||||
|
||||
Args:
|
||||
agent: The agent instance
|
||||
agent_name: The name to use for routing and entity registration
|
||||
callback: Optional callback to receive response updates
|
||||
enable_http_endpoint: Whether the HTTP run route is enabled for
|
||||
this agent
|
||||
enable_http_endpoint: Whether to create HTTP endpoint
|
||||
enable_mcp_tool_trigger: Whether to create MCP tool trigger
|
||||
"""
|
||||
logger.debug(f"[AgentFunctionApp] Setting up functions for agent '{agent_name}'...")
|
||||
|
||||
@@ -290,6 +342,12 @@ class AgentFunctionApp(DFAppBase):
|
||||
)
|
||||
self._setup_agent_entity(agent, agent_name, callback)
|
||||
|
||||
if enable_mcp_tool_trigger:
|
||||
agent_description = agent.description
|
||||
self._setup_mcp_tool_trigger(agent_name, agent_description)
|
||||
else:
|
||||
logger.debug(f"[AgentFunctionApp] MCP tool trigger disabled for agent '{agent_name}'")
|
||||
|
||||
def _setup_http_run_route(self, agent_name: str) -> None:
|
||||
"""Register the POST route that triggers agent execution.
|
||||
|
||||
@@ -448,6 +506,159 @@ class AgentFunctionApp(DFAppBase):
|
||||
entity_function.__name__ = entity_name_with_prefix
|
||||
self.entity_trigger(context_name="context", entity_name=entity_name_with_prefix)(entity_function)
|
||||
|
||||
def _setup_mcp_tool_trigger(self, agent_name: str, agent_description: str | None) -> None:
|
||||
"""Register an MCP tool trigger for an agent using Azure Functions native MCP support.
|
||||
|
||||
This creates a native Azure Functions MCP tool trigger that exposes the agent
|
||||
as an MCP tool, allowing it to be invoked by MCP-compatible clients.
|
||||
|
||||
Args:
|
||||
agent_name: The agent name (used as the MCP tool name)
|
||||
agent_description: Optional description for the MCP tool (shown to clients)
|
||||
"""
|
||||
mcp_function_name = self._build_function_name(agent_name, "mcptool")
|
||||
|
||||
# Define tool properties as JSON (MCP tool parameters)
|
||||
tool_properties = json.dumps([
|
||||
{
|
||||
"propertyName": "query",
|
||||
"propertyType": "string",
|
||||
"description": "The query to send to the agent.",
|
||||
"isRequired": True,
|
||||
"isArray": False,
|
||||
},
|
||||
{
|
||||
"propertyName": "threadId",
|
||||
"propertyType": "string",
|
||||
"description": "Optional thread identifier for conversation continuity.",
|
||||
"isRequired": False,
|
||||
"isArray": False,
|
||||
},
|
||||
])
|
||||
|
||||
function_name_decorator = self.function_name(mcp_function_name)
|
||||
mcp_tool_decorator = self.mcp_tool_trigger(
|
||||
arg_name="context",
|
||||
tool_name=agent_name,
|
||||
description=agent_description or f"Interact with {agent_name} agent",
|
||||
tool_properties=tool_properties,
|
||||
data_type=func.DataType.UNDEFINED,
|
||||
)
|
||||
durable_client_decorator = self.durable_client_input(client_name="client")
|
||||
|
||||
@function_name_decorator
|
||||
@mcp_tool_decorator
|
||||
@durable_client_decorator
|
||||
async def mcp_tool_handler(context: str, client: df.DurableOrchestrationClient) -> str:
|
||||
"""Handle MCP tool invocation for the agent.
|
||||
|
||||
Args:
|
||||
context: MCP tool invocation context containing arguments (query, threadId)
|
||||
client: Durable orchestration client for entity communication
|
||||
|
||||
Returns:
|
||||
Agent response text
|
||||
"""
|
||||
logger.debug("[MCP Tool Trigger] Received invocation for agent: %s", agent_name)
|
||||
return await self._handle_mcp_tool_invocation(agent_name=agent_name, context=context, client=client)
|
||||
|
||||
logger.debug("[AgentFunctionApp] Registered MCP tool trigger for agent: %s", agent_name)
|
||||
|
||||
async def _handle_mcp_tool_invocation(
|
||||
self, agent_name: str, context: str, client: df.DurableOrchestrationClient
|
||||
) -> str:
|
||||
"""Handle an MCP tool invocation.
|
||||
|
||||
This method processes MCP tool requests and delegates to the agent entity.
|
||||
|
||||
Args:
|
||||
agent_name: Name of the agent being invoked
|
||||
context: MCP tool invocation context as a JSON string
|
||||
client: Durable orchestration client
|
||||
|
||||
Returns:
|
||||
Agent response text
|
||||
|
||||
Raises:
|
||||
ValueError: If required arguments are missing or context is invalid JSON
|
||||
RuntimeError: If agent execution fails
|
||||
"""
|
||||
logger.debug("[MCP Tool Handler] Processing invocation for agent '%s'", agent_name)
|
||||
|
||||
# Parse JSON context string
|
||||
try:
|
||||
parsed_context = json.loads(context)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid MCP context format: {e}") from e
|
||||
|
||||
# Extract arguments from MCP context
|
||||
arguments = parsed_context.get("arguments", {}) if isinstance(parsed_context, dict) else {}
|
||||
|
||||
# Validate required 'query' argument
|
||||
query = arguments.get("query")
|
||||
if not query or not isinstance(query, str):
|
||||
raise ValueError("MCP Tool invocation is missing required 'query' argument of type string.")
|
||||
|
||||
# Extract optional threadId
|
||||
thread_id = arguments.get("threadId")
|
||||
|
||||
# Create or parse session ID
|
||||
if thread_id and isinstance(thread_id, str) and thread_id.strip():
|
||||
try:
|
||||
session_id = AgentSessionId.parse(thread_id)
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"Failed to parse AgentSessionId from thread_id '%s': %s. Falling back to new session ID.",
|
||||
thread_id,
|
||||
e,
|
||||
)
|
||||
session_id = AgentSessionId(name=agent_name, key=thread_id)
|
||||
else:
|
||||
# Generate new session ID
|
||||
session_id = AgentSessionId.with_random_key(agent_name)
|
||||
|
||||
# Build entity instance ID
|
||||
entity_instance_id = session_id.to_entity_id()
|
||||
|
||||
# Create run request
|
||||
correlation_id = self._generate_unique_id()
|
||||
run_request = self._build_request_data(
|
||||
req_body={"message": query, "role": "user"},
|
||||
message=query,
|
||||
thread_id=str(session_id),
|
||||
correlation_id=correlation_id,
|
||||
request_response_format=REQUEST_RESPONSE_FORMAT_TEXT,
|
||||
)
|
||||
|
||||
query_preview = query[:50] + "..." if len(query) > 50 else query
|
||||
logger.info("[MCP Tool] Invoking agent '%s' with query: %s", agent_name, query_preview)
|
||||
|
||||
# Signal entity to run agent
|
||||
await client.signal_entity(entity_instance_id, "run_agent", run_request)
|
||||
|
||||
# Poll for response (similar to HTTP handler)
|
||||
try:
|
||||
result = await self._get_response_from_entity(
|
||||
client=client,
|
||||
entity_instance_id=entity_instance_id,
|
||||
correlation_id=correlation_id,
|
||||
message=query,
|
||||
thread_id=str(session_id),
|
||||
)
|
||||
|
||||
# Extract and return response text
|
||||
if result.get("status") == "success":
|
||||
response_text = str(result.get("response", "No response"))
|
||||
logger.info("[MCP Tool] Agent '%s' responded successfully", agent_name)
|
||||
return response_text
|
||||
error_msg = result.get("error", "Unknown error")
|
||||
logger.error("[MCP Tool] Agent '%s' execution failed: %s", agent_name, error_msg)
|
||||
raise RuntimeError(f"Agent execution failed: {error_msg}")
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("[MCP Tool] Error invoking agent '%s': %s", agent_name, exc, exc_info=True)
|
||||
raise
|
||||
|
||||
def _setup_health_route(self) -> None:
|
||||
"""Register the optional health check route."""
|
||||
health_route = self.route(route="health", methods=["GET"])
|
||||
@@ -458,16 +669,14 @@ class AgentFunctionApp(DFAppBase):
|
||||
agent_info = [
|
||||
{
|
||||
"name": name,
|
||||
"type": type(agent).__name__,
|
||||
"http_endpoint_enabled": self.agent_http_endpoint_flags.get(
|
||||
name,
|
||||
self.enable_http_endpoints,
|
||||
),
|
||||
"type": type(metadata.agent).__name__,
|
||||
"http_endpoint_enabled": metadata.http_endpoint_enabled,
|
||||
"mcp_tool_enabled": metadata.mcp_tool_enabled,
|
||||
}
|
||||
for name, agent in self.agents.items()
|
||||
for name, metadata in self._agent_metadata.items()
|
||||
]
|
||||
return func.HttpResponse(
|
||||
json.dumps({"status": "healthy", "agents": agent_info, "agent_count": len(self.agents)}),
|
||||
json.dumps({"status": "healthy", "agents": agent_info, "agent_count": len(self._agent_metadata)}),
|
||||
status_code=200,
|
||||
mimetype=MIMETYPE_APPLICATION_JSON,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
"""Unit tests for AgentFunctionApp."""
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, TypeVar
|
||||
from unittest.mock import ANY, AsyncMock, Mock, patch
|
||||
@@ -87,7 +88,7 @@ class TestAgentFunctionAppInit:
|
||||
app.add_agent(mock_agent, callback=specific_callback)
|
||||
|
||||
setup_mock.assert_called_once()
|
||||
_, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0]
|
||||
_, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0]
|
||||
assert passed_callback is specific_callback
|
||||
assert enable_http_endpoint is True
|
||||
|
||||
@@ -103,7 +104,7 @@ class TestAgentFunctionAppInit:
|
||||
app.add_agent(mock_agent)
|
||||
|
||||
setup_mock.assert_called_once()
|
||||
_, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0]
|
||||
_, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0]
|
||||
assert passed_callback is default_callback
|
||||
assert enable_http_endpoint is True
|
||||
|
||||
@@ -118,7 +119,7 @@ class TestAgentFunctionAppInit:
|
||||
AgentFunctionApp(agents=[mock_agent], default_callback=default_callback)
|
||||
|
||||
setup_mock.assert_called_once()
|
||||
_, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0]
|
||||
_, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0]
|
||||
assert passed_callback is default_callback
|
||||
assert enable_http_endpoint is True
|
||||
|
||||
@@ -239,7 +240,7 @@ class TestAgentFunctionAppSetup:
|
||||
|
||||
http_route_mock.assert_called_once_with("OverrideAgent")
|
||||
agent_entity_mock.assert_called_once_with(mock_agent, "OverrideAgent", ANY)
|
||||
assert app.agent_http_endpoint_flags["OverrideAgent"] is True
|
||||
assert app._agent_metadata["OverrideAgent"].http_endpoint_enabled is True
|
||||
|
||||
def test_agent_override_disables_http_route_when_app_enabled(self) -> None:
|
||||
"""Agent-level override should disable HTTP route even when app enables it."""
|
||||
@@ -256,7 +257,7 @@ class TestAgentFunctionAppSetup:
|
||||
|
||||
http_route_mock.assert_not_called()
|
||||
agent_entity_mock.assert_called_once_with(mock_agent, "DisabledOverride", ANY)
|
||||
assert app.agent_http_endpoint_flags["DisabledOverride"] is False
|
||||
assert app._agent_metadata["DisabledOverride"].http_endpoint_enabled is False
|
||||
|
||||
def test_multiple_apps_independent(self) -> None:
|
||||
"""Test that multiple AgentFunctionApp instances are independent."""
|
||||
@@ -797,5 +798,271 @@ class TestHttpRunRoute:
|
||||
client.signal_entity.assert_not_called()
|
||||
|
||||
|
||||
class TestMCPToolEndpoint:
|
||||
"""Test suite for MCP tool endpoint functionality."""
|
||||
|
||||
def test_init_with_mcp_tool_endpoint_enabled(self) -> None:
|
||||
"""Test initialization with MCP tool endpoint enabled."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True)
|
||||
|
||||
assert app.enable_mcp_tool_trigger is True
|
||||
|
||||
def test_init_with_mcp_tool_endpoint_disabled(self) -> None:
|
||||
"""Test initialization with MCP tool endpoint disabled (default)."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent])
|
||||
|
||||
assert app.enable_mcp_tool_trigger is False
|
||||
|
||||
def test_add_agent_with_mcp_tool_trigger_enabled(self) -> None:
|
||||
"""Test adding an agent with MCP tool trigger explicitly enabled."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "MCPAgent"
|
||||
mock_agent.description = "Test MCP Agent"
|
||||
|
||||
with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock:
|
||||
app = AgentFunctionApp()
|
||||
app.add_agent(mock_agent, enable_mcp_tool_trigger=True)
|
||||
|
||||
setup_mock.assert_called_once()
|
||||
_, _, _, _, enable_mcp = setup_mock.call_args[0]
|
||||
assert enable_mcp is True
|
||||
|
||||
def test_add_agent_with_mcp_tool_trigger_disabled(self) -> None:
|
||||
"""Test adding an agent with MCP tool trigger explicitly disabled."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "NoMCPAgent"
|
||||
|
||||
with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock:
|
||||
app = AgentFunctionApp(enable_mcp_tool_trigger=True)
|
||||
app.add_agent(mock_agent, enable_mcp_tool_trigger=False)
|
||||
|
||||
setup_mock.assert_called_once()
|
||||
_, _, _, _, enable_mcp = setup_mock.call_args[0]
|
||||
assert enable_mcp is False
|
||||
|
||||
def test_agent_override_enables_mcp_when_app_disabled(self) -> None:
|
||||
"""Test that per-agent override can enable MCP when app-level is disabled."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "OverrideAgent"
|
||||
|
||||
with patch.object(AgentFunctionApp, "_setup_mcp_tool_trigger") as mcp_setup_mock:
|
||||
app = AgentFunctionApp(enable_mcp_tool_trigger=False)
|
||||
app.add_agent(mock_agent, enable_mcp_tool_trigger=True)
|
||||
|
||||
mcp_setup_mock.assert_called_once()
|
||||
|
||||
def test_agent_override_disables_mcp_when_app_enabled(self) -> None:
|
||||
"""Test that per-agent override can disable MCP when app-level is enabled."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "NoOverrideAgent"
|
||||
|
||||
with patch.object(AgentFunctionApp, "_setup_mcp_tool_trigger") as mcp_setup_mock:
|
||||
app = AgentFunctionApp(enable_mcp_tool_trigger=True)
|
||||
app.add_agent(mock_agent, enable_mcp_tool_trigger=False)
|
||||
|
||||
mcp_setup_mock.assert_not_called()
|
||||
|
||||
def test_setup_mcp_tool_trigger_registers_decorators(self) -> None:
|
||||
"""Test that _setup_mcp_tool_trigger registers the correct decorators."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "MCPToolAgent"
|
||||
mock_agent.description = "Test MCP Tool"
|
||||
|
||||
app = AgentFunctionApp()
|
||||
|
||||
# Mock the decorators
|
||||
with (
|
||||
patch.object(app, "function_name") as func_name_mock,
|
||||
patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock,
|
||||
patch.object(app, "durable_client_input") as client_mock,
|
||||
):
|
||||
# Setup mock decorator chain
|
||||
func_name_mock.return_value = lambda f: f
|
||||
mcp_trigger_mock.return_value = lambda f: f
|
||||
client_mock.return_value = lambda f: f
|
||||
|
||||
app._setup_mcp_tool_trigger(mock_agent.name, mock_agent.description)
|
||||
|
||||
# Verify decorators were called with correct parameters
|
||||
func_name_mock.assert_called_once()
|
||||
mcp_trigger_mock.assert_called_once_with(
|
||||
arg_name="context",
|
||||
tool_name=mock_agent.name,
|
||||
description=mock_agent.description,
|
||||
tool_properties=ANY,
|
||||
data_type=func.DataType.UNDEFINED,
|
||||
)
|
||||
client_mock.assert_called_once_with(client_name="client")
|
||||
|
||||
def test_setup_mcp_tool_trigger_uses_default_description(self) -> None:
|
||||
"""Test that _setup_mcp_tool_trigger uses default description when none provided."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "NoDescAgent"
|
||||
|
||||
app = AgentFunctionApp()
|
||||
|
||||
with (
|
||||
patch.object(app, "function_name", return_value=lambda f: f),
|
||||
patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock,
|
||||
patch.object(app, "durable_client_input", return_value=lambda f: f),
|
||||
):
|
||||
mcp_trigger_mock.return_value = lambda f: f
|
||||
|
||||
app._setup_mcp_tool_trigger(mock_agent.name, None)
|
||||
|
||||
# Verify default description was used
|
||||
call_args = mcp_trigger_mock.call_args
|
||||
assert call_args[1]["description"] == f"Interact with {mock_agent.name} agent"
|
||||
|
||||
async def test_handle_mcp_tool_invocation_with_json_string(self) -> None:
|
||||
"""Test _handle_mcp_tool_invocation with JSON string context."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent])
|
||||
client = AsyncMock()
|
||||
|
||||
# Mock the entity response
|
||||
mock_state = Mock()
|
||||
mock_state.entity_state = {
|
||||
"schemaVersion": "1.0.0",
|
||||
"data": {"conversationHistory": []},
|
||||
}
|
||||
client.read_entity_state.return_value = mock_state
|
||||
|
||||
# Create JSON string context
|
||||
context = '{"arguments": {"query": "test query", "threadId": "test-thread"}}'
|
||||
|
||||
with patch.object(app, "_get_response_from_entity") as get_response_mock:
|
||||
get_response_mock.return_value = {"status": "success", "response": "Test response"}
|
||||
|
||||
result = await app._handle_mcp_tool_invocation("TestAgent", context, client)
|
||||
|
||||
assert result == "Test response"
|
||||
get_response_mock.assert_called_once()
|
||||
|
||||
async def test_handle_mcp_tool_invocation_with_json_context(self) -> None:
|
||||
"""Test _handle_mcp_tool_invocation with JSON string context."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent])
|
||||
client = AsyncMock()
|
||||
|
||||
# Mock the entity response
|
||||
mock_state = Mock()
|
||||
mock_state.entity_state = {
|
||||
"schemaVersion": "1.0.0",
|
||||
"data": {"conversationHistory": []},
|
||||
}
|
||||
client.read_entity_state.return_value = mock_state
|
||||
|
||||
# Create JSON string context
|
||||
context = json.dumps({"arguments": {"query": "test query", "threadId": "test-thread"}})
|
||||
|
||||
with patch.object(app, "_get_response_from_entity") as get_response_mock:
|
||||
get_response_mock.return_value = {"status": "success", "response": "Test response"}
|
||||
|
||||
result = await app._handle_mcp_tool_invocation("TestAgent", context, client)
|
||||
|
||||
assert result == "Test response"
|
||||
get_response_mock.assert_called_once()
|
||||
|
||||
async def test_handle_mcp_tool_invocation_missing_query(self) -> None:
|
||||
"""Test _handle_mcp_tool_invocation raises ValueError when query is missing."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent])
|
||||
client = AsyncMock()
|
||||
|
||||
# Context missing query (as JSON string)
|
||||
context = json.dumps({"arguments": {}})
|
||||
|
||||
with pytest.raises(ValueError, match="missing required 'query' argument"):
|
||||
await app._handle_mcp_tool_invocation("TestAgent", context, client)
|
||||
|
||||
async def test_handle_mcp_tool_invocation_invalid_json(self) -> None:
|
||||
"""Test _handle_mcp_tool_invocation raises ValueError for invalid JSON."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent])
|
||||
client = AsyncMock()
|
||||
|
||||
# Invalid JSON string
|
||||
context = "not valid json"
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid MCP context format"):
|
||||
await app._handle_mcp_tool_invocation("TestAgent", context, client)
|
||||
|
||||
async def test_handle_mcp_tool_invocation_runtime_error(self) -> None:
|
||||
"""Test _handle_mcp_tool_invocation raises RuntimeError when agent fails."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent])
|
||||
client = AsyncMock()
|
||||
|
||||
# Mock the entity response
|
||||
mock_state = Mock()
|
||||
mock_state.entity_state = {
|
||||
"schemaVersion": "1.0.0",
|
||||
"data": {"conversationHistory": []},
|
||||
}
|
||||
client.read_entity_state.return_value = mock_state
|
||||
|
||||
context = '{"arguments": {"query": "test query"}}'
|
||||
|
||||
with patch.object(app, "_get_response_from_entity") as get_response_mock:
|
||||
get_response_mock.return_value = {"status": "failed", "error": "Agent error"}
|
||||
|
||||
with pytest.raises(RuntimeError, match="Agent execution failed"):
|
||||
await app._handle_mcp_tool_invocation("TestAgent", context, client)
|
||||
|
||||
def test_health_check_includes_mcp_tool_enabled(self) -> None:
|
||||
"""Test that health check endpoint includes mcp_tool_enabled field."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "HealthAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True)
|
||||
|
||||
# Capture the health check handler function
|
||||
captured_handler = None
|
||||
|
||||
def capture_decorator(*args, **kwargs):
|
||||
def decorator(func):
|
||||
nonlocal captured_handler
|
||||
captured_handler = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
with patch.object(app, "route", side_effect=capture_decorator):
|
||||
app._setup_health_route()
|
||||
|
||||
# Verify we captured the handler
|
||||
assert captured_handler is not None
|
||||
|
||||
# Call the health handler
|
||||
request = Mock()
|
||||
response = captured_handler(request)
|
||||
|
||||
# Verify response includes mcp_tool_enabled
|
||||
import json
|
||||
|
||||
body = json.loads(response.get_body().decode("utf-8"))
|
||||
assert "agents" in body
|
||||
assert len(body["agents"]) == 1
|
||||
assert "mcp_tool_enabled" in body["agents"][0]
|
||||
assert body["agents"][0]["mcp_tool_enabled"] is True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
|
||||
@@ -27,6 +27,7 @@ from chatkit.types import (
|
||||
EndOfTurnItem,
|
||||
HiddenContextItem,
|
||||
ImageAttachment,
|
||||
SDKHiddenContextItem,
|
||||
TaskItem,
|
||||
ThreadItem,
|
||||
UserMessageItem,
|
||||
@@ -180,8 +181,10 @@ class ThreadItemConverter:
|
||||
# Subclasses can override this method to provide custom handling
|
||||
return None
|
||||
|
||||
def hidden_context_to_input(self, item: HiddenContextItem) -> ChatMessage | list[ChatMessage] | None:
|
||||
"""Convert a ChatKit HiddenContextItem to Agent Framework ChatMessage(s).
|
||||
def hidden_context_to_input(
|
||||
self, item: HiddenContextItem | SDKHiddenContextItem
|
||||
) -> ChatMessage | list[ChatMessage] | None:
|
||||
"""Convert a ChatKit HiddenContextItem or SDKHiddenContextItem to Agent Framework ChatMessage(s).
|
||||
|
||||
This method is called internally by `to_agent_input()`. Override this method
|
||||
to customize how hidden context is converted.
|
||||
@@ -522,6 +525,9 @@ class ThreadItemConverter:
|
||||
case HiddenContextItem():
|
||||
out = self.hidden_context_to_input(item) or []
|
||||
return out if isinstance(out, list) else [out]
|
||||
case SDKHiddenContextItem():
|
||||
out = self.hidden_context_to_input(item) or []
|
||||
return out if isinstance(out, list) else [out]
|
||||
case _:
|
||||
assert_never(item)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core",
|
||||
"openai-chatkit>=1.1.0,<2.0.0",
|
||||
"openai-chatkit>=1.4.0,<2.0.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -568,10 +568,6 @@ class BaseChatClient(SerializationMixin, ABC):
|
||||
additional_properties=additional_properties,
|
||||
)
|
||||
|
||||
# Validate that store is True when conversation_id is set
|
||||
if chat_options.conversation_id is not None and chat_options.store is not True:
|
||||
chat_options.store = True
|
||||
|
||||
if chat_options.instructions:
|
||||
system_msg = ChatMessage(role="system", text=chat_options.instructions)
|
||||
prepped_messages = [system_msg, *prepare_messages(messages)]
|
||||
@@ -666,10 +662,6 @@ class BaseChatClient(SerializationMixin, ABC):
|
||||
additional_properties=additional_properties,
|
||||
)
|
||||
|
||||
# Validate that store is True when conversation_id is set
|
||||
if chat_options.conversation_id is not None and chat_options.store is not True:
|
||||
chat_options.store = True
|
||||
|
||||
if chat_options.instructions:
|
||||
system_msg = ChatMessage(role="system", text=chat_options.instructions)
|
||||
prepped_messages = [system_msg, *prepare_messages(messages)]
|
||||
|
||||
@@ -61,6 +61,8 @@ from ._group_chat import (
|
||||
GroupChatDirective,
|
||||
GroupChatStateSnapshot,
|
||||
ManagerDirectiveModel,
|
||||
ManagerSelectionRequest,
|
||||
ManagerSelectionResponse,
|
||||
)
|
||||
from ._handoff import HandoffBuilder, HandoffUserInputRequest
|
||||
from ._magentic import (
|
||||
@@ -147,6 +149,8 @@ __all__ = [
|
||||
"MagenticPlanReviewReply",
|
||||
"MagenticPlanReviewRequest",
|
||||
"ManagerDirectiveModel",
|
||||
"ManagerSelectionRequest",
|
||||
"ManagerSelectionResponse",
|
||||
"Message",
|
||||
"OrchestrationState",
|
||||
"RequestInfoEvent",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1424,6 +1424,7 @@ class HandoffBuilder:
|
||||
prompt=self._request_prompt,
|
||||
id="handoff-user-input",
|
||||
)
|
||||
builder = WorkflowBuilder(name=self._name, description=self._description).set_start_executor(input_node)
|
||||
|
||||
specialist_aliases = {alias: exec_id for alias, exec_id in self._aliases.items() if exec_id in specialists}
|
||||
|
||||
@@ -1440,6 +1441,7 @@ class HandoffBuilder:
|
||||
|
||||
wiring = _GroupChatConfig(
|
||||
manager=None,
|
||||
manager_participant=None,
|
||||
manager_name=self._starting_agent_id,
|
||||
participants=participant_specs,
|
||||
max_rounds=None,
|
||||
@@ -1453,14 +1455,13 @@ class HandoffBuilder:
|
||||
orchestrator_factory=_handoff_orchestrator_factory,
|
||||
interceptors=(),
|
||||
checkpoint_storage=self._checkpoint_storage,
|
||||
builder=WorkflowBuilder(name=self._name, description=self._description),
|
||||
builder=builder,
|
||||
return_builder=True,
|
||||
)
|
||||
if not isinstance(result, tuple):
|
||||
raise TypeError("Expected tuple from assemble_group_chat_workflow with return_builder=True")
|
||||
builder, coordinator = result
|
||||
|
||||
builder = builder.set_start_executor(input_node)
|
||||
builder = builder.add_edge(input_node, starting_executor)
|
||||
builder = builder.add_edge(coordinator, user_gateway)
|
||||
builder = builder.add_edge(user_gateway, coordinator)
|
||||
|
||||
@@ -961,7 +961,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
|
||||
async def _emit_orchestrator_message(
|
||||
self,
|
||||
ctx: WorkflowContext[Any, ChatMessage],
|
||||
ctx: WorkflowContext[Any, list[ChatMessage]],
|
||||
message: ChatMessage,
|
||||
kind: str,
|
||||
) -> None:
|
||||
@@ -1110,7 +1110,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
self,
|
||||
message: _MagenticStartMessage,
|
||||
context: WorkflowContext[
|
||||
_MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage
|
||||
_MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, list[ChatMessage]
|
||||
],
|
||||
) -> None:
|
||||
"""Handle the initial start message to begin orchestration."""
|
||||
@@ -1145,7 +1145,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
|
||||
# Start the inner loop
|
||||
ctx2 = cast(
|
||||
WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage],
|
||||
WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]],
|
||||
context,
|
||||
)
|
||||
await self._run_inner_loop(ctx2)
|
||||
@@ -1155,7 +1155,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
self,
|
||||
task_text: str,
|
||||
context: WorkflowContext[
|
||||
_MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage
|
||||
_MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, list[ChatMessage]
|
||||
],
|
||||
) -> None:
|
||||
await self.handle_start_message(_MagenticStartMessage.from_string(task_text), context)
|
||||
@@ -1165,7 +1165,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
self,
|
||||
task_message: ChatMessage,
|
||||
context: WorkflowContext[
|
||||
_MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage
|
||||
_MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, list[ChatMessage]
|
||||
],
|
||||
) -> None:
|
||||
await self.handle_start_message(_MagenticStartMessage(task_message), context)
|
||||
@@ -1175,7 +1175,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
self,
|
||||
conversation: list[ChatMessage],
|
||||
context: WorkflowContext[
|
||||
_MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage
|
||||
_MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, list[ChatMessage]
|
||||
],
|
||||
) -> None:
|
||||
await self.handle_start_message(_MagenticStartMessage(conversation), context)
|
||||
@@ -1184,7 +1184,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
async def handle_response_message(
|
||||
self,
|
||||
message: _MagenticResponseMessage,
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage],
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]],
|
||||
) -> None:
|
||||
"""Handle responses from agents."""
|
||||
if getattr(self, "_terminated", False):
|
||||
@@ -1216,7 +1216,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
response: _MagenticPlanReviewReply,
|
||||
context: WorkflowContext[
|
||||
# may broadcast ledger next, or ask for another round of review
|
||||
_MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage
|
||||
_MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, list[ChatMessage]
|
||||
],
|
||||
) -> None:
|
||||
if getattr(self, "_terminated", False):
|
||||
@@ -1262,7 +1262,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
|
||||
# Enter the normal coordination loop
|
||||
ctx2 = cast(
|
||||
WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage],
|
||||
WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]],
|
||||
context,
|
||||
)
|
||||
await self._run_inner_loop(ctx2)
|
||||
@@ -1289,7 +1289,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
self._context.chat_history.append(self._task_ledger)
|
||||
# No further review requests; proceed directly into coordination
|
||||
ctx2 = cast(
|
||||
WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage],
|
||||
WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]],
|
||||
context,
|
||||
)
|
||||
await self._run_inner_loop(ctx2)
|
||||
@@ -1324,7 +1324,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
|
||||
async def _run_outer_loop(
|
||||
self,
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage],
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]],
|
||||
) -> None:
|
||||
"""Run the outer orchestration loop - planning phase."""
|
||||
if self._context is None:
|
||||
@@ -1347,7 +1347,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
|
||||
async def _run_inner_loop(
|
||||
self,
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage],
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]],
|
||||
) -> None:
|
||||
"""Run the inner orchestration loop. Coordination phase. Serialized with a lock."""
|
||||
if self._context is None or self._task_ledger is None:
|
||||
@@ -1357,7 +1357,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
|
||||
async def _run_inner_loop_helper(
|
||||
self,
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage],
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]],
|
||||
) -> None:
|
||||
"""Run inner loop with exclusive access."""
|
||||
# Narrow optional context for the remainder of this method
|
||||
@@ -1442,7 +1442,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
|
||||
async def _reset_and_replan(
|
||||
self,
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage],
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]],
|
||||
) -> None:
|
||||
"""Reset context and replan."""
|
||||
if self._context is None:
|
||||
@@ -1468,7 +1468,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
|
||||
async def _prepare_final_answer(
|
||||
self,
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage],
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]],
|
||||
) -> None:
|
||||
"""Prepare the final answer using the manager."""
|
||||
if self._context is None:
|
||||
@@ -1478,11 +1478,11 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
final_answer = await self._manager.prepare_final_answer(self._context.clone(deep=True))
|
||||
|
||||
# Emit a completed event for the workflow
|
||||
await context.yield_output(final_answer)
|
||||
await context.yield_output([final_answer])
|
||||
|
||||
async def _check_within_limits_or_complete(
|
||||
self,
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage],
|
||||
context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, list[ChatMessage]],
|
||||
) -> bool:
|
||||
"""Check if orchestrator is within operational limits."""
|
||||
if self._context is None:
|
||||
@@ -1509,7 +1509,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
)
|
||||
|
||||
# Yield the partial result and signal completion
|
||||
await context.yield_output(partial_result)
|
||||
await context.yield_output([partial_result])
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -2283,21 +2283,22 @@ class MagenticWorkflow:
|
||||
return
|
||||
|
||||
# At this point, checkpoint is guaranteed to be WorkflowCheckpoint
|
||||
executor_states: dict[str, Any] = checkpoint.shared_state.get(EXECUTOR_STATE_KEY, {})
|
||||
executor_states = cast(dict[str, Any], checkpoint.shared_state.get(EXECUTOR_STATE_KEY, {}))
|
||||
orchestrator_id = getattr(orchestrator, "id", "")
|
||||
orchestrator_state = executor_states.get(orchestrator_id)
|
||||
orchestrator_state = cast(Any, executor_states.get(orchestrator_id))
|
||||
if orchestrator_state is None:
|
||||
orchestrator_state = executor_states.get("magentic_orchestrator")
|
||||
orchestrator_state = cast(Any, executor_states.get("magentic_orchestrator"))
|
||||
|
||||
if not isinstance(orchestrator_state, dict):
|
||||
return
|
||||
|
||||
context_payload = orchestrator_state.get("magentic_context")
|
||||
orchestrator_state_dict = cast(dict[str, Any], orchestrator_state)
|
||||
context_payload = cast(Any, orchestrator_state_dict.get("magentic_context"))
|
||||
if not isinstance(context_payload, dict):
|
||||
return
|
||||
|
||||
context_dict = cast(dict[str, Any], context_payload)
|
||||
restored_participants = context_dict.get("participant_descriptions")
|
||||
restored_participants = cast(Any, context_dict.get("participant_descriptions"))
|
||||
if not isinstance(restored_participants, dict):
|
||||
return
|
||||
|
||||
|
||||
@@ -186,6 +186,10 @@ class ParticipantRegistry:
|
||||
"""Check if a participant is registered."""
|
||||
return name in self._participant_entry_ids
|
||||
|
||||
def is_participant_registered(self, name: str) -> bool:
|
||||
"""Check if a participant is registered (alias for is_registered for compatibility)."""
|
||||
return self.is_registered(name)
|
||||
|
||||
def all_participants(self) -> set[str]:
|
||||
"""Get all registered participant names."""
|
||||
return set(self._participant_entry_ids.keys())
|
||||
|
||||
@@ -90,7 +90,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
client = await self.ensure_client()
|
||||
run_options = await self.prepare_options(messages, chat_options)
|
||||
run_options = await self.prepare_options(messages, chat_options, **kwargs)
|
||||
response_format = run_options.pop("response_format", None)
|
||||
text_config = run_options.pop("text", None)
|
||||
text_format, text_config = self._prepare_text_config(response_format=response_format, text_config=text_config)
|
||||
@@ -135,7 +135,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[ChatResponseUpdate]:
|
||||
client = await self.ensure_client()
|
||||
run_options = await self.prepare_options(messages, chat_options)
|
||||
run_options = await self.prepare_options(messages, chat_options, **kwargs)
|
||||
function_call_ids: dict[int, tuple[str, str]] = {} # output_index: (call_id, name)
|
||||
response_format = run_options.pop("response_format", None)
|
||||
text_config = run_options.pop("text", None)
|
||||
@@ -248,7 +248,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None
|
||||
) -> str | None:
|
||||
"""Get the conversation ID from the response if store is True."""
|
||||
return response.id if store else None
|
||||
return None if store is False else response.id
|
||||
|
||||
# region Prep methods
|
||||
|
||||
@@ -386,9 +386,17 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
return mcp
|
||||
|
||||
async def prepare_options(
|
||||
self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions
|
||||
self,
|
||||
messages: MutableSequence[ChatMessage],
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Take ChatOptions and create the specific options for Responses API."""
|
||||
conversation_id = kwargs.pop("conversation_id", None)
|
||||
|
||||
if conversation_id:
|
||||
chat_options.conversation_id = conversation_id
|
||||
|
||||
run_options: dict[str, Any] = chat_options.to_dict(
|
||||
exclude={
|
||||
"type",
|
||||
@@ -437,8 +445,6 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
for key, value in additional_properties.items():
|
||||
if value is not None:
|
||||
run_options[key] = value
|
||||
if "store" not in run_options:
|
||||
run_options["store"] = False
|
||||
if (tool_choice := run_options.get("tool_choice")) and len(tool_choice.keys()) == 1:
|
||||
run_options["tool_choice"] = tool_choice["mode"]
|
||||
return run_options
|
||||
@@ -815,8 +821,11 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
"additional_properties": metadata,
|
||||
"raw_representation": response,
|
||||
}
|
||||
if chat_options.store:
|
||||
args["conversation_id"] = self.get_conversation_id(response, chat_options.store)
|
||||
|
||||
conversation_id = self.get_conversation_id(response, chat_options.store)
|
||||
|
||||
if conversation_id:
|
||||
args["conversation_id"] = conversation_id
|
||||
if response.usage and (usage_details := self._usage_details_from_openai(response.usage)):
|
||||
args["usage_details"] = usage_details
|
||||
if structured_response:
|
||||
|
||||
@@ -1423,12 +1423,12 @@ async def test_prepare_options_store_parameter_handling() -> None:
|
||||
|
||||
chat_options = ChatOptions(store=None, conversation_id=None)
|
||||
options = await client.prepare_options(messages, chat_options)
|
||||
assert options["store"] is False
|
||||
assert "store" not in options
|
||||
assert "previous_response_id" not in options
|
||||
|
||||
chat_options = ChatOptions()
|
||||
options = await client.prepare_options(messages, chat_options)
|
||||
assert options["store"] is False
|
||||
assert "store" not in options
|
||||
assert "previous_response_id" not in options
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncIterable, Callable
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework import (
|
||||
MAGENTIC_EVENT_TYPE_AGENT_DELTA,
|
||||
@@ -14,6 +15,7 @@ from agent_framework import (
|
||||
AgentThread,
|
||||
BaseAgent,
|
||||
ChatMessage,
|
||||
Executor,
|
||||
GroupChatBuilder,
|
||||
GroupChatDirective,
|
||||
GroupChatStateSnapshot,
|
||||
@@ -23,21 +25,27 @@ from agent_framework import (
|
||||
Role,
|
||||
TextContent,
|
||||
Workflow,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
|
||||
from agent_framework._workflows._group_chat import (
|
||||
GroupChatOrchestratorExecutor,
|
||||
ManagerSelectionResponse,
|
||||
_default_orchestrator_factory, # type: ignore
|
||||
_default_participant_factory, # type: ignore
|
||||
_GroupChatConfig, # type: ignore
|
||||
_PromptBasedGroupChatManager, # type: ignore
|
||||
_SpeakerSelectorAdapter, # type: ignore
|
||||
assemble_group_chat_workflow,
|
||||
)
|
||||
from agent_framework._workflows._magentic import (
|
||||
_MagenticProgressLedger, # type: ignore
|
||||
_MagenticProgressLedgerItem, # type: ignore
|
||||
_MagenticStartMessage, # type: ignore
|
||||
)
|
||||
from agent_framework._workflows._participant_utils import GroupChatParticipantSpec
|
||||
from agent_framework._workflows._workflow_builder import WorkflowBuilder
|
||||
|
||||
|
||||
class StubAgent(BaseAgent):
|
||||
@@ -70,6 +78,73 @@ class StubAgent(BaseAgent):
|
||||
return _stream()
|
||||
|
||||
|
||||
class StubManagerAgent(BaseAgent):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(name="manager_agent", description="Stub manager")
|
||||
self._call_count = 0
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentRunResponse: # type: ignore[override]
|
||||
if self._call_count == 0:
|
||||
self._call_count += 1
|
||||
payload = {"selected_participant": "agent", "finish": False, "final_message": None}
|
||||
return AgentRunResponse(
|
||||
messages=[
|
||||
ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
text='{"selected_participant": "agent", "finish": false}',
|
||||
author_name=self.name,
|
||||
)
|
||||
],
|
||||
value=payload,
|
||||
)
|
||||
|
||||
payload = {"selected_participant": None, "finish": True, "final_message": "agent manager final"}
|
||||
return AgentRunResponse(
|
||||
messages=[
|
||||
ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
text='{"finish": true, "final_message": "agent manager final"}',
|
||||
author_name=self.name,
|
||||
)
|
||||
],
|
||||
value=payload,
|
||||
)
|
||||
|
||||
def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[AgentRunResponseUpdate]: # type: ignore[override]
|
||||
if self._call_count == 0:
|
||||
self._call_count += 1
|
||||
|
||||
async def _stream_initial() -> AsyncIterable[AgentRunResponseUpdate]:
|
||||
yield AgentRunResponseUpdate(
|
||||
contents=[TextContent(text='{"selected_participant": "agent", "finish": false}')],
|
||||
role=Role.ASSISTANT,
|
||||
author_name=self.name,
|
||||
)
|
||||
|
||||
return _stream_initial()
|
||||
|
||||
async def _stream_final() -> AsyncIterable[AgentRunResponseUpdate]:
|
||||
yield AgentRunResponseUpdate(
|
||||
contents=[TextContent(text='{"finish": true, "final_message": "agent manager final"}')],
|
||||
role=Role.ASSISTANT,
|
||||
author_name=self.name,
|
||||
)
|
||||
|
||||
return _stream_final()
|
||||
|
||||
|
||||
def make_sequence_selector() -> Callable[[GroupChatStateSnapshot], Any]:
|
||||
state_counter = {"value": 0}
|
||||
|
||||
@@ -123,6 +198,22 @@ class StubMagenticManager(MagenticManagerBase):
|
||||
return ChatMessage(role=Role.ASSISTANT, text="final", author_name="magentic_manager")
|
||||
|
||||
|
||||
class PassthroughExecutor(Executor):
|
||||
@handler
|
||||
async def forward(self, message: Any, ctx: WorkflowContext[Any]) -> None:
|
||||
await ctx.send_message(message)
|
||||
|
||||
|
||||
class CountingWorkflowBuilder(WorkflowBuilder):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.start_calls = 0
|
||||
|
||||
def set_start_executor(self, executor: Any) -> "CountingWorkflowBuilder":
|
||||
self.start_calls += 1
|
||||
return cast("CountingWorkflowBuilder", super().set_start_executor(executor))
|
||||
|
||||
|
||||
async def test_group_chat_builder_basic_flow() -> None:
|
||||
selector = make_sequence_selector()
|
||||
alpha = StubAgent("alpha", "ack from alpha")
|
||||
@@ -130,21 +221,23 @@ async def test_group_chat_builder_basic_flow() -> None:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.select_speakers(selector, display_name="manager", final_message="done")
|
||||
.set_select_speakers_func(selector, display_name="manager", final_message="done")
|
||||
.participants(alpha=alpha, beta=beta)
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[ChatMessage] = []
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run_stream("coordinate task"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
data = event.data
|
||||
if isinstance(data, ChatMessage):
|
||||
outputs.append(data)
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0].text == "done"
|
||||
assert outputs[0].author_name == "manager"
|
||||
assert len(outputs[0]) >= 1
|
||||
# The final message should be "done" from the manager
|
||||
assert outputs[0][-1].text == "done"
|
||||
assert outputs[0][-1].author_name == "manager"
|
||||
|
||||
|
||||
async def test_magentic_builder_returns_workflow_and_runs() -> None:
|
||||
@@ -169,11 +262,13 @@ async def test_magentic_builder_returns_workflow_and_runs() -> None:
|
||||
agent_event_count += 1
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
msg = event.data
|
||||
if isinstance(msg, ChatMessage):
|
||||
outputs.append(msg)
|
||||
if isinstance(msg, list):
|
||||
outputs.append(cast(list[ChatMessage], msg))
|
||||
|
||||
assert outputs, "Expected a final output message"
|
||||
final = outputs[-1]
|
||||
conversation = outputs[-1]
|
||||
assert len(conversation) >= 1
|
||||
final = conversation[-1]
|
||||
assert final.text == "final"
|
||||
assert final.author_name == "magentic_manager"
|
||||
assert orchestrator_event_count > 0, "Expected orchestrator events to be emitted"
|
||||
@@ -187,7 +282,7 @@ async def test_group_chat_as_agent_accepts_conversation() -> None:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.select_speakers(selector, display_name="manager", final_message="done")
|
||||
.set_select_speakers_func(selector, display_name="manager", final_message="done")
|
||||
.participants(alpha=alpha, beta=beta)
|
||||
.build()
|
||||
)
|
||||
@@ -239,7 +334,7 @@ class TestGroupChatBuilder:
|
||||
def selector(state: GroupChatStateSnapshot) -> str | None:
|
||||
return None
|
||||
|
||||
builder = GroupChatBuilder().select_speakers(selector)
|
||||
builder = GroupChatBuilder().set_select_speakers_func(selector)
|
||||
|
||||
with pytest.raises(ValueError, match="participants must be configured before build"):
|
||||
builder.build()
|
||||
@@ -250,10 +345,10 @@ class TestGroupChatBuilder:
|
||||
def selector(state: GroupChatStateSnapshot) -> str | None:
|
||||
return None
|
||||
|
||||
builder = GroupChatBuilder().select_speakers(selector)
|
||||
builder = GroupChatBuilder().set_select_speakers_func(selector)
|
||||
|
||||
with pytest.raises(ValueError, match="already has a manager configured"):
|
||||
builder.select_speakers(selector)
|
||||
builder.set_select_speakers_func(selector)
|
||||
|
||||
def test_empty_participants_raises_error(self) -> None:
|
||||
"""Test that empty participants list raises ValueError."""
|
||||
@@ -261,7 +356,7 @@ class TestGroupChatBuilder:
|
||||
def selector(state: GroupChatStateSnapshot) -> str | None:
|
||||
return None
|
||||
|
||||
builder = GroupChatBuilder().select_speakers(selector)
|
||||
builder = GroupChatBuilder().set_select_speakers_func(selector)
|
||||
|
||||
with pytest.raises(ValueError, match="participants cannot be empty"):
|
||||
builder.participants([])
|
||||
@@ -274,7 +369,7 @@ class TestGroupChatBuilder:
|
||||
def selector(state: GroupChatStateSnapshot) -> str | None:
|
||||
return None
|
||||
|
||||
builder = GroupChatBuilder().select_speakers(selector)
|
||||
builder = GroupChatBuilder().set_select_speakers_func(selector)
|
||||
|
||||
with pytest.raises(ValueError, match="Duplicate participant name 'test'"):
|
||||
builder.participants([agent1, agent2])
|
||||
@@ -302,7 +397,7 @@ class TestGroupChatBuilder:
|
||||
def selector(state: GroupChatStateSnapshot) -> str | None:
|
||||
return None
|
||||
|
||||
builder = GroupChatBuilder().select_speakers(selector)
|
||||
builder = GroupChatBuilder().set_select_speakers_func(selector)
|
||||
|
||||
with pytest.raises(ValueError, match="must define a non-empty 'name' attribute"):
|
||||
builder.participants([agent])
|
||||
@@ -314,11 +409,53 @@ class TestGroupChatBuilder:
|
||||
def selector(state: GroupChatStateSnapshot) -> str | None:
|
||||
return None
|
||||
|
||||
builder = GroupChatBuilder().select_speakers(selector)
|
||||
builder = GroupChatBuilder().set_select_speakers_func(selector)
|
||||
|
||||
with pytest.raises(ValueError, match="participant names must be non-empty strings"):
|
||||
builder.participants({"": agent})
|
||||
|
||||
def test_assemble_group_chat_respects_existing_start_executor(self) -> None:
|
||||
"""Ensure assemble_group_chat_workflow does not override preconfigured start executor."""
|
||||
|
||||
async def manager(_: GroupChatStateSnapshot) -> GroupChatDirective:
|
||||
return GroupChatDirective(finish=True)
|
||||
|
||||
builder = CountingWorkflowBuilder()
|
||||
entry = PassthroughExecutor(id="entry")
|
||||
builder = builder.set_start_executor(entry)
|
||||
|
||||
participant = PassthroughExecutor(id="participant")
|
||||
participant_spec = GroupChatParticipantSpec(
|
||||
name="participant",
|
||||
participant=participant,
|
||||
description="participant",
|
||||
)
|
||||
|
||||
wiring = _GroupChatConfig(
|
||||
manager=manager,
|
||||
manager_participant=None,
|
||||
manager_name="manager",
|
||||
participants={"participant": participant_spec},
|
||||
max_rounds=None,
|
||||
termination_condition=None,
|
||||
participant_aliases={},
|
||||
participant_executors={"participant": participant},
|
||||
)
|
||||
|
||||
result = assemble_group_chat_workflow(
|
||||
wiring=wiring,
|
||||
participant_factory=_default_participant_factory,
|
||||
orchestrator_factory=_default_orchestrator_factory,
|
||||
builder=builder,
|
||||
return_builder=True,
|
||||
)
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
assembled_builder, _ = result
|
||||
assert assembled_builder is builder
|
||||
assert builder.start_calls == 1
|
||||
assert assembled_builder._start_executor is entry # type: ignore
|
||||
|
||||
|
||||
class TestGroupChatOrchestrator:
|
||||
"""Tests for GroupChatOrchestratorExecutor core functionality."""
|
||||
@@ -336,25 +473,116 @@ class TestGroupChatOrchestrator:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.select_speakers(selector)
|
||||
.set_select_speakers_func(selector)
|
||||
.participants([agent])
|
||||
.with_max_rounds(2) # Limit to 2 rounds
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[ChatMessage] = []
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run_stream("test task"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
data = event.data
|
||||
if isinstance(data, ChatMessage):
|
||||
outputs.append(data)
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
|
||||
# Should have terminated due to max_rounds, expect at least one output
|
||||
assert len(outputs) >= 1
|
||||
# The final message should be about round limit
|
||||
final_output = outputs[-1]
|
||||
# The final message in the conversation should be about round limit
|
||||
conversation = outputs[-1]
|
||||
assert len(conversation) >= 1
|
||||
final_output = conversation[-1]
|
||||
assert "round limit" in final_output.text.lower()
|
||||
|
||||
async def test_termination_condition_halts_conversation(self) -> None:
|
||||
"""Test that a custom termination condition stops the workflow."""
|
||||
|
||||
def selector(state: GroupChatStateSnapshot) -> str | None:
|
||||
return "agent"
|
||||
|
||||
def termination_condition(conversation: list[ChatMessage]) -> bool:
|
||||
replies = [msg for msg in conversation if msg.role == Role.ASSISTANT and msg.author_name == "agent"]
|
||||
return len(replies) >= 2
|
||||
|
||||
agent = StubAgent("agent", "response")
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_select_speakers_func(selector)
|
||||
.participants([agent])
|
||||
.with_termination_condition(termination_condition)
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run_stream("test task"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
|
||||
assert outputs, "Expected termination to yield output"
|
||||
conversation = outputs[-1]
|
||||
agent_replies = [msg for msg in conversation if msg.author_name == "agent" and msg.role == Role.ASSISTANT]
|
||||
assert len(agent_replies) == 2
|
||||
final_output = conversation[-1]
|
||||
assert final_output.author_name == "manager"
|
||||
assert "termination condition" in final_output.text.lower()
|
||||
|
||||
async def test_termination_condition_uses_manager_final_message(self) -> None:
|
||||
"""Test that manager-provided final message is used on termination."""
|
||||
|
||||
async def selector(state: GroupChatStateSnapshot) -> str | None:
|
||||
return None
|
||||
|
||||
agent = StubAgent("agent", "response")
|
||||
final_text = "manager summary on termination"
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_select_speakers_func(selector, final_message=final_text)
|
||||
.participants([agent])
|
||||
.with_termination_condition(lambda _: True)
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run_stream("test task"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
|
||||
assert outputs, "Expected termination to yield output"
|
||||
conversation = outputs[-1]
|
||||
assert conversation[-1].text == final_text
|
||||
assert conversation[-1].author_name == "manager"
|
||||
|
||||
async def test_termination_condition_agent_manager_finalizes(self) -> None:
|
||||
"""Test that agent-based manager can provide final message on termination."""
|
||||
manager = StubManagerAgent()
|
||||
worker = StubAgent("agent", "response")
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_manager(manager, display_name="Manager")
|
||||
.participants([worker])
|
||||
.with_termination_condition(lambda conv: any(msg.author_name == "agent" for msg in conv))
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run_stream("test task"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
data = event.data
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
|
||||
assert outputs, "Expected termination to yield output"
|
||||
conversation = outputs[-1]
|
||||
assert conversation[-1].text == "agent manager final"
|
||||
assert conversation[-1].author_name == "Manager"
|
||||
|
||||
async def test_unknown_participant_error(self) -> None:
|
||||
"""Test that _apply_directive raises error for unknown participants."""
|
||||
|
||||
@@ -363,7 +591,7 @@ class TestGroupChatOrchestrator:
|
||||
|
||||
agent = StubAgent("agent", "response")
|
||||
|
||||
workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build()
|
||||
workflow = GroupChatBuilder().set_select_speakers_func(selector).participants([agent]).build()
|
||||
|
||||
with pytest.raises(ValueError, match="Manager selected unknown participant 'unknown_agent'"):
|
||||
async for _ in workflow.run_stream("test task"):
|
||||
@@ -379,7 +607,7 @@ class TestGroupChatOrchestrator:
|
||||
agent = StubAgent("agent", "response")
|
||||
|
||||
# The _SpeakerSelectorAdapter will catch this and raise TypeError
|
||||
workflow = GroupChatBuilder().select_speakers(bad_selector).participants([agent]).build() # type: ignore
|
||||
workflow = GroupChatBuilder().set_select_speakers_func(bad_selector).participants([agent]).build() # type: ignore
|
||||
|
||||
# This should raise a TypeError because selector doesn't return str or None
|
||||
with pytest.raises(TypeError, match="must return a participant name \\(str\\) or None"):
|
||||
@@ -394,7 +622,7 @@ class TestGroupChatOrchestrator:
|
||||
|
||||
agent = StubAgent("agent", "response")
|
||||
|
||||
workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build()
|
||||
workflow = GroupChatBuilder().set_select_speakers_func(selector).participants([agent]).build()
|
||||
|
||||
with pytest.raises(ValueError, match="requires at least one chat message"):
|
||||
async for _ in workflow.run_stream([]):
|
||||
@@ -529,69 +757,76 @@ class TestCheckpointing:
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder().select_speakers(selector).participants([agent]).with_checkpointing(storage).build()
|
||||
GroupChatBuilder()
|
||||
.set_select_speakers_func(selector)
|
||||
.participants([agent])
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[ChatMessage] = []
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run_stream("test task"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
data = event.data
|
||||
if isinstance(data, ChatMessage):
|
||||
outputs.append(data)
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
|
||||
assert len(outputs) == 1 # Should complete normally
|
||||
|
||||
|
||||
class TestPromptBasedManager:
|
||||
"""Tests for _PromptBasedGroupChatManager."""
|
||||
class TestAgentManagerConfiguration:
|
||||
"""Tests for agent-based manager configuration."""
|
||||
|
||||
async def test_manager_with_missing_next_agent_raises_error(self) -> None:
|
||||
"""Test that manager directive without next_agent raises RuntimeError."""
|
||||
async def test_set_manager_configures_response_format(self) -> None:
|
||||
"""Ensure ChatAgent managers receive default ManagerSelectionResponse formatting."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
class MockChatClient:
|
||||
async def get_response(self, messages: Any, response_format: Any = None) -> Any:
|
||||
# Return response that has finish=False but no next_agent
|
||||
class MockResponse:
|
||||
def __init__(self) -> None:
|
||||
self.value = {"finish": False, "next_agent": None}
|
||||
self.messages: list[Any] = []
|
||||
from agent_framework import ChatAgent
|
||||
|
||||
return MockResponse()
|
||||
chat_client = MagicMock()
|
||||
manager_agent = ChatAgent(chat_client=chat_client, name="Coordinator")
|
||||
assert manager_agent.chat_options.response_format is None
|
||||
|
||||
manager = _PromptBasedGroupChatManager(MockChatClient()) # type: ignore
|
||||
worker = StubAgent("worker", "response")
|
||||
|
||||
state = {
|
||||
"participants": {"agent": "desc"},
|
||||
"task": ChatMessage(role=Role.USER, text="test"),
|
||||
"conversation": (),
|
||||
}
|
||||
builder = GroupChatBuilder().set_manager(manager_agent).participants([worker])
|
||||
|
||||
with pytest.raises(RuntimeError, match="missing next_agent while finish is False"):
|
||||
await manager(state)
|
||||
assert manager_agent.chat_options.response_format is ManagerSelectionResponse
|
||||
assert builder._manager_participant is manager_agent # type: ignore[attr-defined]
|
||||
|
||||
async def test_manager_with_unknown_participant_raises_error(self) -> None:
|
||||
"""Test that manager selecting unknown participant raises RuntimeError."""
|
||||
async def test_set_manager_accepts_agent_manager(self) -> None:
|
||||
"""Verify agent-based manager can be set and workflow builds."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
class MockChatClient:
|
||||
async def get_response(self, messages: Any, response_format: Any = None) -> Any:
|
||||
# Return response selecting unknown participant
|
||||
class MockResponse:
|
||||
def __init__(self) -> None:
|
||||
self.value = {"finish": False, "next_agent": "unknown"}
|
||||
self.messages: list[Any] = []
|
||||
from agent_framework import ChatAgent
|
||||
|
||||
return MockResponse()
|
||||
chat_client = MagicMock()
|
||||
manager_agent = ChatAgent(chat_client=chat_client, name="Coordinator")
|
||||
worker = StubAgent("worker", "response")
|
||||
|
||||
manager = _PromptBasedGroupChatManager(MockChatClient()) # type: ignore
|
||||
builder = GroupChatBuilder().set_manager(manager_agent, display_name="Orchestrator")
|
||||
builder = builder.participants([worker]).with_max_rounds(1)
|
||||
|
||||
state = {
|
||||
"participants": {"agent": "desc"},
|
||||
"task": ChatMessage(role=Role.USER, text="test"),
|
||||
"conversation": (),
|
||||
}
|
||||
assert builder._manager_participant is manager_agent # type: ignore[attr-defined]
|
||||
assert "worker" in builder._participants # type: ignore[attr-defined]
|
||||
|
||||
with pytest.raises(RuntimeError, match="Manager selected unknown participant 'unknown'"):
|
||||
await manager(state)
|
||||
async def test_set_manager_rejects_custom_response_format(self) -> None:
|
||||
"""Reject custom response_format on ChatAgent managers."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from agent_framework import ChatAgent
|
||||
|
||||
class CustomResponse(BaseModel):
|
||||
value: str
|
||||
|
||||
chat_client = MagicMock()
|
||||
manager_agent = ChatAgent(chat_client=chat_client, name="Coordinator", response_format=CustomResponse)
|
||||
worker = StubAgent("worker", "response")
|
||||
|
||||
with pytest.raises(ValueError, match="response_format must be ManagerSelectionResponse"):
|
||||
GroupChatBuilder().set_manager(manager_agent).participants([worker])
|
||||
|
||||
assert manager_agent.chat_options.response_format is CustomResponse
|
||||
|
||||
|
||||
class TestFactoryFunctions:
|
||||
@@ -599,9 +834,9 @@ class TestFactoryFunctions:
|
||||
|
||||
def test_default_orchestrator_factory_without_manager_raises_error(self) -> None:
|
||||
"""Test that default factory requires manager to be set."""
|
||||
config = _GroupChatConfig(manager=None, manager_name="test", participants={})
|
||||
config = _GroupChatConfig(manager=None, manager_participant=None, manager_name="test", participants={})
|
||||
|
||||
with pytest.raises(RuntimeError, match="requires a manager to be set"):
|
||||
with pytest.raises(RuntimeError, match="requires a manager to be configured"):
|
||||
_default_orchestrator_factory(config)
|
||||
|
||||
|
||||
@@ -619,14 +854,14 @@ class TestConversationHandling:
|
||||
|
||||
agent = StubAgent("agent", "response")
|
||||
|
||||
workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build()
|
||||
workflow = GroupChatBuilder().set_select_speakers_func(selector).participants([agent]).build()
|
||||
|
||||
outputs: list[ChatMessage] = []
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run_stream("test string"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
data = event.data
|
||||
if isinstance(data, ChatMessage):
|
||||
outputs.append(data)
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
|
||||
assert len(outputs) == 1
|
||||
|
||||
@@ -641,14 +876,14 @@ class TestConversationHandling:
|
||||
|
||||
agent = StubAgent("agent", "response")
|
||||
|
||||
workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build()
|
||||
workflow = GroupChatBuilder().set_select_speakers_func(selector).participants([agent]).build()
|
||||
|
||||
outputs: list[ChatMessage] = []
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run_stream(task_message):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
data = event.data
|
||||
if isinstance(data, ChatMessage):
|
||||
outputs.append(data)
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
|
||||
assert len(outputs) == 1
|
||||
|
||||
@@ -667,14 +902,14 @@ class TestConversationHandling:
|
||||
|
||||
agent = StubAgent("agent", "response")
|
||||
|
||||
workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build()
|
||||
workflow = GroupChatBuilder().set_select_speakers_func(selector).participants([agent]).build()
|
||||
|
||||
outputs: list[ChatMessage] = []
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run_stream(conversation):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
data = event.data
|
||||
if isinstance(data, ChatMessage):
|
||||
outputs.append(data)
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
|
||||
assert len(outputs) == 1
|
||||
|
||||
@@ -695,23 +930,25 @@ class TestRoundLimitEnforcement:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.select_speakers(selector)
|
||||
.set_select_speakers_func(selector)
|
||||
.participants([agent])
|
||||
.with_max_rounds(1) # Very low limit
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[ChatMessage] = []
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run_stream("test"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
data = event.data
|
||||
if isinstance(data, ChatMessage):
|
||||
outputs.append(data)
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
|
||||
# Should have at least one output (the round limit message)
|
||||
assert len(outputs) >= 1
|
||||
# The last message should be about round limit
|
||||
final_output = outputs[-1]
|
||||
# The last message in the conversation should be about round limit
|
||||
conversation = outputs[-1]
|
||||
assert len(conversation) >= 1
|
||||
final_output = conversation[-1]
|
||||
assert "round limit" in final_output.text.lower()
|
||||
|
||||
async def test_round_limit_in_ingest_participant_message(self) -> None:
|
||||
@@ -728,23 +965,25 @@ class TestRoundLimitEnforcement:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.select_speakers(selector)
|
||||
.set_select_speakers_func(selector)
|
||||
.participants([agent])
|
||||
.with_max_rounds(1) # Hit limit after first response
|
||||
.build()
|
||||
)
|
||||
|
||||
outputs: list[ChatMessage] = []
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run_stream("test"):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
data = event.data
|
||||
if isinstance(data, ChatMessage):
|
||||
outputs.append(data)
|
||||
if isinstance(data, list):
|
||||
outputs.append(cast(list[ChatMessage], data))
|
||||
|
||||
# Should have at least one output (the round limit message)
|
||||
assert len(outputs) >= 1
|
||||
# The last message should be about round limit
|
||||
final_output = outputs[-1]
|
||||
# The last message in the conversation should be about round limit
|
||||
conversation = outputs[-1]
|
||||
assert len(conversation) >= 1
|
||||
final_output = conversation[-1]
|
||||
assert "round limit" in final_output.text.lower()
|
||||
|
||||
|
||||
@@ -758,12 +997,12 @@ async def test_group_chat_checkpoint_runtime_only() -> None:
|
||||
agent_b = StubAgent("agentB", "Reply from B")
|
||||
selector = make_sequence_selector()
|
||||
|
||||
wf = GroupChatBuilder().participants([agent_a, agent_b]).select_speakers(selector).build()
|
||||
wf = GroupChatBuilder().participants([agent_a, agent_b]).set_select_speakers_func(selector).build()
|
||||
|
||||
baseline_output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
baseline_output = ev.data # type: ignore[assignment]
|
||||
baseline_output = cast(list[ChatMessage], ev.data) if isinstance(ev.data, list) else None
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
|
||||
WorkflowRunState.IDLE,
|
||||
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
|
||||
@@ -794,7 +1033,7 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
wf = (
|
||||
GroupChatBuilder()
|
||||
.participants([agent_a, agent_b])
|
||||
.select_speakers(selector)
|
||||
.set_select_speakers_func(selector)
|
||||
.with_checkpointing(buildtime_storage)
|
||||
.build()
|
||||
)
|
||||
@@ -802,7 +1041,7 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
baseline_output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
baseline_output = ev.data # type: ignore[assignment]
|
||||
baseline_output = cast(list[ChatMessage], ev.data) if isinstance(ev.data, list) else None
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
|
||||
WorkflowRunState.IDLE,
|
||||
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
|
||||
@@ -816,3 +1055,30 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
|
||||
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
|
||||
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
|
||||
|
||||
|
||||
class _StubExecutor(Executor):
|
||||
"""Minimal executor used to satisfy workflow wiring in tests."""
|
||||
|
||||
def __init__(self, id: str) -> None:
|
||||
super().__init__(id=id)
|
||||
|
||||
@handler
|
||||
async def handle(self, message: object, ctx: WorkflowContext[ChatMessage]) -> None:
|
||||
await ctx.yield_output(message)
|
||||
|
||||
|
||||
def test_set_manager_builds_with_agent_manager() -> None:
|
||||
"""GroupChatBuilder should build when using an agent-based manager."""
|
||||
|
||||
manager = _StubExecutor("manager_executor")
|
||||
participant = _StubExecutor("participant_executor")
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder().set_manager(manager, display_name="Moderator").participants({"worker": participant}).build()
|
||||
)
|
||||
|
||||
orchestrator = workflow.get_start_executor()
|
||||
|
||||
assert isinstance(orchestrator, GroupChatOrchestratorExecutor)
|
||||
assert orchestrator._is_manager_agent()
|
||||
|
||||
@@ -23,7 +23,22 @@ from agent_framework import (
|
||||
WorkflowOutputEvent,
|
||||
)
|
||||
from agent_framework._mcp import MCPTool
|
||||
from agent_framework._workflows import _handoff as handoff_module # type: ignore
|
||||
from agent_framework._workflows._handoff import _clone_chat_agent # type: ignore[reportPrivateUsage]
|
||||
from agent_framework._workflows._workflow_builder import WorkflowBuilder
|
||||
|
||||
|
||||
class _CountingWorkflowBuilder(WorkflowBuilder):
|
||||
created: list["_CountingWorkflowBuilder"] = []
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.start_calls = 0
|
||||
_CountingWorkflowBuilder.created.append(self)
|
||||
|
||||
def set_start_executor(self, executor: Any) -> "_CountingWorkflowBuilder": # type: ignore[override]
|
||||
self.start_calls += 1
|
||||
return cast("_CountingWorkflowBuilder", super().set_start_executor(executor))
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -478,6 +493,27 @@ async def test_return_to_previous_enabled():
|
||||
assert len(specialist_a.calls) == 2, "Specialist A should handle follow-up with return_to_previous enabled"
|
||||
|
||||
|
||||
def test_handoff_builder_sets_start_executor_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensure HandoffBuilder.build sets the start executor only once when assembling the workflow."""
|
||||
_CountingWorkflowBuilder.created.clear()
|
||||
monkeypatch.setattr(handoff_module, "WorkflowBuilder", _CountingWorkflowBuilder)
|
||||
|
||||
coordinator = _RecordingAgent(name="coordinator")
|
||||
specialist = _RecordingAgent(name="specialist")
|
||||
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[coordinator, specialist])
|
||||
.set_coordinator("coordinator")
|
||||
.with_termination_condition(lambda conv: len(conv) > 0)
|
||||
.build()
|
||||
)
|
||||
|
||||
assert workflow is not None
|
||||
assert _CountingWorkflowBuilder.created, "Expected CountingWorkflowBuilder to be instantiated"
|
||||
builder = _CountingWorkflowBuilder.created[-1]
|
||||
assert builder.start_calls == 1, "set_start_executor should be invoked exactly once"
|
||||
|
||||
|
||||
async def test_tool_choice_preserved_from_agent_config():
|
||||
"""Verify that agent-level tool_choice configuration is preserved and not overridden."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
@@ -33,6 +33,7 @@ from agent_framework import (
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflows import _group_chat as group_chat_module # type: ignore
|
||||
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
|
||||
from agent_framework._workflows._magentic import ( # type: ignore[reportPrivateUsage]
|
||||
MagenticAgentExecutor,
|
||||
@@ -42,6 +43,7 @@ from agent_framework._workflows._magentic import ( # type: ignore[reportPrivate
|
||||
_MagenticProgressLedgerItem, # type: ignore
|
||||
_MagenticStartMessage, # type: ignore
|
||||
)
|
||||
from agent_framework._workflows._workflow_builder import WorkflowBuilder
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
@@ -162,6 +164,19 @@ class FakeManager(MagenticManagerBase):
|
||||
return ChatMessage(role=Role.ASSISTANT, text="FINAL", author_name="magentic_manager")
|
||||
|
||||
|
||||
class _CountingWorkflowBuilder(WorkflowBuilder):
|
||||
created: list["_CountingWorkflowBuilder"] = []
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.start_calls = 0
|
||||
_CountingWorkflowBuilder.created.append(self)
|
||||
|
||||
def set_start_executor(self, executor: Any) -> "_CountingWorkflowBuilder": # type: ignore[override]
|
||||
self.start_calls += 1
|
||||
return cast("_CountingWorkflowBuilder", super().set_start_executor(executor))
|
||||
|
||||
|
||||
async def test_standard_manager_plan_and_replan_combined_ledger():
|
||||
manager = FakeManager(max_round_count=10, max_stall_count=3, max_reset_count=2)
|
||||
ctx = MagenticContext(
|
||||
@@ -210,7 +225,7 @@ async def test_magentic_workflow_plan_review_approval_to_completion():
|
||||
assert req_event is not None
|
||||
|
||||
completed = False
|
||||
output: ChatMessage | None = None
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.send_responses_streaming(
|
||||
responses={req_event.request_id: MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)}
|
||||
):
|
||||
@@ -222,7 +237,8 @@ async def test_magentic_workflow_plan_review_approval_to_completion():
|
||||
break
|
||||
assert completed
|
||||
assert output is not None
|
||||
assert isinstance(output, ChatMessage)
|
||||
assert isinstance(output, list)
|
||||
assert all(isinstance(msg, ChatMessage) for msg in output)
|
||||
|
||||
|
||||
async def test_magentic_plan_review_approve_with_comments_replans_and_proceeds():
|
||||
@@ -300,8 +316,10 @@ async def test_magentic_orchestrator_round_limit_produces_partial_result():
|
||||
output_event = next((e for e in events if isinstance(e, WorkflowOutputEvent)), None)
|
||||
assert output_event is not None
|
||||
data = output_event.data
|
||||
assert isinstance(data, ChatMessage)
|
||||
assert data.role == Role.ASSISTANT
|
||||
assert isinstance(data, list)
|
||||
assert all(isinstance(msg, ChatMessage) for msg in data)
|
||||
assert len(data) > 0
|
||||
assert data[-1].role == Role.ASSISTANT
|
||||
|
||||
|
||||
async def test_magentic_checkpoint_resume_round_trip():
|
||||
@@ -374,6 +392,23 @@ class _DummyExec(Executor):
|
||||
pass
|
||||
|
||||
|
||||
def test_magentic_builder_sets_start_executor_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensure MagenticBuilder wiring sets the start executor only once."""
|
||||
_CountingWorkflowBuilder.created.clear()
|
||||
monkeypatch.setattr(group_chat_module, "WorkflowBuilder", _CountingWorkflowBuilder)
|
||||
|
||||
manager = FakeManager()
|
||||
|
||||
workflow = (
|
||||
MagenticBuilder().participants(agentA=_DummyExec("agentA")).with_standard_manager(manager=manager).build()
|
||||
)
|
||||
|
||||
assert workflow is not None
|
||||
assert _CountingWorkflowBuilder.created, "Expected CountingWorkflowBuilder to be instantiated"
|
||||
builder = _CountingWorkflowBuilder.created[-1]
|
||||
assert builder.start_calls == 1, "set_start_executor should be called exactly once"
|
||||
|
||||
|
||||
async def test_magentic_agent_executor_on_checkpoint_save_and_restore_roundtrip():
|
||||
backing_executor = _DummyExec("backing")
|
||||
agent_exec = MagenticAgentExecutor(backing_executor, "agentA")
|
||||
@@ -746,9 +781,11 @@ async def test_magentic_stall_and_reset_successfully():
|
||||
assert idle_status is not None
|
||||
output_event = next((e for e in events if isinstance(e, WorkflowOutputEvent)), None)
|
||||
assert output_event is not None
|
||||
assert isinstance(output_event.data, ChatMessage)
|
||||
assert output_event.data.text is not None
|
||||
assert output_event.data.text == "re-ledger"
|
||||
assert isinstance(output_event.data, list)
|
||||
assert all(isinstance(msg, ChatMessage) for msg in output_event.data)
|
||||
assert len(output_event.data) > 0
|
||||
assert output_event.data[-1].text is not None
|
||||
assert output_event.data[-1].text == "re-ledger"
|
||||
|
||||
|
||||
async def test_magentic_checkpoint_runtime_only() -> None:
|
||||
|
||||
@@ -268,97 +268,7 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen
|
||||
|
||||
## Workflows
|
||||
|
||||
### Start Here
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`getting_started/workflows/_start-here/step1_executors_and_edges.py`](./getting_started/workflows/_start-here/step1_executors_and_edges.py) | Step 1: Foundational patterns: Executors and edges |
|
||||
| [`getting_started/workflows/_start-here/step2_agents_in_a_workflow.py`](./getting_started/workflows/_start-here/step2_agents_in_a_workflow.py) | Step 2: Agents in a Workflow non-streaming |
|
||||
| [`getting_started/workflows/_start-here/step3_streaming.py`](./getting_started/workflows/_start-here/step3_streaming.py) | Step 3: Agents in a workflow with streaming |
|
||||
|
||||
### Agents in Workflows
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`getting_started/workflows/agents/azure_ai_agents_streaming.py`](./getting_started/workflows/agents/azure_ai_agents_streaming.py) | Sample: Agents in a workflow with streaming |
|
||||
| [`getting_started/workflows/agents/azure_chat_agents_function_bridge.py`](./getting_started/workflows/agents/azure_chat_agents_function_bridge.py) | Sample: Two agents connected by a function executor bridge |
|
||||
| [`getting_started/workflows/agents/azure_chat_agents_streaming.py`](./getting_started/workflows/agents/azure_chat_agents_streaming.py) | Sample: Agents in a workflow with streaming |
|
||||
| [`getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py`](./getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py) | Sample: Tool-enabled agents with human feedback |
|
||||
| [`getting_started/workflows/agents/custom_agent_executors.py`](./getting_started/workflows/agents/custom_agent_executors.py) | Step 2: Agents in a Workflow non-streaming |
|
||||
| [`getting_started/workflows/agents/workflow_as_agent_human_in_the_loop.py`](./getting_started/workflows/agents/workflow_as_agent_human_in_the_loop.py) | Sample: Workflow Agent with Human-in-the-Loop |
|
||||
| [`getting_started/workflows/agents/workflow_as_agent_reflection_pattern.py`](./getting_started/workflows/agents/workflow_as_agent_reflection_pattern.py) | Sample: Workflow as Agent with Reflection and Retry Pattern |
|
||||
|
||||
### Checkpoint
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py`](./getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py) | Sample: Checkpoint + human-in-the-loop quickstart |
|
||||
| [`getting_started/workflows/checkpoint/checkpoint_with_resume.py`](./getting_started/workflows/checkpoint/checkpoint_with_resume.py) | Sample: Checkpointing and Resuming a Workflow (with an Agent stage) |
|
||||
| [`getting_started/workflows/checkpoint/sub_workflow_checkpoint.py`](./getting_started/workflows/checkpoint/sub_workflow_checkpoint.py) | Sample: Checkpointing for workflows that embed sub-workflows |
|
||||
|
||||
### Composition
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`getting_started/workflows/composition/sub_workflow_basics.py`](./getting_started/workflows/composition/sub_workflow_basics.py) | Sample: Sub-Workflows (Basics) |
|
||||
| [`getting_started/workflows/composition/sub_workflow_parallel_requests.py`](./getting_started/workflows/composition/sub_workflow_parallel_requests.py) | Sample: Sub-workflow with parallel request handling by specialized interceptors |
|
||||
| [`getting_started/workflows/composition/sub_workflow_request_interception.py`](./getting_started/workflows/composition/sub_workflow_request_interception.py) | Sample: Sub-Workflows with Request Interception |
|
||||
|
||||
### Control Flow
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`getting_started/workflows/control-flow/edge_condition.py`](./getting_started/workflows/control-flow/edge_condition.py) | Sample: Conditional routing with structured outputs |
|
||||
| [`getting_started/workflows/control-flow/multi_selection_edge_group.py`](./getting_started/workflows/control-flow/multi_selection_edge_group.py) | Step 06b — Multi-Selection Edge Group sample |
|
||||
| [`getting_started/workflows/control-flow/sequential_executors.py`](./getting_started/workflows/control-flow/sequential_executors.py) | Sample: Sequential workflow with streaming |
|
||||
| [`getting_started/workflows/control-flow/sequential_streaming.py`](./getting_started/workflows/control-flow/sequential_streaming.py) | Sample: Foundational sequential workflow with streaming using function-style executors |
|
||||
| [`getting_started/workflows/control-flow/simple_loop.py`](./getting_started/workflows/control-flow/simple_loop.py) | Sample: Simple Loop (with an Agent Judge) |
|
||||
| [`getting_started/workflows/control-flow/switch_case_edge_group.py`](./getting_started/workflows/control-flow/switch_case_edge_group.py) | Sample: Switch-Case Edge Group with an explicit Uncertain branch |
|
||||
|
||||
### Human-in-the-Loop
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py`](./getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py) | Sample: Human in the loop guessing game |
|
||||
| [`getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py`](./getting_started/workflows/human-in-the-loop/agents_with_approval_requests.py) | Sample: Agents with Approval Requests in Workflows |
|
||||
|
||||
### Orchestration
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`getting_started/workflows/orchestration/concurrent_agents.py`](./getting_started/workflows/orchestration/concurrent_agents.py) | Sample: Concurrent fan-out/fan-in (agent-only API) with default aggregator |
|
||||
| [`getting_started/workflows/orchestration/concurrent_custom_agent_executors.py`](./getting_started/workflows/orchestration/concurrent_custom_agent_executors.py) | Sample: Concurrent Orchestration with Custom Agent Executors |
|
||||
| [`getting_started/workflows/orchestration/concurrent_custom_aggregator.py`](./getting_started/workflows/orchestration/concurrent_custom_aggregator.py) | Sample: Concurrent Orchestration with Custom Aggregator |
|
||||
| [`getting_started/workflows/orchestration/group_chat_prompt_based_manager.py`](./getting_started/workflows/orchestration/group_chat_prompt_based_manager.py) | Sample: Group Chat Orchestration with LLM-based manager |
|
||||
| [`getting_started/workflows/orchestration/group_chat_simple_selector.py`](./getting_started/workflows/orchestration/group_chat_simple_selector.py) | Sample: Group Chat Orchestration with function-based speaker selector |
|
||||
| [`getting_started/workflows/orchestration/handoff_simple.py`](./getting_started/workflows/orchestration/handoff_simple.py) | Sample: Handoff Orchestration with simple agent handoff pattern |
|
||||
| [`getting_started/workflows/orchestration/handoff_specialist_to_specialist.py`](./getting_started/workflows/orchestration/handoff_specialist_to_specialist.py) | Sample: Handoff Orchestration with specialist-to-specialist routing |
|
||||
| [`getting_started/workflows/orchestration/handoff_return_to_previous`](./getting_started/workflows/orchestration/handoff_return_to_previous.py) | Return-to-previous routing: after user input, routes back to the previous specialist instead of coordinator using `.enable_return_to_previous()` |
|
||||
| [`getting_started/workflows/orchestration/magentic.py`](./getting_started/workflows/orchestration/magentic.py) | Sample: Magentic Orchestration (agentic task planning with multi-agent execution) |
|
||||
| [`getting_started/workflows/orchestration/magentic_checkpoint.py`](./getting_started/workflows/orchestration/magentic_checkpoint.py) | Sample: Magentic Orchestration with Checkpointing |
|
||||
| [`getting_started/workflows/orchestration/magentic_human_plan_update.py`](./getting_started/workflows/orchestration/magentic_human_plan_update.py) | Sample: Magentic Orchestration with Human Plan Review |
|
||||
| [`getting_started/workflows/orchestration/sequential_agents.py`](./getting_started/workflows/orchestration/sequential_agents.py) | Sample: Sequential workflow (agent-focused API) with shared conversation context |
|
||||
| [`getting_started/workflows/orchestration/sequential_custom_executors.py`](./getting_started/workflows/orchestration/sequential_custom_executors.py) | Sample: Sequential workflow mixing agents and a custom summarizer executor |
|
||||
|
||||
### Parallelism
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`getting_started/workflows/parallelism/aggregate_results_of_different_types.py`](./getting_started/workflows/parallelism/aggregate_results_of_different_types.py) | Sample: Concurrent fan out and fan in with two different tasks that output results of different types |
|
||||
| [`getting_started/workflows/parallelism/fan_out_fan_in_edges.py`](./getting_started/workflows/parallelism/fan_out_fan_in_edges.py) | Sample: Concurrent fan out and fan in with three domain agents |
|
||||
| [`getting_started/workflows/parallelism/map_reduce_and_visualization.py`](./getting_started/workflows/parallelism/map_reduce_and_visualization.py) | Sample: Map reduce word count with fan out and fan in over file backed intermediate results |
|
||||
|
||||
### State Management
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`getting_started/workflows/state-management/shared_states_with_agents.py`](./getting_started/workflows/state-management/shared_states_with_agents.py) | Sample: Shared state with agents and conditional routing |
|
||||
|
||||
### Visualization
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`getting_started/workflows/visualization/concurrent_with_visualization.py`](./getting_started/workflows/visualization/concurrent_with_visualization.py) | Sample: Concurrent (Fan-out/Fan-in) with Agents + Visualization |
|
||||
View the list of Workflows samples [here](./getting_started/workflows/README.md).
|
||||
|
||||
## Sample Guidelines
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ This folder contains examples demonstrating different ways to create and use age
|
||||
| [`azure_ai_with_code_interpreter.py`](azure_ai_with_code_interpreter.py) | Shows how to use the `HostedCodeInterpreterTool` with Azure AI agents to write and execute Python code for mathematical problem solving and data analysis. |
|
||||
| [`azure_ai_with_existing_agent.py`](azure_ai_with_existing_agent.py) | Shows how to work with a pre-existing agent by providing the agent name and version to the Azure AI client. Demonstrates agent reuse patterns for production scenarios. |
|
||||
| [`azure_ai_with_existing_conversation.py`](azure_ai_with_existing_conversation.py) | Demonstrates how to use an existing conversation created on the service side with Azure AI agents. Shows two approaches: specifying conversation ID at the client level and using AgentThread with an existing conversation ID. |
|
||||
| [`azure_ai_with_application_endpoint.py`](azure_ai_with_application_endpoint.py) | Demonstrates calling the Azure AI application-scoped endpoint. |
|
||||
| [`azure_ai_with_explicit_settings.py`](azure_ai_with_explicit_settings.py) | Shows how to create an agent with explicitly configured `AzureAIClient` settings, including project endpoint, model deployment, and credentials rather than relying on environment variable defaults. |
|
||||
| [`azure_ai_with_file_search.py`](azure_ai_with_file_search.py) | Shows how to use the `HostedFileSearchTool` with Azure AI agents to upload files, create vector stores, and enable agents to search through uploaded documents to answer user questions. |
|
||||
| [`azure_ai_with_hosted_mcp.py`](azure_ai_with_hosted_mcp.py) | Shows how to integrate hosted Model Context Protocol (MCP) tools with Azure AI Agent. |
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import ChatAgent
|
||||
from agent_framework.azure import AzureAIClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
"""
|
||||
Azure AI Agent with Application Endpoint Example
|
||||
|
||||
This sample demonstrates working with pre-existing Azure AI Agents by providing
|
||||
application endpoint instead of project endpoint.
|
||||
"""
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# Create the client
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
# Endpoint here should be application endpoint with format:
|
||||
# /api/projects/<project-name>/applications/<application-name>/protocols
|
||||
AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project_client,
|
||||
ChatAgent(
|
||||
chat_client=AzureAIClient(
|
||||
project_client=project_client,
|
||||
),
|
||||
) as agent,
|
||||
):
|
||||
query = "How are you?"
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -67,19 +67,19 @@ async def example_with_thread_persistence_in_memory() -> None:
|
||||
# First conversation
|
||||
query1 = "What's the weather like in Tokyo?"
|
||||
print(f"User: {query1}")
|
||||
result1 = await agent.run(query1, thread=thread)
|
||||
result1 = await agent.run(query1, thread=thread, store=False)
|
||||
print(f"Agent: {result1.text}")
|
||||
|
||||
# Second conversation using the same thread - maintains context
|
||||
query2 = "How about London?"
|
||||
print(f"\nUser: {query2}")
|
||||
result2 = await agent.run(query2, thread=thread)
|
||||
result2 = await agent.run(query2, thread=thread, store=False)
|
||||
print(f"Agent: {result2.text}")
|
||||
|
||||
# Third conversation - agent should remember both previous cities
|
||||
query3 = "Which of the cities I asked about has better weather?"
|
||||
print(f"\nUser: {query3}")
|
||||
result3 = await agent.run(query3, thread=thread)
|
||||
result3 = await agent.run(query3, thread=thread, store=False)
|
||||
print(f"Agent: {result3.text}")
|
||||
print("Note: The agent remembers context from previous messages in the same thread.\n")
|
||||
|
||||
@@ -105,8 +105,7 @@ async def example_with_existing_thread_id() -> None:
|
||||
|
||||
query1 = "What's the weather in Paris?"
|
||||
print(f"User: {query1}")
|
||||
# Enable OpenAI conversation state by setting `store` parameter to True
|
||||
result1 = await agent.run(query1, thread=thread, store=True)
|
||||
result1 = await agent.run(query1, thread=thread)
|
||||
print(f"Agent: {result1.text}")
|
||||
|
||||
# The thread ID is set after the first response
|
||||
@@ -127,7 +126,7 @@ async def example_with_existing_thread_id() -> None:
|
||||
|
||||
query2 = "What was the last city I asked about?"
|
||||
print(f"User: {query2}")
|
||||
result2 = await agent.run(query2, thread=thread, store=True)
|
||||
result2 = await agent.run(query2, thread=thread)
|
||||
print(f"Agent: {result2.text}")
|
||||
print("Note: The agent continues the conversation from the previous thread by using thread ID.\n")
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# Agent as MCP Tool Sample
|
||||
|
||||
This sample demonstrates how to configure AI agents to be accessible as both HTTP endpoints and [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) tools, enabling flexible integration patterns for AI agent consumption.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- **Multi-trigger Agent Configuration**: Configure agents to support HTTP triggers, MCP tool triggers, or both
|
||||
- **Microsoft Agent Framework Integration**: Use the framework to define AI agents with specific roles and capabilities
|
||||
- **Flexible Agent Registration**: Register agents with customizable trigger configurations
|
||||
- **MCP Server Hosting**: Expose agents as MCP tools for consumption by MCP-compatible clients
|
||||
|
||||
## Sample Architecture
|
||||
|
||||
This sample creates three agents with different trigger configurations:
|
||||
|
||||
| Agent | Role | HTTP Trigger | MCP Tool Trigger | Description |
|
||||
|-------|------|--------------|------------------|-------------|
|
||||
| **Joker** | Comedy specialist | ✅ Enabled | ❌ Disabled | Accessible only via HTTP requests |
|
||||
| **StockAdvisor** | Financial data | ❌ Disabled | ✅ Enabled | Accessible only as MCP tool |
|
||||
| **PlantAdvisor** | Indoor plant recommendations | ✅ Enabled | ✅ Enabled | Accessible via both HTTP and MCP |
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for complete setup instructions, including:
|
||||
|
||||
- Prerequisites installation
|
||||
- Azure OpenAI configuration
|
||||
- Durable Task Scheduler setup
|
||||
- Storage emulator configuration
|
||||
|
||||
## Configuration
|
||||
|
||||
Update your `local.settings.json` with your Azure OpenAI credentials:
|
||||
|
||||
```json
|
||||
{
|
||||
"Values": {
|
||||
"AZURE_OPENAI_ENDPOINT": "https://your-resource.openai.azure.com/",
|
||||
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "your-deployment-name",
|
||||
"AZURE_OPENAI_KEY": "your-api-key-if-not-using-rbac"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
1. **Start the Function App**:
|
||||
```bash
|
||||
cd python/samples/getting_started/azure_functions/08_mcp_server
|
||||
func start
|
||||
```
|
||||
|
||||
2. **Note the MCP Server Endpoint**: When the app starts, you'll see the MCP server endpoint in the terminal output. It will look like:
|
||||
```
|
||||
MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp
|
||||
```
|
||||
|
||||
## Testing MCP Tool Integration
|
||||
|
||||
### Using MCP Inspector
|
||||
|
||||
1. Install the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector)
|
||||
2. Connect using the MCP server endpoint from your terminal output
|
||||
3. Select **"Streamable HTTP"** as the transport method
|
||||
4. Test the available MCP tools:
|
||||
- `StockAdvisor` - Available only as MCP tool
|
||||
- `PlantAdvisor` - Available as both HTTP and MCP tool
|
||||
|
||||
### Using Other MCP Clients
|
||||
|
||||
Any MCP-compatible client can connect to the server endpoint and utilize the exposed agent tools. The agents will appear as callable tools within the MCP protocol.
|
||||
|
||||
## Testing HTTP Endpoints
|
||||
|
||||
For agents with HTTP triggers enabled (Joker and PlantAdvisor), you can test them using curl:
|
||||
|
||||
```bash
|
||||
# Test Joker agent (HTTP only)
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "Tell me a joke"}'
|
||||
|
||||
# Test PlantAdvisor agent (HTTP and MCP)
|
||||
curl -X POST http://localhost:7071/api/agents/PlantAdvisor/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "Recommend an indoor plant"}'
|
||||
```
|
||||
|
||||
Note: StockAdvisor does not have HTTP endpoints and is only accessible via MCP tool triggers.
|
||||
|
||||
## Expected Output
|
||||
|
||||
**HTTP Responses** will be returned directly to your HTTP client.
|
||||
|
||||
**MCP Tool Responses** will be visible in:
|
||||
- The terminal where `func start` is running
|
||||
- Your MCP client interface
|
||||
- The DTS dashboard at `http://localhost:8080` (if using Durable Task Scheduler)
|
||||
|
||||
## Health Check
|
||||
|
||||
Check the health endpoint to see which agents have which triggers enabled:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7071/api/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"agents": [
|
||||
{
|
||||
"name": "Joker",
|
||||
"type": "Agent",
|
||||
"http_endpoint_enabled": true,
|
||||
"mcp_tool_enabled": false
|
||||
},
|
||||
{
|
||||
"name": "StockAdvisor",
|
||||
"type": "Agent",
|
||||
"http_endpoint_enabled": false,
|
||||
"mcp_tool_enabled": true
|
||||
},
|
||||
{
|
||||
"name": "PlantAdvisor",
|
||||
"type": "Agent",
|
||||
"http_endpoint_enabled": true,
|
||||
"mcp_tool_enabled": true
|
||||
}
|
||||
],
|
||||
"agent_count": 3
|
||||
}
|
||||
```
|
||||
|
||||
## Code Structure
|
||||
|
||||
The sample shows how to enable MCP tool triggers with flexible agent configuration:
|
||||
|
||||
```python
|
||||
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
|
||||
|
||||
# Create Azure OpenAI Chat Client
|
||||
chat_client = AzureOpenAIChatClient()
|
||||
|
||||
# Define agents with different roles
|
||||
joker_agent = chat_client.create_agent(
|
||||
name="Joker",
|
||||
instructions="You are good at telling jokes.",
|
||||
)
|
||||
|
||||
stock_agent = chat_client.create_agent(
|
||||
name="StockAdvisor",
|
||||
instructions="Check stock prices.",
|
||||
)
|
||||
|
||||
plant_agent = chat_client.create_agent(
|
||||
name="PlantAdvisor",
|
||||
instructions="Recommend plants.",
|
||||
description="Get plant recommendations.",
|
||||
)
|
||||
|
||||
# Create the AgentFunctionApp
|
||||
app = AgentFunctionApp(enable_health_check=True)
|
||||
|
||||
# Configure agents with different trigger combinations:
|
||||
# HTTP trigger only (default)
|
||||
app.add_agent(joker_agent)
|
||||
|
||||
# MCP tool trigger only (HTTP disabled)
|
||||
app.add_agent(stock_agent, enable_http_endpoint=False, enable_mcp_tool_trigger=True)
|
||||
|
||||
# Both HTTP and MCP tool triggers enabled
|
||||
app.add_agent(plant_agent, enable_http_endpoint=True, enable_mcp_tool_trigger=True)
|
||||
```
|
||||
|
||||
This automatically creates the following endpoints based on agent configuration:
|
||||
- `POST /api/agents/{AgentName}/run` - HTTP endpoint (when `enable_http_endpoint=True`)
|
||||
- MCP tool triggers for agents with `enable_mcp_tool_trigger=True`
|
||||
- `GET /api/health` - Health check endpoint showing agent configurations
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Model Context Protocol Documentation](https://modelcontextprotocol.io/)
|
||||
- [Microsoft Agent Framework Documentation](https://github.com/microsoft/agent-framework)
|
||||
- [Azure Functions Documentation](https://learn.microsoft.com/azure/azure-functions/)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Example showing how to configure AI agents with different trigger configurations.
|
||||
|
||||
This sample demonstrates how to configure agents to be accessible as both HTTP endpoints
|
||||
and Model Context Protocol (MCP) tools, enabling flexible integration patterns for AI agent
|
||||
consumption.
|
||||
|
||||
Key concepts demonstrated:
|
||||
- Multi-trigger Agent Configuration: Configure agents to support HTTP triggers, MCP tool triggers, or both
|
||||
- Microsoft Agent Framework Integration: Use the framework to define AI agents with specific roles
|
||||
- Flexible Agent Registration: Register agents with customizable trigger configurations
|
||||
|
||||
This sample creates three agents with different trigger configurations:
|
||||
- Joker: HTTP trigger only (default)
|
||||
- StockAdvisor: MCP tool trigger only (HTTP disabled)
|
||||
- PlantAdvisor: Both HTTP and MCP tool triggers enabled
|
||||
|
||||
Required environment variables:
|
||||
- AZURE_OPENAI_ENDPOINT: Your Azure OpenAI endpoint
|
||||
- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: Your Azure OpenAI deployment name
|
||||
|
||||
Authentication uses AzureCliCredential (Azure Identity).
|
||||
"""
|
||||
|
||||
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
|
||||
|
||||
# Create Azure OpenAI Chat Client
|
||||
# This uses AzureCliCredential for authentication (requires 'az login')
|
||||
chat_client = AzureOpenAIChatClient()
|
||||
|
||||
# Define three AI agents with different roles
|
||||
# Agent 1: Joker - HTTP trigger only (default)
|
||||
agent1 = chat_client.create_agent(
|
||||
name="Joker",
|
||||
instructions="You are good at telling jokes.",
|
||||
)
|
||||
|
||||
# Agent 2: StockAdvisor - MCP tool trigger only
|
||||
agent2 = chat_client.create_agent(
|
||||
name="StockAdvisor",
|
||||
instructions="Check stock prices.",
|
||||
)
|
||||
|
||||
# Agent 3: PlantAdvisor - Both HTTP and MCP tool triggers
|
||||
agent3 = chat_client.create_agent(
|
||||
name="PlantAdvisor",
|
||||
instructions="Recommend plants.",
|
||||
description="Get plant recommendations.",
|
||||
)
|
||||
|
||||
# Create the AgentFunctionApp with selective trigger configuration
|
||||
app = AgentFunctionApp(
|
||||
enable_health_check=True,
|
||||
)
|
||||
|
||||
# Agent 1: HTTP trigger only (default)
|
||||
app.add_agent(agent1)
|
||||
|
||||
# Agent 2: Disable HTTP trigger, enable MCP tool trigger only
|
||||
app.add_agent(agent2, enable_http_endpoint=False, enable_mcp_tool_trigger=True)
|
||||
|
||||
# Agent 3: Enable both HTTP and MCP tool triggers
|
||||
app.add_agent(agent3, enable_http_endpoint=True, enable_mcp_tool_trigger=True)
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python",
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
||||
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "<AZURE_OPENAI_CHAT_DEPLOYMENT_NAME>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
agent-framework-azurefunctions
|
||||
azure-identity
|
||||
@@ -92,7 +92,8 @@ For observability samples in Agent Framework, see the [observability getting sta
|
||||
| Concurrent Orchestration (Default Aggregator) | [orchestration/concurrent_agents.py](./orchestration/concurrent_agents.py) | Fan-out to multiple agents; fan-in with default aggregator returning combined ChatMessages |
|
||||
| Concurrent Orchestration (Custom Aggregator) | [orchestration/concurrent_custom_aggregator.py](./orchestration/concurrent_custom_aggregator.py) | Override aggregator via callback; summarize results with an LLM |
|
||||
| Concurrent Orchestration (Custom Agent Executors) | [orchestration/concurrent_custom_agent_executors.py](./orchestration/concurrent_custom_agent_executors.py) | Child executors own ChatAgents; concurrent fan-out/fan-in via ConcurrentBuilder |
|
||||
| Group Chat Orchestration with Prompt Based Manager | [orchestration/group_chat_prompt_based_manager.py](./orchestration/group_chat_prompt_based_manager.py) | LLM Manager-directed conversation using GroupChatBuilder |
|
||||
| Group Chat with Agent Manager | [orchestration/group_chat_agent_manager.py](./orchestration/group_chat_agent_manager.py) | Agent-based manager using `set_manager()` to select next speaker |
|
||||
| Group Chat Philosophical Debate | [orchestration/group_chat_philosophical_debate.py](./orchestration/group_chat_philosophical_debate.py) | Agent manager moderates long-form, multi-round debate across diverse participants |
|
||||
| Group Chat with Simple Function Selector | [orchestration/group_chat_simple_selector.py](./orchestration/group_chat_simple_selector.py) | Group chat with a simple function selector for next speaker |
|
||||
| Handoff (Simple) | [orchestration/handoff_simple.py](./orchestration/handoff_simple.py) | Single-tier routing: triage agent routes to specialists, control returns to user after each specialist response |
|
||||
| Handoff (Specialist-to-Specialist) | [orchestration/handoff_specialist_to_specialist.py](./orchestration/handoff_specialist_to_specialist.py) | Multi-tier routing: specialists can hand off to other specialists using `.add_handoff()` fluent API |
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from agent_framework import (
|
||||
AgentRunUpdateEvent,
|
||||
ChatAgent,
|
||||
ChatMessage,
|
||||
GroupChatBuilder,
|
||||
Role,
|
||||
WorkflowOutputEvent,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
"""
|
||||
Sample: Group Chat with Agent-Based Manager
|
||||
|
||||
What it does:
|
||||
- Demonstrates the new set_manager() API for agent-based coordination
|
||||
- Manager is a full ChatAgent with access to tools, context, and observability
|
||||
- Coordinates a researcher and writer agent to solve tasks collaboratively
|
||||
|
||||
Prerequisites:
|
||||
- OpenAI environment variables configured for OpenAIChatClient
|
||||
"""
|
||||
|
||||
|
||||
def _get_chat_client() -> AzureOpenAIChatClient:
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# Create coordinator agent with structured output for speaker selection
|
||||
# Note: response_format is enforced to ManagerSelectionResponse by set_manager()
|
||||
coordinator = ChatAgent(
|
||||
name="Coordinator",
|
||||
description="Coordinates multi-agent collaboration by selecting speakers",
|
||||
instructions="""
|
||||
You coordinate a team conversation to solve the user's task.
|
||||
|
||||
Review the conversation history and select the next participant to speak.
|
||||
|
||||
Guidelines:
|
||||
- Start with Researcher to gather information
|
||||
- Then have Writer synthesize the final answer
|
||||
- Only finish after both have contributed meaningfully
|
||||
- Allow for multiple rounds of information gathering if needed
|
||||
""",
|
||||
chat_client=_get_chat_client(),
|
||||
)
|
||||
|
||||
researcher = ChatAgent(
|
||||
name="Researcher",
|
||||
description="Collects relevant background information",
|
||||
instructions="Gather concise facts that help a teammate answer the question.",
|
||||
chat_client=_get_chat_client(),
|
||||
)
|
||||
|
||||
writer = ChatAgent(
|
||||
name="Writer",
|
||||
description="Synthesizes polished answers from gathered information",
|
||||
instructions="Compose clear and structured answers using any notes provided.",
|
||||
chat_client=_get_chat_client(),
|
||||
)
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_manager(coordinator, display_name="Orchestrator")
|
||||
.with_termination_condition(lambda messages: sum(1 for msg in messages if msg.role == Role.ASSISTANT) >= 2)
|
||||
.participants([researcher, writer])
|
||||
.build()
|
||||
)
|
||||
|
||||
task = "What are the key benefits of using async/await in Python? Provide a concise summary."
|
||||
|
||||
print("\nStarting Group Chat with Agent-Based Manager...\n")
|
||||
print(f"TASK: {task}\n")
|
||||
print("=" * 80)
|
||||
|
||||
final_conversation: list[ChatMessage] = []
|
||||
last_executor_id: str | None = None
|
||||
async for event in workflow.run_stream(task):
|
||||
if isinstance(event, AgentRunUpdateEvent):
|
||||
eid = event.executor_id
|
||||
if eid != last_executor_id:
|
||||
if last_executor_id is not None:
|
||||
print()
|
||||
print(f"{eid}:", end=" ", flush=True)
|
||||
last_executor_id = eid
|
||||
print(event.data, end="", flush=True)
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
final_conversation = cast(list[ChatMessage], event.data)
|
||||
|
||||
if final_conversation and isinstance(final_conversation, list):
|
||||
print("\n\n" + "=" * 80)
|
||||
print("FINAL CONVERSATION")
|
||||
print("=" * 80)
|
||||
for msg in final_conversation:
|
||||
author = getattr(msg, "author_name", "Unknown")
|
||||
text = getattr(msg, "text", str(msg))
|
||||
print(f"\n[{author}]")
|
||||
print(text)
|
||||
print("-" * 80)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from agent_framework import (
|
||||
AgentRunUpdateEvent,
|
||||
ChatAgent,
|
||||
ChatMessage,
|
||||
GroupChatBuilder,
|
||||
Role,
|
||||
WorkflowOutputEvent,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
"""
|
||||
Sample: Philosophical Debate with Agent-Based Manager
|
||||
|
||||
What it does:
|
||||
- Creates a diverse group of agents representing different global perspectives
|
||||
- Uses an agent-based manager to guide a philosophical discussion
|
||||
- Demonstrates longer, multi-round discourse with natural conversation flow
|
||||
- Manager decides when discussion has reached meaningful conclusion
|
||||
|
||||
Topic: "What does a good life mean to you personally?"
|
||||
|
||||
Participants represent:
|
||||
- Farmer from Southeast Asia (tradition, sustainability, land connection)
|
||||
- Software Developer from United States (innovation, technology, work-life balance)
|
||||
- History Teacher from Eastern Europe (legacy, learning, cultural continuity)
|
||||
- Activist from South America (social justice, environmental rights)
|
||||
- Spiritual Leader from Middle East (morality, community service)
|
||||
- Artist from Africa (creative expression, storytelling)
|
||||
- Immigrant Entrepreneur from Asia in Canada (tradition + adaptation)
|
||||
- Doctor from Scandinavia (public health, equity, societal support)
|
||||
|
||||
Prerequisites:
|
||||
- OpenAI environment variables configured for OpenAIChatClient
|
||||
"""
|
||||
|
||||
|
||||
def _get_chat_client() -> AzureOpenAIChatClient:
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# Create debate moderator with structured output for speaker selection
|
||||
# Note: Participant names and descriptions are automatically injected by the orchestrator
|
||||
moderator = ChatAgent(
|
||||
name="Moderator",
|
||||
description="Guides philosophical discussion by selecting next speaker",
|
||||
instructions="""
|
||||
You are a thoughtful moderator guiding a philosophical discussion on the topic handed to you by the user.
|
||||
|
||||
Your participants bring diverse global perspectives. Select speakers strategically to:
|
||||
- Create natural conversation flow and responses to previous points
|
||||
- Ensure all voices are heard throughout the discussion
|
||||
- Build on themes and contrasts that emerge
|
||||
- Allow for respectful challenges and counterpoints
|
||||
- Guide toward meaningful conclusions
|
||||
|
||||
Select speakers who can:
|
||||
1. Respond directly to points just made
|
||||
2. Introduce fresh perspectives when needed
|
||||
3. Bridge or contrast different viewpoints
|
||||
4. Deepen the philosophical exploration
|
||||
|
||||
Finish when:
|
||||
- Multiple rounds have occurred (at least 6-8 exchanges)
|
||||
- Key themes have been explored from different angles
|
||||
- Natural conclusion or synthesis has emerged
|
||||
- Diminishing returns in new insights
|
||||
|
||||
In your final_message, provide a brief synthesis highlighting key themes that emerged.
|
||||
""",
|
||||
chat_client=_get_chat_client(),
|
||||
)
|
||||
|
||||
farmer = ChatAgent(
|
||||
name="Farmer",
|
||||
description="A rural farmer from Southeast Asia",
|
||||
instructions="""
|
||||
You're a farmer from Southeast Asia. Your life is deeply connected to land and family.
|
||||
You value tradition and sustainability. You are in a philosophical debate.
|
||||
|
||||
Share your perspective authentically. Feel free to:
|
||||
- Challenge other participants respectfully
|
||||
- Build on points others have made
|
||||
- Use concrete examples from your experience
|
||||
- Keep responses thoughtful but concise (2-4 sentences)
|
||||
""",
|
||||
chat_client=_get_chat_client(),
|
||||
)
|
||||
|
||||
developer = ChatAgent(
|
||||
name="Developer",
|
||||
description="An urban software developer from the United States",
|
||||
instructions="""
|
||||
You're a software developer from the United States. Your life is fast-paced and technology-driven.
|
||||
You value innovation, freedom, and work-life balance. You are in a philosophical debate.
|
||||
|
||||
Share your perspective authentically. Feel free to:
|
||||
- Challenge other participants respectfully
|
||||
- Build on points others have made
|
||||
- Use concrete examples from your experience
|
||||
- Keep responses thoughtful but concise (2-4 sentences)
|
||||
""",
|
||||
chat_client=_get_chat_client(),
|
||||
)
|
||||
|
||||
teacher = ChatAgent(
|
||||
name="Teacher",
|
||||
description="A retired history teacher from Eastern Europe",
|
||||
instructions="""
|
||||
You're a retired history teacher from Eastern Europe. You bring historical and philosophical
|
||||
perspectives to discussions. You value legacy, learning, and cultural continuity.
|
||||
You are in a philosophical debate.
|
||||
|
||||
Share your perspective authentically. Feel free to:
|
||||
- Challenge other participants respectfully
|
||||
- Build on points others have made
|
||||
- Use concrete examples from history or your teaching experience
|
||||
- Keep responses thoughtful but concise (2-4 sentences)
|
||||
""",
|
||||
chat_client=_get_chat_client(),
|
||||
)
|
||||
|
||||
activist = ChatAgent(
|
||||
name="Activist",
|
||||
description="A young activist from South America",
|
||||
instructions="""
|
||||
You're a young activist from South America. You focus on social justice, environmental rights,
|
||||
and generational change. You are in a philosophical debate.
|
||||
|
||||
Share your perspective authentically. Feel free to:
|
||||
- Challenge other participants respectfully
|
||||
- Build on points others have made
|
||||
- Use concrete examples from your activism
|
||||
- Keep responses thoughtful but concise (2-4 sentences)
|
||||
""",
|
||||
chat_client=_get_chat_client(),
|
||||
)
|
||||
|
||||
spiritual_leader = ChatAgent(
|
||||
name="SpiritualLeader",
|
||||
description="A spiritual leader from the Middle East",
|
||||
instructions="""
|
||||
You're a spiritual leader from the Middle East. You provide insights grounded in religion,
|
||||
morality, and community service. You are in a philosophical debate.
|
||||
|
||||
Share your perspective authentically. Feel free to:
|
||||
- Challenge other participants respectfully
|
||||
- Build on points others have made
|
||||
- Use examples from spiritual teachings or community work
|
||||
- Keep responses thoughtful but concise (2-4 sentences)
|
||||
""",
|
||||
chat_client=_get_chat_client(),
|
||||
)
|
||||
|
||||
artist = ChatAgent(
|
||||
name="Artist",
|
||||
description="An artist from Africa",
|
||||
instructions="""
|
||||
You're an artist from Africa. You view life through creative expression, storytelling,
|
||||
and collective memory. You are in a philosophical debate.
|
||||
|
||||
Share your perspective authentically. Feel free to:
|
||||
- Challenge other participants respectfully
|
||||
- Build on points others have made
|
||||
- Use examples from your art or cultural traditions
|
||||
- Keep responses thoughtful but concise (2-4 sentences)
|
||||
""",
|
||||
chat_client=_get_chat_client(),
|
||||
)
|
||||
|
||||
immigrant = ChatAgent(
|
||||
name="Immigrant",
|
||||
description="An immigrant entrepreneur from Asia living in Canada",
|
||||
instructions="""
|
||||
You're an immigrant entrepreneur from Asia living in Canada. You balance tradition with adaptation.
|
||||
You focus on family success, risk, and opportunity. You are in a philosophical debate.
|
||||
|
||||
Share your perspective authentically. Feel free to:
|
||||
- Challenge other participants respectfully
|
||||
- Build on points others have made
|
||||
- Use examples from your immigrant and entrepreneurial journey
|
||||
- Keep responses thoughtful but concise (2-4 sentences)
|
||||
""",
|
||||
chat_client=_get_chat_client(),
|
||||
)
|
||||
|
||||
doctor = ChatAgent(
|
||||
name="Doctor",
|
||||
description="A doctor from Scandinavia",
|
||||
instructions="""
|
||||
You're a doctor from Scandinavia. Your perspective is shaped by public health, equity,
|
||||
and structured societal support. You are in a philosophical debate.
|
||||
|
||||
Share your perspective authentically. Feel free to:
|
||||
- Challenge other participants respectfully
|
||||
- Build on points others have made
|
||||
- Use examples from healthcare and societal systems
|
||||
- Keep responses thoughtful but concise (2-4 sentences)
|
||||
""",
|
||||
chat_client=_get_chat_client(),
|
||||
)
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_manager(moderator, display_name="Moderator")
|
||||
.participants([farmer, developer, teacher, activist, spiritual_leader, artist, immigrant, doctor])
|
||||
.with_termination_condition(lambda messages: sum(1 for msg in messages if msg.role == Role.ASSISTANT) >= 10)
|
||||
.build()
|
||||
)
|
||||
|
||||
topic = "What does a good life mean to you personally?"
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("PHILOSOPHICAL DEBATE: Perspectives on a Good Life")
|
||||
print("=" * 80)
|
||||
print(f"\nTopic: {topic}")
|
||||
print("\nParticipants:")
|
||||
print(" - Farmer (Southeast Asia)")
|
||||
print(" - Developer (United States)")
|
||||
print(" - Teacher (Eastern Europe)")
|
||||
print(" - Activist (South America)")
|
||||
print(" - SpiritualLeader (Middle East)")
|
||||
print(" - Artist (Africa)")
|
||||
print(" - Immigrant (Asia → Canada)")
|
||||
print(" - Doctor (Scandinavia)")
|
||||
print("\n" + "=" * 80)
|
||||
print("DISCUSSION BEGINS")
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
final_conversation: list[ChatMessage] = []
|
||||
current_speaker: str | None = None
|
||||
|
||||
async for event in workflow.run_stream(f"Please begin the discussion on: {topic}"):
|
||||
if isinstance(event, AgentRunUpdateEvent):
|
||||
speaker_id = event.executor_id.replace("groupchat_agent:", "")
|
||||
|
||||
if speaker_id != current_speaker:
|
||||
if current_speaker is not None:
|
||||
print("\n")
|
||||
print(f"[{speaker_id}]", flush=True)
|
||||
current_speaker = speaker_id
|
||||
|
||||
print(event.data, end="", flush=True)
|
||||
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
final_conversation = cast(list[ChatMessage], event.data)
|
||||
|
||||
print("\n\n" + "=" * 80)
|
||||
print("DISCUSSION SUMMARY")
|
||||
print("=" * 80)
|
||||
|
||||
if final_conversation and isinstance(final_conversation, list) and final_conversation:
|
||||
final_msg = final_conversation[-1]
|
||||
if hasattr(final_msg, "author_name") and final_msg.author_name == "Moderator":
|
||||
print(f"\n{final_msg.text}")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
================================================================================
|
||||
PHILOSOPHICAL DEBATE: Perspectives on a Good Life
|
||||
================================================================================
|
||||
|
||||
Topic: What does a good life mean to you personally?
|
||||
|
||||
Participants:
|
||||
- Farmer (Southeast Asia)
|
||||
- Developer (United States)
|
||||
- Teacher (Eastern Europe)
|
||||
- Activist (South America)
|
||||
- SpiritualLeader (Middle East)
|
||||
- Artist (Africa)
|
||||
- Immigrant (Asia → Canada)
|
||||
- Doctor (Scandinavia)
|
||||
|
||||
================================================================================
|
||||
DISCUSSION BEGINS
|
||||
================================================================================
|
||||
|
||||
[Moderator]
|
||||
{"selected_participant":"Farmer","instruction":"Please start by sharing what living a good life means to you,
|
||||
especially from your perspective living in a rural area in Southeast Asia.","finish":false,"final_message":null}
|
||||
|
||||
[Farmer]
|
||||
To me, a good life is deeply intertwined with the rhythm of the land and the nurturing of relationships with my
|
||||
family and community. It means cultivating crops that respect our environment, ensuring sustainability for future
|
||||
generations, and sharing meals made from our harvests around the dinner table. The joy found in everyday
|
||||
tasks—planting rice or tending to our livestock—creates a sense of fulfillment that cannot be measured by material
|
||||
wealth. It's the simple moments, like sharing stories with my children under the stars, that truly define a good
|
||||
life. What good is progress if it isolates us from those we love and the land that sustains us?
|
||||
|
||||
[Moderator]
|
||||
{"selected_participant":"Developer","instruction":"Given the insights shared by the Farmer, please discuss what a
|
||||
good life means to you as a software developer in an urban setting in the United States and how it might contrast
|
||||
with or complement the Farmer's view.","finish":false,"final_message":null}
|
||||
|
||||
[Developer]
|
||||
As a software developer in an urban environment, a good life for me hinges on the intersection of innovation,
|
||||
creativity, and balance. It's about having the freedom to explore new technologies that can solve real-world
|
||||
problems while ensuring that my work doesn't encroach on my personal life. For instance, I value remote work
|
||||
flexibility, which allows me to maintain connections with family and friends, similar to how the Farmer values
|
||||
community. While our lifestyles may differ markedly, both of us seek fulfillment—whether through meaningful work or
|
||||
rich personal experiences. The challenge is finding harmony between technological progress and preserving the
|
||||
intimate human connections that truly enrich our lives.
|
||||
|
||||
[Moderator]
|
||||
{"selected_participant":"SpiritualLeader","instruction":"Reflect on both the Farmer's and Developer's perspectives
|
||||
and share your view of what constitutes a good life, particularly from your spiritual and cultural standpoint in
|
||||
the Middle East.","finish":false,"final_message":null}
|
||||
|
||||
[SpiritualLeader]
|
||||
From my spiritual perspective, a good life embodies a balance between personal fulfillment and service to others,
|
||||
rooted in compassion and community. In our teachings, we emphasize that true happiness comes from helping those in
|
||||
need and fostering strong connections with our families and neighbors. Whether it's the Farmer nurturing the earth
|
||||
or the Developer creating tools to enhance lives, both contribute to the greater good. The essence of a good life
|
||||
lies in our intentions and actions—finding ways to serve our communities, spread kindness, and live harmoniously
|
||||
with those around us. Ultimately, as we align our personal beliefs with our communal responsibilities, we cultivate
|
||||
a richness that transcends material wealth.
|
||||
|
||||
[Moderator]
|
||||
{"selected_participant":"Activist","instruction":"Add to the discussion by sharing your perspective on what a good
|
||||
life entails, particularly from your background as a young activist in South America.","finish":false,
|
||||
"final_message":null}
|
||||
|
||||
[Activist]
|
||||
As a young activist in South America, a good life for me is about advocating for social justice and environmental
|
||||
sustainability. It means living in a society where everyone's rights are respected and where marginalized voices,
|
||||
particularly those of Indigenous communities, are amplified. I see a good life as one where we work collectively to
|
||||
dismantle oppressive systems—such as deforestation and inequality—while nurturing our planet. For instance, through
|
||||
my activism, I've witnessed the transformative power of community organizing, where collective efforts lead to real
|
||||
change, like resisting destructive mining practices that threaten our rivers and lands. A good life, therefore, is
|
||||
not just lived for oneself but is deeply tied to the well-being of our communities and the health of our
|
||||
environment. How can we, regardless of our backgrounds, collaborate to foster these essential changes?
|
||||
|
||||
[Moderator]
|
||||
{"selected_participant":"Teacher","instruction":"Considering the views shared so far, tell us how your experience
|
||||
as a retired history teacher from Eastern Europe shapes your understanding of a good life, perhaps reflecting on
|
||||
lessons from the past and their impact on present-day life choices.","finish":false,"final_message":null}
|
||||
|
||||
[Teacher]
|
||||
As a retired history teacher from Eastern Europe, my understanding of a good life is deeply rooted in the lessons
|
||||
drawn from history and the struggle for freedom and dignity. Historical events, such as the fall of the Iron
|
||||
Curtain, remind us of the profound importance of liberty and collective resilience. A good life, therefore, is about
|
||||
cherishing our freedoms and working towards a society where everyone has a voice, much as my students and I
|
||||
discussed the impacts of totalitarian regimes. Additionally, I believe it involves fostering cultural continuity,
|
||||
where we honor our heritage while embracing progressive values. We must learn from the past—especially the
|
||||
consequences of neglecting empathy and solidarity—so that we can cultivate a future that values every individual's
|
||||
contributions to the rich tapestry of our shared humanity. How can we ensure that the lessons of history inform a
|
||||
more compassionate and just society moving forward?
|
||||
|
||||
[Moderator]
|
||||
{"selected_participant":"Artist","instruction":"Expound on the themes and perspectives discussed so far by sharing
|
||||
how, as an artist from Africa, you define a good life and how art plays a role in that vision.","finish":false,
|
||||
"final_message":null}
|
||||
|
||||
[Artist]
|
||||
As an artist from Africa, I define a good life as one steeped in cultural expression, storytelling, and the
|
||||
celebration of our collective memories. Art is a powerful medium through which we capture our histories, struggles,
|
||||
and triumphs, creating a tapestry that connects generations. For instance, in my work, I often draw from folktales
|
||||
and traditional music, weaving narratives that reflect the human experience, much like how the retired teacher
|
||||
emphasizes learning from history. A good life involves not only personal fulfillment but also the responsibility to
|
||||
share our narratives and use our creativity to inspire change, whether addressing social injustices or environmental
|
||||
issues. It's in this interplay of art and activism that we can transcend individual existence and contribute to a
|
||||
collective good, fostering empathy and understanding among diverse communities. How can we harness art to bridge
|
||||
differences and amplify marginalized voices in our pursuit of a good life?
|
||||
|
||||
[Moderator]
|
||||
{"selected_participant":null,"instruction":null,"finish":true,"final_message":"As our discussion unfolds, several
|
||||
key themes have gracefully emerged, reflecting the richness of diverse perspectives on what constitutes a good life.
|
||||
From the rural farmer's integration with the land to the developer's search for balance between technology and
|
||||
personal connection, each viewpoint validates that fulfillment, at its core, transcends material wealth. The
|
||||
spiritual leader and the activist highlight the importance of community and social justice, while the history
|
||||
teacher and the artist remind us of the lessons and narratives that shape our cultural and personal identities.
|
||||
|
||||
Ultimately, the good life seems to revolve around meaningful relationships, honoring our legacies while striving for
|
||||
progress, and nurturing both our inner selves and external communities. This dialogue demonstrates that despite our
|
||||
varied backgrounds and experiences, the quest for a good life binds us together, urging cooperation and empathy in
|
||||
our shared human journey."}
|
||||
|
||||
================================================================================
|
||||
DISCUSSION SUMMARY
|
||||
================================================================================
|
||||
|
||||
As our discussion unfolds, several key themes have gracefully emerged, reflecting the richness of diverse
|
||||
perspectives on what constitutes a good life. From the rural farmer's integration with the land to the developer's
|
||||
search for balance between technology and personal connection, each viewpoint validates that fulfillment, at its
|
||||
core, transcends material wealth. The spiritual leader and the activist highlight the importance of community and
|
||||
social justice, while the history teacher and the artist remind us of the lessons and narratives that shape our
|
||||
cultural and personal identities.
|
||||
|
||||
Ultimately, the good life seems to revolve around meaningful relationships, honoring our legacies while striving for
|
||||
progress, and nurturing both our inner selves and external communities. This dialogue demonstrates that despite our
|
||||
varied backgrounds and experiences, the quest for a good life binds us together, urging cooperation and empathy in
|
||||
our shared human journey.
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from agent_framework import AgentRunUpdateEvent, ChatAgent, GroupChatBuilder, WorkflowOutputEvent
|
||||
from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
"""
|
||||
Sample: Group Chat Orchestration (manager-directed)
|
||||
|
||||
What it does:
|
||||
- Demonstrates the generic GroupChatBuilder with a language-model manager directing two agents.
|
||||
- The manager coordinates a researcher (chat completions) and a writer (responses API) to solve a task.
|
||||
- Uses the default group chat orchestration pipeline shared with Magentic.
|
||||
|
||||
Prerequisites:
|
||||
- OpenAI environment variables configured for `OpenAIChatClient` and `OpenAIResponsesClient`.
|
||||
"""
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
researcher = ChatAgent(
|
||||
name="Researcher",
|
||||
description="Collects relevant background information.",
|
||||
instructions="Gather concise facts that help a teammate answer the question.",
|
||||
chat_client=OpenAIChatClient(model_id="gpt-4o-mini"),
|
||||
)
|
||||
|
||||
writer = ChatAgent(
|
||||
name="Writer",
|
||||
description="Synthesizes a polished answer using the gathered notes.",
|
||||
instructions="Compose clear and structured answers using any notes provided.",
|
||||
chat_client=OpenAIResponsesClient(),
|
||||
)
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_prompt_based_manager(chat_client=OpenAIChatClient(), display_name="Coordinator")
|
||||
.participants(researcher=researcher, writer=writer)
|
||||
.build()
|
||||
)
|
||||
|
||||
task = "Outline the core considerations for planning a community hackathon, and finish with a concise action plan."
|
||||
|
||||
print("\nStarting Group Chat Workflow...\n")
|
||||
print(f"TASK: {task}\n")
|
||||
|
||||
final_response = None
|
||||
last_executor_id: str | None = None
|
||||
async for event in workflow.run_stream(task):
|
||||
if isinstance(event, AgentRunUpdateEvent):
|
||||
# Handle the streaming agent update as it's produced
|
||||
eid = event.executor_id
|
||||
if eid != last_executor_id:
|
||||
if last_executor_id is not None:
|
||||
print()
|
||||
print(f"{eid}:", end=" ", flush=True)
|
||||
last_executor_id = eid
|
||||
print(event.data, end="", flush=True)
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
final_response = getattr(event.data, "text", str(event.data))
|
||||
|
||||
if final_response:
|
||||
print("=" * 60)
|
||||
print("FINAL RESPONSE")
|
||||
print("=" * 60)
|
||||
print(final_response)
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+12
-8
@@ -2,8 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from agent_framework import ChatAgent, GroupChatBuilder, GroupChatStateSnapshot, WorkflowOutputEvent
|
||||
from agent_framework import ChatAgent, ChatMessage, GroupChatBuilder, GroupChatStateSnapshot, WorkflowOutputEvent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -12,7 +13,7 @@ logging.basicConfig(level=logging.INFO)
|
||||
Sample: Group Chat with Simple Speaker Selector Function
|
||||
|
||||
What it does:
|
||||
- Demonstrates the select_speakers() API for GroupChat orchestration
|
||||
- Demonstrates the set_select_speakers_func() API for GroupChat orchestration
|
||||
- Uses a pure Python function to control speaker selection based on conversation state
|
||||
- Alternates between researcher and writer agents in a simple round-robin pattern
|
||||
- Shows how to access conversation history, round index, and participant metadata
|
||||
@@ -84,7 +85,7 @@ async def main() -> None:
|
||||
# 2. Dict form - explicit names: .participants(researcher=researcher, writer=writer)
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.select_speakers(select_next_speaker, display_name="Orchestrator")
|
||||
.set_select_speakers_func(select_next_speaker, display_name="Orchestrator")
|
||||
.participants([researcher, writer]) # Uses agent.name for participant names
|
||||
.build()
|
||||
)
|
||||
@@ -97,11 +98,14 @@ async def main() -> None:
|
||||
|
||||
async for event in workflow.run_stream(task):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
final_message = event.data
|
||||
author = getattr(final_message, "author_name", "Unknown")
|
||||
text = getattr(final_message, "text", str(final_message))
|
||||
print(f"\n[{author}]\n{text}\n")
|
||||
print("-" * 80)
|
||||
conversation = cast(list[ChatMessage], event.data)
|
||||
if isinstance(conversation, list):
|
||||
print("\n===== Final Conversation =====\n")
|
||||
for msg in conversation:
|
||||
author = getattr(msg, "author_name", "Unknown")
|
||||
text = getattr(msg, "text", str(msg))
|
||||
print(f"[{author}]\n{text}\n")
|
||||
print("-" * 80)
|
||||
|
||||
print("\nWorkflow completed.")
|
||||
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from agent_framework import (
|
||||
MAGENTIC_EVENT_TYPE_AGENT_DELTA,
|
||||
MAGENTIC_EVENT_TYPE_ORCHESTRATOR,
|
||||
AgentRunUpdateEvent,
|
||||
ChatAgent,
|
||||
ChatMessage,
|
||||
HostedCodeInterpreterTool,
|
||||
MagenticAgentDeltaEvent,
|
||||
MagenticAgentMessageEvent,
|
||||
MagenticBuilder,
|
||||
MagenticFinalResultEvent,
|
||||
MagenticOrchestratorMessageEvent,
|
||||
WorkflowOutputEvent,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
@@ -97,35 +98,30 @@ async def main() -> None:
|
||||
try:
|
||||
output: str | None = None
|
||||
async for event in workflow.run_stream(task):
|
||||
if isinstance(event, MagenticOrchestratorMessageEvent):
|
||||
print(f"\n[ORCH:{event.kind}]\n\n{getattr(event.message, 'text', '')}\n{'-' * 26}")
|
||||
elif isinstance(event, MagenticAgentDeltaEvent):
|
||||
if last_stream_agent_id != event.agent_id or not stream_line_open:
|
||||
if stream_line_open:
|
||||
print()
|
||||
print(f"\n[STREAM:{event.agent_id}]: ", end="", flush=True)
|
||||
last_stream_agent_id = event.agent_id
|
||||
stream_line_open = True
|
||||
if event.text:
|
||||
print(event.text, end="", flush=True)
|
||||
elif isinstance(event, MagenticAgentMessageEvent):
|
||||
if stream_line_open:
|
||||
print(" (final)")
|
||||
stream_line_open = False
|
||||
print()
|
||||
msg = event.message
|
||||
if msg is not None:
|
||||
response_text = (msg.text or "").replace("\n", " ")
|
||||
print(f"\n[AGENT:{event.agent_id}] {msg.role.value}\n\n{response_text}\n{'-' * 26}")
|
||||
elif isinstance(event, MagenticFinalResultEvent):
|
||||
print("\n" + "=" * 50)
|
||||
print("FINAL RESULT:")
|
||||
print("=" * 50)
|
||||
if event.message is not None:
|
||||
print(event.message.text)
|
||||
print("=" * 50)
|
||||
if isinstance(event, AgentRunUpdateEvent):
|
||||
props = event.data.additional_properties if event.data else None
|
||||
event_type = props.get("magentic_event_type") if props else None
|
||||
|
||||
if event_type == MAGENTIC_EVENT_TYPE_ORCHESTRATOR:
|
||||
kind = props.get("orchestrator_message_kind", "") if props else ""
|
||||
text = event.data.text if event.data else ""
|
||||
print(f"\n[ORCH:{kind}]\n\n{text}\n{'-' * 26}")
|
||||
elif event_type == MAGENTIC_EVENT_TYPE_AGENT_DELTA:
|
||||
agent_id = props.get("agent_id", event.executor_id) if props else event.executor_id
|
||||
if last_stream_agent_id != agent_id or not stream_line_open:
|
||||
if stream_line_open:
|
||||
print()
|
||||
print(f"\n[STREAM:{agent_id}]: ", end="", flush=True)
|
||||
last_stream_agent_id = agent_id
|
||||
stream_line_open = True
|
||||
if event.data and event.data.text:
|
||||
print(event.data.text, end="", flush=True)
|
||||
elif event.data and event.data.text:
|
||||
print(event.data.text, end="", flush=True)
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
output = str(event.data) if event.data is not None else None
|
||||
output_messages = cast(list[ChatMessage], event.data)
|
||||
if output_messages:
|
||||
output = output_messages[-1].text
|
||||
|
||||
if stream_line_open:
|
||||
print()
|
||||
|
||||
@@ -233,8 +233,8 @@ async def run_agent_framework_example(task: str) -> str:
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_prompt_based_manager(
|
||||
chat_client=AzureOpenAIChatClient(credential=credential),
|
||||
.set_manager(
|
||||
manager=AzureOpenAIChatClient(credential=credential).create_agent(),
|
||||
display_name="Coordinator",
|
||||
)
|
||||
.participants(researcher=researcher, planner=planner)
|
||||
@@ -245,7 +245,12 @@ async def run_agent_framework_example(task: str) -> str:
|
||||
async for event in workflow.run_stream(task):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
data = event.data
|
||||
final_response = data.text or "" if isinstance(data, ChatMessage) else str(data)
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
# Get the final message from the conversation
|
||||
final_message = data[-1]
|
||||
final_response = final_message.text or "" if isinstance(final_message, ChatMessage) else str(data)
|
||||
else:
|
||||
final_response = str(data)
|
||||
return final_response
|
||||
|
||||
|
||||
|
||||
Generated
+15
-14
@@ -280,7 +280,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "openai-chatkit", specifier = ">=1.1.0,<2.0.0" },
|
||||
{ name = "openai-chatkit", specifier = ">=1.4.0,<2.0.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -798,7 +798,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.74.1"
|
||||
version = "0.75.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -810,9 +810,9 @@ dependencies = [
|
||||
{ name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/7b/609eea5c54ae69b1a4a94169d4b0c86dc5c41b43509989913f6cdc61b81d/anthropic-0.74.1.tar.gz", hash = "sha256:04c087b2751385c524f6d332d066a913870e4de8b3e335fb0a0c595f1f88dc6e", size = 428981, upload-time = "2025-11-19T22:17:31.533Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/1f/08e95f4b7e2d35205ae5dcbb4ae97e7d477fc521c275c02609e2931ece2d/anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb", size = 439565, upload-time = "2025-11-24T20:41:45.28Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/45/6b18d0692302b8cbc01a10c35b43953d3c4172fbd4f83337b8ed21a8eaa4/anthropic-0.74.1-py3-none-any.whl", hash = "sha256:b07b998d1cee7f41d9f02530597d7411672b362cc2417760a40c0167b81c6e65", size = 371473, upload-time = "2025-11-19T22:17:29.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/1c/1cd02b7ae64302a6e06724bf80a96401d5313708651d277b1458504a1730/anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b", size = 388164, upload-time = "2025-11-24T20:41:43.587Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1799,7 +1799,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
|
||||
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -1817,7 +1817,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.121.3"
|
||||
version = "0.122.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -1825,9 +1825,9 @@ dependencies = [
|
||||
{ name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/f0/086c442c6516195786131b8ca70488c6ef11d2f2e33c9a893576b2b0d3f7/fastapi-0.121.3.tar.gz", hash = "sha256:0055bc24fe53e56a40e9e0ad1ae2baa81622c406e548e501e717634e2dfbc40b", size = 344501, upload-time = "2025-11-19T16:53:39.243Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/de/3ee97a4f6ffef1fb70bf20561e4f88531633bb5045dc6cebc0f8471f764d/fastapi-0.122.0.tar.gz", hash = "sha256:cd9b5352031f93773228af8b4c443eedc2ac2aa74b27780387b853c3726fb94b", size = 346436, upload-time = "2025-11-24T19:17:47.95Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/b6/4f620d7720fc0a754c8c1b7501d73777f6ba43b57c8ab99671f4d7441eb8/fastapi-0.121.3-py3-none-any.whl", hash = "sha256:0c78fc87587fcd910ca1bbf5bc8ba37b80e119b388a7206b39f0ecc95ebf53e9", size = 109801, upload-time = "2025-11-19T16:53:37.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/93/aa8072af4ff37b795f6bbf43dcaf61115f40f49935c7dbb180c9afc3f421/fastapi-0.122.0-py3-none-any.whl", hash = "sha256:a456e8915dfc6c8914a50d9651133bd47ec96d331c5b44600baa635538a30d67", size = 110671, upload-time = "2025-11-24T19:17:45.96Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3730,17 +3730,18 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openai-chatkit"
|
||||
version = "1.3.1"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "openai-agents", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/81/906844005976afb3c415e27f6506ed0f2b8b1040f27b4bc9ef118a256986/openai_chatkit-1.3.1.tar.gz", hash = "sha256:91f39b04584f969642a6c3b4099fdad74c2a357e25d8f746f9709046304a06cf", size = 50730, upload-time = "2025-11-21T21:22:11.62Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c5/89/bf2f094997c8e5cad5334e8a02e05fc458823e65fb2675f45b56b6d1ab73/openai_chatkit-1.4.0.tar.gz", hash = "sha256:e2527dffc3794a05596ad75efa66bdc4efb4ded5a77a013a55496cc989bcf2e6", size = 55269, upload-time = "2025-11-25T21:02:58.503Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/61/235e5f49bd068bbe5f7278bc7d7c4bd92226858698fcac53ab92090baf04/openai_chatkit-1.3.1-py3-none-any.whl", hash = "sha256:5626492e5752879e66b2b6d4fbac51994407d46429de99b91515a77c2e0c6148", size = 35899, upload-time = "2025-11-21T21:22:10.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/bf/68d42561dd8a674b6f8541d879dd165b5ac4d81fcf1027462e154de66a4f/openai_chatkit-1.4.0-py3-none-any.whl", hash = "sha256:35d00ca8398908bd70d63e2284adcd836641cc11746f68d7cfa91d276e3dad3d", size = 39077, upload-time = "2025-11-25T21:02:57.288Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5075,7 +5076,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "qdrant-client"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -5087,9 +5088,9 @@ dependencies = [
|
||||
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/16/366541897d270ee3f9c3f87da145baa8a5c9cc5190e0e53e8bbec1267cff/qdrant_client-1.16.0.tar.gz", hash = "sha256:0716aa0b7cca39745829c2e8ea0beb275fe2990e743ad803eabd6218e4b35c1b", size = 284128, upload-time = "2025-11-17T13:19:52.726Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/68/fec3816a223c0b73b0e0036460be45c61ce2770ffb9197ac371e4f615ddc/qdrant_client-1.16.1.tar.gz", hash = "sha256:676c7c10fd4d4cb2981b8fcb32fd764f5f661b04b7334d024034d07212f971fd", size = 332130, upload-time = "2025-11-25T04:31:54.212Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ff/3a69bb56835c4b2e9fa780655790937011ac389b0408b9a1147eaa2cee22/qdrant_client-1.16.0-py3-none-any.whl", hash = "sha256:6b932393e84e4c0233e5b2eb96b0918e968725855adae4d9c541761f4c50cf11", size = 328579, upload-time = "2025-11-17T13:19:51.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/e2/60a20d04b0595c641516463168909c5bbcc192d3d6eacb637c1677109c6a/qdrant_client-1.16.1-py3-none-any.whl", hash = "sha256:1eefe89f66e8a468ba0de1680e28b441e69825cfb62e8fb2e457c15e24ce5e3b", size = 378481, upload-time = "2025-11-25T04:31:52.629Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user