Python: Rebase durable task feature branch with main (#2806)

This commit is contained in:
Laveesh Rohra
2025-12-17 14:02:36 -08:00
committed by GitHub
parent a48a8dd524
commit 87a38bc7da
227 changed files with 11968 additions and 2637 deletions
+1
View File
@@ -209,6 +209,7 @@ dotnet_diagnostic.CA2000.severity = none # Call System.IDisposable.Dispose on ob
dotnet_diagnostic.CA2225.severity = none # Operator overloads have named alternates
dotnet_diagnostic.CA2227.severity = none # Change to be read-only by removing the property setter
dotnet_diagnostic.CA2249.severity = suggestion # Consider using 'Contains' method instead of 'IndexOf' method
dotnet_diagnostic.CA2252.severity = none # Requires preview
dotnet_diagnostic.CA2253.severity = none # Named placeholders in the logging message template should not be comprised of only numeric characters
dotnet_diagnostic.CA2253.severity = none # Named placeholders in the logging message template should not be comprised of only numeric characters
dotnet_diagnostic.CA2263.severity = suggestion # Use generic overload
+9 -11
View File
@@ -19,11 +19,11 @@
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0-beta.440" />
<!-- Azure.* -->
<PackageVersion Include="Azure.AI.Projects" Version="1.2.0-beta.3" />
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="1.0.0-beta.5" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.8" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageVersion Include="Azure.Identity" Version="1.17.0" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageVersion Include="Azure.Identity" Version="1.17.1" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
<!-- Google Gemini -->
<PackageVersion Include="Google.GenAI" Version="0.6.0" />
@@ -61,10 +61,9 @@
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.1.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.1.0" />
<PackageVersion Include="Microsoft.Extensions.AI.AzureAIInference" Version="10.0.0-preview.1.25559.3" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.1.1" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.1.1" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
@@ -101,11 +100,10 @@
<!-- MCP -->
<PackageVersion Include="ModelContextProtocol" Version="0.4.0-preview.3" />
<!-- Inference SDKs -->
<PackageVersion Include="Anthropic.SDK" Version="5.8.0" />
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.7" />
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.11" />
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
<PackageVersion Include="OpenAI" Version="2.7.0" />
<PackageVersion Include="OpenAI" Version="2.8.0" />
<!-- Identity -->
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.78.0" />
<!-- Workflows -->
+1
View File
@@ -129,6 +129,7 @@
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj" />
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/Agent_OpenAI_Step03_CreateFromChatClient.csproj" />
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient.csproj" />
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Agent_OpenAI_Step05_Conversation.csproj" />
</Folder>
<Folder Name="/Samples/Purview/" />
<Folder Name="/Samples/Purview/AgentWithPurview/">
@@ -25,7 +25,6 @@
<PackageReference Include="CommunityToolkit.Aspire.OllamaSharp" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI.AzureAIInference" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.AspNetCore.OpenAPI" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentWebChat.AgentHost.Utilities;
using Azure;
using Azure.AI.Inference;
using Microsoft.Extensions.AI;
using OllamaSharp;
@@ -24,7 +22,6 @@ public static class ChatClientExtensions
ClientChatProvider.Ollama => builder.AddOllamaClient(connectionName, connectionInfo),
ClientChatProvider.OpenAI => builder.AddOpenAIClient(connectionName, connectionInfo),
ClientChatProvider.AzureOpenAI => builder.AddAzureOpenAIClient(connectionName).AddChatClient(connectionInfo.SelectedModel),
ClientChatProvider.AzureAIInference => builder.AddAzureInferenceClient(connectionName, connectionInfo),
_ => throw new NotSupportedException($"Unsupported provider: {connectionInfo.Provider}")
};
@@ -44,16 +41,6 @@ public static class ChatClientExtensions
})
.AddChatClient(connectionInfo.SelectedModel);
private static ChatClientBuilder AddAzureInferenceClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo) =>
builder.Services.AddChatClient(sp =>
{
var credential = new AzureKeyCredential(connectionInfo.AccessKey!);
var client = new ChatCompletionsClient(connectionInfo.Endpoint, credential, new AzureAIInferenceClientOptions());
return client.AsIChatClient(connectionInfo.SelectedModel);
});
private static ChatClientBuilder AddOllamaClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
var httpKey = $"{connectionName}_http";
@@ -83,7 +70,6 @@ public static class ChatClientExtensions
ClientChatProvider.Ollama => builder.AddKeyedOllamaClient(connectionName, connectionInfo),
ClientChatProvider.OpenAI => builder.AddKeyedOpenAIClient(connectionName, connectionInfo),
ClientChatProvider.AzureOpenAI => builder.AddKeyedAzureOpenAIClient(connectionName).AddKeyedChatClient(connectionName, connectionInfo.SelectedModel),
ClientChatProvider.AzureAIInference => builder.AddKeyedAzureInferenceClient(connectionName, connectionInfo),
_ => throw new NotSupportedException($"Unsupported provider: {connectionInfo.Provider}")
};
@@ -103,16 +89,6 @@ public static class ChatClientExtensions
})
.AddKeyedChatClient(connectionName, connectionInfo.SelectedModel);
private static ChatClientBuilder AddKeyedAzureInferenceClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo) =>
builder.Services.AddKeyedChatClient(connectionName, sp =>
{
var credential = new AzureKeyCredential(connectionInfo.AccessKey!);
var client = new ChatCompletionsClient(connectionInfo.Endpoint, credential, new AzureAIInferenceClientOptions());
return client.AsIChatClient(connectionInfo.SelectedModel);
});
private static ChatClientBuilder AddKeyedOllamaClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
var httpKey = $"{connectionName}_http";
@@ -27,7 +27,7 @@ internal sealed class OpenAIResponsesAgentClient(HttpClient httpClient) : AgentC
Transport = new HttpClientPipelineTransport(httpClient)
};
var openAiClient = new OpenAIResponseClient(model: agentName, credential: new ApiKeyCredential("dummy-key"), options: options).AsIChatClient();
var openAiClient = new ResponsesClient(model: agentName, credential: new ApiKeyCredential("dummy-key"), options: options).AsIChatClient();
var chatOptions = new ChatOptions()
{
ConversationId = threadId
@@ -32,6 +32,6 @@ AIAgent agent = client.GetChatClient(deploymentName).CreateAIAgent(JokerInstruct
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableAgents(options => options.AddAIAgent(agent))
.ConfigureDurableAgents(options => options.AddAIAgent(agent, timeToLive: TimeSpan.FromHours(1)))
.Build();
app.Run();
@@ -13,7 +13,7 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.GetResponsesClient(deploymentName)
.CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker");
// Invoke the agent and output the text result.
@@ -11,7 +11,7 @@ var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o-mini";
AIAgent agent = new OpenAIClient(
apiKey)
.GetOpenAIResponseClient(model)
.GetResponsesClient(model)
.CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker");
// Invoke the agent and output the text result.
@@ -11,11 +11,11 @@ var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new I
var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-5";
var client = new OpenAIClient(apiKey)
.GetOpenAIResponseClient(model)
.GetResponsesClient(model)
.AsIChatClient().AsBuilder()
.ConfigureOptions(o =>
{
o.RawRepresentationFactory = _ => new ResponseCreationOptions()
o.RawRepresentationFactory = _ => new CreateResponseOptions()
{
ReasoningOptions = new()
{
@@ -16,13 +16,13 @@ public class OpenAIResponseClientAgent : DelegatingAIAgent
/// <summary>
/// Initialize an instance of <see cref="OpenAIResponseClientAgent"/>.
/// </summary>
/// <param name="client">Instance of <see cref="OpenAIResponseClient"/></param>
/// <param name="client">Instance of <see cref="ResponsesClient"/></param>
/// <param name="instructions">Optional instructions for the agent.</param>
/// <param name="name">Optional name for the agent.</param>
/// <param name="description">Optional description for the agent.</param>
/// <param name="loggerFactory">Optional instance of <see cref="ILoggerFactory"/></param>
public OpenAIResponseClientAgent(
OpenAIResponseClient client,
ResponsesClient client,
string? instructions = null,
string? name = null,
string? description = null,
@@ -39,11 +39,11 @@ public class OpenAIResponseClientAgent : DelegatingAIAgent
/// <summary>
/// Initialize an instance of <see cref="OpenAIResponseClientAgent"/>.
/// </summary>
/// <param name="client">Instance of <see cref="OpenAIResponseClient"/></param>
/// <param name="client">Instance of <see cref="ResponsesClient"/></param>
/// <param name="options">Options to create the agent.</param>
/// <param name="loggerFactory">Optional instance of <see cref="ILoggerFactory"/></param>
public OpenAIResponseClientAgent(
OpenAIResponseClient client, ChatClientAgentOptions options, ILoggerFactory? loggerFactory = null) :
ResponsesClient client, ChatClientAgentOptions options, ILoggerFactory? loggerFactory = null) :
base(new ChatClientAgent((client ?? throw new ArgumentNullException(nameof(client))).AsIChatClient(), options, loggerFactory))
{
}
@@ -55,8 +55,8 @@ public class OpenAIResponseClientAgent : DelegatingAIAgent
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="OpenAIResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public virtual async Task<OpenAIResponse> RunAsync(
/// <returns>A <see cref="ResponseResult"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public virtual async Task<ResponseResult> RunAsync(
IEnumerable<ResponseItem> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -74,7 +74,7 @@ public class OpenAIResponseClientAgent : DelegatingAIAgent
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="OpenAIResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
/// <returns>A <see cref="ResponseResult"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public virtual async IAsyncEnumerable<StreamingResponseUpdate> RunStreamingAsync(
IEnumerable<ResponseItem> messages,
AgentThread? thread = null,
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to create OpenAIResponseClientAgent directly from an OpenAIResponseClient instance.
// This sample demonstrates how to create OpenAIResponseClientAgent directly from an ResponsesClient instance.
using OpenAI;
using OpenAI.Responses;
@@ -9,16 +9,16 @@ using OpenAIResponseClientSample;
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set.");
var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o-mini";
// Create an OpenAIResponseClient directly from OpenAIClient
OpenAIResponseClient responseClient = new OpenAIClient(apiKey).GetOpenAIResponseClient(model);
// Create a ResponsesClient directly from OpenAIClient
ResponsesClient responseClient = new OpenAIClient(apiKey).GetResponsesClient(model);
// Create an agent directly from the OpenAIResponseClient using OpenAIResponseClientAgent
// Create an agent directly from the ResponsesClient using OpenAIResponseClientAgent
OpenAIResponseClientAgent agent = new(responseClient, instructions: "You are good at telling jokes.", name: "Joker");
ResponseItem userMessage = ResponseItem.CreateUserMessageItem("Tell me a joke about a pirate.");
// Invoke the agent and output the text result.
OpenAIResponse response = await agent.RunAsync([userMessage]);
ResponseResult response = await agent.RunAsync([userMessage]);
Console.WriteLine(response.GetOutputText());
// Invoke the agent with streaming support.
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to maintain conversation state using the OpenAIResponseClientAgent
// and AgentThread. By passing the same thread to multiple agent invocations, the agent
// automatically maintains the conversation history, allowing the AI model to understand
// context from previous exchanges.
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
using OpenAI.Conversations;
string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set.");
string model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o-mini";
// Create a ConversationClient directly from OpenAIClient
OpenAIClient openAIClient = new(apiKey);
ConversationClient conversationClient = openAIClient.GetConversationClient();
// Create an agent directly from the ResponsesClient using OpenAIResponseClientAgent
ChatClientAgent agent = new(openAIClient.GetResponsesClient(model).AsIChatClient(), instructions: "You are a helpful assistant.", name: "ConversationAgent");
ClientResult createConversationResult = await conversationClient.CreateConversationAsync(BinaryContent.Create(BinaryData.FromString("{}")));
using JsonDocument createConversationResultAsJson = JsonDocument.Parse(createConversationResult.GetRawResponse().Content.ToString());
string conversationId = createConversationResultAsJson.RootElement.GetProperty("id"u8)!.GetString()!;
// Create a thread for the conversation - this enables conversation state management for subsequent turns
AgentThread thread = agent.GetNewThread(conversationId);
Console.WriteLine("=== Multi-turn Conversation Demo ===\n");
// First turn: Ask about a topic
Console.WriteLine("User: What is the capital of France?");
UserChatMessage firstMessage = new("What is the capital of France?");
// After this call, the conversation state associated in the options is stored in 'thread' and used in subsequent calls
ChatCompletion firstResponse = await agent.RunAsync([firstMessage], thread);
Console.WriteLine($"Assistant: {firstResponse.Content.Last().Text}\n");
// Second turn: Follow-up question that relies on conversation context
Console.WriteLine("User: What famous landmarks are located there?");
UserChatMessage secondMessage = new("What famous landmarks are located there?");
ChatCompletion secondResponse = await agent.RunAsync([secondMessage], thread);
Console.WriteLine($"Assistant: {secondResponse.Content.Last().Text}\n");
// Third turn: Another follow-up that demonstrates context continuity
Console.WriteLine("User: How tall is the most famous one?");
UserChatMessage thirdMessage = new("How tall is the most famous one?");
ChatCompletion thirdResponse = await agent.RunAsync([thirdMessage], thread);
Console.WriteLine($"Assistant: {thirdResponse.Content.Last().Text}\n");
Console.WriteLine("=== End of Conversation ===");
// Show full conversation history
Console.WriteLine("Full Conversation History:");
ClientResult getConversationResult = await conversationClient.GetConversationAsync(conversationId);
Console.WriteLine("Conversation created.");
Console.WriteLine($" Conversation ID: {conversationId}");
Console.WriteLine();
CollectionResult getConversationItemsResults = conversationClient.GetConversationItems(conversationId);
foreach (ClientResult result in getConversationItemsResults.GetRawPages())
{
Console.WriteLine("Message contents retrieved. Order is most recent first by default.");
using JsonDocument getConversationItemsResultAsJson = JsonDocument.Parse(result.GetRawResponse().Content.ToString());
foreach (JsonElement element in getConversationItemsResultAsJson.RootElement.GetProperty("data").EnumerateArray())
{
string messageId = element.GetProperty("id"u8).ToString();
string messageRole = element.GetProperty("role"u8).ToString();
Console.WriteLine($" Message ID: {messageId}");
Console.WriteLine($" Message Role: {messageRole}");
foreach (var content in element.GetProperty("content").EnumerateArray())
{
string messageContentText = content.GetProperty("text"u8).ToString();
Console.WriteLine($" Message Text: {messageContentText}");
}
Console.WriteLine();
}
}
ClientResult deleteConversationResult = conversationClient.DeleteConversation(conversationId);
using JsonDocument deleteConversationResultAsJson = JsonDocument.Parse(deleteConversationResult.GetRawResponse().Content.ToString());
bool deleted = deleteConversationResultAsJson.RootElement
.GetProperty("deleted"u8)
.GetBoolean();
Console.WriteLine("Conversation deleted.");
Console.WriteLine($" Deleted: {deleted}");
Console.WriteLine();
@@ -0,0 +1,90 @@
# Managing Conversation State with OpenAI
This sample demonstrates how to maintain conversation state across multiple turns using the Agent Framework with OpenAI's Conversation API.
## What This Sample Shows
- **Conversation State Management**: Shows how to use `ConversationClient` and `AgentThread` to maintain conversation context across multiple agent invocations
- **Multi-turn Conversations**: Demonstrates follow-up questions that rely on context from previous messages in the conversation
- **Server-Side Storage**: Uses OpenAI's Conversation API to manage conversation history server-side, allowing the model to access previous messages without resending them
- **Conversation Lifecycle**: Demonstrates creating, retrieving, and deleting conversations
## Key Concepts
### ConversationClient for Server-Side Storage
The `ConversationClient` manages conversations on OpenAI's servers:
```csharp
// Create a ConversationClient from OpenAIClient
OpenAIClient openAIClient = new(apiKey);
ConversationClient conversationClient = openAIClient.GetConversationClient();
// Create a new conversation
ClientResult createConversationResult = await conversationClient.CreateConversationAsync(BinaryContent.Create(BinaryData.FromString("{}")));
```
### AgentThread for Conversation State
The `AgentThread` works with `ChatClientAgentRunOptions` to link the agent to a server-side conversation:
```csharp
// Set up agent run options with the conversation ID
ChatClientAgentRunOptions agentRunOptions = new() { ChatOptions = new ChatOptions() { ConversationId = conversationId } };
// Create a thread for the conversation
AgentThread thread = agent.GetNewThread();
// First call links the thread to the conversation
ChatCompletion firstResponse = await agent.RunAsync([firstMessage], thread, agentRunOptions);
// Subsequent calls use the thread without needing to pass options again
ChatCompletion secondResponse = await agent.RunAsync([secondMessage], thread);
```
### Retrieving Conversation History
You can retrieve the full conversation history from the server:
```csharp
CollectionResult getConversationItemsResults = conversationClient.GetConversationItems(conversationId);
foreach (ClientResult result in getConversationItemsResults.GetRawPages())
{
// Process conversation items
}
```
### How It Works
1. **Create an OpenAI Client**: Initialize an `OpenAIClient` with your API key
2. **Create a Conversation**: Use `ConversationClient` to create a server-side conversation
3. **Create an Agent**: Initialize an `OpenAIResponseClientAgent` with the desired model and instructions
4. **Create a Thread**: Call `agent.GetNewThread()` to create a new conversation thread
5. **Link Thread to Conversation**: Pass `ChatClientAgentRunOptions` with the `ConversationId` on the first call
6. **Send Messages**: Subsequent calls to `agent.RunAsync()` only need the thread - context is maintained
7. **Cleanup**: Delete the conversation when done using `conversationClient.DeleteConversation()`
## Running the Sample
1. Set the required environment variables:
```powershell
$env:OPENAI_API_KEY = "your_api_key_here"
$env:OPENAI_MODEL = "gpt-4o-mini"
```
2. Run the sample:
```powershell
dotnet run
```
## Expected Output
The sample demonstrates a three-turn conversation where each follow-up question relies on context from previous messages:
1. First question asks about the capital of France
2. Second question asks about landmarks "there" - requiring understanding of the previous answer
3. Third question asks about "the most famous one" - requiring context from both previous turns
After the conversation, the sample retrieves and displays the full conversation history from the server, then cleans up by deleting the conversation.
This demonstrates that the conversation state is properly maintained across multiple agent invocations using OpenAI's server-side conversation storage.
@@ -13,4 +13,5 @@ Agent Framework provides additional support to allow OpenAI developers to use th
|[Creating an AIAgent](./Agent_OpenAI_Step01_Running/)|This sample demonstrates how to create and run a basic agent with native OpenAI SDK types. Shows both regular and streaming invocation of the agent.|
|[Using Reasoning Capabilities](./Agent_OpenAI_Step02_Reasoning/)|This sample demonstrates how to create an AI agent with reasoning capabilities using OpenAI's reasoning models and response types.|
|[Creating an Agent from a ChatClient](./Agent_OpenAI_Step03_CreateFromChatClient/)|This sample demonstrates how to create an AI agent directly from an OpenAI.Chat.ChatClient instance using OpenAIChatClientAgent.|
|[Creating an Agent from an OpenAIResponseClient](./Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/)|This sample demonstrates how to create an AI agent directly from an OpenAI.Responses.OpenAIResponseClient instance using OpenAIResponseClientAgent.|
|[Creating an Agent from an OpenAIResponseClient](./Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/)|This sample demonstrates how to create an AI agent directly from an OpenAI.Responses.OpenAIResponseClient instance using OpenAIResponseClientAgent.|
|[Managing Conversation State](./Agent_OpenAI_Step05_Conversation/)|This sample demonstrates how to maintain conversation state across multiple turns using the AgentThread for context continuity.|
@@ -22,7 +22,7 @@ var stateStore = new Dictionary<string, JsonElement?>();
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.GetResponsesClient(deploymentName)
.CreateAIAgent(
name: "SpaceNovelWriter",
instructions: "You are a space novel writer. Always research relevant facts and generate character profiles for the main characters before writing novels." +
@@ -13,7 +13,7 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.GetResponsesClient(deploymentName)
.CreateAIAgent();
// Enable background responses (only supported by OpenAI Responses at this time).
@@ -73,7 +73,7 @@ internal sealed class Program
Dictionary<string, byte[]> screenshots = ComputerUseUtil.LoadScreenshotAssets();
ChatOptions chatOptions = new();
ResponseCreationOptions responseCreationOptions = new()
CreateResponseOptions responseCreationOptions = new()
{
TruncationMode = ResponseTruncationMode.Auto
};
@@ -30,7 +30,7 @@ var mcpTool = new HostedMcpServerTool(
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.GetResponsesClient(deploymentName)
.CreateAIAgent(
instructions: "You answer questions by searching the Microsoft Learn content only.",
name: "MicrosoftLearnAgent",
@@ -57,7 +57,7 @@ var mcpToolWithApproval = new HostedMcpServerTool(
AIAgent agentWithRequiredApproval = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.GetResponsesClient(deploymentName)
.CreateAIAgent(
instructions: "You answer questions by searching the Microsoft Learn content only.",
name: "MicrosoftLearnAgentWithApproval",
@@ -36,9 +36,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.4" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.0" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251125.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
</ItemGroup>
@@ -37,7 +37,7 @@
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.4" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.0" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251125.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
</ItemGroup>
@@ -37,7 +37,7 @@
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.4" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.0" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251125.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
</ItemGroup>
@@ -27,7 +27,7 @@ TokenCredential browserCredential = new InteractiveBrowserCredential(
using IChatClient client = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.GetResponsesClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App"))
@@ -198,7 +198,7 @@ internal sealed class A2AAgent : AIAgent
}
/// <inheritdoc/>
public override string Id => this._id ?? base.Id;
protected override string? IdCore => this._id;
/// <inheritdoc/>
public override string? Name => this._name ?? base.Name;
@@ -22,9 +22,6 @@ namespace Microsoft.Agents.AI;
[DebuggerDisplay("{DisplayName,nq}")]
public abstract class AIAgent
{
/// <summary>Default ID of this agent instance.</summary>
private readonly string _id = Guid.NewGuid().ToString("N");
/// <summary>
/// Gets the unique identifier for this agent instance.
/// </summary>
@@ -37,7 +34,19 @@ public abstract class AIAgent
/// agent instances in multi-agent scenarios. They should remain stable for the lifetime
/// of the agent instance.
/// </remarks>
public virtual string Id => this._id;
public string Id { get => this.IdCore ?? field; } = Guid.NewGuid().ToString("N");
/// <summary>
/// Gets a custom identifier for the agent, which can be overridden by derived classes.
/// </summary>
/// <value>
/// A string representing the agent's identifier, or <see langword="null"/> if the default ID should be used.
/// </value>
/// <remarks>
/// Derived classes can override this property to provide a custom identifier.
/// When <see langword="null"/> is returned, the <see cref="Id"/> property will use the default randomly-generated identifier.
/// </remarks>
protected virtual string? IdCore => null;
/// <summary>
/// Gets the human-readable name of the agent.
@@ -61,7 +70,7 @@ public abstract class AIAgent
/// This property provides a guaranteed non-null string suitable for display in user interfaces,
/// logs, or other contexts where a readable identifier is needed.
/// </remarks>
public virtual string DisplayName => this.Name ?? this.Id ?? this._id; // final fallback to _id in case Id override returns null
public virtual string DisplayName => this.Name ?? this.Id;
/// <summary>
/// Gets a description of the agent's purpose, capabilities, or behavior.
@@ -25,7 +25,7 @@ namespace Microsoft.Agents.AI;
/// Derived classes can override specific methods to add custom behavior while maintaining compatibility with the agent interface.
/// </para>
/// </remarks>
public class DelegatingAIAgent : AIAgent
public abstract class DelegatingAIAgent : AIAgent
{
/// <summary>
/// Initializes a new instance of the <see cref="DelegatingAIAgent"/> class with the specified inner agent.
@@ -54,7 +54,7 @@ public class DelegatingAIAgent : AIAgent
protected AIAgent InnerAgent { get; }
/// <inheritdoc />
public override string Id => this.InnerAgent.Id;
protected override string? IdCore => this.InnerAgent.Id;
/// <inheritdoc />
public override string? Name => this.InnerAgent.Name;
@@ -23,11 +23,6 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient
private readonly AgentRecord? _agentRecord;
private readonly ChatOptions? _chatOptions;
private readonly AgentReference _agentReference;
/// <summary>
/// The usage of a no-op model is a necessary change to avoid OpenAIClients to throw exceptions when
/// used with Azure AI Agents as the model used is now defined at the agent creation time.
/// </summary>
private const string NoOpModel = "no-op";
/// <summary>
/// Initializes a new instance of the <see cref="AzureAIProjectChatClient"/> class.
@@ -42,7 +37,7 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentReference agentReference, string? defaultModelId, ChatOptions? chatOptions)
: base(Throw.IfNull(aiProjectClient)
.GetProjectOpenAIClient()
.GetOpenAIResponseClient(defaultModelId ?? NoOpModel)
.GetProjectResponsesClientForAgent(agentReference)
.AsIChatClient())
{
this._agentClient = aiProjectClient;
@@ -132,13 +127,15 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient
agentEnabledChatOptions.RawRepresentationFactory = (client) =>
{
if (originalFactory?.Invoke(this) is not ResponseCreationOptions responseCreationOptions)
if (originalFactory?.Invoke(this) is not CreateResponseOptions responseCreationOptions)
{
responseCreationOptions = new ResponseCreationOptions();
responseCreationOptions = new CreateResponseOptions();
}
ResponseCreationOptionsExtensions.set_Agent(responseCreationOptions, this._agentReference);
ResponseCreationOptionsExtensions.set_Model(responseCreationOptions, null);
responseCreationOptions.Agent = this._agentReference;
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
responseCreationOptions.Patch.Remove("$.model"u8);
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
return responseCreationOptions;
};
@@ -400,7 +400,7 @@ public static partial class AzureAIProjectChatClientExtensions
};
// Attempt to capture breaking glass options from the raw representation factory that match the agent definition.
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is ResponseCreationOptions respCreationOptions)
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions)
{
agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions;
}
@@ -466,7 +466,7 @@ public static partial class AzureAIProjectChatClientExtensions
};
// Attempt to capture breaking glass options from the raw representation factory that match the agent definition.
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is ResponseCreationOptions respCreationOptions)
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions)
{
agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions;
}
@@ -217,9 +217,7 @@ public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
}
}
/// <summary>
/// Represents a checkpoint document stored in Cosmos DB.
/// </summary>
/// <summary>Represents a checkpoint document stored in Cosmos DB.</summary>
internal sealed class CosmosCheckpointDocument
{
[JsonProperty("id")]
@@ -81,7 +81,7 @@ internal sealed partial class DevUIMiddleware
}
context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
context.Response.Headers.Location = redirectUrl;
context.Response.Headers.Location = redirectUrl; // CodeQL [SM04598] justification: The redirect URL is constructed from a server-configured base path (_basePath), not user input. The query string is only appended as parameters and cannot change the redirect destination since this is a relative URL.
if (this._logger.IsEnabled(LogLevel.Debug))
{
@@ -16,29 +16,34 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
private readonly DurableTaskClient _client = services.GetRequiredService<DurableTaskClient>();
private readonly ILoggerFactory _loggerFactory = services.GetRequiredService<ILoggerFactory>();
private readonly IAgentResponseHandler? _messageHandler = services.GetService<IAgentResponseHandler>();
private readonly DurableAgentsOptions _options = services.GetRequiredService<DurableAgentsOptions>();
private readonly CancellationToken _cancellationToken = cancellationToken != default
? cancellationToken
: services.GetService<IHostApplicationLifetime>()?.ApplicationStopping ?? CancellationToken.None;
public async Task<AgentRunResponse> RunAgentAsync(RunRequest request)
public Task<AgentRunResponse> RunAgentAsync(RunRequest request)
{
return this.Run(request);
}
// IDE1006 and VSTHRD200 disabled to allow method name to match the common cross-platform entity operation name.
#pragma warning disable IDE1006
#pragma warning disable VSTHRD200
public async Task<AgentRunResponse> Run(RunRequest request)
#pragma warning restore VSTHRD200
#pragma warning restore IDE1006
{
AgentSessionId sessionId = this.Context.Id;
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents =
this._services.GetRequiredService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>();
if (!agents.TryGetValue(sessionId.Name, out Func<IServiceProvider, AIAgent>? agentFactory))
{
throw new InvalidOperationException($"Agent '{sessionId.Name}' not found");
}
AIAgent agent = agentFactory(this._services);
AIAgent agent = this.GetAgent(sessionId);
EntityAgentWrapper agentWrapper = new(agent, this.Context, request, this._services);
// Logger category is Microsoft.DurableTask.Agents.{agentName}.{sessionId}
ILogger logger = this._loggerFactory.CreateLogger($"Microsoft.DurableTask.Agents.{agent.Name}.{sessionId.Key}");
ILogger logger = this.GetLogger(agent.Name!, sessionId.Key);
if (request.Messages.Count == 0)
{
logger.LogInformation("Ignoring empty request");
return new AgentRunResponse();
}
this.State.Data.ConversationHistory.Add(DurableAgentStateRequest.FromRunRequest(request));
@@ -113,6 +118,36 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
response.Usage?.TotalTokenCount);
}
// Update TTL expiration time. Only schedule deletion check on first interaction.
// Subsequent interactions just update the expiration time; CheckAndDeleteIfExpiredAsync
// will reschedule the deletion check when it runs.
TimeSpan? timeToLive = this._options.GetTimeToLive(sessionId.Name);
if (timeToLive.HasValue)
{
DateTime newExpirationTime = DateTime.UtcNow.Add(timeToLive.Value);
bool isFirstInteraction = this.State.Data.ExpirationTimeUtc is null;
this.State.Data.ExpirationTimeUtc = newExpirationTime;
logger.LogTTLExpirationTimeUpdated(sessionId, newExpirationTime);
// Only schedule deletion check on the first interaction when entity is created.
// On subsequent interactions, we just update the expiration time. The scheduled
// CheckAndDeleteIfExpiredAsync will reschedule itself if the entity hasn't expired.
if (isFirstInteraction)
{
this.ScheduleDeletionCheck(sessionId, logger, timeToLive.Value);
}
}
else
{
// TTL is disabled. Clear the expiration time if it was previously set.
if (this.State.Data.ExpirationTimeUtc.HasValue)
{
logger.LogTTLExpirationTimeCleared(sessionId);
this.State.Data.ExpirationTimeUtc = null;
}
}
return response;
}
finally
@@ -121,4 +156,78 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
DurableAgentContext.ClearCurrent();
}
}
/// <summary>
/// Checks if the entity has expired and deletes it if so, otherwise reschedules the deletion check.
/// </summary>
/// <remarks>
/// This method is called by the durable task runtime when a <c>CheckAndDeleteIfExpired</c> signal is received.
/// </remarks>
public void CheckAndDeleteIfExpired()
{
AgentSessionId sessionId = this.Context.Id;
AIAgent agent = this.GetAgent(sessionId);
ILogger logger = this.GetLogger(agent.Name!, sessionId.Key);
DateTime currentTime = DateTime.UtcNow;
DateTime? expirationTime = this.State.Data.ExpirationTimeUtc;
logger.LogTTLDeletionCheck(sessionId, expirationTime, currentTime);
if (expirationTime.HasValue)
{
if (currentTime >= expirationTime.Value)
{
// Entity has expired, delete it
logger.LogTTLEntityExpired(sessionId, expirationTime.Value);
this.State = null!;
}
else
{
// Entity hasn't expired yet, reschedule the deletion check
TimeSpan? timeToLive = this._options.GetTimeToLive(sessionId.Name);
if (timeToLive.HasValue)
{
this.ScheduleDeletionCheck(sessionId, logger, timeToLive.Value);
}
}
}
}
private void ScheduleDeletionCheck(AgentSessionId sessionId, ILogger logger, TimeSpan timeToLive)
{
DateTime currentTime = DateTime.UtcNow;
DateTime expirationTime = this.State.Data.ExpirationTimeUtc ?? currentTime.Add(timeToLive);
TimeSpan minimumDelay = this._options.MinimumTimeToLiveSignalDelay;
// To avoid excessive scheduling, we schedule the deletion check for no less than the minimum delay.
DateTime scheduledTime = expirationTime > currentTime.Add(minimumDelay)
? expirationTime
: currentTime.Add(minimumDelay);
logger.LogTTLDeletionScheduled(sessionId, scheduledTime);
// Schedule a signal to self to check for expiration
this.Context.SignalEntity(
this.Context.Id,
nameof(CheckAndDeleteIfExpired), // self-signal
options: new SignalEntityOptions { SignalTime = scheduledTime });
}
private AIAgent GetAgent(AgentSessionId sessionId)
{
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents =
this._services.GetRequiredService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>();
if (!agents.TryGetValue(sessionId.Name, out Func<IServiceProvider, AIAgent>? agentFactory))
{
throw new InvalidOperationException($"Agent '{sessionId.Name}' not found");
}
return agentFactory(this._services);
}
private ILogger GetLogger(string agentName, string sessionKey)
{
return this._loggerFactory.CreateLogger($"Microsoft.DurableTask.Agents.{agentName}.{sessionKey}");
}
}
@@ -1,5 +1,12 @@
# Release History
## [Unreleased]
### Changed
- Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679))
- Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843))
## v1.0.0-preview.251204.1
- Added orchestration ID to durable agent entity state ([#2137](https://github.com/microsoft/agent-framework/pull/2137))
@@ -22,7 +22,7 @@ internal class DefaultDurableAgentClient(DurableTaskClient client, ILoggerFactor
await this._client.Entities.SignalEntityAsync(
sessionId,
nameof(AgentEntity.RunAgentAsync),
nameof(AgentEntity.Run),
request,
cancellation: cancellationToken);
@@ -107,7 +107,7 @@ public sealed class DurableAIAgent : AIAgent
{
return await this._context.Entities.CallEntityAsync<AgentRunResponse>(
durableThread.SessionId,
nameof(AgentEntity.RunAgentAsync),
nameof(AgentEntity.Run),
request);
}
catch (EntityOperationFailedException e) when (e.FailureDetails.ErrorType == "EntityTaskNotFound")
@@ -9,23 +9,67 @@ public sealed class DurableAgentsOptions
{
// Agent names are case-insensitive
private readonly Dictionary<string, Func<IServiceProvider, AIAgent>> _agentFactories = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, TimeSpan?> _agentTimeToLive = new(StringComparer.OrdinalIgnoreCase);
internal DurableAgentsOptions()
{
}
/// <summary>
/// Gets or sets the default time-to-live (TTL) for agent entities.
/// </summary>
/// <remarks>
/// If an agent entity is idle for this duration, it will be automatically deleted.
/// Defaults to 14 days. Set to <see langword="null"/> to disable TTL for agents without explicit TTL configuration.
/// </remarks>
public TimeSpan? DefaultTimeToLive { get; set; } = TimeSpan.FromDays(14);
/// <summary>
/// Gets or sets the minimum delay for scheduling TTL deletion signals. Defaults to 5 minutes.
/// </summary>
/// <remarks>
/// This property is primarily useful for testing (where shorter delays are needed) or for
/// shorter-lived agents in workflows that need more rapid cleanup. The maximum allowed value is 5 minutes.
/// Reducing the minimum deletion delay below 5 minutes can be useful for testing or for ensuring rapid cleanup of short-lived agent sessions.
/// However, this can also increase the load on the system and should be used with caution.
/// </remarks>
/// <exception cref="ArgumentOutOfRangeException">Thrown when the value exceeds 5 minutes.</exception>
public TimeSpan MinimumTimeToLiveSignalDelay
{
get;
set
{
const int MaximumDelayMinutes = 5;
if (value > TimeSpan.FromMinutes(MaximumDelayMinutes))
{
throw new ArgumentOutOfRangeException(
nameof(value),
value,
$"The minimum time-to-live signal delay cannot exceed {MaximumDelayMinutes} minutes.");
}
field = value;
}
} = TimeSpan.FromMinutes(5);
/// <summary>
/// Adds an AI agent factory to the options.
/// </summary>
/// <param name="name">The name of the agent.</param>
/// <param name="factory">The factory function to create the agent.</param>
/// <param name="timeToLive">Optional time-to-live for this agent's entities. If not specified, uses <see cref="DefaultTimeToLive"/>.</param>
/// <returns>The options instance.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> or <paramref name="factory"/> is null.</exception>
public DurableAgentsOptions AddAIAgentFactory(string name, Func<IServiceProvider, AIAgent> factory)
public DurableAgentsOptions AddAIAgentFactory(string name, Func<IServiceProvider, AIAgent> factory, TimeSpan? timeToLive = null)
{
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(factory);
this._agentFactories.Add(name, factory);
if (timeToLive.HasValue)
{
this._agentTimeToLive[name] = timeToLive;
}
return this;
}
@@ -50,12 +94,13 @@ public sealed class DurableAgentsOptions
/// Adds an AI agent to the options.
/// </summary>
/// <param name="agent">The agent to add.</param>
/// <param name="timeToLive">Optional time-to-live for this agent's entities. If not specified, uses <see cref="DefaultTimeToLive"/>.</param>
/// <returns>The options instance.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> is null.</exception>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
/// </exception>
public DurableAgentsOptions AddAIAgent(AIAgent agent)
public DurableAgentsOptions AddAIAgent(AIAgent agent, TimeSpan? timeToLive = null)
{
ArgumentNullException.ThrowIfNull(agent);
@@ -70,6 +115,11 @@ public sealed class DurableAgentsOptions
}
this._agentFactories.Add(agent.Name, sp => agent);
if (timeToLive.HasValue)
{
this._agentTimeToLive[agent.Name] = timeToLive;
}
return this;
}
@@ -81,4 +131,14 @@ public sealed class DurableAgentsOptions
{
return this._agentFactories.AsReadOnly();
}
/// <summary>
/// Gets the time-to-live for a specific agent, or the default TTL if not specified.
/// </summary>
/// <param name="agentName">The name of the agent.</param>
/// <returns>The time-to-live for the agent, or the default TTL if not specified.</returns>
internal TimeSpan? GetTimeToLive(string agentName)
{
return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive;
}
}
@@ -19,7 +19,7 @@ internal sealed class EntityAgentWrapper(
private readonly IServiceProvider? _entityScopedServices = entityScopedServices;
// The ID of the agent is always the entity ID.
public override string Id => this._entityContext.Id.ToString();
protected override string? IdCore => this._entityContext.Id.ToString();
public override async Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
@@ -46,4 +46,58 @@ internal static partial class Logs
Level = LogLevel.Information,
Message = "Found response for agent with session ID '{SessionId}' with correlation ID '{CorrelationId}'")]
public static partial void LogDonePollingForResponse(this ILogger logger, AgentSessionId sessionId, string correlationId);
[LoggerMessage(
EventId = 6,
Level = LogLevel.Information,
Message = "[{SessionId}] TTL expiration time updated to {ExpirationTime:O}")]
public static partial void LogTTLExpirationTimeUpdated(
this ILogger logger,
AgentSessionId sessionId,
DateTime expirationTime);
[LoggerMessage(
EventId = 7,
Level = LogLevel.Information,
Message = "[{SessionId}] TTL deletion signal scheduled for {ScheduledTime:O}")]
public static partial void LogTTLDeletionScheduled(
this ILogger logger,
AgentSessionId sessionId,
DateTime scheduledTime);
[LoggerMessage(
EventId = 8,
Level = LogLevel.Information,
Message = "[{SessionId}] TTL deletion check running. Expiration time: {ExpirationTime:O}, Current time: {CurrentTime:O}")]
public static partial void LogTTLDeletionCheck(
this ILogger logger,
AgentSessionId sessionId,
DateTime? expirationTime,
DateTime currentTime);
[LoggerMessage(
EventId = 9,
Level = LogLevel.Information,
Message = "[{SessionId}] Entity expired and deleted due to TTL. Expiration time: {ExpirationTime:O}")]
public static partial void LogTTLEntityExpired(
this ILogger logger,
AgentSessionId sessionId,
DateTime expirationTime);
[LoggerMessage(
EventId = 10,
Level = LogLevel.Information,
Message = "[{SessionId}] TTL deletion signal rescheduled for {ScheduledTime:O}")]
public static partial void LogTTLRescheduled(
this ILogger logger,
AgentSessionId sessionId,
DateTime scheduledTime);
[LoggerMessage(
EventId = 11,
Level = LogLevel.Information,
Message = "[{SessionId}] TTL expiration time cleared (TTL disabled)")]
public static partial void LogTTLExpirationTimeCleared(
this ILogger logger,
AgentSessionId sessionId);
}
@@ -85,6 +85,9 @@ public static class ServiceCollectionExtensions
// The agent dictionary contains the real agent factories, which is used by the agent entities.
services.AddSingleton(agents);
// Register the options so AgentEntity can access TTL configuration
services.AddSingleton(options);
// The keyed services are used to resolve durable agent *proxy* instances for external clients.
foreach (var factory in agents)
{
@@ -17,6 +17,13 @@ internal sealed class DurableAgentStateData
[JsonPropertyName("conversationHistory")]
public IList<DurableAgentStateEntry> ConversationHistory { get; init; } = [];
/// <summary>
/// Gets or sets the expiration time (UTC) for this agent entity.
/// If the entity is idle beyond this time, it will be automatically deleted.
/// </summary>
[JsonPropertyName("expirationTimeUtc")]
public DateTime? ExpirationTimeUtc { get; set; }
/// <summary>
/// Gets any additional data found during deserialization that does not map to known properties.
/// </summary>
@@ -84,22 +84,18 @@ internal sealed class ConversationReferenceJsonConverter : JsonConverter<Convers
return;
}
// If only ID is present and no metadata, serialize as a simple string
if (value.Metadata is null || value.Metadata.Count == 0)
// Ideally if only ID is present and no metadata, we would serialize as a simple string.
// However, while a request's "conversation" property can be either a string or an object
// containing a string, a response's "conversation" property is always an object. Since
// here we don't know which scenario we're in, we always serialize as an object, which works
// in any scenario.
writer.WriteStartObject();
writer.WriteString("id", value.Id);
if (value.Metadata is not null)
{
writer.WriteStringValue(value.Id);
}
else
{
// Otherwise, serialize as an object
writer.WriteStartObject();
writer.WriteString("id", value.Id);
if (value.Metadata is not null)
{
writer.WritePropertyName("metadata");
JsonSerializer.Serialize(writer, value.Metadata, OpenAIHostingJsonContext.Default.DictionaryStringString);
}
writer.WriteEndObject();
writer.WritePropertyName("metadata");
JsonSerializer.Serialize(writer, value.Metadata, OpenAIHostingJsonContext.Default.DictionaryStringString);
}
writer.WriteEndObject();
}
}
@@ -73,22 +73,22 @@ public static class AIAgentWithOpenAIExtensions
}
/// <summary>
/// Runs the AI agent with a collection of OpenAI response items and returns the response as a native OpenAI <see cref="OpenAIResponse"/>.
/// Runs the AI agent with a collection of OpenAI response items and returns the response as a native OpenAI <see cref="ResponseResult"/>.
/// </summary>
/// <param name="agent">The AI agent to run.</param>
/// <param name="messages">The collection of OpenAI response items to send to the agent.</param>
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="Task{OpenAIResponse}"/> representing the asynchronous operation that returns a native OpenAI <see cref="OpenAIResponse"/> response.</returns>
/// <returns>A <see cref="Task{ResponseResult}"/> representing the asynchronous operation that returns a native OpenAI <see cref="ResponseResult"/> response.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> or <paramref name="messages"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when the agent's response cannot be converted to an <see cref="OpenAIResponse"/>, typically when the underlying representation is not an OpenAI response.</exception>
/// <exception cref="ArgumentException">Thrown when the agent's response cannot be converted to an <see cref="ResponseResult"/>, typically when the underlying representation is not an OpenAI response.</exception>
/// <exception cref="NotSupportedException">Thrown when any message in <paramref name="messages"/> has a type that is not supported by the message conversion method.</exception>
/// <remarks>
/// This method converts the OpenAI response items to the Microsoft Extensions AI format using the appropriate conversion method,
/// runs the agent with the converted message collection, and then extracts the native OpenAI <see cref="OpenAIResponse"/> from the response using <see cref="AgentRunResponseExtensions.AsOpenAIResponse"/>.
/// runs the agent with the converted message collection, and then extracts the native OpenAI <see cref="ResponseResult"/> from the response using <see cref="AgentRunResponseExtensions.AsOpenAIResponse"/>.
/// </remarks>
public static async Task<OpenAIResponse> RunAsync(this AIAgent agent, IEnumerable<ResponseItem> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
public static async Task<ResponseResult> RunAsync(this AIAgent agent, IEnumerable<ResponseItem> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(messages);
@@ -29,17 +29,17 @@ public static class AgentRunResponseExtensions
}
/// <summary>
/// Creates or extracts a native OpenAI <see cref="OpenAIResponse"/> object from an <see cref="AgentRunResponse"/>.
/// Creates or extracts a native OpenAI <see cref="ResponseResult"/> object from an <see cref="AgentRunResponse"/>.
/// </summary>
/// <param name="response">The agent response.</param>
/// <returns>The OpenAI <see cref="OpenAIResponse"/> object.</returns>
/// <returns>The OpenAI <see cref="ResponseResult"/> object.</returns>
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
public static OpenAIResponse AsOpenAIResponse(this AgentRunResponse response)
public static ResponseResult AsOpenAIResponse(this AgentRunResponse response)
{
Throw.IfNull(response);
return
response.RawRepresentation as OpenAIResponse ??
response.AsChatResponse().AsOpenAIResponse();
response.RawRepresentation as ResponseResult ??
response.AsChatResponse().AsOpenAIResponseResult();
}
}
@@ -8,7 +8,7 @@ using Microsoft.Shared.Diagnostics;
namespace OpenAI.Responses;
/// <summary>
/// Provides extension methods for <see cref="OpenAIResponseClient"/>
/// Provides extension methods for <see cref="ResponsesClient"/>
/// to simplify the creation of AI agents that work with OpenAI services.
/// </summary>
/// <remarks>
@@ -20,9 +20,9 @@ namespace OpenAI.Responses;
public static class OpenAIResponseClientExtensions
{
/// <summary>
/// Creates an AI agent from an <see cref="OpenAIResponseClient"/> using the OpenAI Response API.
/// Creates an AI agent from an <see cref="ResponsesClient"/> using the OpenAI Response API.
/// </summary>
/// <param name="client">The <see cref="OpenAIResponseClient" /> to use for the agent.</param>
/// <param name="client">The <see cref="ResponsesClient" /> to use for the agent.</param>
/// <param name="instructions">Optional system instructions that define the agent's behavior and personality.</param>
/// <param name="name">Optional name for the agent for identification purposes.</param>
/// <param name="description">Optional description of the agent's capabilities and purpose.</param>
@@ -33,7 +33,7 @@ public static class OpenAIResponseClientExtensions
/// <returns>An <see cref="ChatClientAgent"/> instance backed by the OpenAI Response service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is <see langword="null"/>.</exception>
public static ChatClientAgent CreateAIAgent(
this OpenAIResponseClient client,
this ResponsesClient client,
string? instructions = null,
string? name = null,
string? description = null,
@@ -61,9 +61,9 @@ public static class OpenAIResponseClientExtensions
}
/// <summary>
/// Creates an AI agent from an <see cref="OpenAIResponseClient"/> using the OpenAI Response API.
/// Creates an AI agent from an <see cref="ResponsesClient"/> using the OpenAI Response API.
/// </summary>
/// <param name="client">The <see cref="OpenAIResponseClient" /> to use for the agent.</param>
/// <param name="client">The <see cref="ResponsesClient" /> to use for the agent.</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
@@ -71,7 +71,7 @@ public static class OpenAIResponseClientExtensions
/// <returns>An <see cref="ChatClientAgent"/> instance backed by the OpenAI Response service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
public static ChatClientAgent CreateAIAgent(
this OpenAIResponseClient client,
this ResponsesClient client,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null,
@@ -111,7 +111,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
if (inputArguments is not null)
{
JsonNode jsonNode = ConvertDictionaryToJson(inputArguments);
ResponseCreationOptions responseCreationOptions = new();
CreateResponseOptions responseCreationOptions = new();
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
responseCreationOptions.Patch.Set("$.structured_inputs"u8, BinaryData.FromString(jsonNode.ToJsonString()));
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
@@ -206,7 +206,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
public override async Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default)
{
AgentResponseItem responseItem = await this.GetConversationClient().GetProjectConversationItemAsync(conversationId, messageId, include: null, cancellationToken).ConfigureAwait(false);
ResponseItem[] items = [responseItem.AsOpenAIResponseItem()];
ResponseItem[] items = [responseItem.AsResponseResultItem()];
return items.AsChatMessages().Single();
}
@@ -223,7 +223,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
await foreach (AgentResponseItem responseItem in this.GetConversationClient().GetProjectConversationItemsAsync(conversationId, null, limit, order.ToString(), after, before, include: null, cancellationToken).ConfigureAwait(false))
{
ResponseItem[] items = [responseItem.AsOpenAIResponseItem()];
ResponseItem[] items = [responseItem.AsResponseResultItem()];
foreach (ChatMessage message in items.AsChatMessages())
{
yield return message;
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// </summary>
public sealed class DirectEdgeData : EdgeData
{
internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null) : base(id)
internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null, string? label = null) : base(id, label)
{
this.SourceId = sourceId;
this.SinkId = sinkId;
@@ -14,10 +14,16 @@ public abstract class EdgeData
/// </summary>
internal abstract EdgeConnection Connection { get; }
internal EdgeData(EdgeId id)
internal EdgeData(EdgeId id, string? label = null)
{
this.Id = id;
this.Label = label;
}
internal EdgeId Id { get; }
/// <summary>
/// An optional label for the edge, allowing for arbitrary metadata to be associated with it.
/// </summary>
public string? Label { get; }
}
@@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// </summary>
internal sealed class FanInEdgeData : EdgeData
{
internal FanInEdgeData(List<string> sourceIds, string sinkId, EdgeId id) : base(id)
internal FanInEdgeData(List<string> sourceIds, string sinkId, EdgeId id, string? label) : base(id, label)
{
this.SourceIds = sourceIds;
this.SinkId = sinkId;
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// </summary>
internal sealed class FanOutEdgeData : EdgeData
{
internal FanOutEdgeData(string sourceId, List<string> sinkIds, EdgeId edgeId, AssignerF? assigner = null) : base(edgeId)
internal FanOutEdgeData(string sourceId, List<string> sinkIds, EdgeId edgeId, AssignerF? assigner = null, string? label = null) : base(edgeId, label)
{
this.SourceId = sourceId;
this.SinkIds = sinkIds;
@@ -99,10 +99,30 @@ public static class WorkflowVisualizer
}
// Emit normal edges
foreach (var (src, target, isConditional) in ComputeNormalEdges(workflow))
foreach (var (src, target, isConditional, label) in ComputeNormalEdges(workflow))
{
var edgeAttr = isConditional ? " [style=dashed, label=\"conditional\"]" : "";
lines.Add($"{indent}\"{MapId(src)}\" -> \"{MapId(target)}\"{edgeAttr};");
// Build edge attributes
var attributes = new List<string>();
// Add style for conditional edges
if (isConditional)
{
attributes.Add("style=dashed");
}
// Add label (custom label or default "conditional" for conditional edges)
if (label != null)
{
attributes.Add($"label=\"{EscapeDotLabel(label)}\"");
}
else if (isConditional)
{
attributes.Add("label=\"conditional\"");
}
// Combine attributes
var attrString = attributes.Count > 0 ? $" [{string.Join(", ", attributes)}]" : "";
lines.Add($"{indent}\"{MapId(src)}\" -> \"{MapId(target)}\"{attrString};");
}
}
@@ -133,12 +153,7 @@ public static class WorkflowVisualizer
private static void EmitWorkflowMermaid(Workflow workflow, List<string> lines, string indent, string? ns = null)
{
string sanitize(string input)
{
return input;
}
string MapId(string id) => ns != null ? $"{sanitize(ns)}/{sanitize(id)}" : id;
string MapId(string id) => ns != null ? $"{ns}/{id}" : id;
// Add start node
var startExecutorId = workflow.StartExecutorId;
@@ -175,14 +190,23 @@ public static class WorkflowVisualizer
}
// Emit normal edges
foreach (var (src, target, isConditional) in ComputeNormalEdges(workflow))
foreach (var (src, target, isConditional, label) in ComputeNormalEdges(workflow))
{
if (isConditional)
{
lines.Add($"{indent}{MapId(src)} -. conditional .--> {MapId(target)};");
string effectiveLabel = label != null ? EscapeMermaidLabel(label) : "conditional";
// Conditional edge, with user label or default
lines.Add($"{indent}{MapId(src)} -. {effectiveLabel} .--> {MapId(target)};");
}
else if (label != null)
{
// Regular edge with label
lines.Add($"{indent}{MapId(src)} -->|{EscapeMermaidLabel(label)}| {MapId(target)};");
}
else
{
// Regular edge without label
lines.Add($"{indent}{MapId(src)} --> {MapId(target)};");
}
}
@@ -214,9 +238,9 @@ public static class WorkflowVisualizer
return result;
}
private static List<(string Source, string Target, bool IsConditional)> ComputeNormalEdges(Workflow workflow)
private static List<(string Source, string Target, bool IsConditional, string? Label)> ComputeNormalEdges(Workflow workflow)
{
var edges = new List<(string, string, bool)>();
var edges = new List<(string, string, bool, string?)>();
foreach (var edgeGroup in workflow.Edges.Values.SelectMany(x => x))
{
if (edgeGroup.Kind == EdgeKind.FanIn)
@@ -229,14 +253,15 @@ public static class WorkflowVisualizer
case EdgeKind.Direct when edgeGroup.DirectEdgeData != null:
var directData = edgeGroup.DirectEdgeData;
var isConditional = directData.Condition != null;
edges.Add((directData.SourceId, directData.SinkId, isConditional));
var label = directData.Label;
edges.Add((directData.SourceId, directData.SinkId, isConditional, label));
break;
case EdgeKind.FanOut when edgeGroup.FanOutEdgeData != null:
var fanOutData = edgeGroup.FanOutEdgeData;
foreach (var sinkId in fanOutData.SinkIds)
{
edges.Add((fanOutData.SourceId, sinkId, false));
edges.Add((fanOutData.SourceId, sinkId, false, fanOutData.Label));
}
break;
}
@@ -276,5 +301,24 @@ public static class WorkflowVisualizer
return false;
}
// Helper method to escape special characters in DOT labels
private static string EscapeDotLabel(string label)
{
return label.Replace("\"", "\\\"").Replace("\n", "\\n");
}
// Helper method to escape special characters in Mermaid labels
private static string EscapeMermaidLabel(string label)
{
return label
.Replace("&", "&amp;") // Must be first to avoid double-escaping
.Replace("|", "&#124;") // Pipe breaks Mermaid delimiter syntax
.Replace("\"", "&quot;") // Quote character
.Replace("<", "&lt;") // Less than
.Replace(">", "&gt;") // Greater than
.Replace("\n", "<br/>") // Newline to HTML break
.Replace("\r", ""); // Remove carriage return
}
#endregion
}
@@ -168,6 +168,18 @@ public class WorkflowBuilder
return edges;
}
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target)
=> this.AddEdge<object>(source, target, null, false);
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
@@ -182,6 +194,20 @@ public class WorkflowBuilder
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, bool idempotent = false)
=> this.AddEdge<object>(source, target, null, idempotent);
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
/// rather than an error.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, string? label = null, bool idempotent = false)
=> this.AddEdge<object>(source, target, null, label, idempotent);
internal static Func<object?, bool>? CreateConditionFunc<T>(Func<T?, bool>? condition)
{
if (condition is null)
@@ -222,6 +248,20 @@ public class WorkflowBuilder
private EdgeId TakeEdgeId() => new(Interlocked.Increment(ref this._edgeCount));
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <param name="condition">An optional predicate that determines whether the edge should be followed based on the input.
/// If null, the edge is always activated when the source sends a message.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null)
=> this.AddEdge(source, target, condition, label: null, false);
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
@@ -236,6 +276,23 @@ public class WorkflowBuilder
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null, bool idempotent = false)
=> this.AddEdge(source, target, condition, label: null, idempotent);
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <param name="condition">An optional predicate that determines whether the edge should be followed based on the input.
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
/// rather than an error.</param>
/// If null, the edge is always activated when the source sends a message.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null, string? label = null, bool idempotent = false)
{
// Add an edge from source to target with an optional condition.
// This is a low-level builder method that does not enforce any specific executor type.
@@ -256,7 +313,7 @@ public class WorkflowBuilder
"You cannot add another edge without a condition for the same source and target.");
}
DirectEdgeData directEdge = new(this.Track(source).Id, this.Track(target).Id, this.TakeEdgeId(), CreateConditionFunc(condition));
DirectEdgeData directEdge = new(this.Track(source).Id, this.Track(target).Id, this.TakeEdgeId(), CreateConditionFunc(condition), label);
this.EnsureEdgesFor(source.Id).Add(new(directEdge));
@@ -275,6 +332,19 @@ public class WorkflowBuilder
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, IEnumerable<ExecutorBinding> targets)
=> this.AddFanOutEdge<object>(source, targets, null);
/// <summary>
/// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a
/// custom partitioning function.
/// </summary>
/// <remarks>If a partitioner function is provided, it will be used to distribute input across the target
/// executors. The order of targets determines their mapping in the partitioning process.</remarks>
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
/// <param name="label">A label for the edge. Will be used in visualization.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, string label)
=> this.AddFanOutEdge<object>(source, targets, null, label);
internal static Func<object?, int, IEnumerable<int>>? CreateTargetAssignerFunc<T>(Func<T?, int, IEnumerable<int>>? targetAssigner)
{
if (targetAssigner is null)
@@ -305,6 +375,21 @@ public class WorkflowBuilder
/// <param name="targetSelector">An optional function that determines how input is assigned among the target executors.
/// If null, messages will route to all targets.</param>
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<T?, int, IEnumerable<int>>? targetSelector = null)
=> this.AddFanOutEdge(source, targets, targetSelector, label: null);
/// <summary>
/// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a
/// custom partitioning function.
/// </summary>
/// <remarks>If a partitioner function is provided, it will be used to distribute input across the target
/// executors. The order of targets determines their mapping in the partitioning process.</remarks>
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <param name="targetSelector">An optional function that determines how input is assigned among the target executors.
/// If null, messages will route to all targets.</param>
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<T?, int, IEnumerable<int>>? targetSelector = null, string? label = null)
{
Throw.IfNull(source);
Throw.IfNull(targets);
@@ -321,7 +406,8 @@ public class WorkflowBuilder
this.Track(source).Id,
sinkIds,
this.TakeEdgeId(),
CreateTargetAssignerFunc(targetSelector));
CreateTargetAssignerFunc(targetSelector),
label);
this.EnsureEdgesFor(source.Id).Add(new(fanOutEdge));
@@ -339,6 +425,20 @@ public class WorkflowBuilder
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanInEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target)
=> this.AddFanInEdge(sources, target, label: null);
/// <summary>
/// Adds a fan-in edge to the workflow, connecting multiple source executors to a single target executor with an
/// optional trigger condition.
/// </summary>
/// <remarks>This method establishes a fan-in relationship, allowing the target executor to be activated
/// based on the completion or state of multiple sources. The trigger parameter can be used to customize activation
/// behavior.</remarks>
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanInEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target, string? label = null)
{
Throw.IfNull(target);
Throw.IfNull(sources);
@@ -354,7 +454,8 @@ public class WorkflowBuilder
FanInEdgeData edgeData = new(
sourceIds,
this.Track(target).Id,
this.TakeEdgeId());
this.TakeEdgeId(),
label);
foreach (string sourceId in edgeData.SourceIds)
{
@@ -39,7 +39,7 @@ internal sealed class WorkflowHostAgent : AIAgent
this._describeTask = this._workflow.DescribeProtocolAsync().AsTask();
}
public override string Id => this._id ?? base.Id;
protected override string? IdCore => this._id;
public override string? Name { get; }
public override string? Description { get; }
@@ -121,7 +121,7 @@ public sealed partial class ChatClientAgent : AIAgent
public IChatClient ChatClient { get; }
/// <inheritdoc/>
public override string Id => this._agentOptions?.Id ?? base.Id;
protected override string? IdCore => this._agentOptions?.Id;
/// <inheritdoc/>
public override string? Name => this._agentOptions?.Name;
@@ -89,7 +89,7 @@ public class AIProjectClientFixture : IChatClientAgentFixture
List<ChatMessage> messages = [];
await foreach (AgentResponseItem item in this._client.GetProjectOpenAIClient().GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc"))
{
var openAIItem = item.AsOpenAIResponseItem();
var openAIItem = item.AsResponseResultItem();
if (openAIItem is MessageResponseItem messageItem)
{
messages.Add(new ChatMessage
@@ -214,13 +214,31 @@ public class AIAgentTests
[Fact]
public void ValidateAgentIDIsIdempotent()
{
// Arrange
var agent = new MockAgent();
// Act
string id = agent.Id;
// Assert
Assert.NotNull(id);
Assert.Equal(id, agent.Id);
}
[Fact]
public void ValidateAgentIDCanBeProvidedByDerivedAgentClass()
{
// Arrange
var agent = new MockAgent(id: "test-agent-id");
// Act
string id = agent.Id;
// Assert
Assert.NotNull(id);
Assert.Equal("test-agent-id", id);
}
#region GetService Method Tests
/// <summary>
@@ -344,6 +362,13 @@ public class AIAgentTests
private sealed class MockAgent : AIAgent
{
public MockAgent(string? id = null)
{
this.IdCore = id;
}
protected override string? IdCore { get; }
public override AgentThread GetNewThread()
=> throw new NotImplementedException();
@@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
@@ -31,7 +32,7 @@ public class DelegatingAIAgentTests
this._testThread = new TestAgentThread();
// Setup inner agent mock
this._innerAgentMock.Setup(x => x.Id).Returns("test-agent-id");
this._innerAgentMock.Protected().SetupGet<string>("IdCore").Returns("test-agent-id");
this._innerAgentMock.Setup(x => x.Name).Returns("Test Agent");
this._innerAgentMock.Setup(x => x.Description).Returns("Test Description");
this._innerAgentMock.Setup(x => x.GetNewThread()).Returns(this._testThread);
@@ -93,7 +94,7 @@ public class DelegatingAIAgentTests
// Assert
Assert.Equal("test-agent-id", id);
this._innerAgentMock.Verify(x => x.Id, Times.Once);
this._innerAgentMock.Protected().VerifyGet<string>("IdCore", Times.Once());
}
/// <summary>
@@ -10,7 +10,6 @@ using Azure.Core;
using Azure.Identity;
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.AI;
using Xunit;
namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests;
@@ -59,6 +58,9 @@ public sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
public async Task InitializeAsync()
{
// Fail fast if emulator is not available
this.SkipIfEmulatorNotAvailable();
// 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);
@@ -7,7 +7,6 @@ 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;
@@ -58,6 +57,9 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
public async Task InitializeAsync()
{
// Fail fast if emulator is not available
this.SkipIfEmulatorNotAvailable();
// 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);
@@ -81,6 +81,64 @@ public sealed class AgentEntityTests(ITestOutputHelper outputHelper) : IDisposab
Assert.Null(request.OrchestrationId);
}
[Theory]
[InlineData("run")]
[InlineData("Run")]
[InlineData("RunAgentAsync")]
public async Task RunAgentMethodNamesAllWorkAsync(string runAgentMethodName)
{
// Setup
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent(
name: "TestAgent",
instructions: "You are a helpful assistant that always responds with a friendly greeting."
);
using TestHelper testHelper = TestHelper.Start([simpleAgent], this._outputHelper);
// A proxy agent is needed to call the hosted test agent
AIAgent simpleAgentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services);
AgentThread thread = simpleAgentProxy.GetNewThread();
DurableTaskClient client = testHelper.GetClient();
AgentSessionId sessionId = thread.GetService<AgentSessionId>();
EntityInstanceId expectedEntityId = new($"dafx-{simpleAgent.Name}", sessionId.Key);
EntityMetadata? entity = await client.Entities.GetEntityAsync(expectedEntityId, false, this.TestTimeoutToken);
Assert.Null(entity);
// Act: send a prompt to the agent
await client.Entities.SignalEntityAsync(
expectedEntityId,
runAgentMethodName,
new RunRequest("Hello!"),
cancellation: this.TestTimeoutToken);
while (!this.TestTimeoutToken.IsCancellationRequested)
{
await Task.Delay(500, this.TestTimeoutToken);
// Assert: verify the agent state was stored with the correct entity name prefix
entity = await client.Entities.GetEntityAsync(expectedEntityId, true, this.TestTimeoutToken);
if (entity is not null)
{
break;
}
}
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
DurableAgentState state = entity.State.ReadAs<DurableAgentState>();
DurableAgentStateRequest request = Assert.Single(state.Data.ConversationHistory.OfType<DurableAgentStateRequest>());
Assert.Null(request.OrchestrationId);
}
[Fact]
public async Task OrchestrationIdSetDuringOrchestrationAsync()
{
@@ -0,0 +1,197 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Reflection;
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.Entities;
using Microsoft.Extensions.Configuration;
using OpenAI.Chat;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
/// <summary>
/// Tests for Time-To-Live (TTL) functionality of durable agent entities.
/// </summary>
[Collection("Sequential")]
[Trait("Category", "Integration")]
public sealed class TimeToLiveTests(ITestOutputHelper outputHelper) : IDisposable
{
private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached
? TimeSpan.FromMinutes(5)
: TimeSpan.FromSeconds(30);
private static readonly IConfiguration s_configuration =
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.AddEnvironmentVariables()
.Build();
private readonly ITestOutputHelper _outputHelper = outputHelper;
private readonly CancellationTokenSource _cts = new(delay: s_defaultTimeout);
private CancellationToken TestTimeoutToken => this._cts.Token;
public void Dispose() => this._cts.Dispose();
[Fact]
public async Task EntityExpiresAfterTTLAsync()
{
// Arrange: Create agent with short TTL (10 seconds)
TimeSpan ttl = TimeSpan.FromSeconds(10);
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent(
name: "TTLTestAgent",
instructions: "You are a helpful assistant."
);
using TestHelper testHelper = TestHelper.Start(
this._outputHelper,
options =>
{
options.DefaultTimeToLive = ttl;
options.MinimumTimeToLiveSignalDelay = TimeSpan.FromSeconds(1);
options.AddAIAgent(simpleAgent);
});
AIAgent agentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services);
AgentThread thread = agentProxy.GetNewThread();
DurableTaskClient client = testHelper.GetClient();
AgentSessionId sessionId = thread.GetService<AgentSessionId>();
// Act: Send a message to the agent
await agentProxy.RunAsync(
message: "Hello!",
thread,
cancellationToken: this.TestTimeoutToken);
// Verify entity exists and get expiration time
EntityMetadata? entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
DurableAgentState state = entity.State.ReadAs<DurableAgentState>();
Assert.NotNull(state.Data.ExpirationTimeUtc);
DateTime expirationTime = state.Data.ExpirationTimeUtc.Value;
Assert.True(expirationTime > DateTime.UtcNow);
// Calculate how long to wait: expiration time + buffer for signal processing
TimeSpan waitTime = expirationTime - DateTime.UtcNow + TimeSpan.FromSeconds(1);
if (waitTime > TimeSpan.Zero)
{
await Task.Delay(waitTime, this.TestTimeoutToken);
}
// Poll the entity state until it's deleted (with timeout)
DateTime pollTimeout = DateTime.UtcNow.AddSeconds(10);
bool entityDeleted = false;
while (DateTime.UtcNow < pollTimeout && !entityDeleted)
{
entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
entityDeleted = entity is null;
if (!entityDeleted)
{
await Task.Delay(TimeSpan.FromSeconds(1), this.TestTimeoutToken);
}
}
// Assert: Verify entity state is deleted
Assert.True(entityDeleted, "Entity should have been deleted after TTL expiration");
}
[Fact]
public async Task EntityTTLResetsOnInteractionAsync()
{
// Arrange: Create agent with short TTL
TimeSpan ttl = TimeSpan.FromSeconds(6);
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent(
name: "TTLResetTestAgent",
instructions: "You are a helpful assistant."
);
using TestHelper testHelper = TestHelper.Start(
this._outputHelper,
options =>
{
options.DefaultTimeToLive = ttl;
options.MinimumTimeToLiveSignalDelay = TimeSpan.FromSeconds(1);
options.AddAIAgent(simpleAgent);
});
AIAgent agentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services);
AgentThread thread = agentProxy.GetNewThread();
DurableTaskClient client = testHelper.GetClient();
AgentSessionId sessionId = thread.GetService<AgentSessionId>();
// Act: Send first message
await agentProxy.RunAsync(
message: "Hello!",
thread,
cancellationToken: this.TestTimeoutToken);
EntityMetadata? entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
DurableAgentState state = entity.State.ReadAs<DurableAgentState>();
DateTime firstExpirationTime = state.Data.ExpirationTimeUtc!.Value;
// Wait partway through TTL
await Task.Delay(TimeSpan.FromSeconds(3), this.TestTimeoutToken);
// Send second message (should reset TTL)
await agentProxy.RunAsync(
message: "Hello again!",
thread,
cancellationToken: this.TestTimeoutToken);
// Verify expiration time was updated
entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
state = entity.State.ReadAs<DurableAgentState>();
DateTime secondExpirationTime = state.Data.ExpirationTimeUtc!.Value;
Assert.True(secondExpirationTime > firstExpirationTime);
// Calculate when the original expiration time would have been
DateTime originalExpirationTime = firstExpirationTime;
TimeSpan waitUntilOriginalExpiration = originalExpirationTime - DateTime.UtcNow + TimeSpan.FromSeconds(2);
if (waitUntilOriginalExpiration > TimeSpan.Zero)
{
await Task.Delay(waitUntilOriginalExpiration, this.TestTimeoutToken);
}
// Assert: Entity should still exist because TTL was reset
// The new expiration time should be in the future
entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
state = entity.State.ReadAs<DurableAgentState>();
Assert.NotNull(state);
Assert.NotNull(state.Data.ExpirationTimeUtc);
Assert.True(
state.Data.ExpirationTimeUtc > DateTime.UtcNow,
"Entity should still be valid because TTL was reset");
// Wait for the entity to be deleted
DateTime pollTimeout = DateTime.UtcNow.AddSeconds(10);
bool entityDeleted = false;
while (DateTime.UtcNow < pollTimeout && !entityDeleted)
{
entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
entityDeleted = entity is null;
if (!entityDeleted)
{
await Task.Delay(TimeSpan.FromSeconds(1), this.TestTimeoutToken);
}
}
// Assert: Entity should have been deleted
Assert.True(entityDeleted, "Entity should have been deleted after TTL expiration");
}
}
@@ -276,15 +276,9 @@ public sealed class BasicStreamingTests : IAsyncDisposable
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")]
internal sealed class FakeChatClientAgent : AIAgent
{
public FakeChatClientAgent()
{
this.Id = "fake-agent";
this.Description = "A fake agent for testing";
}
protected override string? IdCore => "fake-agent";
public override string Id { get; }
public override string? Description { get; }
public override string? Description => "A fake agent for testing";
public override AgentThread GetNewThread()
{
@@ -350,15 +344,9 @@ internal sealed class FakeChatClientAgent : AIAgent
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")]
internal sealed class FakeMultiMessageAgent : AIAgent
{
public FakeMultiMessageAgent()
{
this.Id = "fake-multi-message-agent";
this.Description = "A fake agent that sends multiple messages for testing";
}
protected override string? IdCore => "fake-multi-message-agent";
public override string Id { get; }
public override string? Description { get; }
public override string? Description => "A fake agent that sends multiple messages for testing";
public override AgentThread GetNewThread()
{
@@ -421,7 +421,7 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
private sealed class MultiResponseAgent : AIAgent
{
public override string Id => "multi-response-agent";
protected override string? IdCore => "multi-response-agent";
public override string? Description => "Agent that produces multiple text chunks";
@@ -510,7 +510,7 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
private sealed class TestAgent : AIAgent
{
public override string Id => "test-agent";
protected override string? IdCore => "test-agent";
public override string? Description => "Test agent";
@@ -49,7 +49,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "One Two Three";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Count to 3");
@@ -90,10 +90,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Hello! How can I help you today?";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Hello");
ResponseResult response = await responseClient.CreateResponseAsync("Hello");
// Assert
Assert.NotNull(response);
@@ -117,7 +117,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "This is a test response with multiple words";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -162,12 +162,12 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
(Agent1Name, Agent1Instructions, Agent1Response),
(Agent2Name, Agent2Instructions, Agent2Response));
OpenAIResponseClient responseClient1 = this.CreateResponseClient(Agent1Name);
OpenAIResponseClient responseClient2 = this.CreateResponseClient(Agent2Name);
ResponsesClient responseClient1 = this.CreateResponseClient(Agent1Name);
ResponsesClient responseClient2 = this.CreateResponseClient(Agent2Name);
// Act
OpenAIResponse response1 = await responseClient1.CreateResponseAsync("Hello");
OpenAIResponse response2 = await responseClient2.CreateResponseAsync("Hello");
ResponseResult response1 = await responseClient1.CreateResponseAsync("Hello");
ResponseResult response2 = await responseClient2.CreateResponseAsync("Hello");
// Assert
string content1 = response1.GetOutputText();
@@ -190,10 +190,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "This is the response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act - Non-streaming
OpenAIResponse nonStreamingResponse = await responseClient.CreateResponseAsync("Test");
ResponseResult nonStreamingResponse = await responseClient.CreateResponseAsync("Test");
// Act - Streaming
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -224,10 +224,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Complete";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Test");
ResponseResult response = await responseClient.CreateResponseAsync("Test");
// Assert
Assert.Equal(ResponseStatus.Completed, response.Status);
@@ -247,7 +247,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Test response with multiple words";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -286,7 +286,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -316,10 +316,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Response with metadata";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Test");
ResponseResult response = await responseClient.CreateResponseAsync("Test");
// Assert
Assert.NotNull(response.Id);
@@ -340,7 +340,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
string expectedResponse = string.Join(" ", Enumerable.Range(1, 100).Select(i => $"Word{i}"));
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, expectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Generate long text");
@@ -371,7 +371,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Test output index";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -407,7 +407,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Hello";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -437,7 +437,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Hello! How are you? I'm fine. 100% great!";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -467,10 +467,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Symbols: @#$%^&*() Quotes: \"Hello\" 'World' Unicode: 你好 🌍";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Test");
ResponseResult response = await responseClient.CreateResponseAsync("Test");
// Assert
string content = response.GetOutputText();
@@ -489,7 +489,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Testing item IDs";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -525,12 +525,12 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act & Assert - Make 5 sequential requests
for (int i = 0; i < 5; i++)
{
OpenAIResponse response = await responseClient.CreateResponseAsync($"Request {i}");
ResponseResult response = await responseClient.CreateResponseAsync($"Request {i}");
Assert.NotNull(response);
Assert.Equal(ResponseStatus.Completed, response.Status);
Assert.Equal(ExpectedResponse, response.GetOutputText());
@@ -549,7 +549,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Streaming response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act & Assert - Make 3 sequential streaming requests
for (int i = 0; i < 3; i++)
@@ -581,13 +581,13 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
List<string> responseIds = [];
for (int i = 0; i < 10; i++)
{
OpenAIResponse response = await responseClient.CreateResponseAsync($"Request {i}");
ResponseResult response = await responseClient.CreateResponseAsync($"Request {i}");
responseIds.Add(response.Id);
}
@@ -608,7 +608,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Test sequence numbers with multiple words";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -641,10 +641,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Test model info";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Test");
ResponseResult response = await responseClient.CreateResponseAsync("Test");
// Assert
Assert.NotNull(response.Model);
@@ -663,7 +663,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Hello, world! How are you today? I'm doing well.";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -693,10 +693,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "OK";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Hi");
ResponseResult response = await responseClient.CreateResponseAsync("Hi");
// Assert
Assert.NotNull(response);
@@ -716,7 +716,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Test content indices";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -748,10 +748,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Line 1\nLine 2\nLine 3";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Test");
ResponseResult response = await responseClient.CreateResponseAsync("Test");
// Assert
string content = response.GetOutputText();
@@ -771,7 +771,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "First line\nSecond line\nThird line";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -807,10 +807,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.ImageContentMockChatClient(ImageUrl));
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Show me an image");
ResponseResult response = await responseClient.CreateResponseAsync("Show me an image");
// Assert
Assert.NotNull(response);
@@ -834,7 +834,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.ImageContentMockChatClient(ImageUrl));
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Show me an image");
@@ -868,10 +868,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.AudioContentMockChatClient(AudioData, Transcript));
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Generate audio");
ResponseResult response = await responseClient.CreateResponseAsync("Generate audio");
// Assert
Assert.NotNull(response);
@@ -896,7 +896,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.AudioContentMockChatClient(AudioData, Transcript));
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Generate audio");
@@ -930,10 +930,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.FunctionCallMockChatClient(FunctionName, Arguments));
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("What's the weather?");
ResponseResult response = await responseClient.CreateResponseAsync("What's the weather?");
// Assert
Assert.NotNull(response);
@@ -957,7 +957,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.FunctionCallMockChatClient(FunctionName, Arguments));
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Calculate 2+2");
@@ -988,10 +988,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.MixedContentMockChatClient());
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Show me various content");
ResponseResult response = await responseClient.CreateResponseAsync("Show me various content");
// Assert
Assert.NotNull(response);
@@ -1014,7 +1014,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.MixedContentMockChatClient());
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Show me various content");
@@ -1047,7 +1047,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Complete text response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -1075,7 +1075,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Response with content parts";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -1122,7 +1122,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
string conversationId = convDoc.RootElement.GetProperty("id").GetString()!;
// Act - Send request with conversation ID using raw HTTP
// (OpenAI SDK doesn't expose ConversationId directly on ResponseCreationOptions)
// (OpenAI SDK doesn't expose ConversationId directly on CreateResponseOptions)
var requestBody = new
{
input = "Test",
@@ -1201,9 +1201,9 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
Assert.Null(mockChatClient.LastChatOptions.ConversationId);
}
private OpenAIResponseClient CreateResponseClient(string agentName)
private ResponsesClient CreateResponseClient(string agentName)
{
return new OpenAIResponseClient(
return new ResponsesClient(
model: "test-model",
credential: new ApiKeyCredential("test-api-key"),
options: new OpenAIClientOptions
@@ -55,9 +55,9 @@ public sealed class OpenAIResponseClientExtensionsTests
}
/// <summary>
/// Creates a test OpenAIResponseClient implementation for testing.
/// Creates a test ResponsesClient implementation for testing.
/// </summary>
private sealed class TestOpenAIResponseClient : OpenAIResponseClient
private sealed class TestOpenAIResponseClient : ResponsesClient
{
public TestOpenAIResponseClient()
{
@@ -147,7 +147,7 @@ public sealed class OpenAIResponseClientExtensionsTests
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
((OpenAIResponseClient)null!).CreateAIAgent());
((ResponsesClient)null!).CreateAIAgent());
Assert.Equal("client", exception.ParamName);
}
@@ -24,7 +24,7 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o
private const string ImageReference = "https://sample-files.com/downloads/images/jpg/web_optimized_1200x800_97kb.jpg";
[Theory]
[InlineData(ImageReference, "image/jpeg")]
[InlineData(ImageReference, "image/jpeg", Skip = "Failing consistently in the agent service api")]
[InlineData(PdfReference, "application/pdf", Skip = "Not currently supported by agent service api")]
public async Task ValidateFileUrlAsync(string fileSource, string mediaType)
{
@@ -21,7 +21,7 @@ public class EdgeMapSmokeTests
Dictionary<string, HashSet<Edge>> workflowEdges = [];
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0));
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
Edge fanInEdge = new(edgeData);
workflowEdges["executor1"] = [fanInEdge];
@@ -155,7 +155,7 @@ public class EdgeRunnerTests
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0));
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
FanInEdgeRunner runner = new(runContext, edgeData);
// Step 1: Send message from executor1, should not forward yet.
@@ -118,7 +118,7 @@ public class JsonSerializationTests
RunJsonRoundtrip(TestFanOutEdgeInfo_Assigner, predicate: TestFanOutEdgeInfo_Assigner.CreateValidator());
}
private static FanInEdgeData TestFanInEdgeData => new(["SourceExecutor1", "SourceExecutor2"], "TargetExecutor", TakeEdgeId());
private static FanInEdgeData TestFanInEdgeData => new(["SourceExecutor1", "SourceExecutor2"], "TargetExecutor", TakeEdgeId(), null);
private static FanInEdgeInfo TestFanInEdgeInfo => new(TestFanInEdgeData);
[Fact]
@@ -137,17 +137,17 @@ public class RepresentationTests
RunEdgeInfoMatchTest(fanOutEdgeWithAssigner);
// FanIn Edges
Edge fanInEdge = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId()));
Edge fanInEdge = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId(), null));
RunEdgeInfoMatchTest(fanInEdge);
Edge fanInEdge2 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId()));
Edge fanInEdge2 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId(), null));
RunEdgeInfoMatchTest(fanInEdge, fanInEdge2);
Edge fanInEdge3 = new(new FanInEdgeData([Source(2), Source(3), Source(1)], Sink(1), TakeEdgeId()));
Edge fanInEdge3 = new(new FanInEdgeData([Source(2), Source(3), Source(1)], Sink(1), TakeEdgeId(), null));
RunEdgeInfoMatchTest(fanInEdge, fanInEdge3, expect: false); // Order matters (though for FanIn maybe it shouldn't?)
Edge fanInEdge4 = new(new FanInEdgeData([Source(1), Source(2), Source(4)], Sink(1), TakeEdgeId()));
Edge fanInEdge5 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(2), TakeEdgeId()));
Edge fanInEdge4 = new(new FanInEdgeData([Source(1), Source(2), Source(4)], Sink(1), TakeEdgeId(), null));
Edge fanInEdge5 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(2), TakeEdgeId(), null));
RunEdgeInfoMatchTest(fanInEdge, fanInEdge4, expect: false); // Identity matters
RunEdgeInfoMatchTest(fanInEdge, fanInEdge5, expect: false);
@@ -57,7 +57,7 @@ internal sealed class HelloAgent(string id = nameof(HelloAgent)) : AIAgent
public const string Greeting = "Hello World!";
public const string DefaultId = nameof(HelloAgent);
public override string Id => id;
protected override string? IdCore => id;
public override string? Name => id;
public override AgentThread GetNewThread()
@@ -19,7 +19,7 @@ public class SpecializedExecutorSmokeTests
{
public class TestAIAgent(List<ChatMessage>? messages = null, string? id = null, string? name = null) : AIAgent
{
public override string Id => id ?? base.Id;
protected override string? IdCore => id;
public override string? Name => name;
public static List<ChatMessage> ToChatMessages(params string[] messages)
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal class TestEchoAgent(string? id = null, string? name = null, string? prefix = null) : AIAgent
{
public override string Id => id ?? base.Id;
protected override string? IdCore => id;
public override string? Name => name ?? base.Name;
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
@@ -394,4 +394,61 @@ public class WorkflowVisualizerTests
// Check fan-in (should have intermediate node)
mermaidContent.Should().Contain("((fan-in))");
}
[Fact]
public void Test_WorkflowViz_Mermaid_Edge_Label_With_Pipe()
{
// Test that pipe characters in labels are properly escaped
var start = new MockExecutor("start");
var end = new MockExecutor("end");
var workflow = new WorkflowBuilder("start")
.AddEdge(start, end, label: "High | Low Priority")
.Build();
var mermaidContent = workflow.ToMermaidString();
// Should escape pipe character
mermaidContent.Should().Contain("start -->|High &#124; Low Priority| end");
// Should not contain unescaped pipe that would break syntax
mermaidContent.Should().NotContain("-->|High | Low");
}
[Fact]
public void Test_WorkflowViz_Mermaid_Edge_Label_With_Special_Chars()
{
// Test that special characters are properly escaped
var start = new MockExecutor("start");
var end = new MockExecutor("end");
var workflow = new WorkflowBuilder("start")
.AddEdge(start, end, label: "Score >= 90 & < 100")
.Build();
var mermaidContent = workflow.ToMermaidString();
// Should escape special characters
mermaidContent.Should().Contain("&amp;");
mermaidContent.Should().Contain("&gt;");
mermaidContent.Should().Contain("&lt;");
}
[Fact]
public void Test_WorkflowViz_Mermaid_Edge_Label_With_Newline()
{
// Test that newlines are converted to <br/>
var start = new MockExecutor("start");
var end = new MockExecutor("end");
var workflow = new WorkflowBuilder("start")
.AddEdge(start, end, label: "Line 1\nLine 2")
.Build();
var mermaidContent = workflow.ToMermaidString();
// Should convert newline to <br/>
mermaidContent.Should().Contain("Line 1<br/>Line 2");
// Should not contain literal newline in the label (but the overall output has newlines between statements)
mermaidContent.Should().NotContain("Line 1\nLine 2");
}
}
@@ -3,11 +3,11 @@
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace OpenAIResponse.IntegrationTests;
namespace ResponseResult.IntegrationTests;
public class OpenAIResponseStoreTrueChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<OpenAIResponseFixture>(() => new(store: true))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() =>
@@ -16,7 +16,7 @@ public class OpenAIResponseStoreTrueChatClientAgentRunStreamingTests() : ChatCli
public class OpenAIResponseStoreFalseChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<OpenAIResponseFixture>(() => new(store: false))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() =>
@@ -3,11 +3,11 @@
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace OpenAIResponse.IntegrationTests;
namespace ResponseResult.IntegrationTests;
public class OpenAIResponseStoreTrueChatClientAgentRunTests() : ChatClientAgentRunTests<OpenAIResponseFixture>(() => new(store: true))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() =>
@@ -16,7 +16,7 @@ public class OpenAIResponseStoreTrueChatClientAgentRunTests() : ChatClientAgentR
public class OpenAIResponseStoreFalseChatClientAgentRunTests() : ChatClientAgentRunTests<OpenAIResponseFixture>(() => new(store: false))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() =>
@@ -12,13 +12,13 @@ using OpenAI;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace OpenAIResponse.IntegrationTests;
namespace ResponseResult.IntegrationTests;
public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture
{
private static readonly OpenAIConfiguration s_config = TestConfiguration.LoadSection<OpenAIConfiguration>();
private OpenAIResponseClient _openAIResponseClient = null!;
private ResponsesClient _openAIResponseClient = null!;
private ChatClientAgent _agent = null!;
public AIAgent Agent => this._agent;
@@ -77,7 +77,7 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture
{
Instructions = instructions,
Tools = aiTools,
RawRepresentationFactory = new Func<IChatClient, object>(_ => new ResponseCreationOptions() { StoredOutputEnabled = store })
RawRepresentationFactory = new Func<IChatClient, object>(_ => new CreateResponseOptions() { StoredOutputEnabled = store })
},
});
@@ -92,7 +92,7 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture
public async Task InitializeAsync()
{
this._openAIResponseClient = new OpenAIClient(s_config.ApiKey)
.GetOpenAIResponseClient(s_config.ChatModelId);
.GetResponsesClient(s_config.ChatModelId);
this._agent = await this.CreateChatClientAgentAsync();
}
@@ -3,11 +3,11 @@
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace OpenAIResponse.IntegrationTests;
namespace ResponseResult.IntegrationTests;
public class OpenAIResponseStoreTrueRunStreamingTests() : RunStreamingTests<OpenAIResponseFixture>(() => new(store: true))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithNoMessageDoesNotFailAsync() =>
Task.CompletedTask;
@@ -15,7 +15,7 @@ public class OpenAIResponseStoreTrueRunStreamingTests() : RunStreamingTests<Open
public class OpenAIResponseStoreFalseRunStreamingTests() : RunStreamingTests<OpenAIResponseFixture>(() => new(store: false))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithNoMessageDoesNotFailAsync() =>
@@ -3,11 +3,11 @@
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace OpenAIResponse.IntegrationTests;
namespace ResponseResult.IntegrationTests;
public class OpenAIResponseStoreTrueRunTests() : RunTests<OpenAIResponseFixture>(() => new(store: true))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithNoMessageDoesNotFailAsync() =>
Task.CompletedTask;
@@ -15,7 +15,7 @@ public class OpenAIResponseStoreTrueRunTests() : RunTests<OpenAIResponseFixture>
public class OpenAIResponseStoreFalseRunTests() : RunTests<OpenAIResponseFixture>(() => new(store: false))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithNoMessageDoesNotFailAsync() =>