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
Unverified
parent a48a8dd524
commit 87a38bc7da
227 changed files with 11969 additions and 2638 deletions
@@ -839,7 +839,7 @@ var agentOptions = new ChatClientAgentRunOptions(new ChatOptions
{
MaxOutputTokens = 8000,
// Breaking glass to access provider-specific options
RawRepresentationFactory = (_) => new OpenAI.Responses.ResponseCreationOptions()
RawRepresentationFactory = (_) => new OpenAI.Responses.CreateResponseOptions()
{
ReasoningOptions = new()
{
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
persist-credentials: false
+21 -15
View File
@@ -35,19 +35,25 @@ jobs:
contents: read
pull-requests: read
outputs:
dotnetChanges: ${{ steps.filter.outputs.dotnet}}
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
dotnet:
- 'dotnet/**'
cosmosdb:
- 'dotnet/src/Microsoft.Agents.AI.CosmosNoSql/**'
# run only if 'dotnet' files were changed
- name: dotnet tests
if: steps.filter.outputs.dotnet == 'true'
run: echo "Dotnet file"
- name: dotnet CosmosDB tests
if: steps.filter.outputs.cosmosdb == 'true'
run: echo "Dotnet CosmosDB changes"
# run only if not 'dotnet' files were changed
- name: not dotnet tests
if: steps.filter.outputs.dotnet != 'true'
@@ -68,7 +74,7 @@ jobs:
runs-on: ${{ matrix.os }}
environment: ${{ matrix.environment }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
@@ -77,6 +83,16 @@ jobs:
dotnet
python
workflow-samples
# Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened)
- name: Start Azure Cosmos DB Emulator
if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
shell: pwsh
run: |
Write-Host "Launching Azure Cosmos DB Emulator"
Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator"
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v5.0.1
@@ -123,17 +139,7 @@ jobs:
popd
popd
rm -rf "$TEMP_DIR"
# Start Cosmos DB Emulator for Cosmos-based unit tests (only on Windows)
- name: Start Azure Cosmos DB Emulator
if: runner.os == 'Windows'
shell: pwsh
run: |
Write-Host "Launching Azure Cosmos DB Emulator"
Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator"
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Run Unit Tests
shell: bash
run: |
@@ -225,7 +231,7 @@ jobs:
- name: Upload coverage report artifact
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name
path: ./TestResults/Reports # Directory containing files to upload
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-22.04
# check out the latest version of the code
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
persist-credentials: false
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python and install the project
@@ -39,7 +39,7 @@ jobs:
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: ~/.cache/pre-commit
key: pre-commit|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
outputs:
pythonChanges: ${{ steps.filter.outputs.python}}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
@@ -59,7 +59,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
outputs:
pythonChanges: ${{ steps.filter.outputs.python}}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
@@ -75,7 +75,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -135,7 +135,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -19,9 +19,9 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Download coverage report
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
run-id: ${{ github.event.workflow_run.id }}
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
env:
UV_PYTHON: "3.10"
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
# Save the PR number to a file since the workflow_run event
# in the coverage report workflow does not have access to it
- name: Save PR number
@@ -38,7 +38,7 @@ jobs:
- name: Run all tests with coverage report
run: uv run poe all-tests-cov --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
- name: Upload coverage report
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
path: |
python/python-coverage.xml
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
+5 -5
View File
@@ -10,19 +10,19 @@ model:
temperature: 0.9
topP: 0.95
connection:
kind: ApiKey
key: =Env.OPENAI_API_KEY
kind: key
apiKey: =Env.OPENAI_APIKEY
outputSchema:
properties:
language:
type: string
kind: string
required: true
description: The language of the answer.
answer:
type: string
kind: string
required: true
description: The answer text.
type:
type: string
kind: string
required: true
description: The type of the response.
@@ -0,0 +1,147 @@
# Time-To-Live (TTL) for durable agent sessions
## Overview
The durable agents automatically maintain conversation history and state for each session. Without automatic cleanup, this state can accumulate indefinitely, consuming storage resources and increasing costs. The Time-To-Live (TTL) feature provides automatic cleanup of idle agent sessions, ensuring that sessions are automatically deleted after a period of inactivity.
## What is TTL?
Time-To-Live (TTL) is a configurable duration that determines how long an agent session state will be retained after its last interaction. When an agent session is idle (no messages sent to it) for longer than the TTL period, the session state is automatically deleted. Each new interaction with an agent resets the TTL timer, extending the session's lifetime.
## Benefits
- **Automatic cleanup**: No manual intervention required to clean up idle agent sessions
- **Cost optimization**: Reduces storage costs by automatically removing unused session state
- **Resource management**: Prevents unbounded growth of agent session state in storage
- **Configurable**: Set TTL globally or per-agent type to match your application's needs
## Configuration
TTL can be configured at two levels:
1. **Global default TTL**: Applies to all agent sessions unless overridden
2. **Per-agent type TTL**: Overrides the global default for specific agent types
Additionally, you can configure a **minimum deletion delay** that controls how frequently deletion operations are scheduled. The default value is 5 minutes, and the maximum allowed value is also 5 minutes.
> [!NOTE]
> 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.
### Default values
- **Default TTL**: 14 days
- **Minimum TTL deletion delay**: 5 minutes (maximum allowed value, subject to change in future releases)
### Configuration examples
#### .NET
```csharp
// Configure global default TTL and minimum signal delay
services.ConfigureDurableAgents(
options =>
{
// Set global default TTL to 7 days
options.DefaultTimeToLive = TimeSpan.FromDays(7);
// Add agents (will use global default TTL)
options.AddAIAgent(myAgent);
});
// Configure per-agent TTL
services.ConfigureDurableAgents(
options =>
{
options.DefaultTimeToLive = TimeSpan.FromDays(14); // Global default
// Agent with custom TTL of 1 day
options.AddAIAgent(shortLivedAgent, timeToLive: TimeSpan.FromDays(1));
// Agent with custom TTL of 90 days
options.AddAIAgent(longLivedAgent, timeToLive: TimeSpan.FromDays(90));
// Agent using global default (14 days)
options.AddAIAgent(defaultAgent);
});
// Disable TTL for specific agents by setting TTL to null
services.ConfigureDurableAgents(
options =>
{
options.DefaultTimeToLive = TimeSpan.FromDays(14);
// Agent with no TTL (never expires)
options.AddAIAgent(permanentAgent, timeToLive: null);
});
```
## How TTL works
The following sections describe how TTL works in detail.
### Expiration tracking
Each agent session maintains an expiration timestamp in its internally managed state that is updated whenever the session processes a message:
1. When a message is sent to an agent session, the expiration time is set to `current time + TTL`
2. The runtime schedules a delete operation for the expiration time (subject to minimum delay constraints)
3. When the delete operation runs, if the current time is past the expiration time, the session state is deleted. Otherwise, the delete operation is rescheduled for the next expiration time.
### State deletion
When an agent session expires, its entire state is deleted, including:
- Conversation history
- Any custom state data
- Expiration timestamps
After deletion, if a message is sent to the same agent session, a new session is created with a fresh conversation history.
## Behavior examples
The following examples illustrate how TTL works in different scenarios.
### Example 1: Agent session expires after TTL
1. Agent configured with 30-day TTL
2. User sends message at Day 0 → agent session created, expiration set to Day 30
3. No further messages sent
4. At Day 30 → Agent session is deleted
5. User sends message at Day 31 → New agent session created with fresh conversation history
### Example 2: TTL reset on interaction
1. Agent configured with 30-day TTL
2. User sends message at Day 0 → agent session created, expiration set to Day 30
3. User sends message at Day 15 → Expiration reset to Day 45
4. User sends message at Day 40 → Expiration reset to Day 70
5. Agent session remains active as long as there are regular interactions
## Logging
The TTL feature includes comprehensive logging to track state changes:
- **Expiration time updated**: Logged when TTL expiration time is set or updated
- **Deletion scheduled**: Logged when a deletion check signal is scheduled
- **Deletion check**: Logged when a deletion check operation runs
- **Session expired**: Logged when an agent session is deleted due to expiration
- **TTL rescheduled**: Logged when a deletion signal is rescheduled
These logs help monitor TTL behavior and troubleshoot any issues.
## Best practices
1. **Choose appropriate TTL values**: Balance between storage costs and user experience. Too short TTLs may delete active sessions, while too long TTLs may accumulate unnecessary state.
2. **Use per-agent TTLs**: Different agents may have different usage patterns. Configure TTLs per-agent based on expected session lifetimes.
3. **Monitor expiration logs**: Review logs to understand TTL behavior and adjust configuration as needed.
4. **Test with short TTLs**: During development, use short TTLs (e.g., minutes) to verify TTL behavior without waiting for long periods.
## Limitations
- TTL is based on wall-clock time, not activity time. The expiration timer starts from the last message timestamp.
- Deletion checks are durably scheduled operations and may have slight delays depending on system load.
- Once an agent session is deleted, its conversation history cannot be recovered.
- TTL deletion requires at least one worker to be available to process the deletion operation message.
+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() =>
+2 -3
View File
@@ -33,7 +33,6 @@ ANTHROPIC_MODEL=""
OLLAMA_ENDPOINT=""
OLLAMA_MODEL=""
# Observability
ENABLE_OTEL=true
ENABLE_INSTRUMENTATION=true
ENABLE_SENSITIVE_DATA=true
OTLP_ENDPOINT="http://localhost:4317/"
# APPLICATIONINSIGHTS_CONNECTION_STRING="..."
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317/"
+48 -1
View File
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **agent-framework-azurefunctions**: Durable Agents: platforms should use consistent entity method names (#2234)
## [1.0.0b251216] - 2025-12-16
### Added
- **agent-framework-ollama**: Ollama connector for Agent Framework (#1104)
- **agent-framework-core**: Added custom args and thread object to `ai_function` kwargs (#2769)
- **agent-framework-core**: Enable checkpointing for `WorkflowAgent` (#2774)
### Changed
- **agent-framework-core**: [BREAKING] Observability updates (#2782)
- **agent-framework-core**: Use agent description in `HandoffBuilder` auto-generated tools (#2714)
- **agent-framework-core**: Remove warnings from workflow builder when not using factories (#2808)
### Fixed
- **agent-framework-core**: Fix `WorkflowAgent` to include thread conversation history (#2774)
- **agent-framework-core**: Fix context duplication in handoff workflows when restoring from checkpoint (#2867)
- **agent-framework-core**: Fix middleware terminate flag to exit function calling loop immediately (#2868)
- **agent-framework-core**: Fix `WorkflowAgent` to emit `yield_output` as agent response (#2866)
- **agent-framework-core**: Filter framework kwargs from MCP tool invocations (#2870)
## [1.0.0b251211] - 2025-12-11
### Added
- **agent-framework-core**: Extend HITL support for all orchestration patterns (#2620)
- **agent-framework-core**: Add factory pattern to concurrent orchestration builder (#2738)
- **agent-framework-core**: Add factory pattern to sequential orchestration builder (#2710)
- **agent-framework-azure-ai**: Capture file IDs from code interpreter in streaming responses (#2741)
### Changed
- **agent-framework-azurefunctions**: Change DurableAIAgent log level from warning to debug when invoked without thread (#2736)
### Fixed
- **agent-framework-core**: Added more complete parsing for mcp tool arguments (#2756)
- **agent-framework-core**: Fix GroupChat ManagerSelectionResponse JSON Schema for OpenAI Structured Outputs (#2750)
- **samples**: Standardize OpenAI API key environment variable naming (#2629)
## [1.0.0b251209] - 2025-12-09
### Added
@@ -347,7 +392,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251209...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251216...HEAD
[1.0.0b251216]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251211...python-1.0.0b251216
[1.0.0b251211]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251209...python-1.0.0b251211
[1.0.0b251209]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251204...python-1.0.0b251209
[1.0.0b251204]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251120...python-1.0.0b251204
[1.0.0b251120]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251117...python-1.0.0b251120
@@ -5,7 +5,7 @@ import json
import re
import uuid
from collections.abc import AsyncIterable, Sequence
from typing import Any, cast
from typing import Any, Final, cast
import httpx
from a2a.client import Client, ClientConfig, ClientFactory, minimal_agent_card
@@ -38,6 +38,7 @@ from agent_framework import (
UriContent,
prepend_agent_framework_to_user_agent,
)
from agent_framework.observability import use_agent_instrumentation
__all__ = ["A2AAgent"]
@@ -58,6 +59,7 @@ def _get_uri_data(uri: str) -> str:
return match.group("base64_data")
@use_agent_instrumentation
class A2AAgent(BaseAgent):
"""Agent2Agent (A2A) protocol implementation.
@@ -69,6 +71,8 @@ class A2AAgent(BaseAgent):
Can be initialized with a URL, AgentCard, or existing A2A Client instance.
"""
AGENT_PROVIDER_NAME: Final[str] = "A2A"
def __init__(
self,
*,
+1 -1
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251209"
version = "1.0.0b251216"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ from agent_framework import (
from agent_framework._middleware import use_chat_middleware
from agent_framework._tools import use_function_invocation
from agent_framework._types import BaseContent, Contents
from agent_framework.observability import use_observability
from agent_framework.observability import use_instrumentation
from ._event_converters import AGUIEventConverter
from ._http_service import AGUIHttpService
@@ -89,7 +89,7 @@ def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseCha
@_apply_server_function_call_unwrap
@use_function_invocation
@use_observability
@use_instrumentation
@use_chat_middleware
class AGUIChatClient(BaseChatClient):
"""Chat client for communicating with AG-UI compliant servers.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b251209"
version = "1.0.0b251216"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -35,7 +35,7 @@ from agent_framework import (
)
from agent_framework._pydantic import AFBaseSettings
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.observability import use_observability
from agent_framework.observability import use_instrumentation
from anthropic import AsyncAnthropic
from anthropic.types.beta import (
BetaContentBlock,
@@ -110,7 +110,7 @@ TAnthropicClient = TypeVar("TAnthropicClient", bound="AnthropicClient")
@use_function_invocation
@use_observability
@use_instrumentation
@use_chat_middleware
class AnthropicClient(BaseChatClient):
"""Anthropic Chat client."""

Some files were not shown because too many files have changed in this diff Show More