mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6adcac2e97 | ||
|
|
8fca71e5ad | ||
|
|
2bde58f915 | ||
|
|
03a403d2fa | ||
|
|
e319707058 | ||
|
|
54f482df73 | ||
|
|
754dfb2c9d | ||
|
|
b15466f058 | ||
|
|
3a7047f6e4 | ||
|
|
2f06fe557a | ||
|
|
1dbf3fd5cf | ||
|
|
0132cf65e4 | ||
|
|
a53a3c7af8 | ||
|
|
3c322c91e7 | ||
|
|
958a488f96 | ||
|
|
11d6dcfe80 | ||
|
|
3139347526 | ||
|
|
3c379718e9 | ||
|
|
a7298757f5 | ||
|
|
0dcebc6eae | ||
|
|
e0ff153ee9 | ||
|
|
e008144187 | ||
|
|
0fc7933a92 | ||
|
|
d7434d59ce | ||
|
|
eb1117fff4 |
@@ -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()
|
||||
{
|
||||
|
||||
@@ -35,7 +35,8 @@ 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@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
@@ -44,10 +45,15 @@ jobs:
|
||||
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'
|
||||
@@ -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
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
steps:
|
||||
- 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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<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.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 -->
|
||||
@@ -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.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 -->
|
||||
|
||||
@@ -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();
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+2
-2
@@ -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()
|
||||
{
|
||||
|
||||
+7
-7
@@ -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,
|
||||
|
||||
+5
-5
@@ -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.
|
||||
|
||||
+2
-2
@@ -21,8 +21,8 @@ string model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o-min
|
||||
OpenAIClient openAIClient = new(apiKey);
|
||||
ConversationClient conversationClient = openAIClient.GetConversationClient();
|
||||
|
||||
// Create an agent directly from the OpenAIResponseClient using OpenAIResponseClientAgent
|
||||
ChatClientAgent agent = new(openAIClient.GetOpenAIResponseClient(model).AsIChatClient(), instructions: "You are a helpful assistant.", name: "ConversationAgent");
|
||||
// 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("{}")));
|
||||
|
||||
|
||||
+1
-1
@@ -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).
|
||||
|
||||
+1
-1
@@ -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
|
||||
};
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+11
-15
@@ -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("&", "&") // Must be first to avoid double-escaping
|
||||
.Replace("|", "|") // Pipe breaks Mermaid delimiter syntax
|
||||
.Replace("\"", """) // Quote character
|
||||
.Replace("<", "<") // Less than
|
||||
.Replace(">", ">") // 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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
+3
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
+55
-55
@@ -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
|
||||
|
||||
+3
-3
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
|
||||
@@ -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 | 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("&");
|
||||
mermaidContent.Should().Contain(">");
|
||||
mermaidContent.Should().Contain("<");
|
||||
}
|
||||
|
||||
[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
-3
@@ -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
-3
@@ -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
@@ -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/"
|
||||
|
||||
+28
-1
@@ -7,6 +7,32 @@ 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
|
||||
@@ -366,7 +392,8 @@ 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.0b251211...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
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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.0b251211"
|
||||
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,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b251211"
|
||||
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."""
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251211"
|
||||
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"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251211"
|
||||
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"
|
||||
|
||||
@@ -43,7 +43,7 @@ from agent_framework import (
|
||||
use_function_invocation,
|
||||
)
|
||||
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
|
||||
from agent_framework.observability import use_observability
|
||||
from agent_framework.observability import use_instrumentation
|
||||
from azure.ai.agents.aio import AgentsClient
|
||||
from azure.ai.agents.models import (
|
||||
Agent,
|
||||
@@ -107,7 +107,7 @@ TAzureAIAgentClient = TypeVar("TAzureAIAgentClient", bound="AzureAIAgentClient")
|
||||
|
||||
|
||||
@use_function_invocation
|
||||
@use_observability
|
||||
@use_instrumentation
|
||||
@use_chat_middleware
|
||||
class AzureAIAgentClient(BaseChatClient):
|
||||
"""Azure AI Agent Chat client."""
|
||||
|
||||
@@ -15,7 +15,7 @@ from agent_framework import (
|
||||
use_function_invocation,
|
||||
)
|
||||
from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError
|
||||
from agent_framework.observability import use_observability
|
||||
from agent_framework.observability import use_instrumentation
|
||||
from agent_framework.openai._responses_client import OpenAIBaseResponsesClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import (
|
||||
@@ -49,7 +49,7 @@ TAzureAIClient = TypeVar("TAzureAIClient", bound="AzureAIClient")
|
||||
|
||||
|
||||
@use_function_invocation
|
||||
@use_observability
|
||||
@use_instrumentation
|
||||
@use_chat_middleware
|
||||
class AzureAIClient(OpenAIBaseResponsesClient):
|
||||
"""Azure AI Agent client."""
|
||||
@@ -164,27 +164,94 @@ class AzureAIClient(OpenAIBaseResponsesClient):
|
||||
# Track whether we should close client connection
|
||||
self._should_close_client = should_close_client
|
||||
|
||||
async def setup_azure_ai_observability(self, enable_sensitive_data: bool | None = None) -> None:
|
||||
"""Use this method to setup tracing in your Azure AI Project.
|
||||
async def configure_azure_monitor(
|
||||
self,
|
||||
enable_sensitive_data: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Setup observability with Azure Monitor (Azure AI Foundry integration).
|
||||
|
||||
This will take the connection string from the project project_client.
|
||||
It will override any connection string that is set in the environment variables.
|
||||
It will disable any OTLP endpoint that might have been set.
|
||||
This method configures Azure Monitor for telemetry collection using the
|
||||
connection string from the Azure AI project client.
|
||||
|
||||
Args:
|
||||
enable_sensitive_data: Enable sensitive data logging (prompts, responses).
|
||||
Should only be enabled in development/test environments. Default is False.
|
||||
**kwargs: Additional arguments passed to configure_azure_monitor().
|
||||
Common options include:
|
||||
- enable_live_metrics (bool): Enable Azure Monitor Live Metrics
|
||||
- credential (TokenCredential): Azure credential for Entra ID auth
|
||||
- resource (Resource): Custom OpenTelemetry resource
|
||||
See https://learn.microsoft.com/python/api/azure-monitor-opentelemetry/azure.monitor.opentelemetry.configure_azure_monitor
|
||||
for full list of options.
|
||||
|
||||
Raises:
|
||||
ImportError: If azure-monitor-opentelemetry-exporter is not installed.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.azure import AzureAIClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
|
||||
async with (
|
||||
DefaultAzureCredential() as credential,
|
||||
AIProjectClient(
|
||||
endpoint="https://your-project.api.azureml.ms", credential=credential
|
||||
) as project_client,
|
||||
AzureAIClient(project_client=project_client) as client,
|
||||
):
|
||||
# Setup observability with defaults
|
||||
await client.configure_azure_monitor()
|
||||
|
||||
# With live metrics enabled
|
||||
await client.configure_azure_monitor(enable_live_metrics=True)
|
||||
|
||||
# With sensitive data logging (dev/test only)
|
||||
await client.configure_azure_monitor(enable_sensitive_data=True)
|
||||
|
||||
Note:
|
||||
This method retrieves the Application Insights connection string from the
|
||||
Azure AI project client automatically. You must have Application Insights
|
||||
configured in your Azure AI project for this to work.
|
||||
"""
|
||||
# Get connection string from project client
|
||||
try:
|
||||
conn_string = await self.project_client.telemetry.get_application_insights_connection_string()
|
||||
except ResourceNotFoundError:
|
||||
logger.warning(
|
||||
"No Application Insights connection string found for the Azure AI Project, "
|
||||
"please call setup_observability() manually."
|
||||
"No Application Insights connection string found for the Azure AI Project. "
|
||||
"Please ensure Application Insights is configured in your Azure AI project, "
|
||||
"or call configure_otel_providers() manually with custom exporters."
|
||||
)
|
||||
return
|
||||
from agent_framework.observability import setup_observability
|
||||
|
||||
setup_observability(
|
||||
applicationinsights_connection_string=conn_string, enable_sensitive_data=enable_sensitive_data
|
||||
# Import Azure Monitor with proper error handling
|
||||
try:
|
||||
from azure.monitor.opentelemetry import configure_azure_monitor
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"azure-monitor-opentelemetry is required for Azure Monitor integration. "
|
||||
"Install it with: pip install azure-monitor-opentelemetry"
|
||||
) from exc
|
||||
|
||||
from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation
|
||||
|
||||
# Create resource if not provided in kwargs
|
||||
if "resource" not in kwargs:
|
||||
kwargs["resource"] = create_resource()
|
||||
|
||||
# Configure Azure Monitor with connection string and kwargs
|
||||
configure_azure_monitor(
|
||||
connection_string=conn_string,
|
||||
views=create_metric_views(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Complete setup with core observability
|
||||
enable_instrumentation(enable_sensitive_data=enable_sensitive_data)
|
||||
|
||||
async def __aenter__(self) -> "Self":
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251211"
|
||||
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"
|
||||
|
||||
@@ -414,7 +414,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
request_response_format,
|
||||
)
|
||||
logger.debug("Signalling entity %s with request: %s", entity_instance_id, run_request)
|
||||
await client.signal_entity(entity_instance_id, "run_agent", run_request)
|
||||
await client.signal_entity(entity_instance_id, "run", run_request)
|
||||
|
||||
logger.debug(f"[HTTP Trigger] Signal sent to entity {session_id}")
|
||||
|
||||
@@ -495,7 +495,8 @@ class AgentFunctionApp(DFAppBase):
|
||||
"""Durable entity that manages agent execution and conversation state.
|
||||
|
||||
Operations:
|
||||
- run_agent: Execute the agent with a message
|
||||
- run: Execute the agent with a message
|
||||
- run_agent: (Deprecated) Execute the agent with a message
|
||||
- reset: Clear conversation history
|
||||
"""
|
||||
entity_handler = create_agent_entity(agent, callback)
|
||||
@@ -637,7 +638,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
logger.info("[MCP Tool] Invoking agent '%s' with query: %s", agent_name, query_preview)
|
||||
|
||||
# Signal entity to run agent
|
||||
await client.signal_entity(entity_instance_id, "run_agent", run_request)
|
||||
await client.signal_entity(entity_instance_id, "run", run_request)
|
||||
|
||||
# Poll for response (similar to HTTP handler)
|
||||
try:
|
||||
|
||||
@@ -46,7 +46,8 @@ class AgentEntity:
|
||||
- Handles tool execution
|
||||
|
||||
Operations:
|
||||
- run_agent: Execute the agent with a message
|
||||
- run: Execute the agent with a message
|
||||
- run_agent: (Deprecated) Execute the agent with a message
|
||||
- reset: Clear conversation history
|
||||
|
||||
Attributes:
|
||||
@@ -94,6 +95,22 @@ class AgentEntity:
|
||||
self,
|
||||
context: df.DurableEntityContext,
|
||||
request: RunRequest | dict[str, Any] | str,
|
||||
) -> AgentRunResponse:
|
||||
"""(Deprecated) Execute the agent with a message directly in the entity.
|
||||
|
||||
Args:
|
||||
context: Entity context
|
||||
request: RunRequest object, dict, or string message (for backward compatibility)
|
||||
|
||||
Returns:
|
||||
AgentRunResponse enriched with execution metadata.
|
||||
"""
|
||||
return await self.run(context, request)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
context: df.DurableEntityContext,
|
||||
request: RunRequest | dict[str, Any] | str,
|
||||
) -> AgentRunResponse:
|
||||
"""Execute the agent with a message directly in the entity.
|
||||
|
||||
@@ -124,7 +141,7 @@ class AgentEntity:
|
||||
state_request = DurableAgentStateRequest.from_run_request(run_request)
|
||||
self.state.data.conversation_history.append(state_request)
|
||||
|
||||
logger.debug(f"[AgentEntity.run_agent] Received Message: {state_request}")
|
||||
logger.debug(f"[AgentEntity.run] Received Message: {state_request}")
|
||||
|
||||
try:
|
||||
# Build messages from conversation history, excluding error responses
|
||||
@@ -150,7 +167,7 @@ class AgentEntity:
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[AgentEntity.run_agent] Agent invocation completed - response type: %s",
|
||||
"[AgentEntity.run] Agent invocation completed - response type: %s",
|
||||
type(agent_run_response).__name__,
|
||||
)
|
||||
|
||||
@@ -167,12 +184,12 @@ class AgentEntity:
|
||||
state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_run_response)
|
||||
self.state.data.conversation_history.append(state_response)
|
||||
|
||||
logger.debug("[AgentEntity.run_agent] AgentRunResponse stored in conversation history")
|
||||
logger.debug("[AgentEntity.run] AgentRunResponse stored in conversation history")
|
||||
|
||||
return agent_run_response
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("[AgentEntity.run_agent] Agent execution failed.")
|
||||
logger.exception("[AgentEntity.run] Agent execution failed.")
|
||||
|
||||
# Create error message
|
||||
error_message = ChatMessage(
|
||||
@@ -367,7 +384,7 @@ def create_agent_entity(
|
||||
|
||||
operation = context.operation_name
|
||||
|
||||
if operation == "run_agent":
|
||||
if operation == "run" or operation == "run_agent":
|
||||
input_data: Any = context.get_input()
|
||||
|
||||
request: str | dict[str, Any]
|
||||
@@ -377,7 +394,7 @@ def create_agent_entity(
|
||||
# Fall back to treating input as message string
|
||||
request = "" if input_data is None else str(cast(object, input_data))
|
||||
|
||||
result = await entity.run_agent(context, request)
|
||||
result = await entity.run(context, request)
|
||||
context.set_result(result.to_dict())
|
||||
|
||||
elif operation == "reset":
|
||||
|
||||
@@ -285,7 +285,7 @@ class DurableAIAgent(AgentProtocol):
|
||||
logger.debug("[DurableAIAgent] Calling entity %s with message: %s", entity_id, message_str[:100])
|
||||
|
||||
# Call the entity to get the underlying task
|
||||
entity_task = self.context.call_entity(entity_id, "run_agent", run_request.to_dict())
|
||||
entity_task = self.context.call_entity(entity_id, "run", run_request.to_dict())
|
||||
|
||||
# Wrap it in an AgentTask that will convert the result to AgentRunResponse
|
||||
agent_task = AgentTask(
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251211"
|
||||
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"
|
||||
|
||||
@@ -29,7 +29,7 @@ docker run -d -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azu
|
||||
|
||||
**Durable Task Scheduler:**
|
||||
```bash
|
||||
docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
|
||||
docker run -d -p 8080:8080 -p 8082:8082 -e DTS_USE_DYNAMIC_TASK_HUBS=true mcr.microsoft.com/dts/dts-emulator:latest
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
@@ -338,7 +338,7 @@ class TestAgentEntityOperations:
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
result = await entity.run(
|
||||
mock_context,
|
||||
{"message": "Test message", "thread_id": "test-conv-123", "correlationId": "corr-app-entity-1"},
|
||||
)
|
||||
@@ -358,7 +358,7 @@ class TestAgentEntityOperations:
|
||||
mock_context = Mock()
|
||||
|
||||
# Send first message
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-2"}
|
||||
)
|
||||
|
||||
@@ -367,7 +367,7 @@ class TestAgentEntityOperations:
|
||||
assert len(history) == 1 # Just the user message
|
||||
|
||||
# Send second message
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-2", "correlationId": "corr-app-entity-2b"}
|
||||
)
|
||||
|
||||
@@ -398,12 +398,12 @@ class TestAgentEntityOperations:
|
||||
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-3a"}
|
||||
)
|
||||
assert len(entity.state.data.conversation_history) == 2
|
||||
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-app-entity-3b"}
|
||||
)
|
||||
assert len(entity.state.data.conversation_history) == 4
|
||||
@@ -433,8 +433,36 @@ class TestAgentEntityFactory:
|
||||
|
||||
assert callable(entity_function)
|
||||
|
||||
def test_entity_function_handles_run_operation(self) -> None:
|
||||
"""Test that the entity function handles the run operation."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(
|
||||
return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response")])
|
||||
)
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
# Mock context
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"thread_id": "conv-123",
|
||||
"correlationId": "corr-app-factory-1",
|
||||
}
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
# Execute entity function
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify result was set
|
||||
assert mock_context.set_result.called
|
||||
assert mock_context.set_state.called
|
||||
result_call = mock_context.set_result.call_args[0][0]
|
||||
assert "error" not in result_call
|
||||
|
||||
def test_entity_function_handles_run_agent_operation(self) -> None:
|
||||
"""Test that the entity function handles the run_agent operation."""
|
||||
"""Test that the entity function handles the deprecated run_agent operation for backward compatibility."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(
|
||||
return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response")])
|
||||
@@ -458,6 +486,8 @@ class TestAgentEntityFactory:
|
||||
# Verify result was set
|
||||
assert mock_context.set_result.called
|
||||
assert mock_context.set_state.called
|
||||
result_call = mock_context.set_result.call_args[0][0]
|
||||
assert "error" not in result_call
|
||||
|
||||
def test_entity_function_handles_reset_operation(self) -> None:
|
||||
"""Test that the entity function handles the reset operation."""
|
||||
@@ -585,7 +615,7 @@ class TestErrorHandling:
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
result = await entity.run(
|
||||
mock_context, {"message": "Test message", "thread_id": "conv-1", "correlationId": "corr-app-error-1"}
|
||||
)
|
||||
|
||||
@@ -605,7 +635,7 @@ class TestErrorHandling:
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run_agent"
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.get_input.side_effect = Exception("Input error")
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
|
||||
@@ -108,6 +108,33 @@ class TestAgentEntityInit:
|
||||
class TestAgentEntityRunAgent:
|
||||
"""Test suite for the run_agent operation."""
|
||||
|
||||
async def test_run_executes_agent(self) -> None:
|
||||
"""Test that run executes the agent."""
|
||||
mock_agent = Mock()
|
||||
mock_response = _agent_response("Test response")
|
||||
mock_agent.run = AsyncMock(return_value=mock_response)
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run(
|
||||
mock_context, {"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-1"}
|
||||
)
|
||||
|
||||
# Verify agent.run was called
|
||||
mock_agent.run.assert_called_once()
|
||||
_, kwargs = mock_agent.run.call_args
|
||||
sent_messages: list[Any] = kwargs.get("messages")
|
||||
assert len(sent_messages) == 1
|
||||
sent_message = sent_messages[0]
|
||||
assert isinstance(sent_message, ChatMessage)
|
||||
assert getattr(sent_message, "text", None) == "Test message"
|
||||
assert getattr(sent_message.role, "value", sent_message.role) == "user"
|
||||
|
||||
# Verify result
|
||||
assert isinstance(result, AgentRunResponse)
|
||||
assert result.text == "Test response"
|
||||
|
||||
async def test_run_agent_executes_agent(self) -> None:
|
||||
"""Test that run_agent executes the agent."""
|
||||
mock_agent = Mock()
|
||||
@@ -156,7 +183,7 @@ class TestAgentEntityRunAgent:
|
||||
entity = AgentEntity(mock_agent, callback=callback)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
result = await entity.run(
|
||||
mock_context,
|
||||
{
|
||||
"message": "Tell me something",
|
||||
@@ -203,7 +230,7 @@ class TestAgentEntityRunAgent:
|
||||
entity = AgentEntity(mock_agent, callback=callback)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
result = await entity.run(
|
||||
mock_context,
|
||||
{
|
||||
"message": "Hi",
|
||||
@@ -235,7 +262,7 @@ class TestAgentEntityRunAgent:
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "User message", "thread_id": "conv-1", "correlationId": "corr-entity-2"}
|
||||
)
|
||||
|
||||
@@ -263,17 +290,17 @@ class TestAgentEntityRunAgent:
|
||||
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-3a"}
|
||||
)
|
||||
assert len(entity.state.data.conversation_history) == 2
|
||||
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-3b"}
|
||||
)
|
||||
assert len(entity.state.data.conversation_history) == 4
|
||||
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-3c"}
|
||||
)
|
||||
assert len(entity.state.data.conversation_history) == 6
|
||||
@@ -287,9 +314,7 @@ class TestAgentEntityRunAgent:
|
||||
mock_context = Mock()
|
||||
|
||||
with pytest.raises(ValueError, match="thread_id"):
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message", "thread_id": None, "correlationId": "corr-entity-5"}
|
||||
)
|
||||
await entity.run(mock_context, {"message": "Message", "thread_id": None, "correlationId": "corr-entity-5"})
|
||||
|
||||
async def test_run_agent_multiple_conversations(self) -> None:
|
||||
"""Test that run_agent maintains history across multiple messages."""
|
||||
@@ -300,13 +325,13 @@ class TestAgentEntityRunAgent:
|
||||
mock_context = Mock()
|
||||
|
||||
# Send multiple messages
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-8a"}
|
||||
)
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-8b"}
|
||||
)
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-8c"}
|
||||
)
|
||||
|
||||
@@ -374,10 +399,10 @@ class TestAgentEntityReset:
|
||||
mock_context = Mock()
|
||||
|
||||
# Have a conversation
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-10a"}
|
||||
)
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-10b"}
|
||||
)
|
||||
|
||||
@@ -413,7 +438,7 @@ class TestCreateAgentEntity:
|
||||
|
||||
# Mock context
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run_agent"
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"thread_id": "conv-123",
|
||||
@@ -576,7 +601,7 @@ class TestErrorHandling:
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
result = await entity.run(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-1"}
|
||||
)
|
||||
|
||||
@@ -595,7 +620,7 @@ class TestErrorHandling:
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
result = await entity.run(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-2"}
|
||||
)
|
||||
|
||||
@@ -614,7 +639,7 @@ class TestErrorHandling:
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
result = await entity.run(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-3"}
|
||||
)
|
||||
|
||||
@@ -631,7 +656,7 @@ class TestErrorHandling:
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run_agent"
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.get_input.side_effect = Exception("Input error")
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
@@ -651,7 +676,7 @@ class TestErrorHandling:
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
result = await entity.run(
|
||||
mock_context,
|
||||
{"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-error-4"},
|
||||
)
|
||||
@@ -674,7 +699,7 @@ class TestConversationHistory:
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-history-1"}
|
||||
)
|
||||
|
||||
@@ -694,19 +719,19 @@ class TestConversationHistory:
|
||||
|
||||
# Send multiple messages with different responses
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context,
|
||||
{"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-2a"},
|
||||
)
|
||||
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context,
|
||||
{"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-2b"},
|
||||
)
|
||||
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 3"))
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context,
|
||||
{"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-history-2c"},
|
||||
)
|
||||
@@ -729,11 +754,11 @@ class TestConversationHistory:
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context,
|
||||
{"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-3a"},
|
||||
)
|
||||
await entity.run_agent(
|
||||
await entity.run(
|
||||
mock_context,
|
||||
{"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-3b"},
|
||||
)
|
||||
@@ -766,7 +791,7 @@ class TestRunRequestSupport:
|
||||
correlation_id="corr-runreq-1",
|
||||
)
|
||||
|
||||
result = await entity.run_agent(mock_context, request)
|
||||
result = await entity.run(mock_context, request)
|
||||
|
||||
assert isinstance(result, AgentRunResponse)
|
||||
assert result.text == "Response"
|
||||
@@ -787,7 +812,7 @@ class TestRunRequestSupport:
|
||||
"correlationId": "corr-runreq-2",
|
||||
}
|
||||
|
||||
result = await entity.run_agent(mock_context, request_dict)
|
||||
result = await entity.run(mock_context, request_dict)
|
||||
|
||||
assert isinstance(result, AgentRunResponse)
|
||||
assert result.text == "Response"
|
||||
@@ -801,7 +826,7 @@ class TestRunRequestSupport:
|
||||
mock_context = Mock()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await entity.run_agent(mock_context, "Simple message")
|
||||
await entity.run(mock_context, "Simple message")
|
||||
|
||||
async def test_run_agent_stores_role_in_history(self) -> None:
|
||||
"""Test that run_agent stores the role in conversation history."""
|
||||
@@ -819,7 +844,7 @@ class TestRunRequestSupport:
|
||||
correlation_id="corr-runreq-3",
|
||||
)
|
||||
|
||||
await entity.run_agent(mock_context, request)
|
||||
await entity.run(mock_context, request)
|
||||
|
||||
# Check that system role was stored
|
||||
history = entity.state.data.conversation_history
|
||||
@@ -842,7 +867,7 @@ class TestRunRequestSupport:
|
||||
correlation_id="corr-runreq-4",
|
||||
)
|
||||
|
||||
result = await entity.run_agent(mock_context, request)
|
||||
result = await entity.run(mock_context, request)
|
||||
|
||||
assert isinstance(result, AgentRunResponse)
|
||||
assert result.text == '{"answer": 42}'
|
||||
@@ -860,7 +885,7 @@ class TestRunRequestSupport:
|
||||
message="Test", thread_id="conv-runreq-5", enable_tool_calls=False, correlation_id="corr-runreq-5"
|
||||
)
|
||||
|
||||
result = await entity.run_agent(mock_context, request)
|
||||
result = await entity.run(mock_context, request)
|
||||
|
||||
assert isinstance(result, AgentRunResponse)
|
||||
# Agent should have been called (tool disabling is framework-dependent)
|
||||
@@ -874,7 +899,7 @@ class TestRunRequestSupport:
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run_agent"
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"thread_id": "conv-789",
|
||||
|
||||
@@ -295,7 +295,7 @@ class TestDurableAIAgent:
|
||||
call_args = mock_context.call_entity.call_args
|
||||
entity_id, operation, request = call_args[0]
|
||||
|
||||
assert operation == "run_agent"
|
||||
assert operation == "run"
|
||||
assert request["message"] == "Test message"
|
||||
assert request["enable_tool_calls"] is True
|
||||
assert "correlationId" in request
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251211"
|
||||
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"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251211"
|
||||
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"
|
||||
|
||||
@@ -34,7 +34,7 @@ from ._types import (
|
||||
ToolMode,
|
||||
)
|
||||
from .exceptions import AgentExecutionException, AgentInitializationError
|
||||
from .observability import use_agent_observability
|
||||
from .observability import use_agent_instrumentation
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
@@ -516,8 +516,8 @@ class BaseAgent(SerializationMixin):
|
||||
|
||||
|
||||
@use_agent_middleware
|
||||
@use_agent_observability
|
||||
class ChatAgent(BaseAgent):
|
||||
@use_agent_instrumentation(capture_usage=False) # type: ignore[arg-type,misc]
|
||||
class ChatAgent(BaseAgent): # type: ignore[misc]
|
||||
"""A Chat Client Agent.
|
||||
|
||||
This is the primary agent implementation that uses a chat client to interact
|
||||
@@ -583,7 +583,7 @@ class ChatAgent(BaseAgent):
|
||||
print(update.text, end="")
|
||||
"""
|
||||
|
||||
AGENT_SYSTEM_NAME: ClassVar[str] = "microsoft.agent_framework"
|
||||
AGENT_PROVIDER_NAME: ClassVar[str] = "microsoft.agent_framework"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -878,6 +878,9 @@ class ChatAgent(BaseAgent):
|
||||
user=user,
|
||||
additional_properties=merged_additional_options, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# Ensure thread is forwarded in kwargs for tool invocation
|
||||
kwargs["thread"] = thread
|
||||
# Filter chat_options from kwargs to prevent duplicate keyword argument
|
||||
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "chat_options"}
|
||||
response = await self.chat_client.get_response(
|
||||
@@ -895,7 +898,12 @@ class ChatAgent(BaseAgent):
|
||||
|
||||
# Only notify the thread of new messages if the chatResponse was successful
|
||||
# to avoid inconsistent messages state in the thread.
|
||||
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
|
||||
await self._notify_thread_of_new_messages(
|
||||
thread,
|
||||
input_messages,
|
||||
response.messages,
|
||||
**{k: v for k, v in kwargs.items() if k != "thread"},
|
||||
)
|
||||
return AgentRunResponse(
|
||||
messages=response.messages,
|
||||
response_id=response.response_id,
|
||||
@@ -1017,6 +1025,8 @@ class ChatAgent(BaseAgent):
|
||||
additional_properties=merged_additional_options, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# Ensure thread is forwarded in kwargs for tool invocation
|
||||
kwargs["thread"] = thread
|
||||
# Filter chat_options from kwargs to prevent duplicate keyword argument
|
||||
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "chat_options"}
|
||||
response_updates: list[ChatResponseUpdate] = []
|
||||
@@ -1043,7 +1053,13 @@ class ChatAgent(BaseAgent):
|
||||
|
||||
response = ChatResponse.from_chat_response_updates(response_updates, output_format_type=co.response_format)
|
||||
await self._update_thread_with_type_and_conversation_id(thread, response.conversation_id)
|
||||
await self._notify_thread_of_new_messages(thread, input_messages, response.messages, **kwargs)
|
||||
|
||||
await self._notify_thread_of_new_messages(
|
||||
thread,
|
||||
input_messages,
|
||||
response.messages,
|
||||
**{k: v for k, v in kwargs.items() if k != "thread"},
|
||||
)
|
||||
|
||||
@override
|
||||
def get_new_thread(
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, Protocol, TypeVar, run
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._logging import get_logger
|
||||
from ._mcp import MCPTool
|
||||
from ._memory import AggregateContextProvider, ContextProvider
|
||||
from ._middleware import (
|
||||
ChatMiddleware,
|
||||
@@ -426,6 +425,8 @@ class BaseChatClient(SerializationMixin, ABC):
|
||||
else [tools]
|
||||
)
|
||||
for tool in tools_list: # type: ignore[reportUnknownType]
|
||||
from ._mcp import MCPTool
|
||||
|
||||
if isinstance(tool, MCPTool):
|
||||
if not tool.is_connected:
|
||||
await tool.connect()
|
||||
|
||||
@@ -685,8 +685,16 @@ class MCPTool:
|
||||
raise ToolExecutionException(
|
||||
"Tools are not loaded for this server, please set load_tools=True in the constructor."
|
||||
)
|
||||
# Filter out framework kwargs that cannot be serialized by the MCP SDK.
|
||||
# These are internal objects passed through the function invocation pipeline
|
||||
# that should not be forwarded to external MCP servers.
|
||||
filtered_kwargs = {
|
||||
k: v for k, v in kwargs.items() if k not in {"chat_options", "tools", "tool_choice", "thread"}
|
||||
}
|
||||
try:
|
||||
return _mcp_call_tool_result_to_ai_contents(await self.session.call_tool(tool_name, arguments=kwargs))
|
||||
return _mcp_call_tool_result_to_ai_contents(
|
||||
await self.session.call_tool(tool_name, arguments=filtered_kwargs)
|
||||
)
|
||||
except McpError as mcp_exc:
|
||||
raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc
|
||||
except Exception as ex:
|
||||
|
||||
@@ -6,11 +6,13 @@ from abc import ABC, abstractmethod
|
||||
from collections.abc import MutableSequence, Sequence
|
||||
from contextlib import AsyncExitStack
|
||||
from types import TracebackType
|
||||
from typing import Any, Final, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
from ._tools import ToolProtocol
|
||||
from ._types import ChatMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._tools import ToolProtocol
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
@@ -54,7 +56,7 @@ class Context:
|
||||
self,
|
||||
instructions: str | None = None,
|
||||
messages: Sequence[ChatMessage] | None = None,
|
||||
tools: Sequence[ToolProtocol] | None = None,
|
||||
tools: Sequence["ToolProtocol"] | None = None,
|
||||
):
|
||||
"""Create a new Context object.
|
||||
|
||||
@@ -65,7 +67,7 @@ class Context:
|
||||
"""
|
||||
self.instructions = instructions
|
||||
self.messages: Sequence[ChatMessage] = messages or []
|
||||
self.tools: Sequence[ToolProtocol] = tools or []
|
||||
self.tools: Sequence["ToolProtocol"] = tools or []
|
||||
|
||||
|
||||
# region ContextProvider
|
||||
@@ -247,7 +249,7 @@ class AggregateContextProvider(ContextProvider):
|
||||
contexts = await asyncio.gather(*[provider.invoking(messages, **kwargs) for provider in self.providers])
|
||||
instructions: str = ""
|
||||
return_messages: list[ChatMessage] = []
|
||||
tools: list[ToolProtocol] = []
|
||||
tools: list["ToolProtocol"] = []
|
||||
for ctx in contexts:
|
||||
if ctx.instructions:
|
||||
instructions += ctx.instructions
|
||||
|
||||
@@ -1405,13 +1405,17 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien
|
||||
call_middleware = kwargs.pop("middleware", None)
|
||||
instance_middleware = getattr(self, "middleware", None)
|
||||
|
||||
# Merge middleware from both sources, filtering for chat middleware only
|
||||
all_middleware: list[ChatMiddleware | ChatMiddlewareCallable] = _merge_and_filter_chat_middleware(
|
||||
instance_middleware, call_middleware
|
||||
)
|
||||
# Merge all middleware and separate by type
|
||||
middleware = categorize_middleware(instance_middleware, call_middleware)
|
||||
chat_middleware_list = middleware["chat"]
|
||||
function_middleware_list = middleware["function"]
|
||||
|
||||
# If no middleware, use original method
|
||||
if not all_middleware:
|
||||
# Pass function middleware to function invocation system if present
|
||||
if function_middleware_list:
|
||||
kwargs["_function_middleware_pipeline"] = FunctionMiddlewarePipeline(function_middleware_list)
|
||||
|
||||
# If no chat middleware, use original method
|
||||
if not chat_middleware_list:
|
||||
async for update in original_get_streaming_response(self, messages, **kwargs):
|
||||
yield update
|
||||
return
|
||||
@@ -1422,7 +1426,7 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien
|
||||
# Extract chat_options or create default
|
||||
chat_options = kwargs.pop("chat_options", ChatOptions())
|
||||
|
||||
pipeline = ChatMiddlewarePipeline(all_middleware) # type: ignore[arg-type]
|
||||
pipeline = ChatMiddlewarePipeline(chat_middleware_list) # type: ignore[arg-type]
|
||||
context = ChatContext(
|
||||
chat_client=self,
|
||||
messages=prepare_messages(messages),
|
||||
@@ -1536,27 +1540,40 @@ def _merge_and_filter_chat_middleware(
|
||||
return middleware["chat"] # type: ignore[return-value]
|
||||
|
||||
|
||||
def extract_and_merge_function_middleware(chat_client: Any, **kwargs: Any) -> None:
|
||||
def extract_and_merge_function_middleware(
|
||||
chat_client: Any, kwargs: dict[str, Any]
|
||||
) -> "FunctionMiddlewarePipeline | None":
|
||||
"""Extract function middleware from chat client and merge with existing pipeline in kwargs.
|
||||
|
||||
Args:
|
||||
chat_client: The chat client instance to extract middleware from.
|
||||
kwargs: Dictionary containing middleware and pipeline information.
|
||||
|
||||
Keyword Args:
|
||||
**kwargs: Dictionary containing middleware and pipeline information.
|
||||
Returns:
|
||||
A FunctionMiddlewarePipeline if function middleware is found, None otherwise.
|
||||
"""
|
||||
# Check if a pipeline was already created by use_chat_middleware
|
||||
existing_pipeline: FunctionMiddlewarePipeline | None = kwargs.get("_function_middleware_pipeline")
|
||||
|
||||
# Get middleware sources
|
||||
client_middleware = getattr(chat_client, "middleware", None) if hasattr(chat_client, "middleware") else None
|
||||
run_level_middleware = kwargs.get("middleware")
|
||||
existing_pipeline = kwargs.get("_function_middleware_pipeline")
|
||||
|
||||
# Extract existing pipeline middlewares if present
|
||||
existing_middlewares = existing_pipeline._middlewares if existing_pipeline else None
|
||||
# If we have an existing pipeline but no additional middleware sources, return it directly
|
||||
if existing_pipeline and not client_middleware and not run_level_middleware:
|
||||
return existing_pipeline
|
||||
|
||||
# If we have an existing pipeline with additional middleware, we need to merge
|
||||
# Extract existing pipeline middlewares if present - cast to list[Middleware] for type compatibility
|
||||
existing_middlewares: list[Middleware] | None = list(existing_pipeline._middlewares) if existing_pipeline else None
|
||||
|
||||
# Create combined pipeline from all sources using existing helper
|
||||
combined_pipeline = create_function_middleware_pipeline(
|
||||
client_middleware, run_level_middleware, existing_middlewares
|
||||
)
|
||||
|
||||
if combined_pipeline:
|
||||
kwargs["_function_middleware_pipeline"] = combined_pipeline
|
||||
# If we have an existing pipeline but combined is None (no new middlewares), return existing
|
||||
if existing_pipeline and combined_pipeline is None:
|
||||
return existing_pipeline
|
||||
|
||||
return combined_pipeline
|
||||
|
||||
@@ -339,11 +339,17 @@ class SerializationMixin:
|
||||
continue
|
||||
# Handle dicts containing SerializationProtocol values
|
||||
if isinstance(value, dict):
|
||||
from datetime import date, datetime, time
|
||||
|
||||
serialized_dict: dict[str, Any] = {}
|
||||
for k, v in value.items():
|
||||
if isinstance(v, SerializationProtocol):
|
||||
serialized_dict[k] = v.to_dict(exclude=exclude, exclude_none=exclude_none)
|
||||
continue
|
||||
# Convert datetime objects to strings
|
||||
if isinstance(v, (datetime, date, time)):
|
||||
serialized_dict[k] = str(v)
|
||||
continue
|
||||
# Check if the value is JSON serializable
|
||||
if is_serializable(v):
|
||||
serialized_dict[k] = v
|
||||
|
||||
@@ -627,6 +627,12 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
|
||||
self._invocation_duration_histogram = _default_histogram()
|
||||
self.type: Literal["ai_function"] = "ai_function"
|
||||
self._forward_runtime_kwargs: bool = False
|
||||
if self.func:
|
||||
sig = inspect.signature(self.func)
|
||||
for param in sig.parameters.values():
|
||||
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
||||
self._forward_runtime_kwargs = True
|
||||
break
|
||||
|
||||
@property
|
||||
def declaration_only(self) -> bool:
|
||||
@@ -915,6 +921,7 @@ def _create_input_model_from_func(func: Callable[..., Any], name: str) -> type[B
|
||||
)
|
||||
for pname, param in sig.parameters.items()
|
||||
if pname not in {"self", "cls"}
|
||||
and param.kind not in {inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD}
|
||||
}
|
||||
return create_model(f"{name}_input", **fields) # type: ignore[call-overload, no-any-return]
|
||||
|
||||
@@ -1341,6 +1348,35 @@ class FunctionInvocationConfiguration(SerializationMixin):
|
||||
self.include_detailed_errors = include_detailed_errors
|
||||
|
||||
|
||||
class FunctionExecutionResult:
|
||||
"""Internal wrapper pairing function output with loop control signals.
|
||||
|
||||
Function execution produces two distinct concerns: the semantic result (returned to
|
||||
the LLM as FunctionResultContent) and control flow decisions (whether middleware
|
||||
requested early termination). This wrapper keeps control signals out of user-facing
|
||||
content types while allowing _try_execute_function_calls to communicate both.
|
||||
|
||||
Not exposed to users.
|
||||
|
||||
Attributes:
|
||||
content: The FunctionResultContent or other content from the function execution.
|
||||
terminate: If True, the function invocation loop should exit immediately without
|
||||
another LLM call. Set when middleware sets context.terminate=True.
|
||||
"""
|
||||
|
||||
__slots__ = ("content", "terminate")
|
||||
|
||||
def __init__(self, content: "Contents", terminate: bool = False) -> None:
|
||||
"""Initialize FunctionExecutionResult.
|
||||
|
||||
Args:
|
||||
content: The content from the function execution.
|
||||
terminate: Whether to terminate the function calling loop.
|
||||
"""
|
||||
self.content = content
|
||||
self.terminate = terminate
|
||||
|
||||
|
||||
async def _auto_invoke_function(
|
||||
function_call_content: "FunctionCallContent | FunctionApprovalResponseContent",
|
||||
custom_args: dict[str, Any] | None = None,
|
||||
@@ -1350,7 +1386,7 @@ async def _auto_invoke_function(
|
||||
sequence_index: int | None = None,
|
||||
request_index: int | None = None,
|
||||
middleware_pipeline: Any = None, # Optional MiddlewarePipeline
|
||||
) -> "Contents":
|
||||
) -> "FunctionExecutionResult | Contents":
|
||||
"""Invoke a function call requested by the agent, applying middleware that is defined.
|
||||
|
||||
Args:
|
||||
@@ -1365,7 +1401,8 @@ async def _auto_invoke_function(
|
||||
middleware_pipeline: Optional middleware pipeline to apply during execution.
|
||||
|
||||
Returns:
|
||||
A FunctionResultContent containing the result or exception.
|
||||
A FunctionExecutionResult wrapping the content and terminate signal,
|
||||
or a Contents object for approval/hosted tool scenarios.
|
||||
|
||||
Raises:
|
||||
KeyError: If the requested function is not found in the tool map.
|
||||
@@ -1385,10 +1422,12 @@ async def _auto_invoke_function(
|
||||
# Tool should exist because _try_execute_function_calls validates this
|
||||
if tool is None:
|
||||
exc = KeyError(f'Function "{function_call_content.name}" not found.')
|
||||
return FunctionResultContent(
|
||||
call_id=function_call_content.call_id,
|
||||
result=f'Error: Requested function "{function_call_content.name}" not found.',
|
||||
exception=exc,
|
||||
return FunctionExecutionResult(
|
||||
content=FunctionResultContent(
|
||||
call_id=function_call_content.call_id,
|
||||
result=f'Error: Requested function "{function_call_content.name}" not found.',
|
||||
exception=exc,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Note: Unapproved tools (approved=False) are handled in _replace_approval_contents_with_results
|
||||
@@ -1413,7 +1452,9 @@ async def _auto_invoke_function(
|
||||
message = "Error: Argument parsing failed."
|
||||
if config.include_detailed_errors:
|
||||
message = f"{message} Exception: {exc}"
|
||||
return FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
|
||||
return FunctionExecutionResult(
|
||||
content=FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
|
||||
)
|
||||
|
||||
if not middleware_pipeline or (
|
||||
not hasattr(middleware_pipeline, "has_middlewares") and not middleware_pipeline.has_middlewares
|
||||
@@ -1425,15 +1466,19 @@ async def _auto_invoke_function(
|
||||
tool_call_id=function_call_content.call_id,
|
||||
**runtime_kwargs if getattr(tool, "_forward_runtime_kwargs", False) else {},
|
||||
)
|
||||
return FunctionResultContent(
|
||||
call_id=function_call_content.call_id,
|
||||
result=function_result,
|
||||
return FunctionExecutionResult(
|
||||
content=FunctionResultContent(
|
||||
call_id=function_call_content.call_id,
|
||||
result=function_result,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
message = "Error: Function failed."
|
||||
if config.include_detailed_errors:
|
||||
message = f"{message} Exception: {exc}"
|
||||
return FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
|
||||
return FunctionExecutionResult(
|
||||
content=FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
|
||||
)
|
||||
# Execute through middleware pipeline if available
|
||||
from ._middleware import FunctionInvocationContext
|
||||
|
||||
@@ -1457,15 +1502,20 @@ async def _auto_invoke_function(
|
||||
context=middleware_context,
|
||||
final_handler=final_function_handler,
|
||||
)
|
||||
return FunctionResultContent(
|
||||
call_id=function_call_content.call_id,
|
||||
result=function_result,
|
||||
return FunctionExecutionResult(
|
||||
content=FunctionResultContent(
|
||||
call_id=function_call_content.call_id,
|
||||
result=function_result,
|
||||
),
|
||||
terminate=middleware_context.terminate,
|
||||
)
|
||||
except Exception as exc:
|
||||
message = "Error: Function failed."
|
||||
if config.include_detailed_errors:
|
||||
message = f"{message} Exception: {exc}"
|
||||
return FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
|
||||
return FunctionExecutionResult(
|
||||
content=FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
|
||||
)
|
||||
|
||||
|
||||
def _get_tool_map(
|
||||
@@ -1496,7 +1546,7 @@ async def _try_execute_function_calls(
|
||||
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]",
|
||||
config: FunctionInvocationConfiguration,
|
||||
middleware_pipeline: Any = None, # Optional MiddlewarePipeline to avoid circular imports
|
||||
) -> Sequence["Contents"]:
|
||||
) -> tuple[Sequence["Contents"], bool]:
|
||||
"""Execute multiple function calls concurrently.
|
||||
|
||||
Args:
|
||||
@@ -1508,9 +1558,11 @@ async def _try_execute_function_calls(
|
||||
middleware_pipeline: Optional middleware pipeline to apply during execution.
|
||||
|
||||
Returns:
|
||||
A list of Contents containing the results of each function call,
|
||||
or the approval requests if any function requires approval,
|
||||
or the original function calls if any are declaration only.
|
||||
A tuple of:
|
||||
- A list of Contents containing the results of each function call,
|
||||
or the approval requests if any function requires approval,
|
||||
or the original function calls if any are declaration only.
|
||||
- A boolean indicating whether to terminate the function calling loop.
|
||||
"""
|
||||
from ._types import FunctionApprovalRequestContent, FunctionCallContent
|
||||
|
||||
@@ -1533,17 +1585,20 @@ async def _try_execute_function_calls(
|
||||
raise KeyError(f'Error: Requested function "{fcc.name}" not found.')
|
||||
if approval_needed:
|
||||
# approval can only be needed for Function Call Contents, not Approval Responses.
|
||||
return [
|
||||
FunctionApprovalRequestContent(id=fcc.call_id, function_call=fcc)
|
||||
for fcc in function_calls
|
||||
if isinstance(fcc, FunctionCallContent)
|
||||
]
|
||||
return (
|
||||
[
|
||||
FunctionApprovalRequestContent(id=fcc.call_id, function_call=fcc)
|
||||
for fcc in function_calls
|
||||
if isinstance(fcc, FunctionCallContent)
|
||||
],
|
||||
False,
|
||||
)
|
||||
if declaration_only_flag:
|
||||
# return the declaration only tools to the user, since we cannot execute them.
|
||||
return [fcc for fcc in function_calls if isinstance(fcc, FunctionCallContent)]
|
||||
return ([fcc for fcc in function_calls if isinstance(fcc, FunctionCallContent)], False)
|
||||
|
||||
# Run all function calls concurrently
|
||||
return await asyncio.gather(*[
|
||||
execution_results = await asyncio.gather(*[
|
||||
_auto_invoke_function(
|
||||
function_call_content=function_call, # type: ignore[arg-type]
|
||||
custom_args=custom_args,
|
||||
@@ -1556,6 +1611,20 @@ async def _try_execute_function_calls(
|
||||
for seq_idx, function_call in enumerate(function_calls)
|
||||
])
|
||||
|
||||
# Unpack FunctionExecutionResult wrappers and check for terminate signal
|
||||
contents: list[Contents] = []
|
||||
should_terminate = False
|
||||
for result in execution_results:
|
||||
if isinstance(result, FunctionExecutionResult):
|
||||
contents.append(result.content)
|
||||
if result.terminate:
|
||||
should_terminate = True
|
||||
else:
|
||||
# Direct Contents (e.g., from hosted tools)
|
||||
contents.append(result)
|
||||
|
||||
return (contents, should_terminate)
|
||||
|
||||
|
||||
def _update_conversation_id(kwargs: dict[str, Any], conversation_id: str | None) -> None:
|
||||
"""Update kwargs with conversation id.
|
||||
@@ -1688,12 +1757,8 @@ def _handle_function_calls_response(
|
||||
prepare_messages,
|
||||
)
|
||||
|
||||
# Extract and merge function middleware from chat client with kwargs pipeline
|
||||
extract_and_merge_function_middleware(self, **kwargs)
|
||||
|
||||
# Extract the middleware pipeline before calling the underlying function
|
||||
# because the underlying function may not preserve it in kwargs
|
||||
stored_middleware_pipeline = kwargs.get("_function_middleware_pipeline")
|
||||
# Extract and merge function middleware from chat client with kwargs
|
||||
stored_middleware_pipeline = extract_and_merge_function_middleware(self, kwargs)
|
||||
|
||||
# Get the config for function invocation (not part of ChatClientProtocol, hence getattr)
|
||||
config: FunctionInvocationConfiguration | None = getattr(self, "function_invocation_configuration", None)
|
||||
@@ -1719,7 +1784,7 @@ def _handle_function_calls_response(
|
||||
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
|
||||
approved_function_results: list[Contents] = []
|
||||
if approved_responses:
|
||||
approved_function_results = await _try_execute_function_calls(
|
||||
results, _ = await _try_execute_function_calls(
|
||||
custom_args=kwargs,
|
||||
attempt_idx=attempt_idx,
|
||||
function_calls=approved_responses,
|
||||
@@ -1727,6 +1792,7 @@ def _handle_function_calls_response(
|
||||
middleware_pipeline=stored_middleware_pipeline,
|
||||
config=config,
|
||||
)
|
||||
approved_function_results = list(results)
|
||||
if any(
|
||||
fcr.exception is not None
|
||||
for fcr in approved_function_results
|
||||
@@ -1744,7 +1810,9 @@ def _handle_function_calls_response(
|
||||
break
|
||||
_replace_approval_contents_with_results(prepped_messages, fcc_todo, approved_function_results)
|
||||
|
||||
response = await func(self, messages=prepped_messages, **kwargs)
|
||||
# Filter out internal framework kwargs before passing to clients.
|
||||
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
|
||||
response = await func(self, messages=prepped_messages, **filtered_kwargs)
|
||||
# if there are function calls, we will handle them first
|
||||
function_results = {
|
||||
it.call_id for it in response.messages[0].contents if isinstance(it, FunctionResultContent)
|
||||
@@ -1764,7 +1832,7 @@ def _handle_function_calls_response(
|
||||
if function_calls and tools:
|
||||
# Use the stored middleware pipeline instead of extracting from kwargs
|
||||
# because kwargs may have been modified by the underlying function
|
||||
function_call_results: list[Contents] = await _try_execute_function_calls(
|
||||
function_call_results, should_terminate = await _try_execute_function_calls(
|
||||
custom_args=kwargs,
|
||||
attempt_idx=attempt_idx,
|
||||
function_calls=function_calls,
|
||||
@@ -1789,6 +1857,17 @@ def _handle_function_calls_response(
|
||||
# the function calls are already in the response, so we just continue
|
||||
return response
|
||||
|
||||
# Check if middleware signaled to terminate the loop (context.terminate=True)
|
||||
# This allows middleware to short-circuit the tool loop without another LLM call
|
||||
if should_terminate:
|
||||
# Add tool results to response and return immediately without calling LLM again
|
||||
result_message = ChatMessage(role="tool", contents=function_call_results)
|
||||
response.messages.append(result_message)
|
||||
if fcc_messages:
|
||||
for msg in reversed(fcc_messages):
|
||||
response.messages.insert(0, msg)
|
||||
return response
|
||||
|
||||
if any(
|
||||
fcr.exception is not None
|
||||
for fcr in function_call_results
|
||||
@@ -1833,7 +1912,10 @@ def _handle_function_calls_response(
|
||||
|
||||
# Failsafe: give up on tools, ask model for plain answer
|
||||
kwargs["tool_choice"] = "none"
|
||||
response = await func(self, messages=prepped_messages, **kwargs)
|
||||
|
||||
# Filter out internal framework kwargs before passing to clients.
|
||||
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
|
||||
response = await func(self, messages=prepped_messages, **filtered_kwargs)
|
||||
if fcc_messages:
|
||||
for msg in reversed(fcc_messages):
|
||||
response.messages.insert(0, msg)
|
||||
@@ -1878,12 +1960,8 @@ def _handle_function_calls_streaming_response(
|
||||
prepare_messages,
|
||||
)
|
||||
|
||||
# Extract and merge function middleware from chat client with kwargs pipeline
|
||||
extract_and_merge_function_middleware(self, **kwargs)
|
||||
|
||||
# Extract the middleware pipeline before calling the underlying function
|
||||
# because the underlying function may not preserve it in kwargs
|
||||
stored_middleware_pipeline = kwargs.get("_function_middleware_pipeline")
|
||||
# Extract and merge function middleware from chat client with kwargs
|
||||
stored_middleware_pipeline = extract_and_merge_function_middleware(self, kwargs)
|
||||
|
||||
# Get the config for function invocation (not part of ChatClientProtocol, hence getattr)
|
||||
config: FunctionInvocationConfiguration | None = getattr(self, "function_invocation_configuration", None)
|
||||
@@ -1902,7 +1980,7 @@ def _handle_function_calls_streaming_response(
|
||||
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
|
||||
approved_function_results: list[Contents] = []
|
||||
if approved_responses:
|
||||
approved_function_results = await _try_execute_function_calls(
|
||||
results, _ = await _try_execute_function_calls(
|
||||
custom_args=kwargs,
|
||||
attempt_idx=attempt_idx,
|
||||
function_calls=approved_responses,
|
||||
@@ -1910,6 +1988,7 @@ def _handle_function_calls_streaming_response(
|
||||
middleware_pipeline=stored_middleware_pipeline,
|
||||
config=config,
|
||||
)
|
||||
approved_function_results = list(results)
|
||||
if any(
|
||||
fcr.exception is not None
|
||||
for fcr in approved_function_results
|
||||
@@ -1920,7 +1999,9 @@ def _handle_function_calls_streaming_response(
|
||||
_replace_approval_contents_with_results(prepped_messages, fcc_todo, approved_function_results)
|
||||
|
||||
all_updates: list["ChatResponseUpdate"] = []
|
||||
async for update in func(self, messages=prepped_messages, **kwargs):
|
||||
# Filter out internal framework kwargs before passing to clients.
|
||||
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
|
||||
async for update in func(self, messages=prepped_messages, **filtered_kwargs):
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
|
||||
@@ -1962,7 +2043,7 @@ def _handle_function_calls_streaming_response(
|
||||
if function_calls and tools:
|
||||
# Use the stored middleware pipeline instead of extracting from kwargs
|
||||
# because kwargs may have been modified by the underlying function
|
||||
function_call_results: list[Contents] = await _try_execute_function_calls(
|
||||
function_call_results, should_terminate = await _try_execute_function_calls(
|
||||
custom_args=kwargs,
|
||||
attempt_idx=attempt_idx,
|
||||
function_calls=function_calls,
|
||||
@@ -1991,6 +2072,13 @@ def _handle_function_calls_streaming_response(
|
||||
# the function calls were already yielded.
|
||||
return
|
||||
|
||||
# Check if middleware signaled to terminate the loop (context.terminate=True)
|
||||
# This allows middleware to short-circuit the tool loop without another LLM call
|
||||
if should_terminate:
|
||||
# Yield tool results and return immediately without calling LLM again
|
||||
yield ChatResponseUpdate(contents=function_call_results, role="tool")
|
||||
return
|
||||
|
||||
if any(
|
||||
fcr.exception is not None
|
||||
for fcr in function_call_results
|
||||
@@ -2031,7 +2119,9 @@ def _handle_function_calls_streaming_response(
|
||||
|
||||
# Failsafe: give up on tools, ask model for plain answer
|
||||
kwargs["tool_choice"] = "none"
|
||||
async for update in func(self, messages=prepped_messages, **kwargs):
|
||||
# Filter out internal framework kwargs before passing to clients.
|
||||
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
|
||||
async for update in func(self, messages=prepped_messages, **filtered_kwargs):
|
||||
yield update
|
||||
|
||||
return streaming_function_invocation_wrapper
|
||||
|
||||
@@ -1816,13 +1816,14 @@ def prepare_function_call_results(content: Contents | Any | list[Contents | Any]
|
||||
"""Prepare the values of the function call results."""
|
||||
if isinstance(content, Contents):
|
||||
# For BaseContent objects, use to_dict and serialize to JSON
|
||||
return json.dumps(content.to_dict(exclude={"raw_representation", "additional_properties"}))
|
||||
# Use default=str to handle datetime and other non-JSON-serializable objects
|
||||
return json.dumps(content.to_dict(exclude={"raw_representation", "additional_properties"}), default=str)
|
||||
|
||||
dumpable = _prepare_function_call_results_as_dumpable(content)
|
||||
if isinstance(dumpable, str):
|
||||
return dumpable
|
||||
# fallback
|
||||
return json.dumps(dumpable)
|
||||
# fallback - use default=str to handle datetime and other non-JSON-serializable objects
|
||||
return json.dumps(dumpable, default=str)
|
||||
|
||||
|
||||
# region Chat Response constants
|
||||
|
||||
@@ -13,20 +13,25 @@ from agent_framework import (
|
||||
AgentRunResponseUpdate,
|
||||
AgentThread,
|
||||
BaseAgent,
|
||||
BaseContent,
|
||||
ChatMessage,
|
||||
Contents,
|
||||
FunctionApprovalRequestContent,
|
||||
FunctionApprovalResponseContent,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
Role,
|
||||
TextContent,
|
||||
UsageDetails,
|
||||
)
|
||||
|
||||
from ..exceptions import AgentExecutionException
|
||||
from ._checkpoint import CheckpointStorage
|
||||
from ._events import (
|
||||
AgentRunUpdateEvent,
|
||||
RequestInfoEvent,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
)
|
||||
from ._message_utils import normalize_messages_input
|
||||
from ._typing_utils import is_type_compatible
|
||||
@@ -117,6 +122,8 @@ class WorkflowAgent(BaseAgent):
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
checkpoint_id: str | None = None,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentRunResponse:
|
||||
"""Get a response from the workflow agent (non-streaming).
|
||||
@@ -124,10 +131,16 @@ class WorkflowAgent(BaseAgent):
|
||||
This method collects all streaming updates and merges them into a single response.
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the workflow.
|
||||
messages: The message(s) to send to the workflow. Required for new runs,
|
||||
should be None when resuming from checkpoint.
|
||||
|
||||
Keyword Args:
|
||||
thread: The conversation thread. If None, a new thread will be created.
|
||||
checkpoint_id: ID of checkpoint to restore from. If provided, the workflow
|
||||
resumes from this checkpoint instead of starting fresh.
|
||||
checkpoint_storage: Runtime checkpoint storage. When provided with checkpoint_id,
|
||||
used to load and restore the checkpoint. When provided without checkpoint_id,
|
||||
enables checkpointing for this run.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
@@ -139,7 +152,9 @@ class WorkflowAgent(BaseAgent):
|
||||
thread = thread or self.get_new_thread()
|
||||
response_id = str(uuid.uuid4())
|
||||
|
||||
async for update in self._run_stream_impl(input_messages, response_id):
|
||||
async for update in self._run_stream_impl(
|
||||
input_messages, response_id, thread, checkpoint_id, checkpoint_storage
|
||||
):
|
||||
response_updates.append(update)
|
||||
|
||||
# Convert updates to final response.
|
||||
@@ -155,15 +170,23 @@ class WorkflowAgent(BaseAgent):
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
checkpoint_id: str | None = None,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[AgentRunResponseUpdate]:
|
||||
"""Stream response updates from the workflow agent.
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the workflow.
|
||||
messages: The message(s) to send to the workflow. Required for new runs,
|
||||
should be None when resuming from checkpoint.
|
||||
|
||||
Keyword Args:
|
||||
thread: The conversation thread. If None, a new thread will be created.
|
||||
checkpoint_id: ID of checkpoint to restore from. If provided, the workflow
|
||||
resumes from this checkpoint instead of starting fresh.
|
||||
checkpoint_storage: Runtime checkpoint storage. When provided with checkpoint_id,
|
||||
used to load and restore the checkpoint. When provided without checkpoint_id,
|
||||
enables checkpointing for this run.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Yields:
|
||||
@@ -174,7 +197,9 @@ class WorkflowAgent(BaseAgent):
|
||||
response_updates: list[AgentRunResponseUpdate] = []
|
||||
response_id = str(uuid.uuid4())
|
||||
|
||||
async for update in self._run_stream_impl(input_messages, response_id):
|
||||
async for update in self._run_stream_impl(
|
||||
input_messages, response_id, thread, checkpoint_id, checkpoint_storage
|
||||
):
|
||||
response_updates.append(update)
|
||||
yield update
|
||||
|
||||
@@ -188,12 +213,18 @@ class WorkflowAgent(BaseAgent):
|
||||
self,
|
||||
input_messages: list[ChatMessage],
|
||||
response_id: str,
|
||||
thread: AgentThread,
|
||||
checkpoint_id: str | None = None,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
) -> AsyncIterable[AgentRunResponseUpdate]:
|
||||
"""Internal implementation of streaming execution.
|
||||
|
||||
Args:
|
||||
input_messages: Normalized input messages to process.
|
||||
response_id: The unique response ID for this workflow execution.
|
||||
thread: The conversation thread containing message history.
|
||||
checkpoint_id: ID of checkpoint to restore from.
|
||||
checkpoint_storage: Runtime checkpoint storage.
|
||||
|
||||
Yields:
|
||||
AgentRunResponseUpdate objects representing the workflow execution progress.
|
||||
@@ -217,10 +248,27 @@ class WorkflowAgent(BaseAgent):
|
||||
# and we will let the workflow to handle this -- the agent does not
|
||||
# have an opinion on this.
|
||||
event_stream = self.workflow.send_responses_streaming(function_responses)
|
||||
elif checkpoint_id is not None:
|
||||
# Resume from checkpoint - don't prepend thread history since workflow state
|
||||
# is being restored from the checkpoint
|
||||
event_stream = self.workflow.run_stream(
|
||||
message=None,
|
||||
checkpoint_id=checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
)
|
||||
else:
|
||||
# Execute workflow with streaming (initial run or no function responses)
|
||||
# Pass the new input messages directly to the workflow
|
||||
event_stream = self.workflow.run_stream(input_messages)
|
||||
# Build the complete conversation by prepending thread history to input messages
|
||||
conversation_messages: list[ChatMessage] = []
|
||||
if thread.message_store:
|
||||
history = await thread.message_store.list_messages()
|
||||
if history:
|
||||
conversation_messages.extend(history)
|
||||
conversation_messages.extend(input_messages)
|
||||
event_stream = self.workflow.run_stream(
|
||||
message=conversation_messages,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
)
|
||||
|
||||
# Process events from the stream
|
||||
async for event in event_stream:
|
||||
@@ -236,9 +284,8 @@ class WorkflowAgent(BaseAgent):
|
||||
) -> AgentRunResponseUpdate | None:
|
||||
"""Convert a workflow event to an AgentRunResponseUpdate.
|
||||
|
||||
Only AgentRunUpdateEvent and RequestInfoEvent are processed.
|
||||
Other workflow events are ignored as they are workflow-internal and should
|
||||
have corresponding AgentRunUpdateEvent emissions if relevant to agent consumers.
|
||||
AgentRunUpdateEvent, RequestInfoEvent, and WorkflowOutputEvent are processed.
|
||||
Other workflow events are ignored as they are workflow-internal.
|
||||
"""
|
||||
match event:
|
||||
case AgentRunUpdateEvent(data=update):
|
||||
@@ -247,6 +294,42 @@ class WorkflowAgent(BaseAgent):
|
||||
return update
|
||||
return None
|
||||
|
||||
case WorkflowOutputEvent(data=data, source_executor_id=source_executor_id):
|
||||
# Convert workflow output to an agent response update.
|
||||
# Handle different data types appropriately.
|
||||
if isinstance(data, AgentRunResponseUpdate):
|
||||
# Already an update, pass through
|
||||
return data
|
||||
if isinstance(data, ChatMessage):
|
||||
# Convert ChatMessage to update
|
||||
return AgentRunResponseUpdate(
|
||||
contents=list(data.contents),
|
||||
role=data.role,
|
||||
author_name=data.author_name or source_executor_id,
|
||||
response_id=response_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=data,
|
||||
)
|
||||
# Determine contents based on data type
|
||||
if isinstance(data, BaseContent):
|
||||
# Already a content type (TextContent, ImageContent, etc.)
|
||||
contents: list[Contents] = [cast(Contents, data)]
|
||||
elif isinstance(data, str):
|
||||
contents = [TextContent(text=data)]
|
||||
else:
|
||||
# Fallback: convert to string representation
|
||||
contents = [TextContent(text=str(data))]
|
||||
return AgentRunResponseUpdate(
|
||||
contents=contents,
|
||||
role=Role.ASSISTANT,
|
||||
author_name=source_executor_id,
|
||||
response_id=response_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=data,
|
||||
)
|
||||
|
||||
case RequestInfoEvent(request_id=request_id):
|
||||
# Store the pending request for later correlation
|
||||
self.pending_requests[request_id] = event
|
||||
|
||||
@@ -11,6 +11,7 @@ from .._agents import AgentProtocol, ChatAgent
|
||||
from .._threads import AgentThread
|
||||
from .._types import AgentRunResponse, AgentRunResponseUpdate, ChatMessage
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from ._const import WORKFLOW_RUN_KWARGS_KEY
|
||||
from ._conversation_state import encode_chat_messages
|
||||
from ._events import (
|
||||
AgentRunEvent,
|
||||
@@ -105,6 +106,11 @@ class AgentExecutor(Executor):
|
||||
return [AgentRunResponse]
|
||||
return []
|
||||
|
||||
@property
|
||||
def description(self) -> str | None:
|
||||
"""Get the description of the underlying agent."""
|
||||
return self._agent.description
|
||||
|
||||
@handler
|
||||
async def run(
|
||||
self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse]
|
||||
@@ -304,9 +310,12 @@ class AgentExecutor(Executor):
|
||||
Returns:
|
||||
The complete AgentRunResponse, or None if waiting for user input.
|
||||
"""
|
||||
run_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
|
||||
|
||||
response = await self._agent.run(
|
||||
self._cache,
|
||||
thread=self._agent_thread,
|
||||
**run_kwargs,
|
||||
)
|
||||
await ctx.add_event(AgentRunEvent(self.id, response))
|
||||
|
||||
@@ -328,11 +337,14 @@ class AgentExecutor(Executor):
|
||||
Returns:
|
||||
The complete AgentRunResponse, or None if waiting for user input.
|
||||
"""
|
||||
run_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
|
||||
|
||||
updates: list[AgentRunResponseUpdate] = []
|
||||
user_input_requests: list[FunctionApprovalRequestContent] = []
|
||||
async for update in self._agent.run_stream(
|
||||
self._cache,
|
||||
thread=self._agent_thread,
|
||||
**run_kwargs,
|
||||
):
|
||||
updates.append(update)
|
||||
await ctx.add_event(AgentRunUpdateEvent(self.id, update))
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
@@ -30,7 +29,8 @@ parallel workflow with:
|
||||
- a default aggregator that combines all agent conversations and completes the workflow
|
||||
|
||||
Notes:
|
||||
- Participants should be AgentProtocol instances or Executors.
|
||||
- Participants can be provided as AgentProtocol or Executor instances via `.participants()`,
|
||||
or as factories returning AgentProtocol or Executor via `.register_participants()`.
|
||||
- A custom aggregator can be provided as:
|
||||
- an Executor instance (it should handle list[AgentExecutorResponse],
|
||||
yield output), or
|
||||
@@ -396,7 +396,7 @@ class ConcurrentBuilder:
|
||||
| Callable[[list[AgentExecutorResponse]], Any]
|
||||
| Callable[[list[AgentExecutorResponse], WorkflowContext[Never, Any]], Any],
|
||||
) -> "ConcurrentBuilder":
|
||||
r"""Override the default aggregator with an executor, an executor factory, or a callback.
|
||||
r"""Override the default aggregator with an executor or a callback.
|
||||
|
||||
- Executor: must handle `list[AgentExecutorResponse]` and yield output using `ctx.yield_output(...)`
|
||||
- Callback: sync or async callable with one of the signatures:
|
||||
@@ -521,52 +521,30 @@ class ConcurrentBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
builder = WorkflowBuilder()
|
||||
participants: list[Executor | AgentProtocol] = []
|
||||
if self._participant_factories:
|
||||
# Register executors/agents to avoid warnings from the workflow builder
|
||||
# if factories are provided instead of direct instances. This doesn't
|
||||
# break the factory pattern since the concurrent builder still creates
|
||||
# new instances per workflow build.
|
||||
factory_names: list[str] = []
|
||||
# Resolve the participant factories now. This doesn't break the factory pattern
|
||||
# since the Concurrent builder still creates new instances per workflow build.
|
||||
for factory in self._participant_factories:
|
||||
factory_name = uuid.uuid4().hex
|
||||
factory_names.append(factory_name)
|
||||
instance = factory()
|
||||
if isinstance(instance, Executor):
|
||||
builder.register_executor(lambda executor=instance: executor, name=factory_name) # type: ignore[misc]
|
||||
else:
|
||||
builder.register_agent(lambda agent=instance: agent, name=factory_name) # type: ignore[misc]
|
||||
# Register the dispatcher and the aggregator
|
||||
builder.register_executor(lambda: dispatcher, name="dispatcher")
|
||||
builder.register_executor(lambda: aggregator, name="aggregator")
|
||||
|
||||
builder.set_start_executor("dispatcher")
|
||||
builder.add_fan_out_edges("dispatcher", factory_names)
|
||||
if self._request_info_enabled:
|
||||
# Insert interceptor between fan-in and aggregator
|
||||
# participants -> fan-in -> interceptor -> aggregator
|
||||
builder.register_executor(
|
||||
lambda: RequestInfoInterceptor(executor_id="request_info"),
|
||||
name="request_info_interceptor",
|
||||
)
|
||||
builder.add_fan_in_edges(factory_names, "request_info_interceptor")
|
||||
builder.add_edge("request_info_interceptor", "aggregator")
|
||||
else:
|
||||
# Direct fan-in to aggregator
|
||||
builder.add_fan_in_edges(factory_names, "aggregator")
|
||||
p = factory()
|
||||
participants.append(p)
|
||||
else:
|
||||
builder.set_start_executor(dispatcher)
|
||||
builder.add_fan_out_edges(dispatcher, self._participants)
|
||||
participants = self._participants
|
||||
|
||||
builder = WorkflowBuilder()
|
||||
builder.set_start_executor(dispatcher)
|
||||
builder.add_fan_out_edges(dispatcher, participants)
|
||||
|
||||
if self._request_info_enabled:
|
||||
# Insert interceptor between fan-in and aggregator
|
||||
# participants -> fan-in -> interceptor -> aggregator
|
||||
request_info_interceptor = RequestInfoInterceptor(executor_id="request_info")
|
||||
builder.add_fan_in_edges(participants, request_info_interceptor)
|
||||
builder.add_edge(request_info_interceptor, aggregator)
|
||||
else:
|
||||
# Direct fan-in to aggregator
|
||||
builder.add_fan_in_edges(participants, aggregator)
|
||||
|
||||
if self._request_info_enabled:
|
||||
# Insert interceptor between fan-in and aggregator
|
||||
# participants -> fan-in -> interceptor -> aggregator
|
||||
request_info_interceptor = RequestInfoInterceptor(executor_id="request_info")
|
||||
builder.add_fan_in_edges(self._participants, request_info_interceptor)
|
||||
builder.add_edge(request_info_interceptor, aggregator)
|
||||
else:
|
||||
# Direct fan-in to aggregator
|
||||
builder.add_fan_in_edges(self._participants, aggregator)
|
||||
if self._checkpoint_storage is not None:
|
||||
builder = builder.with_checkpointing(self._checkpoint_storage)
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ EXECUTOR_STATE_KEY = "_executor_state"
|
||||
# Source identifier for internal workflow messages.
|
||||
INTERNAL_SOURCE_PREFIX = "internal"
|
||||
|
||||
# SharedState key for storing run kwargs that should be passed to agent invocations.
|
||||
# Used by all orchestration patterns (Sequential, Concurrent, GroupChat, Handoff, Magentic)
|
||||
# to pass kwargs from workflow.run_stream() through to agent.run_stream() and @ai_function tools.
|
||||
WORKFLOW_RUN_KWARGS_KEY = "_workflow_run_kwargs"
|
||||
|
||||
|
||||
def INTERNAL_SOURCE_ID(executor_id: str) -> str:
|
||||
"""Generate an internal source ID for a given executor."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@ from agent_framework import (
|
||||
|
||||
from ._base_group_chat_orchestrator import BaseGroupChatOrchestrator
|
||||
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
|
||||
from ._const import EXECUTOR_STATE_KEY
|
||||
from ._const import EXECUTOR_STATE_KEY, WORKFLOW_RUN_KWARGS_KEY
|
||||
from ._events import AgentRunUpdateEvent, WorkflowEvent
|
||||
from ._executor import Executor, handler
|
||||
from ._group_chat import (
|
||||
@@ -286,12 +286,14 @@ class _MagenticStartMessage(DictConvertible):
|
||||
"""Internal: A message to start a magentic workflow."""
|
||||
|
||||
messages: list[ChatMessage] = field(default_factory=_new_chat_message_list)
|
||||
run_kwargs: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
messages: str | ChatMessage | Sequence[str] | Sequence[ChatMessage] | None = None,
|
||||
*,
|
||||
task: ChatMessage | None = None,
|
||||
run_kwargs: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
normalized = normalize_messages_input(messages)
|
||||
if task is not None:
|
||||
@@ -299,6 +301,7 @@ class _MagenticStartMessage(DictConvertible):
|
||||
if not normalized:
|
||||
raise ValueError("MagenticStartMessage requires at least one message input.")
|
||||
self.messages: list[ChatMessage] = normalized
|
||||
self.run_kwargs: dict[str, Any] = run_kwargs or {}
|
||||
|
||||
@property
|
||||
def task(self) -> ChatMessage:
|
||||
@@ -1179,6 +1182,10 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
return
|
||||
logger.info("Magentic Orchestrator: Received start message")
|
||||
|
||||
# Store run_kwargs in SharedState so agent executors can access them
|
||||
# Always store (even empty dict) so retrieval is deterministic
|
||||
await context.set_shared_state(WORKFLOW_RUN_KWARGS_KEY, message.run_kwargs or {})
|
||||
|
||||
self._context = MagenticContext(
|
||||
task=message.task,
|
||||
participant_descriptions=self._participants,
|
||||
@@ -2004,10 +2011,12 @@ class MagenticAgentExecutor(Executor):
|
||||
"""
|
||||
logger.debug(f"Agent {self._agent_id}: Running with {len(self._chat_history)} messages")
|
||||
|
||||
run_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
|
||||
|
||||
updates: list[AgentRunResponseUpdate] = []
|
||||
# The wrapped participant is guaranteed to be an BaseAgent when this is called.
|
||||
agent = cast("AgentProtocol", self._agent)
|
||||
async for update in agent.run_stream(messages=self._chat_history): # type: ignore[attr-defined]
|
||||
async for update in agent.run_stream(messages=self._chat_history, **run_kwargs): # type: ignore[attr-defined]
|
||||
updates.append(update)
|
||||
await self._emit_agent_delta_event(ctx, update)
|
||||
|
||||
@@ -2604,38 +2613,48 @@ class MagenticWorkflow:
|
||||
"""Access the underlying workflow."""
|
||||
return self._workflow
|
||||
|
||||
async def run_streaming_with_string(self, task_text: str) -> AsyncIterable[WorkflowEvent]:
|
||||
async def run_streaming_with_string(self, task_text: str, **kwargs: Any) -> AsyncIterable[WorkflowEvent]:
|
||||
"""Run the workflow with a task string.
|
||||
|
||||
Args:
|
||||
task_text: The task description as a string.
|
||||
**kwargs: Additional keyword arguments to pass through to agent invocations.
|
||||
These kwargs will be available in @ai_function tools via **kwargs.
|
||||
|
||||
Yields:
|
||||
WorkflowEvent: The events generated during the workflow execution.
|
||||
"""
|
||||
start_message = _MagenticStartMessage.from_string(task_text)
|
||||
start_message.run_kwargs = kwargs
|
||||
async for event in self._workflow.run_stream(start_message):
|
||||
yield event
|
||||
|
||||
async def run_streaming_with_message(self, task_message: ChatMessage) -> AsyncIterable[WorkflowEvent]:
|
||||
async def run_streaming_with_message(
|
||||
self, task_message: ChatMessage, **kwargs: Any
|
||||
) -> AsyncIterable[WorkflowEvent]:
|
||||
"""Run the workflow with a ChatMessage.
|
||||
|
||||
Args:
|
||||
task_message: The task as a ChatMessage.
|
||||
**kwargs: Additional keyword arguments to pass through to agent invocations.
|
||||
These kwargs will be available in @ai_function tools via **kwargs.
|
||||
|
||||
Yields:
|
||||
WorkflowEvent: The events generated during the workflow execution.
|
||||
"""
|
||||
start_message = _MagenticStartMessage(task_message)
|
||||
start_message = _MagenticStartMessage(task_message, run_kwargs=kwargs)
|
||||
async for event in self._workflow.run_stream(start_message):
|
||||
yield event
|
||||
|
||||
async def run_stream(self, message: Any | None = None) -> AsyncIterable[WorkflowEvent]:
|
||||
async def run_stream(self, message: Any | None = None, **kwargs: Any) -> AsyncIterable[WorkflowEvent]:
|
||||
"""Run the workflow with either a message object or the preset task string.
|
||||
|
||||
Args:
|
||||
message: The message to send. If None and task_text was provided during construction,
|
||||
uses the preset task string.
|
||||
**kwargs: Additional keyword arguments to pass through to agent invocations.
|
||||
These kwargs will be available in @ai_function tools via **kwargs.
|
||||
Example: workflow.run_stream("task", user_id="123", custom_data={...})
|
||||
|
||||
Yields:
|
||||
WorkflowEvent: The events generated during the workflow execution.
|
||||
@@ -2643,13 +2662,19 @@ class MagenticWorkflow:
|
||||
if message is None:
|
||||
if self._task_text is None:
|
||||
raise ValueError("No message provided and no preset task text available")
|
||||
message = _MagenticStartMessage.from_string(self._task_text)
|
||||
start_message = _MagenticStartMessage.from_string(self._task_text)
|
||||
elif isinstance(message, str):
|
||||
message = _MagenticStartMessage.from_string(message)
|
||||
start_message = _MagenticStartMessage.from_string(message)
|
||||
elif isinstance(message, (ChatMessage, list)):
|
||||
message = _MagenticStartMessage(message) # type: ignore[arg-type]
|
||||
start_message = _MagenticStartMessage(message) # type: ignore[arg-type]
|
||||
else:
|
||||
start_message = message
|
||||
|
||||
async for event in self._workflow.run_stream(message):
|
||||
# Attach kwargs to the start message
|
||||
if isinstance(start_message, _MagenticStartMessage):
|
||||
start_message.run_kwargs = kwargs
|
||||
|
||||
async for event in self._workflow.run_stream(start_message):
|
||||
yield event
|
||||
|
||||
async def _validate_checkpoint_participants(
|
||||
@@ -2730,46 +2755,49 @@ class MagenticWorkflow:
|
||||
f"Missing names: {missing}; unexpected names: {unexpected}."
|
||||
)
|
||||
|
||||
async def run_with_string(self, task_text: str) -> WorkflowRunResult:
|
||||
async def run_with_string(self, task_text: str, **kwargs: Any) -> WorkflowRunResult:
|
||||
"""Run the workflow with a task string and return all events.
|
||||
|
||||
Args:
|
||||
task_text: The task description as a string.
|
||||
**kwargs: Additional keyword arguments to pass through to agent invocations.
|
||||
|
||||
Returns:
|
||||
WorkflowRunResult: All events generated during the workflow execution.
|
||||
"""
|
||||
events: list[WorkflowEvent] = []
|
||||
async for event in self.run_streaming_with_string(task_text):
|
||||
async for event in self.run_streaming_with_string(task_text, **kwargs):
|
||||
events.append(event)
|
||||
return WorkflowRunResult(events)
|
||||
|
||||
async def run_with_message(self, task_message: ChatMessage) -> WorkflowRunResult:
|
||||
async def run_with_message(self, task_message: ChatMessage, **kwargs: Any) -> WorkflowRunResult:
|
||||
"""Run the workflow with a ChatMessage and return all events.
|
||||
|
||||
Args:
|
||||
task_message: The task as a ChatMessage.
|
||||
**kwargs: Additional keyword arguments to pass through to agent invocations.
|
||||
|
||||
Returns:
|
||||
WorkflowRunResult: All events generated during the workflow execution.
|
||||
"""
|
||||
events: list[WorkflowEvent] = []
|
||||
async for event in self.run_streaming_with_message(task_message):
|
||||
async for event in self.run_streaming_with_message(task_message, **kwargs):
|
||||
events.append(event)
|
||||
return WorkflowRunResult(events)
|
||||
|
||||
async def run(self, message: Any | None = None) -> WorkflowRunResult:
|
||||
async def run(self, message: Any | None = None, **kwargs: Any) -> WorkflowRunResult:
|
||||
"""Run the workflow and return all events.
|
||||
|
||||
Args:
|
||||
message: The message to send. If None and task_text was provided during construction,
|
||||
uses the preset task string.
|
||||
**kwargs: Additional keyword arguments to pass through to agent invocations.
|
||||
|
||||
Returns:
|
||||
WorkflowRunResult: All events generated during the workflow execution.
|
||||
"""
|
||||
events: list[WorkflowEvent] = []
|
||||
async for event in self.run_stream(message):
|
||||
async for event in self.run_stream(message, **kwargs):
|
||||
events.append(event)
|
||||
return WorkflowRunResult(events)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user