mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16230d3b20 | ||
|
|
8d53b20026 | ||
|
|
c376868ec9 | ||
|
|
8bb9927f3c | ||
|
|
194486c4cc | ||
|
|
0413f4220a | ||
|
|
67e83042cf | ||
|
|
5da1c2fd4c | ||
|
|
989b6ebe71 | ||
|
|
3481914981 | ||
|
|
4c6a5d4aa1 | ||
|
|
191779ce80 | ||
|
|
638fbb5f03 | ||
|
|
523305ac62 | ||
|
|
3f4eeb00be | ||
|
|
b378ca75d1 |
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
outputs:
|
||||
dotnetChanges: ${{ steps.filter.outputs.dotnet}}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: ${{ matrix.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
# check out the latest version of the code
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
outputs:
|
||||
pythonChanges: ${{ steps.filter.outputs.python}}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
outputs:
|
||||
pythonChanges: ${{ steps.filter.outputs.python}}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Download coverage report
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: "3.10"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
# Save the PR number to a file since the workflow_run event
|
||||
# in the coverage report workflow does not have access to it
|
||||
- name: Save PR number
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="1.0.0-beta.4" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.8" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.17.0" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
|
||||
<!-- Google Gemini -->
|
||||
<PackageVersion Include="Google.GenAI" Version="0.6.0" />
|
||||
@@ -33,18 +33,18 @@
|
||||
<!-- Newtonsoft.Json -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- System.* -->
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.8.1" />
|
||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.0" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.1" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.1" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.1" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
@@ -61,10 +61,10 @@
|
||||
<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.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.0.1" />
|
||||
<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.0.1-preview.1.25571.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
|
||||
<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" />
|
||||
@@ -72,11 +72,11 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
|
||||
@@ -102,7 +102,7 @@
|
||||
<PackageVersion Include="ModelContextProtocol" Version="0.4.0-preview.3" />
|
||||
<!-- Inference SDKs -->
|
||||
<PackageVersion Include="Anthropic.SDK" Version="5.8.0" />
|
||||
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.7" />
|
||||
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.11" />
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.7.0" />
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/Agent_OpenAI_Step03_CreateFromChatClient.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Agent_OpenAI_Step05_Conversation.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/Purview/" />
|
||||
<Folder Name="/Samples/Purview/AgentWithPurview/">
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to maintain conversation state using the OpenAIResponseClientAgent
|
||||
// and AgentThread. By passing the same thread to multiple agent invocations, the agent
|
||||
// automatically maintains the conversation history, allowing the AI model to understand
|
||||
// context from previous exchanges.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Chat;
|
||||
using OpenAI.Conversations;
|
||||
|
||||
string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set.");
|
||||
string model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o-mini";
|
||||
|
||||
// Create a ConversationClient directly from OpenAIClient
|
||||
OpenAIClient openAIClient = new(apiKey);
|
||||
ConversationClient conversationClient = openAIClient.GetConversationClient();
|
||||
|
||||
// Create an agent directly from the OpenAIResponseClient using OpenAIResponseClientAgent
|
||||
ChatClientAgent agent = new(openAIClient.GetOpenAIResponseClient(model).AsIChatClient(), instructions: "You are a helpful assistant.", name: "ConversationAgent");
|
||||
|
||||
ClientResult createConversationResult = await conversationClient.CreateConversationAsync(BinaryContent.Create(BinaryData.FromString("{}")));
|
||||
|
||||
using JsonDocument createConversationResultAsJson = JsonDocument.Parse(createConversationResult.GetRawResponse().Content.ToString());
|
||||
string conversationId = createConversationResultAsJson.RootElement.GetProperty("id"u8)!.GetString()!;
|
||||
|
||||
// Create a thread for the conversation - this enables conversation state management for subsequent turns
|
||||
AgentThread thread = agent.GetNewThread(conversationId);
|
||||
|
||||
Console.WriteLine("=== Multi-turn Conversation Demo ===\n");
|
||||
|
||||
// First turn: Ask about a topic
|
||||
Console.WriteLine("User: What is the capital of France?");
|
||||
UserChatMessage firstMessage = new("What is the capital of France?");
|
||||
|
||||
// After this call, the conversation state associated in the options is stored in 'thread' and used in subsequent calls
|
||||
ChatCompletion firstResponse = await agent.RunAsync([firstMessage], thread);
|
||||
Console.WriteLine($"Assistant: {firstResponse.Content.Last().Text}\n");
|
||||
|
||||
// Second turn: Follow-up question that relies on conversation context
|
||||
Console.WriteLine("User: What famous landmarks are located there?");
|
||||
UserChatMessage secondMessage = new("What famous landmarks are located there?");
|
||||
|
||||
ChatCompletion secondResponse = await agent.RunAsync([secondMessage], thread);
|
||||
Console.WriteLine($"Assistant: {secondResponse.Content.Last().Text}\n");
|
||||
|
||||
// Third turn: Another follow-up that demonstrates context continuity
|
||||
Console.WriteLine("User: How tall is the most famous one?");
|
||||
UserChatMessage thirdMessage = new("How tall is the most famous one?");
|
||||
|
||||
ChatCompletion thirdResponse = await agent.RunAsync([thirdMessage], thread);
|
||||
Console.WriteLine($"Assistant: {thirdResponse.Content.Last().Text}\n");
|
||||
|
||||
Console.WriteLine("=== End of Conversation ===");
|
||||
|
||||
// Show full conversation history
|
||||
Console.WriteLine("Full Conversation History:");
|
||||
ClientResult getConversationResult = await conversationClient.GetConversationAsync(conversationId);
|
||||
|
||||
Console.WriteLine("Conversation created.");
|
||||
Console.WriteLine($" Conversation ID: {conversationId}");
|
||||
Console.WriteLine();
|
||||
|
||||
CollectionResult getConversationItemsResults = conversationClient.GetConversationItems(conversationId);
|
||||
foreach (ClientResult result in getConversationItemsResults.GetRawPages())
|
||||
{
|
||||
Console.WriteLine("Message contents retrieved. Order is most recent first by default.");
|
||||
using JsonDocument getConversationItemsResultAsJson = JsonDocument.Parse(result.GetRawResponse().Content.ToString());
|
||||
foreach (JsonElement element in getConversationItemsResultAsJson.RootElement.GetProperty("data").EnumerateArray())
|
||||
{
|
||||
string messageId = element.GetProperty("id"u8).ToString();
|
||||
string messageRole = element.GetProperty("role"u8).ToString();
|
||||
Console.WriteLine($" Message ID: {messageId}");
|
||||
Console.WriteLine($" Message Role: {messageRole}");
|
||||
|
||||
foreach (var content in element.GetProperty("content").EnumerateArray())
|
||||
{
|
||||
string messageContentText = content.GetProperty("text"u8).ToString();
|
||||
Console.WriteLine($" Message Text: {messageContentText}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
ClientResult deleteConversationResult = conversationClient.DeleteConversation(conversationId);
|
||||
using JsonDocument deleteConversationResultAsJson = JsonDocument.Parse(deleteConversationResult.GetRawResponse().Content.ToString());
|
||||
bool deleted = deleteConversationResultAsJson.RootElement
|
||||
.GetProperty("deleted"u8)
|
||||
.GetBoolean();
|
||||
|
||||
Console.WriteLine("Conversation deleted.");
|
||||
Console.WriteLine($" Deleted: {deleted}");
|
||||
Console.WriteLine();
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
# Managing Conversation State with OpenAI
|
||||
|
||||
This sample demonstrates how to maintain conversation state across multiple turns using the Agent Framework with OpenAI's Conversation API.
|
||||
|
||||
## What This Sample Shows
|
||||
|
||||
- **Conversation State Management**: Shows how to use `ConversationClient` and `AgentThread` to maintain conversation context across multiple agent invocations
|
||||
- **Multi-turn Conversations**: Demonstrates follow-up questions that rely on context from previous messages in the conversation
|
||||
- **Server-Side Storage**: Uses OpenAI's Conversation API to manage conversation history server-side, allowing the model to access previous messages without resending them
|
||||
- **Conversation Lifecycle**: Demonstrates creating, retrieving, and deleting conversations
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### ConversationClient for Server-Side Storage
|
||||
|
||||
The `ConversationClient` manages conversations on OpenAI's servers:
|
||||
|
||||
```csharp
|
||||
// Create a ConversationClient from OpenAIClient
|
||||
OpenAIClient openAIClient = new(apiKey);
|
||||
ConversationClient conversationClient = openAIClient.GetConversationClient();
|
||||
|
||||
// Create a new conversation
|
||||
ClientResult createConversationResult = await conversationClient.CreateConversationAsync(BinaryContent.Create(BinaryData.FromString("{}")));
|
||||
```
|
||||
|
||||
### AgentThread for Conversation State
|
||||
|
||||
The `AgentThread` works with `ChatClientAgentRunOptions` to link the agent to a server-side conversation:
|
||||
|
||||
```csharp
|
||||
// Set up agent run options with the conversation ID
|
||||
ChatClientAgentRunOptions agentRunOptions = new() { ChatOptions = new ChatOptions() { ConversationId = conversationId } };
|
||||
|
||||
// Create a thread for the conversation
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
// First call links the thread to the conversation
|
||||
ChatCompletion firstResponse = await agent.RunAsync([firstMessage], thread, agentRunOptions);
|
||||
|
||||
// Subsequent calls use the thread without needing to pass options again
|
||||
ChatCompletion secondResponse = await agent.RunAsync([secondMessage], thread);
|
||||
```
|
||||
|
||||
### Retrieving Conversation History
|
||||
|
||||
You can retrieve the full conversation history from the server:
|
||||
|
||||
```csharp
|
||||
CollectionResult getConversationItemsResults = conversationClient.GetConversationItems(conversationId);
|
||||
foreach (ClientResult result in getConversationItemsResults.GetRawPages())
|
||||
{
|
||||
// Process conversation items
|
||||
}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Create an OpenAI Client**: Initialize an `OpenAIClient` with your API key
|
||||
2. **Create a Conversation**: Use `ConversationClient` to create a server-side conversation
|
||||
3. **Create an Agent**: Initialize an `OpenAIResponseClientAgent` with the desired model and instructions
|
||||
4. **Create a Thread**: Call `agent.GetNewThread()` to create a new conversation thread
|
||||
5. **Link Thread to Conversation**: Pass `ChatClientAgentRunOptions` with the `ConversationId` on the first call
|
||||
6. **Send Messages**: Subsequent calls to `agent.RunAsync()` only need the thread - context is maintained
|
||||
7. **Cleanup**: Delete the conversation when done using `conversationClient.DeleteConversation()`
|
||||
|
||||
## Running the Sample
|
||||
|
||||
1. Set the required environment variables:
|
||||
```powershell
|
||||
$env:OPENAI_API_KEY = "your_api_key_here"
|
||||
$env:OPENAI_MODEL = "gpt-4o-mini"
|
||||
```
|
||||
|
||||
2. Run the sample:
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Expected Output
|
||||
|
||||
The sample demonstrates a three-turn conversation where each follow-up question relies on context from previous messages:
|
||||
|
||||
1. First question asks about the capital of France
|
||||
2. Second question asks about landmarks "there" - requiring understanding of the previous answer
|
||||
3. Third question asks about "the most famous one" - requiring context from both previous turns
|
||||
|
||||
After the conversation, the sample retrieves and displays the full conversation history from the server, then cleans up by deleting the conversation.
|
||||
|
||||
This demonstrates that the conversation state is properly maintained across multiple agent invocations using OpenAI's server-side conversation storage.
|
||||
@@ -13,4 +13,5 @@ Agent Framework provides additional support to allow OpenAI developers to use th
|
||||
|[Creating an AIAgent](./Agent_OpenAI_Step01_Running/)|This sample demonstrates how to create and run a basic agent with native OpenAI SDK types. Shows both regular and streaming invocation of the agent.|
|
||||
|[Using Reasoning Capabilities](./Agent_OpenAI_Step02_Reasoning/)|This sample demonstrates how to create an AI agent with reasoning capabilities using OpenAI's reasoning models and response types.|
|
||||
|[Creating an Agent from a ChatClient](./Agent_OpenAI_Step03_CreateFromChatClient/)|This sample demonstrates how to create an AI agent directly from an OpenAI.Chat.ChatClient instance using OpenAIChatClientAgent.|
|
||||
|[Creating an Agent from an OpenAIResponseClient](./Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/)|This sample demonstrates how to create an AI agent directly from an OpenAI.Responses.OpenAIResponseClient instance using OpenAIResponseClientAgent.|
|
||||
|[Creating an Agent from an OpenAIResponseClient](./Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/)|This sample demonstrates how to create an AI agent directly from an OpenAI.Responses.OpenAIResponseClient instance using OpenAIResponseClientAgent.|
|
||||
|[Managing Conversation State](./Agent_OpenAI_Step05_Conversation/)|This sample demonstrates how to maintain conversation state across multiple turns using the AgentThread for context continuity.|
|
||||
@@ -36,11 +36,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.4" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.0" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251125.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.0.1-preview.1.25571.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.4" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.0" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251125.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.0.1-preview.1.25571.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.4" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.0" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251125.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.0.1-preview.1.25571.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -198,7 +198,7 @@ internal sealed class A2AAgent : AIAgent
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Id => this._id ?? base.Id;
|
||||
protected override string? IdCore => this._id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Name => this._name ?? base.Name;
|
||||
|
||||
@@ -22,9 +22,6 @@ namespace Microsoft.Agents.AI;
|
||||
[DebuggerDisplay("{DisplayName,nq}")]
|
||||
public abstract class AIAgent
|
||||
{
|
||||
/// <summary>Default ID of this agent instance.</summary>
|
||||
private readonly string _id = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this agent instance.
|
||||
/// </summary>
|
||||
@@ -37,7 +34,19 @@ public abstract class AIAgent
|
||||
/// agent instances in multi-agent scenarios. They should remain stable for the lifetime
|
||||
/// of the agent instance.
|
||||
/// </remarks>
|
||||
public virtual string Id => this._id;
|
||||
public string Id { get => this.IdCore ?? field; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Gets a custom identifier for the agent, which can be overridden by derived classes.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// A string representing the agent's identifier, or <see langword="null"/> if the default ID should be used.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// Derived classes can override this property to provide a custom identifier.
|
||||
/// When <see langword="null"/> is returned, the <see cref="Id"/> property will use the default randomly-generated identifier.
|
||||
/// </remarks>
|
||||
protected virtual string? IdCore => null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable name of the agent.
|
||||
@@ -61,7 +70,7 @@ public abstract class AIAgent
|
||||
/// This property provides a guaranteed non-null string suitable for display in user interfaces,
|
||||
/// logs, or other contexts where a readable identifier is needed.
|
||||
/// </remarks>
|
||||
public virtual string DisplayName => this.Name ?? this.Id ?? this._id; // final fallback to _id in case Id override returns null
|
||||
public virtual string DisplayName => this.Name ?? this.Id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a description of the agent's purpose, capabilities, or behavior.
|
||||
|
||||
@@ -54,7 +54,7 @@ public class DelegatingAIAgent : AIAgent
|
||||
protected AIAgent InnerAgent { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Id => this.InnerAgent.Id;
|
||||
protected override string? IdCore => this.InnerAgent.Id;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string? Name => this.InnerAgent.Name;
|
||||
|
||||
@@ -81,7 +81,7 @@ internal sealed partial class DevUIMiddleware
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
|
||||
context.Response.Headers.Location = redirectUrl;
|
||||
context.Response.Headers.Location = redirectUrl; // CodeQL [SM04598] justification: The redirect URL is constructed from a server-configured base path (_basePath), not user input. The query string is only appended as parameters and cannot change the redirect destination since this is a relative URL.
|
||||
|
||||
if (this._logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ internal sealed class EntityAgentWrapper(
|
||||
private readonly IServiceProvider? _entityScopedServices = entityScopedServices;
|
||||
|
||||
// The ID of the agent is always the entity ID.
|
||||
public override string Id => this._entityContext.Id.ToString();
|
||||
protected override string? IdCore => this._entityContext.Id.ToString();
|
||||
|
||||
public override async Task<AgentRunResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
|
||||
@@ -39,7 +39,7 @@ internal sealed class WorkflowHostAgent : AIAgent
|
||||
this._describeTask = this._workflow.DescribeProtocolAsync().AsTask();
|
||||
}
|
||||
|
||||
public override string Id => this._id ?? base.Id;
|
||||
protected override string? IdCore => this._id;
|
||||
public override string? Name { get; }
|
||||
public override string? Description { get; }
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
public IChatClient ChatClient { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Id => this._agentOptions?.Id ?? base.Id;
|
||||
protected override string? IdCore => this._agentOptions?.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Name => this._agentOptions?.Name;
|
||||
|
||||
@@ -214,13 +214,31 @@ public class AIAgentTests
|
||||
[Fact]
|
||||
public void ValidateAgentIDIsIdempotent()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgent();
|
||||
|
||||
// Act
|
||||
string id = agent.Id;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(id);
|
||||
Assert.Equal(id, agent.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAgentIDCanBeProvidedByDerivedAgentClass()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new MockAgent(id: "test-agent-id");
|
||||
|
||||
// Act
|
||||
string id = agent.Id;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(id);
|
||||
Assert.Equal("test-agent-id", id);
|
||||
}
|
||||
|
||||
#region GetService Method Tests
|
||||
|
||||
/// <summary>
|
||||
@@ -344,6 +362,13 @@ public class AIAgentTests
|
||||
|
||||
private sealed class MockAgent : AIAgent
|
||||
{
|
||||
public MockAgent(string? id = null)
|
||||
{
|
||||
this.IdCore = id;
|
||||
}
|
||||
|
||||
protected override string? IdCore { get; }
|
||||
|
||||
public override AgentThread GetNewThread()
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
|
||||
|
||||
@@ -31,7 +32,7 @@ public class DelegatingAIAgentTests
|
||||
this._testThread = new TestAgentThread();
|
||||
|
||||
// Setup inner agent mock
|
||||
this._innerAgentMock.Setup(x => x.Id).Returns("test-agent-id");
|
||||
this._innerAgentMock.Protected().SetupGet<string>("IdCore").Returns("test-agent-id");
|
||||
this._innerAgentMock.Setup(x => x.Name).Returns("Test Agent");
|
||||
this._innerAgentMock.Setup(x => x.Description).Returns("Test Description");
|
||||
this._innerAgentMock.Setup(x => x.GetNewThread()).Returns(this._testThread);
|
||||
@@ -93,7 +94,7 @@ public class DelegatingAIAgentTests
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-agent-id", id);
|
||||
this._innerAgentMock.Verify(x => x.Id, Times.Once);
|
||||
this._innerAgentMock.Protected().VerifyGet<string>("IdCore", Times.Once());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+4
-16
@@ -276,15 +276,9 @@ public sealed class BasicStreamingTests : IAsyncDisposable
|
||||
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")]
|
||||
internal sealed class FakeChatClientAgent : AIAgent
|
||||
{
|
||||
public FakeChatClientAgent()
|
||||
{
|
||||
this.Id = "fake-agent";
|
||||
this.Description = "A fake agent for testing";
|
||||
}
|
||||
protected override string? IdCore => "fake-agent";
|
||||
|
||||
public override string Id { get; }
|
||||
|
||||
public override string? Description { get; }
|
||||
public override string? Description => "A fake agent for testing";
|
||||
|
||||
public override AgentThread GetNewThread()
|
||||
{
|
||||
@@ -350,15 +344,9 @@ internal sealed class FakeChatClientAgent : AIAgent
|
||||
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")]
|
||||
internal sealed class FakeMultiMessageAgent : AIAgent
|
||||
{
|
||||
public FakeMultiMessageAgent()
|
||||
{
|
||||
this.Id = "fake-multi-message-agent";
|
||||
this.Description = "A fake agent that sends multiple messages for testing";
|
||||
}
|
||||
protected override string? IdCore => "fake-multi-message-agent";
|
||||
|
||||
public override string Id { get; }
|
||||
|
||||
public override string? Description { get; }
|
||||
public override string? Description => "A fake agent that sends multiple messages for testing";
|
||||
|
||||
public override AgentThread GetNewThread()
|
||||
{
|
||||
|
||||
+2
-2
@@ -421,7 +421,7 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
|
||||
|
||||
private sealed class MultiResponseAgent : AIAgent
|
||||
{
|
||||
public override string Id => "multi-response-agent";
|
||||
protected override string? IdCore => "multi-response-agent";
|
||||
|
||||
public override string? Description => "Agent that produces multiple text chunks";
|
||||
|
||||
@@ -510,7 +510,7 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
|
||||
|
||||
private sealed class TestAgent : AIAgent
|
||||
{
|
||||
public override string Id => "test-agent";
|
||||
protected override string? IdCore => "test-agent";
|
||||
|
||||
public override string? Description => "Test agent";
|
||||
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ internal sealed class HelloAgent(string id = nameof(HelloAgent)) : AIAgent
|
||||
public const string Greeting = "Hello World!";
|
||||
public const string DefaultId = nameof(HelloAgent);
|
||||
|
||||
public override string Id => id;
|
||||
protected override string? IdCore => id;
|
||||
public override string? Name => id;
|
||||
|
||||
public override AgentThread GetNewThread()
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ public class SpecializedExecutorSmokeTests
|
||||
{
|
||||
public class TestAIAgent(List<ChatMessage>? messages = null, string? id = null, string? name = null) : AIAgent
|
||||
{
|
||||
public override string Id => id ?? base.Id;
|
||||
protected override string? IdCore => id;
|
||||
public override string? Name => name;
|
||||
|
||||
public static List<ChatMessage> ToChatMessages(params string[] messages)
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
internal class TestEchoAgent(string? id = null, string? name = null, string? prefix = null) : AIAgent
|
||||
{
|
||||
public override string Id => id ?? base.Id;
|
||||
protected override string? IdCore => id;
|
||||
public override string? Name => name ?? base.Name;
|
||||
|
||||
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
|
||||
+21
-1
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0b251211] - 2025-12-11
|
||||
|
||||
### Added
|
||||
|
||||
- **agent-framework-core**: Extend HITL support for all orchestration patterns (#2620)
|
||||
- **agent-framework-core**: Add factory pattern to concurrent orchestration builder (#2738)
|
||||
- **agent-framework-core**: Add factory pattern to sequential orchestration builder (#2710)
|
||||
- **agent-framework-azure-ai**: Capture file IDs from code interpreter in streaming responses (#2741)
|
||||
|
||||
### Changed
|
||||
|
||||
- **agent-framework-azurefunctions**: Change DurableAIAgent log level from warning to debug when invoked without thread (#2736)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **agent-framework-core**: Added more complete parsing for mcp tool arguments (#2756)
|
||||
- **agent-framework-core**: Fix GroupChat ManagerSelectionResponse JSON Schema for OpenAI Structured Outputs (#2750)
|
||||
- **samples**: Standardize OpenAI API key environment variable naming (#2629)
|
||||
|
||||
## [1.0.0b251209] - 2025-12-09
|
||||
|
||||
### Added
|
||||
@@ -347,7 +366,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.0b251209...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251211...HEAD
|
||||
[1.0.0b251211]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251209...python-1.0.0b251211
|
||||
[1.0.0b251209]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251204...python-1.0.0b251209
|
||||
[1.0.0b251204]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251120...python-1.0.0b251204
|
||||
[1.0.0b251120]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251117...python-1.0.0b251120
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
@@ -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.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
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.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -63,6 +63,8 @@ from azure.ai.agents.models import (
|
||||
McpTool,
|
||||
MessageDeltaChunk,
|
||||
MessageDeltaTextContent,
|
||||
MessageDeltaTextFileCitationAnnotation,
|
||||
MessageDeltaTextFilePathAnnotation,
|
||||
MessageDeltaTextUrlCitationAnnotation,
|
||||
MessageImageUrlParam,
|
||||
MessageInputContentBlock,
|
||||
@@ -471,6 +473,45 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
|
||||
return url_citations
|
||||
|
||||
def _extract_file_path_contents(self, message_delta_chunk: MessageDeltaChunk) -> list[HostedFileContent]:
|
||||
"""Extract file references from MessageDeltaChunk annotations.
|
||||
|
||||
Code interpreter generates files that are referenced via file path or file citation
|
||||
annotations in the message content. This method extracts those file IDs and returns
|
||||
them as HostedFileContent objects.
|
||||
|
||||
Handles two annotation types:
|
||||
- MessageDeltaTextFilePathAnnotation: Contains file_path.file_id
|
||||
- MessageDeltaTextFileCitationAnnotation: Contains file_citation.file_id
|
||||
|
||||
Args:
|
||||
message_delta_chunk: The message delta chunk to process
|
||||
|
||||
Returns:
|
||||
List of HostedFileContent objects for any files referenced in annotations
|
||||
"""
|
||||
file_contents: list[HostedFileContent] = []
|
||||
|
||||
for content in message_delta_chunk.delta.content:
|
||||
if isinstance(content, MessageDeltaTextContent) and content.text and content.text.annotations:
|
||||
for annotation in content.text.annotations:
|
||||
if isinstance(annotation, MessageDeltaTextFilePathAnnotation):
|
||||
# Extract file_id from the file_path annotation
|
||||
file_path = getattr(annotation, "file_path", None)
|
||||
if file_path is not None:
|
||||
file_id = getattr(file_path, "file_id", None)
|
||||
if file_id:
|
||||
file_contents.append(HostedFileContent(file_id=file_id))
|
||||
elif isinstance(annotation, MessageDeltaTextFileCitationAnnotation):
|
||||
# Extract file_id from the file_citation annotation
|
||||
file_citation = getattr(annotation, "file_citation", None)
|
||||
if file_citation is not None:
|
||||
file_id = getattr(file_citation, "file_id", None)
|
||||
if file_id:
|
||||
file_contents.append(HostedFileContent(file_id=file_id))
|
||||
|
||||
return file_contents
|
||||
|
||||
def _get_real_url_from_citation_reference(
|
||||
self, citation_url: str, azure_search_tool_calls: list[dict[str, Any]]
|
||||
) -> str:
|
||||
@@ -530,6 +571,9 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
# Extract URL citations from the delta chunk
|
||||
url_citations = self._extract_url_citations(event_data, azure_search_tool_calls)
|
||||
|
||||
# Extract file path contents from code interpreter outputs
|
||||
file_contents = self._extract_file_path_contents(event_data)
|
||||
|
||||
# Create contents with citations if any exist
|
||||
citation_content: list[Contents] = []
|
||||
if event_data.text or url_citations:
|
||||
@@ -538,6 +582,9 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
text_content_obj.annotations = url_citations
|
||||
citation_content.append(text_content_obj)
|
||||
|
||||
# Add file contents from file path annotations
|
||||
citation_content.extend(file_contents)
|
||||
|
||||
yield ChatResponseUpdate(
|
||||
role=role,
|
||||
contents=citation_content if citation_content else None,
|
||||
|
||||
@@ -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.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -24,6 +24,7 @@ from agent_framework import (
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
HostedCodeInterpreterTool,
|
||||
HostedFileContent,
|
||||
HostedFileSearchTool,
|
||||
HostedMCPTool,
|
||||
HostedVectorStoreContent,
|
||||
@@ -42,6 +43,8 @@ from azure.ai.agents.models import (
|
||||
FileInfo,
|
||||
MessageDeltaChunk,
|
||||
MessageDeltaTextContent,
|
||||
MessageDeltaTextFileCitationAnnotation,
|
||||
MessageDeltaTextFilePathAnnotation,
|
||||
MessageDeltaTextUrlCitationAnnotation,
|
||||
RequiredFunctionToolCall,
|
||||
RequiredMcpToolCall,
|
||||
@@ -1362,6 +1365,108 @@ def test_azure_ai_chat_client_extract_url_citations_with_citations(mock_agents_c
|
||||
assert citation.annotated_regions[0].end_index == 20
|
||||
|
||||
|
||||
def test_azure_ai_chat_client_extract_file_path_contents_with_file_path_annotation(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _extract_file_path_contents with MessageDeltaChunk containing file path annotation."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
# Create mock file_path annotation
|
||||
mock_file_path = MagicMock()
|
||||
mock_file_path.file_id = "assistant-test-file-123"
|
||||
|
||||
mock_annotation = MagicMock(spec=MessageDeltaTextFilePathAnnotation)
|
||||
mock_annotation.file_path = mock_file_path
|
||||
|
||||
# Create mock text content with annotations
|
||||
mock_text = MagicMock()
|
||||
mock_text.annotations = [mock_annotation]
|
||||
|
||||
mock_text_content = MagicMock(spec=MessageDeltaTextContent)
|
||||
mock_text_content.text = mock_text
|
||||
|
||||
# Create mock delta
|
||||
mock_delta = MagicMock()
|
||||
mock_delta.content = [mock_text_content]
|
||||
|
||||
# Create mock MessageDeltaChunk
|
||||
mock_chunk = MagicMock(spec=MessageDeltaChunk)
|
||||
mock_chunk.delta = mock_delta
|
||||
|
||||
# Call the method
|
||||
file_contents = chat_client._extract_file_path_contents(mock_chunk)
|
||||
|
||||
# Verify results
|
||||
assert len(file_contents) == 1
|
||||
assert isinstance(file_contents[0], HostedFileContent)
|
||||
assert file_contents[0].file_id == "assistant-test-file-123"
|
||||
|
||||
|
||||
def test_azure_ai_chat_client_extract_file_path_contents_with_file_citation_annotation(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _extract_file_path_contents with MessageDeltaChunk containing file citation annotation."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
# Create mock file_citation annotation
|
||||
mock_file_citation = MagicMock()
|
||||
mock_file_citation.file_id = "cfile_test-citation-456"
|
||||
|
||||
mock_annotation = MagicMock(spec=MessageDeltaTextFileCitationAnnotation)
|
||||
mock_annotation.file_citation = mock_file_citation
|
||||
|
||||
# Create mock text content with annotations
|
||||
mock_text = MagicMock()
|
||||
mock_text.annotations = [mock_annotation]
|
||||
|
||||
mock_text_content = MagicMock(spec=MessageDeltaTextContent)
|
||||
mock_text_content.text = mock_text
|
||||
|
||||
# Create mock delta
|
||||
mock_delta = MagicMock()
|
||||
mock_delta.content = [mock_text_content]
|
||||
|
||||
# Create mock MessageDeltaChunk
|
||||
mock_chunk = MagicMock(spec=MessageDeltaChunk)
|
||||
mock_chunk.delta = mock_delta
|
||||
|
||||
# Call the method
|
||||
file_contents = chat_client._extract_file_path_contents(mock_chunk)
|
||||
|
||||
# Verify results
|
||||
assert len(file_contents) == 1
|
||||
assert isinstance(file_contents[0], HostedFileContent)
|
||||
assert file_contents[0].file_id == "cfile_test-citation-456"
|
||||
|
||||
|
||||
def test_azure_ai_chat_client_extract_file_path_contents_empty_annotations(
|
||||
mock_agents_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _extract_file_path_contents with no annotations returns empty list."""
|
||||
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
|
||||
|
||||
# Create mock text content with no annotations
|
||||
mock_text = MagicMock()
|
||||
mock_text.annotations = []
|
||||
|
||||
mock_text_content = MagicMock(spec=MessageDeltaTextContent)
|
||||
mock_text_content.text = mock_text
|
||||
|
||||
# Create mock delta
|
||||
mock_delta = MagicMock()
|
||||
mock_delta.content = [mock_text_content]
|
||||
|
||||
# Create mock MessageDeltaChunk
|
||||
mock_chunk = MagicMock(spec=MessageDeltaChunk)
|
||||
mock_chunk.delta = mock_delta
|
||||
|
||||
# Call the method
|
||||
file_contents = chat_client._extract_file_path_contents(mock_chunk)
|
||||
|
||||
# Verify results
|
||||
assert len(file_contents) == 0
|
||||
|
||||
|
||||
def get_weather(
|
||||
location: Annotated[str, Field(description="The location to get the weather for.")],
|
||||
) -> str:
|
||||
|
||||
@@ -256,7 +256,7 @@ class DurableAIAgent(AgentProtocol):
|
||||
# This ensures each call gets its own conversation context
|
||||
session_key = str(self.context.new_uuid())
|
||||
session_id = AgentSessionId(name=self.agent_name, key=session_key)
|
||||
logger.warning("[DurableAIAgent] No thread provided, created unique session_id: %s", session_id)
|
||||
logger.debug("[DurableAIAgent] No thread provided, created unique session_id: %s", session_id)
|
||||
|
||||
# Create entity ID from session ID
|
||||
entity_id = session_id.to_entity_id()
|
||||
|
||||
@@ -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.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
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 = "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.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
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.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
@@ -19,9 +18,9 @@ from mcp.client.websocket import websocket_client
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.shared.session import RequestResponder
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
from pydantic import BaseModel, create_model
|
||||
|
||||
from ._tools import AIFunction, HostedMCPSpecificApproval
|
||||
from ._tools import AIFunction, HostedMCPSpecificApproval, _build_pydantic_model_from_json_schema
|
||||
from ._types import (
|
||||
ChatMessage,
|
||||
Contents,
|
||||
@@ -274,95 +273,26 @@ def _get_input_model_from_mcp_prompt(prompt: types.Prompt) -> type[BaseModel]:
|
||||
if not prompt.arguments:
|
||||
return create_model(f"{prompt.name}_input")
|
||||
|
||||
field_definitions: dict[str, Any] = {}
|
||||
# Convert prompt arguments to JSON schema format
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
|
||||
for prompt_argument in prompt.arguments:
|
||||
# For prompts, all arguments are typically required and string type
|
||||
# unless specified otherwise in the prompt argument
|
||||
python_type = str # Default type for prompt arguments
|
||||
|
||||
# Create field definition for create_model
|
||||
# For prompts, all arguments are typically string type unless specified otherwise
|
||||
properties[prompt_argument.name] = {
|
||||
"type": "string",
|
||||
"description": prompt_argument.description if hasattr(prompt_argument, "description") else "",
|
||||
}
|
||||
if prompt_argument.required:
|
||||
field_definitions[prompt_argument.name] = (python_type, ...)
|
||||
else:
|
||||
field_definitions[prompt_argument.name] = (python_type, None)
|
||||
required.append(prompt_argument.name)
|
||||
|
||||
return create_model(f"{prompt.name}_input", **field_definitions)
|
||||
schema = {"properties": properties, "required": required}
|
||||
return _build_pydantic_model_from_json_schema(prompt.name, schema)
|
||||
|
||||
|
||||
def _get_input_model_from_mcp_tool(tool: types.Tool) -> type[BaseModel]:
|
||||
"""Creates a Pydantic model from a tools parameters."""
|
||||
properties = tool.inputSchema.get("properties", None)
|
||||
required = tool.inputSchema.get("required", [])
|
||||
definitions = tool.inputSchema.get("$defs", {})
|
||||
|
||||
# Check if 'properties' is missing or not a dictionary
|
||||
if not properties:
|
||||
return create_model(f"{tool.name}_input")
|
||||
|
||||
def resolve_type(prop_details: dict[str, Any]) -> type:
|
||||
"""Resolve JSON Schema type to Python type, handling $ref."""
|
||||
# Handle $ref by resolving the reference
|
||||
if "$ref" in prop_details:
|
||||
ref = prop_details["$ref"]
|
||||
# Extract the reference path (e.g., "#/$defs/CustomerIdParam" -> "CustomerIdParam")
|
||||
if ref.startswith("#/$defs/"):
|
||||
def_name = ref.split("/")[-1]
|
||||
if def_name in definitions:
|
||||
# Resolve the reference and use its type
|
||||
resolved = definitions[def_name]
|
||||
return resolve_type(resolved)
|
||||
# If we can't resolve the ref, default to dict for safety
|
||||
return dict
|
||||
|
||||
# Map JSON Schema types to Python types
|
||||
json_type = prop_details.get("type", "string")
|
||||
match json_type:
|
||||
case "integer":
|
||||
return int
|
||||
case "number":
|
||||
return float
|
||||
case "boolean":
|
||||
return bool
|
||||
case "array":
|
||||
return list
|
||||
case "object":
|
||||
return dict
|
||||
case _:
|
||||
return str # default
|
||||
|
||||
field_definitions: dict[str, Any] = {}
|
||||
for prop_name, prop_details in properties.items():
|
||||
prop_details = json.loads(prop_details) if isinstance(prop_details, str) else prop_details
|
||||
|
||||
python_type = resolve_type(prop_details)
|
||||
description = prop_details.get("description", "")
|
||||
|
||||
# Build field kwargs (description, array items schema, etc.)
|
||||
field_kwargs: dict[str, Any] = {}
|
||||
if description:
|
||||
field_kwargs["description"] = description
|
||||
|
||||
# Preserve array items schema if present
|
||||
if prop_details.get("type") == "array" and "items" in prop_details:
|
||||
items_schema = prop_details["items"]
|
||||
if items_schema and items_schema != {}:
|
||||
field_kwargs["json_schema_extra"] = {"items": items_schema}
|
||||
|
||||
# Create field definition for create_model
|
||||
if prop_name in required:
|
||||
if field_kwargs:
|
||||
field_definitions[prop_name] = (python_type, Field(**field_kwargs))
|
||||
else:
|
||||
field_definitions[prop_name] = (python_type, ...)
|
||||
else:
|
||||
default_value = prop_details.get("default", None)
|
||||
field_kwargs["default"] = default_value
|
||||
if field_kwargs and any(k != "default" for k in field_kwargs):
|
||||
field_definitions[prop_name] = (python_type, Field(**field_kwargs))
|
||||
else:
|
||||
field_definitions[prop_name] = (python_type, default_value)
|
||||
|
||||
return create_model(f"{tool.name}_input", **field_definitions)
|
||||
return _build_pydantic_model_from_json_schema(tool.name, tool.inputSchema)
|
||||
|
||||
|
||||
def _normalize_mcp_name(name: str) -> str:
|
||||
|
||||
@@ -25,7 +25,6 @@ from typing import (
|
||||
|
||||
from opentelemetry.metrics import Histogram
|
||||
from pydantic import AnyUrl, BaseModel, Field, ValidationError, create_model
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
from ._logging import get_logger
|
||||
from ._serialization import SerializationMixin
|
||||
@@ -932,6 +931,151 @@ TYPE_MAPPING = {
|
||||
}
|
||||
|
||||
|
||||
def _build_pydantic_model_from_json_schema(
|
||||
model_name: str,
|
||||
schema: Mapping[str, Any],
|
||||
) -> type[BaseModel]:
|
||||
"""Creates a Pydantic model from JSON Schema with support for $refs, nested objects, and typed arrays.
|
||||
|
||||
Args:
|
||||
model_name: The name of the model to be created.
|
||||
schema: The JSON Schema definition (should contain 'properties', 'required', '$defs', etc.).
|
||||
|
||||
Returns:
|
||||
The dynamically created Pydantic model class.
|
||||
"""
|
||||
properties = schema.get("properties")
|
||||
required = schema.get("required", [])
|
||||
definitions = schema.get("$defs", {})
|
||||
|
||||
# Check if 'properties' is missing or not a dictionary
|
||||
if not properties:
|
||||
return create_model(f"{model_name}_input")
|
||||
|
||||
def _resolve_type(prop_details: dict[str, Any], parent_name: str = "") -> type:
|
||||
"""Resolve JSON Schema type to Python type, handling $ref, nested objects, and typed arrays.
|
||||
|
||||
Args:
|
||||
prop_details: The JSON Schema property details
|
||||
parent_name: Name to use for creating nested models (for uniqueness)
|
||||
|
||||
Returns:
|
||||
Python type annotation (could be int, str, list[str], or a nested Pydantic model)
|
||||
"""
|
||||
# Handle $ref by resolving the reference
|
||||
if "$ref" in prop_details:
|
||||
ref = prop_details["$ref"]
|
||||
# Extract the reference path (e.g., "#/$defs/CustomerIdParam" -> "CustomerIdParam")
|
||||
if ref.startswith("#/$defs/"):
|
||||
def_name = ref.split("/")[-1]
|
||||
if def_name in definitions:
|
||||
# Resolve the reference and use its type
|
||||
resolved = definitions[def_name]
|
||||
return _resolve_type(resolved, def_name)
|
||||
# If we can't resolve the ref, default to dict for safety
|
||||
return dict
|
||||
|
||||
# Map JSON Schema types to Python types
|
||||
json_type = prop_details.get("type", "string")
|
||||
match json_type:
|
||||
case "integer":
|
||||
return int
|
||||
case "number":
|
||||
return float
|
||||
case "boolean":
|
||||
return bool
|
||||
case "array":
|
||||
# Handle typed arrays
|
||||
items_schema = prop_details.get("items")
|
||||
if items_schema and isinstance(items_schema, dict):
|
||||
# Recursively resolve the item type
|
||||
item_type = _resolve_type(items_schema, f"{parent_name}_item")
|
||||
# Return list[ItemType] instead of bare list
|
||||
return list[item_type] # type: ignore
|
||||
# If no items schema or invalid, return bare list
|
||||
return list
|
||||
case "object":
|
||||
# Handle nested objects by creating a nested Pydantic model
|
||||
nested_properties = prop_details.get("properties")
|
||||
nested_required = prop_details.get("required", [])
|
||||
|
||||
if nested_properties and isinstance(nested_properties, dict):
|
||||
# Create the name for the nested model
|
||||
nested_model_name = f"{parent_name}_nested" if parent_name else "NestedModel"
|
||||
|
||||
# Recursively build field definitions for the nested model
|
||||
nested_field_definitions: dict[str, Any] = {}
|
||||
for nested_prop_name, nested_prop_details in nested_properties.items():
|
||||
nested_prop_details = (
|
||||
json.loads(nested_prop_details)
|
||||
if isinstance(nested_prop_details, str)
|
||||
else nested_prop_details
|
||||
)
|
||||
|
||||
nested_python_type = _resolve_type(
|
||||
nested_prop_details, f"{nested_model_name}_{nested_prop_name}"
|
||||
)
|
||||
nested_description = nested_prop_details.get("description", "")
|
||||
|
||||
# Build field kwargs for nested property
|
||||
nested_field_kwargs: dict[str, Any] = {}
|
||||
if nested_description:
|
||||
nested_field_kwargs["description"] = nested_description
|
||||
|
||||
# Create field definition
|
||||
if nested_prop_name in nested_required:
|
||||
nested_field_definitions[nested_prop_name] = (
|
||||
(
|
||||
nested_python_type,
|
||||
Field(**nested_field_kwargs),
|
||||
)
|
||||
if nested_field_kwargs
|
||||
else (nested_python_type, ...)
|
||||
)
|
||||
else:
|
||||
nested_field_kwargs["default"] = nested_prop_details.get("default", None)
|
||||
nested_field_definitions[nested_prop_name] = (
|
||||
nested_python_type,
|
||||
Field(**nested_field_kwargs),
|
||||
)
|
||||
|
||||
# Create and return the nested Pydantic model
|
||||
return create_model(nested_model_name, **nested_field_definitions) # type: ignore
|
||||
|
||||
# If no properties defined, return bare dict
|
||||
return dict
|
||||
case _:
|
||||
return str # default
|
||||
|
||||
field_definitions: dict[str, Any] = {}
|
||||
for prop_name, prop_details in properties.items():
|
||||
prop_details = json.loads(prop_details) if isinstance(prop_details, str) else prop_details
|
||||
|
||||
python_type = _resolve_type(prop_details, f"{model_name}_{prop_name}")
|
||||
description = prop_details.get("description", "")
|
||||
|
||||
# Build field kwargs (description, etc.)
|
||||
field_kwargs: dict[str, Any] = {}
|
||||
if description:
|
||||
field_kwargs["description"] = description
|
||||
|
||||
# Create field definition for create_model
|
||||
if prop_name in required:
|
||||
if field_kwargs:
|
||||
field_definitions[prop_name] = (python_type, Field(**field_kwargs))
|
||||
else:
|
||||
field_definitions[prop_name] = (python_type, ...)
|
||||
else:
|
||||
default_value = prop_details.get("default", None)
|
||||
field_kwargs["default"] = default_value
|
||||
if field_kwargs and any(k != "default" for k in field_kwargs):
|
||||
field_definitions[prop_name] = (python_type, Field(**field_kwargs))
|
||||
else:
|
||||
field_definitions[prop_name] = (python_type, default_value)
|
||||
|
||||
return create_model(f"{model_name}_input", **field_definitions)
|
||||
|
||||
|
||||
def _create_model_from_json_schema(tool_name: str, schema_json: Mapping[str, Any]) -> type[BaseModel]:
|
||||
"""Creates a Pydantic model from a given JSON Schema.
|
||||
|
||||
@@ -948,29 +1092,8 @@ def _create_model_from_json_schema(tool_name: str, schema_json: Mapping[str, Any
|
||||
f"JSON schema for tool '{tool_name}' must contain a 'properties' key of type dict. "
|
||||
f"Got: {schema_json.get('properties', None)}"
|
||||
)
|
||||
# Extract field definitions with type annotations
|
||||
field_definitions: dict[str, tuple[type, FieldInfo]] = {}
|
||||
for field_name, field_schema in schema_json["properties"].items():
|
||||
field_args: dict[str, Any] = {}
|
||||
if (field_description := field_schema.get("description", None)) is not None:
|
||||
field_args["description"] = field_description
|
||||
if (field_default := field_schema.get("default", None)) is not None:
|
||||
field_args["default"] = field_default
|
||||
field_type = field_schema.get("type", None)
|
||||
if field_type is None:
|
||||
raise ValueError(
|
||||
f"Missing 'type' for field '{field_name}' in JSON schema. "
|
||||
f"Got: {field_schema}, Supported types: {list(TYPE_MAPPING.keys())}"
|
||||
)
|
||||
python_type = TYPE_MAPPING.get(field_type)
|
||||
if python_type is None:
|
||||
raise ValueError(
|
||||
f"Unsupported type '{field_type}' for field '{field_name}' in JSON schema. "
|
||||
f"Got: {field_schema}, Supported types: {list(TYPE_MAPPING.keys())}"
|
||||
)
|
||||
field_definitions[field_name] = (python_type, Field(**field_args))
|
||||
|
||||
return create_model(f"{tool_name}_input", **field_definitions) # type: ignore[call-overload, no-any-return]
|
||||
return _build_pydantic_model_from_json_schema(tool_name, schema_json)
|
||||
|
||||
|
||||
@overload
|
||||
|
||||
@@ -85,6 +85,7 @@ from ._magentic import (
|
||||
MagenticStallInterventionRequest,
|
||||
StandardMagenticManager,
|
||||
)
|
||||
from ._orchestration_request_info import AgentInputRequest, AgentResponseReviewRequest, RequestInfoInterceptor
|
||||
from ._orchestration_state import OrchestrationState
|
||||
from ._request_info_mixin import response_handler
|
||||
from ._runner import Runner
|
||||
@@ -122,6 +123,8 @@ __all__ = [
|
||||
"AgentExecutor",
|
||||
"AgentExecutorRequest",
|
||||
"AgentExecutorResponse",
|
||||
"AgentInputRequest",
|
||||
"AgentResponseReviewRequest",
|
||||
"AgentRunEvent",
|
||||
"AgentRunUpdateEvent",
|
||||
"Case",
|
||||
@@ -164,6 +167,7 @@ __all__ = [
|
||||
"Message",
|
||||
"OrchestrationState",
|
||||
"RequestInfoEvent",
|
||||
"RequestInfoInterceptor",
|
||||
"Runner",
|
||||
"RunnerContext",
|
||||
"SequentialBuilder",
|
||||
|
||||
@@ -47,7 +47,9 @@ class BaseGroupChatOrchestrator(Executor, ABC):
|
||||
self._max_rounds: int | None = None
|
||||
self._termination_condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]] | None = None
|
||||
|
||||
def register_participant_entry(self, name: str, *, entry_id: str, is_agent: bool) -> None:
|
||||
def register_participant_entry(
|
||||
self, name: str, *, entry_id: str, is_agent: bool, exit_id: str | None = None
|
||||
) -> None:
|
||||
"""Record routing details for a participant's entry executor.
|
||||
|
||||
This method provides a unified interface for registering participants
|
||||
@@ -57,8 +59,10 @@ class BaseGroupChatOrchestrator(Executor, ABC):
|
||||
name: Participant name (used for selection and tracking)
|
||||
entry_id: Executor ID for this participant's entry point
|
||||
is_agent: Whether this is an AgentExecutor (True) or custom Executor (False)
|
||||
exit_id: Executor ID for this participant's exit point (where responses come from).
|
||||
If None, defaults to entry_id.
|
||||
"""
|
||||
self._registry.register(name, entry_id=entry_id, is_agent=is_agent)
|
||||
self._registry.register(name, entry_id=entry_id, is_agent=is_agent, exit_id=exit_id)
|
||||
|
||||
# Conversation state management (shared across all patterns)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
@@ -14,6 +15,7 @@ from ._agent_executor import AgentExecutorRequest, AgentExecutorResponse
|
||||
from ._checkpoint import CheckpointStorage
|
||||
from ._executor import Executor, handler
|
||||
from ._message_utils import normalize_messages_input
|
||||
from ._orchestration_request_info import RequestInfoInterceptor
|
||||
from ._workflow import Workflow
|
||||
from ._workflow_builder import WorkflowBuilder
|
||||
from ._workflow_context import WorkflowContext
|
||||
@@ -188,8 +190,11 @@ class ConcurrentBuilder:
|
||||
r"""High-level builder for concurrent agent workflows.
|
||||
|
||||
- `participants([...])` accepts a list of AgentProtocol (recommended) or Executor.
|
||||
- `register_participants([...])` accepts a list of factories for AgentProtocol (recommended)
|
||||
or Executor factories
|
||||
- `build()` wires: dispatcher -> fan-out -> participants -> fan-in -> aggregator.
|
||||
- `with_custom_aggregator(...)` overrides the default aggregator with an Executor or callback.
|
||||
- `with_aggregator(...)` overrides the default aggregator with an Executor or callback.
|
||||
- `register_aggregator(...)` accepts a factory for an Executor as custom aggregator.
|
||||
|
||||
Usage:
|
||||
|
||||
@@ -200,24 +205,103 @@ class ConcurrentBuilder:
|
||||
# Minimal: use default aggregator (returns list[ChatMessage])
|
||||
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).build()
|
||||
|
||||
# With agent factories
|
||||
workflow = ConcurrentBuilder().register_participants([create_agent1, create_agent2, create_agent3]).build()
|
||||
|
||||
|
||||
# Custom aggregator via callback (sync or async). The callback receives
|
||||
# list[AgentExecutorResponse] and its return value becomes the workflow's output.
|
||||
def summarize(results):
|
||||
def summarize(results: list[AgentExecutorResponse]) -> str:
|
||||
return " | ".join(r.agent_run_response.messages[-1].text for r in results)
|
||||
|
||||
|
||||
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).with_custom_aggregator(summarize).build()
|
||||
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).with_aggregator(summarize).build()
|
||||
|
||||
|
||||
# Custom aggregator via a factory
|
||||
class MyAggregator(Executor):
|
||||
@handler
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output(" | ".join(r.agent_run_response.messages[-1].text for r in results))
|
||||
|
||||
|
||||
workflow = (
|
||||
ConcurrentBuilder()
|
||||
.register_participants([create_agent1, create_agent2, create_agent3])
|
||||
.register_aggregator(lambda: MyAggregator(id="my_aggregator"))
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
# Enable checkpoint persistence so runs can resume
|
||||
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).with_checkpointing(storage).build()
|
||||
|
||||
# Enable request info before aggregation
|
||||
workflow = ConcurrentBuilder().participants([agent1, agent2]).with_request_info().build()
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._participants: list[AgentProtocol | Executor] = []
|
||||
self._participant_factories: list[Callable[[], AgentProtocol | Executor]] = []
|
||||
self._aggregator: Executor | None = None
|
||||
self._aggregator_factory: Callable[[], Executor] | None = None
|
||||
self._checkpoint_storage: CheckpointStorage | None = None
|
||||
self._request_info_enabled: bool = False
|
||||
|
||||
def register_participants(
|
||||
self,
|
||||
participant_factories: Sequence[Callable[[], AgentProtocol | Executor]],
|
||||
) -> "ConcurrentBuilder":
|
||||
r"""Define the parallel participants for this concurrent workflow.
|
||||
|
||||
Accepts factories (callables) that return AgentProtocol instances (e.g., created
|
||||
by a chat client) or Executor instances. Each participant created by a factory
|
||||
is wired as a parallel branch using fan-out edges from an internal dispatcher.
|
||||
|
||||
Args:
|
||||
participant_factories: Sequence of callables returning AgentProtocol or Executor instances
|
||||
|
||||
Raises:
|
||||
ValueError: if `participant_factories` is empty or `.participants()`
|
||||
or `.register_participants()` were already called
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
def create_researcher() -> ChatAgent:
|
||||
return ...
|
||||
|
||||
|
||||
def create_marketer() -> ChatAgent:
|
||||
return ...
|
||||
|
||||
|
||||
def create_legal() -> ChatAgent:
|
||||
return ...
|
||||
|
||||
|
||||
class MyCustomExecutor(Executor): ...
|
||||
|
||||
|
||||
wf = ConcurrentBuilder().register_participants([create_researcher, create_marketer, create_legal]).build()
|
||||
|
||||
# Mixing agent(s) and executor(s) is supported
|
||||
wf2 = ConcurrentBuilder().register_participants([create_researcher, MyCustomExecutor]).build()
|
||||
"""
|
||||
if self._participants:
|
||||
raise ValueError(
|
||||
"Cannot mix .participants([...]) and .register_participants() in the same builder instance."
|
||||
)
|
||||
|
||||
if self._participant_factories:
|
||||
raise ValueError("register_participants() has already been called on this builder instance.")
|
||||
|
||||
if not participant_factories:
|
||||
raise ValueError("participant_factories cannot be empty")
|
||||
|
||||
self._participant_factories = list(participant_factories)
|
||||
return self
|
||||
|
||||
def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "ConcurrentBuilder":
|
||||
r"""Define the parallel participants for this concurrent workflow.
|
||||
@@ -226,8 +310,12 @@ class ConcurrentBuilder:
|
||||
instances. Each participant is wired as a parallel branch using fan-out edges
|
||||
from an internal dispatcher.
|
||||
|
||||
Args:
|
||||
participants: Sequence of AgentProtocol or Executor instances
|
||||
|
||||
Raises:
|
||||
ValueError: if `participants` is empty or contains duplicates
|
||||
ValueError: if `participants` is empty, contains duplicates, or `.register_participants()`
|
||||
or `.participants()` were already called
|
||||
TypeError: if any entry is not AgentProtocol or Executor
|
||||
|
||||
Example:
|
||||
@@ -239,6 +327,14 @@ class ConcurrentBuilder:
|
||||
# Mixing agent(s) and executor(s) is supported
|
||||
wf2 = ConcurrentBuilder().participants([researcher_agent, my_custom_executor]).build()
|
||||
"""
|
||||
if self._participant_factories:
|
||||
raise ValueError(
|
||||
"Cannot mix .participants([...]) and .register_participants() in the same builder instance."
|
||||
)
|
||||
|
||||
if self._participants:
|
||||
raise ValueError("participants() has already been called on this builder instance.")
|
||||
|
||||
if not participants:
|
||||
raise ValueError("participants cannot be empty")
|
||||
|
||||
@@ -261,50 +357,140 @@ class ConcurrentBuilder:
|
||||
self._participants = list(participants)
|
||||
return self
|
||||
|
||||
def with_aggregator(self, aggregator: Executor | Callable[..., Any]) -> "ConcurrentBuilder":
|
||||
r"""Override the default aggregator with an Executor or a callback.
|
||||
def register_aggregator(self, aggregator_factory: Callable[[], Executor]) -> "ConcurrentBuilder":
|
||||
r"""Define a custom aggregator for this concurrent workflow.
|
||||
|
||||
- Executor: must handle `list[AgentExecutorResponse]` and
|
||||
yield output using `ctx.yield_output(...)` and add a
|
||||
output and the workflow becomes idle.
|
||||
Accepts a factory (callable) that returns an Executor instance. The executor
|
||||
should handle `list[AgentExecutorResponse]` and yield output using `ctx.yield_output(...)`.
|
||||
|
||||
Args:
|
||||
aggregator_factory: Callable that returns an Executor instance
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
class MyCustomExecutor(Executor): ...
|
||||
|
||||
|
||||
wf = (
|
||||
ConcurrentBuilder()
|
||||
.register_participants([create_researcher, create_marketer, create_legal])
|
||||
.register_aggregator(lambda: MyCustomExecutor(id="my_aggregator"))
|
||||
.build()
|
||||
)
|
||||
"""
|
||||
if self._aggregator is not None:
|
||||
raise ValueError(
|
||||
"Cannot mix .with_aggregator(...) and .register_aggregator(...) in the same builder instance."
|
||||
)
|
||||
|
||||
if self._aggregator_factory is not None:
|
||||
raise ValueError("register_aggregator() has already been called on this builder instance.")
|
||||
|
||||
self._aggregator_factory = aggregator_factory
|
||||
return self
|
||||
|
||||
def with_aggregator(
|
||||
self,
|
||||
aggregator: Executor
|
||||
| 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.
|
||||
|
||||
- Executor: must handle `list[AgentExecutorResponse]` and yield output using `ctx.yield_output(...)`
|
||||
- Callback: sync or async callable with one of the signatures:
|
||||
`(results: list[AgentExecutorResponse]) -> Any | None` or
|
||||
`(results: list[AgentExecutorResponse], ctx: WorkflowContext) -> Any | None`.
|
||||
If the callback returns a non-None value, it becomes the workflow's output.
|
||||
|
||||
Args:
|
||||
aggregator: Executor instance, or callback function
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
# Executor-based aggregator
|
||||
class CustomAggregator(Executor):
|
||||
@handler
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext) -> None:
|
||||
await ctx.yield_output(" | ".join(r.agent_run_response.messages[-1].text for r in results))
|
||||
|
||||
|
||||
wf = ConcurrentBuilder().participants([a1, a2, a3]).with_aggregator(CustomAggregator()).build()
|
||||
|
||||
|
||||
# Callback-based aggregator (string result)
|
||||
async def summarize(results):
|
||||
async def summarize(results: list[AgentExecutorResponse]) -> str:
|
||||
return " | ".join(r.agent_run_response.messages[-1].text for r in results)
|
||||
|
||||
|
||||
wf = ConcurrentBuilder().participants([a1, a2, a3]).with_custom_aggregator(summarize).build()
|
||||
wf = ConcurrentBuilder().participants([a1, a2, a3]).with_aggregator(summarize).build()
|
||||
|
||||
|
||||
# Callback-based aggregator (yield result)
|
||||
async def summarize(results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output(" | ".join(r.agent_run_response.messages[-1].text for r in results))
|
||||
|
||||
|
||||
wf = ConcurrentBuilder().participants([a1, a2, a3]).with_aggregator(summarize).build()
|
||||
"""
|
||||
if self._aggregator_factory is not None:
|
||||
raise ValueError(
|
||||
"Cannot mix .with_aggregator(...) and .register_aggregator(...) in the same builder instance."
|
||||
)
|
||||
|
||||
if self._aggregator is not None:
|
||||
raise ValueError("with_aggregator() has already been called on this builder instance.")
|
||||
|
||||
if isinstance(aggregator, Executor):
|
||||
self._aggregator = aggregator
|
||||
elif callable(aggregator):
|
||||
self._aggregator = _CallbackAggregator(aggregator)
|
||||
else:
|
||||
raise TypeError("aggregator must be an Executor or a callable")
|
||||
|
||||
return self
|
||||
|
||||
def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "ConcurrentBuilder":
|
||||
"""Enable checkpoint persistence using the provided storage backend."""
|
||||
"""Enable checkpoint persistence using the provided storage backend.
|
||||
|
||||
Args:
|
||||
checkpoint_storage: CheckpointStorage instance for persisting workflow state
|
||||
"""
|
||||
self._checkpoint_storage = checkpoint_storage
|
||||
return self
|
||||
|
||||
def with_request_info(self) -> "ConcurrentBuilder":
|
||||
"""Enable request info before aggregation in the workflow.
|
||||
|
||||
When enabled, the workflow pauses after all parallel agents complete,
|
||||
emitting a RequestInfoEvent that allows the caller to review and optionally
|
||||
modify the combined results before aggregation. The caller provides feedback
|
||||
via the standard response_handler/request_info pattern.
|
||||
|
||||
Note:
|
||||
Unlike SequentialBuilder and GroupChatBuilder, ConcurrentBuilder does not
|
||||
support per-agent filtering since all agents run in parallel and results
|
||||
are collected together. The pause occurs once with all agent outputs received.
|
||||
|
||||
Returns:
|
||||
self: The builder instance for fluent chaining.
|
||||
"""
|
||||
self._request_info_enabled = True
|
||||
return self
|
||||
|
||||
def build(self) -> Workflow:
|
||||
r"""Build and validate the concurrent workflow.
|
||||
|
||||
Wiring pattern:
|
||||
- Dispatcher (internal) fans out the input to all `participants`
|
||||
- Fan-in aggregator collects `AgentExecutorResponse` objects
|
||||
- Fan-in collects `AgentExecutorResponse` objects from all participants
|
||||
- If request info is enabled, the orchestration emits a request info event with outputs from all participants
|
||||
before sending the outputs to the aggregator
|
||||
- Aggregator yields output and the workflow becomes idle. The output is either:
|
||||
- list[ChatMessage] (default aggregator: one user + one assistant per agent)
|
||||
- custom payload from the provided callback/executor
|
||||
- custom payload from the provided aggregator
|
||||
|
||||
Returns:
|
||||
Workflow: a ready-to-run workflow instance
|
||||
@@ -318,17 +504,69 @@ class ConcurrentBuilder:
|
||||
|
||||
workflow = ConcurrentBuilder().participants([agent1, agent2]).build()
|
||||
"""
|
||||
if not self._participants:
|
||||
raise ValueError("No participants provided. Call .participants([...]) first.")
|
||||
if not self._participants and not self._participant_factories:
|
||||
raise ValueError(
|
||||
"No participants provided. Call .participants([...]) or .register_participants([...]) first."
|
||||
)
|
||||
|
||||
# Internal nodes
|
||||
dispatcher = _DispatchToAllParticipants(id="dispatcher")
|
||||
aggregator = self._aggregator or _AggregateAgentConversations(id="aggregator")
|
||||
aggregator = (
|
||||
self._aggregator
|
||||
if self._aggregator is not None
|
||||
else (
|
||||
self._aggregator_factory()
|
||||
if self._aggregator_factory is not None
|
||||
else _AggregateAgentConversations(id="aggregator")
|
||||
)
|
||||
)
|
||||
|
||||
builder = WorkflowBuilder()
|
||||
builder.set_start_executor(dispatcher)
|
||||
builder.add_fan_out_edges(dispatcher, list(self._participants))
|
||||
builder.add_fan_in_edges(list(self._participants), aggregator)
|
||||
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] = []
|
||||
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")
|
||||
else:
|
||||
builder.set_start_executor(dispatcher)
|
||||
builder.add_fan_out_edges(dispatcher, self._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(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)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ from ._base_group_chat_orchestrator import BaseGroupChatOrchestrator
|
||||
from ._checkpoint import CheckpointStorage
|
||||
from ._conversation_history import ensure_author, latest_user_message
|
||||
from ._executor import Executor, handler
|
||||
from ._orchestration_request_info import RequestInfoInterceptor
|
||||
from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, wrap_participant
|
||||
from ._workflow import Workflow
|
||||
from ._workflow_builder import WorkflowBuilder
|
||||
@@ -131,7 +132,11 @@ class ManagerSelectionResponse(BaseModel):
|
||||
final_message: Optional final message string when finishing conversation (will be converted to ChatMessage)
|
||||
"""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
# OpenAI strict mode requires all properties to be in required array
|
||||
"json_schema_extra": {"required": ["selected_participant", "instruction", "finish", "final_message"]},
|
||||
}
|
||||
|
||||
selected_participant: str | None = None
|
||||
instruction: str | None = None
|
||||
@@ -562,14 +567,36 @@ class GroupChatOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
participant_name: str,
|
||||
message: ChatMessage,
|
||||
ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, list[ChatMessage]],
|
||||
trailing_messages: list[ChatMessage] | None = None,
|
||||
) -> None:
|
||||
"""Common response ingestion logic shared by agent and custom participants."""
|
||||
"""Common response ingestion logic shared by agent and custom participants.
|
||||
|
||||
Args:
|
||||
participant_name: Name of the participant who sent the message
|
||||
message: The participant's response message
|
||||
ctx: Workflow context for routing and output
|
||||
trailing_messages: Optional list of messages to inject after the participant's
|
||||
message (e.g., additional input from the RequestInfoInterceptor)
|
||||
"""
|
||||
if participant_name not in self._participants:
|
||||
raise ValueError(f"Received response from unknown participant '{participant_name}'.")
|
||||
|
||||
message = ensure_author(message, participant_name)
|
||||
self._conversation.extend((message,))
|
||||
self._history.append(_GroupChatTurn(participant_name, "agent", message))
|
||||
|
||||
# Inject any trailing messages (e.g., human input) into the conversation
|
||||
if trailing_messages:
|
||||
for trailing_msg in trailing_messages:
|
||||
self._conversation.extend((trailing_msg,))
|
||||
# Record as user input in history
|
||||
author = trailing_msg.author_name or "human"
|
||||
self._history.append(_GroupChatTurn(author, "user", trailing_msg))
|
||||
logger.debug(
|
||||
f"Injected human input into group chat conversation: "
|
||||
f"{trailing_msg.text[:50] if trailing_msg.text else '(empty)'}..."
|
||||
)
|
||||
|
||||
self._pending_agent = None
|
||||
|
||||
if await self._complete_on_termination(ctx):
|
||||
@@ -685,14 +712,18 @@ class GroupChatOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
to the selected participant. This method implements the core orchestration
|
||||
logic for agent-based managers.
|
||||
|
||||
Also handles any human input that was injected into the response's full_conversation
|
||||
by the human input hook interceptor.
|
||||
|
||||
Args:
|
||||
response: AgentExecutor response from manager agent
|
||||
ctx: Workflow context for routing and output
|
||||
|
||||
Behavior:
|
||||
- Extracts any human input from the response
|
||||
- Parses manager selection from response
|
||||
- If finish=True: yields final message and completes workflow
|
||||
- If participant selected: routes request to that participant
|
||||
- If participant selected: routes request to that participant with human input included
|
||||
- Validates selected participant exists
|
||||
- Enforces round limits if configured
|
||||
|
||||
@@ -700,6 +731,9 @@ class GroupChatOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
ValueError: If manager selects invalid/unknown participant
|
||||
RuntimeError: If manager response cannot be parsed
|
||||
"""
|
||||
# Extract any human input that was injected by the human input hook
|
||||
trailing_user_messages = self._extract_trailing_user_messages(response)
|
||||
|
||||
selection = self._parse_manager_selection(response)
|
||||
|
||||
if self._pending_finalization:
|
||||
@@ -753,6 +787,19 @@ class GroupChatOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
self._conversation.append(manager_message)
|
||||
self._history.append(_GroupChatTurn(self._manager_name, "manager", manager_message))
|
||||
|
||||
# Inject any human input that was attached to the manager's response
|
||||
# This ensures the next participant sees the human's guidance
|
||||
if trailing_user_messages:
|
||||
for human_msg in trailing_user_messages:
|
||||
conversation.append(human_msg)
|
||||
self._conversation.append(human_msg)
|
||||
author = human_msg.author_name or "human"
|
||||
self._history.append(_GroupChatTurn(author, "user", human_msg))
|
||||
logger.debug(
|
||||
f"Injected human input after manager selection: "
|
||||
f"{human_msg.text[:50] if human_msg.text else '(empty)'}..."
|
||||
)
|
||||
|
||||
if await self._complete_on_termination(ctx):
|
||||
return
|
||||
|
||||
@@ -808,6 +855,41 @@ class GroupChatOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
)
|
||||
return ensure_author(final_message, participant_name)
|
||||
|
||||
@staticmethod
|
||||
def _extract_trailing_user_messages(response: AgentExecutorResponse) -> list[ChatMessage]:
|
||||
"""Extract any user messages that appear after the last assistant message.
|
||||
|
||||
This is used to capture human input that was injected by the human input hook
|
||||
interceptor. The hook adds user messages to full_conversation after the agent's
|
||||
response, so they appear at the end of the sequence.
|
||||
|
||||
Args:
|
||||
response: AgentExecutor response that may contain trailing user messages
|
||||
|
||||
Returns:
|
||||
List of user messages that appear after the last assistant message,
|
||||
or empty list if none found
|
||||
"""
|
||||
if not response.full_conversation:
|
||||
return []
|
||||
|
||||
# Find index of last assistant message
|
||||
last_assistant_idx = -1
|
||||
for i, msg in enumerate(response.full_conversation):
|
||||
if msg.role == Role.ASSISTANT:
|
||||
last_assistant_idx = i
|
||||
|
||||
if last_assistant_idx < 0:
|
||||
return []
|
||||
|
||||
# Collect any user messages after the last assistant message
|
||||
trailing_user: list[ChatMessage] = []
|
||||
for msg in response.full_conversation[last_assistant_idx + 1 :]:
|
||||
if msg.role == Role.USER:
|
||||
trailing_user.append(msg)
|
||||
|
||||
return trailing_user
|
||||
|
||||
async def _handle_task_message(
|
||||
self,
|
||||
task_message: ChatMessage,
|
||||
@@ -979,6 +1061,9 @@ class GroupChatOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
Routes responses based on whether they come from the manager or a participant:
|
||||
- Manager responses: parsed for speaker selection decisions
|
||||
- Participant responses: ingested as conversation messages
|
||||
|
||||
Also handles any human input that was injected into the response's full_conversation
|
||||
by the human input hook interceptor.
|
||||
"""
|
||||
participant_name = self._registry.get_participant_name(response.executor_id)
|
||||
if participant_name is None:
|
||||
@@ -994,7 +1079,13 @@ class GroupChatOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
else:
|
||||
# Regular participant response
|
||||
message = self._extract_agent_message(response, participant_name)
|
||||
await self._ingest_participant_message(participant_name, message, ctx)
|
||||
|
||||
# Check for human input injected by human input hook
|
||||
# Human input appears as user messages at the end of full_conversation
|
||||
# after the agent's assistant message
|
||||
trailing_user_messages = self._extract_trailing_user_messages(response)
|
||||
|
||||
await self._ingest_participant_message(participant_name, message, ctx, trailing_user_messages)
|
||||
|
||||
|
||||
def _default_orchestrator_factory(wiring: _GroupChatConfig) -> Executor:
|
||||
@@ -1089,13 +1180,14 @@ def assemble_group_chat_workflow(
|
||||
manager_entry = manager_pipeline[0]
|
||||
manager_exit = manager_pipeline[-1]
|
||||
|
||||
# Register manager with orchestrator
|
||||
# Register manager with orchestrator (with entry and exit IDs for pipeline routing)
|
||||
register_entry = getattr(orchestrator, "register_participant_entry", None)
|
||||
if callable(register_entry):
|
||||
register_entry(
|
||||
wiring.manager_name,
|
||||
entry_id=manager_entry.id,
|
||||
is_agent=not isinstance(wiring.manager_participant, Executor),
|
||||
exit_id=manager_exit.id if manager_exit is not manager_entry else None,
|
||||
)
|
||||
|
||||
# Wire manager edges: Orchestrator ↔ Manager
|
||||
@@ -1118,10 +1210,13 @@ def assemble_group_chat_workflow(
|
||||
|
||||
register_entry = getattr(orchestrator, "register_participant_entry", None)
|
||||
if callable(register_entry):
|
||||
# Register both entry and exit IDs so responses can be routed correctly
|
||||
# when interceptors are prepended to the pipeline
|
||||
register_entry(
|
||||
name,
|
||||
entry_id=entry_executor.id,
|
||||
is_agent=not isinstance(spec.participant, Executor),
|
||||
exit_id=exit_executor.id if exit_executor is not entry_executor else None,
|
||||
)
|
||||
|
||||
workflow_builder = workflow_builder.add_edge(orchestrator, entry_executor)
|
||||
@@ -1213,6 +1308,30 @@ class GroupChatBuilder:
|
||||
.build()
|
||||
)
|
||||
|
||||
*Pattern 3: Request info for mid-conversation feedback*
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import GroupChatBuilder
|
||||
|
||||
# Pause before all participants
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_select_speakers_func(select_next_speaker)
|
||||
.participants([researcher, writer])
|
||||
.with_request_info()
|
||||
.build()
|
||||
)
|
||||
|
||||
# Pause only before specific participants
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_select_speakers_func(select_next_speaker)
|
||||
.participants([researcher, writer, editor])
|
||||
.with_request_info(agents=[editor]) # Only pause before editor responds
|
||||
.build()
|
||||
)
|
||||
|
||||
**Participant Specification:**
|
||||
|
||||
Two ways to specify participants:
|
||||
@@ -1262,6 +1381,8 @@ class GroupChatBuilder:
|
||||
self._interceptors: list[_InterceptorSpec] = []
|
||||
self._orchestrator_factory = group_chat_orchestrator(_orchestrator_factory)
|
||||
self._participant_factory = _participant_factory or _default_participant_factory
|
||||
self._request_info_enabled: bool = False
|
||||
self._request_info_filter: set[str] | None = None
|
||||
|
||||
def _set_manager_function(
|
||||
self,
|
||||
@@ -1338,6 +1459,12 @@ class GroupChatBuilder:
|
||||
Note:
|
||||
The manager agent's response_format must be ManagerSelectionResponse for structured output.
|
||||
Custom response formats raise ValueError instead of being overridden.
|
||||
|
||||
The manager can be included in :py:meth:`with_request_info` to pause before the manager
|
||||
runs, allowing human steering of orchestration decisions. If no filter is specified,
|
||||
the manager is included automatically. To filter explicitly::
|
||||
|
||||
.with_request_info(agents=[manager, writer]) # Pause before manager and writer
|
||||
"""
|
||||
if self._manager is not None or self._manager_participant is not None:
|
||||
raise ValueError(
|
||||
@@ -1668,6 +1795,54 @@ class GroupChatBuilder:
|
||||
self._max_rounds = max_rounds
|
||||
return self
|
||||
|
||||
def with_request_info(
|
||||
self,
|
||||
*,
|
||||
agents: Sequence[str | AgentProtocol | Executor] | None = None,
|
||||
) -> "GroupChatBuilder":
|
||||
"""Enable request info before participants run in the workflow.
|
||||
|
||||
When enabled, the workflow pauses before each participant runs, emitting
|
||||
a RequestInfoEvent that allows the caller to review the conversation and
|
||||
optionally inject guidance before the participant responds. The caller provides
|
||||
input via the standard response_handler/request_info pattern.
|
||||
|
||||
Args:
|
||||
agents: Optional filter - only pause before these specific agents/executors.
|
||||
Accepts agent names (str), agent instances, or executor instances.
|
||||
If None (default), pauses before every participant.
|
||||
|
||||
Returns:
|
||||
self: The builder instance for fluent chaining.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Pause before all participants
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_manager(manager)
|
||||
.participants([optimist, pragmatist, creative])
|
||||
.with_request_info()
|
||||
.build()
|
||||
)
|
||||
|
||||
# Pause only before specific participants
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_manager(manager)
|
||||
.participants([optimist, pragmatist, creative])
|
||||
.with_request_info(agents=[pragmatist]) # Only pause before pragmatist
|
||||
.build()
|
||||
)
|
||||
"""
|
||||
from ._orchestration_request_info import resolve_request_info_filter
|
||||
|
||||
self._request_info_enabled = True
|
||||
self._request_info_filter = resolve_request_info_filter(list(agents) if agents else None)
|
||||
return self
|
||||
|
||||
def _get_participant_metadata(self) -> dict[str, Any]:
|
||||
if self._participant_metadata is None:
|
||||
self._participant_metadata = prepare_participant_metadata(
|
||||
@@ -1754,9 +1929,32 @@ class GroupChatBuilder:
|
||||
participant_executors=metadata["executors"],
|
||||
)
|
||||
|
||||
# Determine participant factory - wrap if request info is enabled
|
||||
participant_factory = self._participant_factory
|
||||
if self._request_info_enabled:
|
||||
# Create a wrapper factory that adds request info interceptor before each participant
|
||||
base_factory = participant_factory
|
||||
agent_filter = self._request_info_filter
|
||||
|
||||
def _factory_with_request_info(
|
||||
spec: GroupChatParticipantSpec,
|
||||
config: _GroupChatConfig,
|
||||
) -> _GroupChatParticipantPipeline:
|
||||
pipeline = list(base_factory(spec, config))
|
||||
if pipeline:
|
||||
# Add interceptor executor BEFORE the participant (prepend)
|
||||
interceptor = RequestInfoInterceptor(
|
||||
executor_id=f"request_info:{spec.name}",
|
||||
agent_filter=agent_filter,
|
||||
)
|
||||
pipeline.insert(0, interceptor)
|
||||
return tuple(pipeline)
|
||||
|
||||
participant_factory = _factory_with_request_info
|
||||
|
||||
result = assemble_group_chat_workflow(
|
||||
wiring=wiring,
|
||||
participant_factory=self._participant_factory,
|
||||
participant_factory=participant_factory,
|
||||
orchestrator_factory=self._orchestrator_factory,
|
||||
interceptors=self._interceptors,
|
||||
checkpoint_storage=self._checkpoint_storage,
|
||||
|
||||
@@ -45,8 +45,10 @@ from ._executor import Executor, handler
|
||||
from ._group_chat import (
|
||||
_default_participant_factory, # type: ignore[reportPrivateUsage]
|
||||
_GroupChatConfig, # type: ignore[reportPrivateUsage]
|
||||
_GroupChatParticipantPipeline, # type: ignore[reportPrivateUsage]
|
||||
assemble_group_chat_workflow,
|
||||
)
|
||||
from ._orchestration_request_info import RequestInfoInterceptor
|
||||
from ._orchestrator_helpers import clean_conversation_for_handoff
|
||||
from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, sanitize_identifier
|
||||
from ._request_info_mixin import response_handler
|
||||
@@ -315,6 +317,30 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
|
||||
"""Get the coordinator name for orchestrator-generated messages."""
|
||||
return "handoff_coordinator"
|
||||
|
||||
def _extract_agent_id_from_source(self, source: str | None) -> str | None:
|
||||
"""Extract the original agent ID from the source executor ID.
|
||||
|
||||
When a request info interceptor is in the pipeline, the source will be
|
||||
like 'request_info:agent_name'. This method extracts the
|
||||
actual agent ID.
|
||||
|
||||
Args:
|
||||
source: The source executor ID from the workflow context
|
||||
|
||||
Returns:
|
||||
The actual agent ID, or the original source if not an interceptor
|
||||
"""
|
||||
if source is None:
|
||||
return None
|
||||
if source.startswith("request_info:"):
|
||||
return source[len("request_info:") :]
|
||||
# TODO(@moonbox3): Remove legacy prefix support in a separate PR (GA cleanup)
|
||||
if source.startswith("human_review:"):
|
||||
return source[len("human_review:") :]
|
||||
if source.startswith("human_input_interceptor:"):
|
||||
return source[len("human_input_interceptor:") :]
|
||||
return source
|
||||
|
||||
@handler
|
||||
async def handle_agent_response(
|
||||
self,
|
||||
@@ -322,7 +348,8 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
|
||||
ctx: WorkflowContext[AgentExecutorRequest | list[ChatMessage], list[ChatMessage] | _ConversationForUserInput],
|
||||
) -> None:
|
||||
"""Process an agent's response and determine whether to route, request input, or terminate."""
|
||||
source = ctx.get_source_executor_id()
|
||||
raw_source = ctx.get_source_executor_id()
|
||||
source = self._extract_agent_id_from_source(raw_source)
|
||||
is_starting_agent = source == self._starting_agent_id
|
||||
|
||||
# On first turn of a run, conversation is empty
|
||||
@@ -400,8 +427,8 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
|
||||
cleaned_for_display = clean_conversation_for_handoff(conversation)
|
||||
|
||||
# The awaiting_agent_id is the agent that just responded and is awaiting user input
|
||||
# This is the source of the current response
|
||||
next_agent_id = source
|
||||
# This is the source of the current response (fallback to starting agent if source is unknown)
|
||||
next_agent_id = source or self._starting_agent_id
|
||||
|
||||
message_to_gateway = _ConversationForUserInput(conversation=cleaned_for_display, next_agent_id=next_agent_id)
|
||||
await ctx.send_message(message_to_gateway, target_id=self._input_gateway_id) # type: ignore[arg-type]
|
||||
@@ -826,6 +853,8 @@ class HandoffBuilder:
|
||||
self._return_to_previous: bool = False
|
||||
self._interaction_mode: Literal["human_in_loop", "autonomous"] = "human_in_loop"
|
||||
self._autonomous_turn_limit: int | None = _DEFAULT_AUTONOMOUS_TURN_LIMIT
|
||||
self._request_info_enabled: bool = False
|
||||
self._request_info_filter: set[str] | None = None
|
||||
|
||||
if participants:
|
||||
self.participants(participants)
|
||||
@@ -1418,6 +1447,52 @@ class HandoffBuilder:
|
||||
self._return_to_previous = enabled
|
||||
return self
|
||||
|
||||
def with_request_info(
|
||||
self,
|
||||
*,
|
||||
agents: Sequence[str | AgentProtocol | Executor] | None = None,
|
||||
) -> "HandoffBuilder":
|
||||
"""Enable request info before participants run in the workflow.
|
||||
|
||||
When enabled, the workflow pauses before each participant runs, emitting
|
||||
a RequestInfoEvent that allows the caller to review the conversation and
|
||||
optionally inject guidance before the participant responds. The caller provides
|
||||
input via the standard response_handler/request_info pattern.
|
||||
|
||||
Args:
|
||||
agents: Optional filter - only pause before these specific agents/executors.
|
||||
Accepts agent names (str), agent instances, or executor instances.
|
||||
If None (default), pauses before every participant.
|
||||
|
||||
Returns:
|
||||
self: The builder instance for fluent chaining.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Pause before all participants
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[coordinator, refund, shipping])
|
||||
.set_coordinator("coordinator_agent")
|
||||
.with_request_info()
|
||||
.build()
|
||||
)
|
||||
|
||||
# Pause only before specialist agents (not coordinator)
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[coordinator, refund, shipping])
|
||||
.set_coordinator("coordinator_agent")
|
||||
.with_request_info(agents=[refund, shipping])
|
||||
.build()
|
||||
)
|
||||
"""
|
||||
from ._orchestration_request_info import resolve_request_info_filter
|
||||
|
||||
self._request_info_enabled = True
|
||||
self._request_info_filter = resolve_request_info_filter(list(agents) if agents else None)
|
||||
return self
|
||||
|
||||
def build(self) -> Workflow:
|
||||
"""Construct the final Workflow instance from the configured builder.
|
||||
|
||||
@@ -1562,9 +1637,33 @@ class HandoffBuilder:
|
||||
participant_executors=self._executors,
|
||||
)
|
||||
|
||||
# Determine participant factory - wrap with request info interceptor if enabled
|
||||
participant_factory: Callable[[GroupChatParticipantSpec, _GroupChatConfig], _GroupChatParticipantPipeline] = (
|
||||
_default_participant_factory
|
||||
)
|
||||
if self._request_info_enabled:
|
||||
base_factory = _default_participant_factory
|
||||
agent_filter = self._request_info_filter
|
||||
|
||||
def _factory_with_request_info(
|
||||
spec: GroupChatParticipantSpec,
|
||||
config: _GroupChatConfig,
|
||||
) -> _GroupChatParticipantPipeline:
|
||||
pipeline = list(base_factory(spec, config))
|
||||
if pipeline:
|
||||
# Add interceptor executor BEFORE the participant (prepend)
|
||||
interceptor = RequestInfoInterceptor(
|
||||
executor_id=f"request_info:{spec.name}",
|
||||
agent_filter=agent_filter,
|
||||
)
|
||||
pipeline.insert(0, interceptor)
|
||||
return tuple(pipeline)
|
||||
|
||||
participant_factory = _factory_with_request_info
|
||||
|
||||
result = assemble_group_chat_workflow(
|
||||
wiring=wiring,
|
||||
participant_factory=_default_participant_factory,
|
||||
participant_factory=participant_factory,
|
||||
orchestrator_factory=_handoff_orchestrator_factory,
|
||||
interceptors=(),
|
||||
checkpoint_storage=self._checkpoint_storage,
|
||||
@@ -1575,7 +1674,18 @@ class HandoffBuilder:
|
||||
raise TypeError("Expected tuple from assemble_group_chat_workflow with return_builder=True")
|
||||
builder, coordinator = result
|
||||
|
||||
builder = builder.add_edge(input_node, starting_executor)
|
||||
# When request_info is enabled, the input should go through the interceptor first
|
||||
if self._request_info_enabled:
|
||||
# Get the entry executor from the builder's registered executors
|
||||
starting_entry_id = f"request_info:{self._starting_agent_id}"
|
||||
starting_entry_executor = builder._executors.get(starting_entry_id) # type: ignore
|
||||
if starting_entry_executor:
|
||||
builder = builder.add_edge(input_node, starting_entry_executor)
|
||||
else:
|
||||
# Fallback to direct connection if interceptor not found
|
||||
builder = builder.add_edge(input_node, starting_executor)
|
||||
else:
|
||||
builder = builder.add_edge(input_node, starting_executor)
|
||||
builder = builder.add_edge(coordinator, user_gateway)
|
||||
builder = builder.add_edge(user_gateway, coordinator)
|
||||
|
||||
|
||||
@@ -2089,6 +2089,17 @@ class MagenticBuilder:
|
||||
The builder provides a fluent API for configuring participants, the manager, optional
|
||||
plan review, checkpointing, and event callbacks.
|
||||
|
||||
Human-in-the-loop Support:
|
||||
Magentic provides specialized HITL mechanisms via:
|
||||
|
||||
- `.with_plan_review()` - Review and approve/revise plans before execution
|
||||
- `.with_human_input_on_stall()` - Intervene when workflow stalls
|
||||
- Tool approval via `FunctionApprovalRequestContent` - Approve individual tool calls
|
||||
|
||||
These emit `MagenticHumanInterventionRequest` events that provide structured
|
||||
decision options (APPROVE, REVISE, CONTINUE, REPLAN, GUIDANCE) appropriate
|
||||
for Magentic's planning-based orchestration.
|
||||
|
||||
Usage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Request info support for high-level builder APIs.
|
||||
|
||||
This module provides a mechanism for pausing workflows to request external input
|
||||
before agent turns in `SequentialBuilder`, `ConcurrentBuilder`, `GroupChatBuilder`,
|
||||
and `HandoffBuilder`.
|
||||
|
||||
The design follows the standard `request_info` pattern used throughout the
|
||||
workflow system, keeping the API consistent and predictable.
|
||||
|
||||
Key components:
|
||||
- AgentInputRequest: Request type emitted via RequestInfoEvent for pre-agent steering
|
||||
- RequestInfoInterceptor: Internal executor that pauses workflow before agent runs
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .._agents import AgentProtocol
|
||||
from .._types import ChatMessage, Role
|
||||
from ._agent_executor import AgentExecutorRequest
|
||||
from ._executor import Executor, handler
|
||||
from ._request_info_mixin import response_handler
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_request_info_filter(
|
||||
agents: list[str | AgentProtocol | Executor] | None,
|
||||
) -> set[str] | None:
|
||||
"""Resolve a list of agent/executor references to a set of IDs for filtering.
|
||||
|
||||
Args:
|
||||
agents: List of agent names (str), AgentProtocol instances, or Executor instances.
|
||||
If None, returns None (meaning no filtering - pause for all).
|
||||
|
||||
Returns:
|
||||
Set of executor/agent IDs to filter on, or None if no filtering.
|
||||
"""
|
||||
if agents is None:
|
||||
return None
|
||||
|
||||
result: set[str] = set()
|
||||
for agent in agents:
|
||||
if isinstance(agent, str):
|
||||
result.add(agent)
|
||||
elif isinstance(agent, Executor):
|
||||
result.add(agent.id)
|
||||
elif isinstance(agent, AgentProtocol):
|
||||
name = getattr(agent, "name", None)
|
||||
if name:
|
||||
result.add(name)
|
||||
else:
|
||||
logger.warning("AgentProtocol without name cannot be used for request_info filtering")
|
||||
else:
|
||||
logger.warning(f"Unsupported type for request_info filter: {type(agent).__name__}")
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentInputRequest:
|
||||
"""Request for human input before an agent runs in high-level builder workflows.
|
||||
|
||||
Emitted via RequestInfoEvent when a workflow pauses before an agent executes.
|
||||
The response is injected into the conversation as a user message to steer
|
||||
the agent's behavior.
|
||||
|
||||
This is the standard request type used by `.with_request_info()` on
|
||||
SequentialBuilder, ConcurrentBuilder, GroupChatBuilder, and HandoffBuilder.
|
||||
|
||||
Attributes:
|
||||
target_agent_id: ID of the agent that is about to run
|
||||
conversation: Current conversation history the agent will receive
|
||||
instruction: Optional instruction from the orchestrator (e.g., manager in GroupChat)
|
||||
metadata: Builder-specific context (stores internal state for resume)
|
||||
"""
|
||||
|
||||
target_agent_id: str | None
|
||||
conversation: list[ChatMessage] = field(default_factory=lambda: [])
|
||||
instruction: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=lambda: {})
|
||||
|
||||
|
||||
# Keep legacy name as alias for backward compatibility
|
||||
AgentResponseReviewRequest = AgentInputRequest
|
||||
|
||||
|
||||
DEFAULT_REQUEST_INFO_ID = "request_info_interceptor"
|
||||
|
||||
|
||||
class RequestInfoInterceptor(Executor):
|
||||
"""Internal executor that pauses workflow for human input before agent runs.
|
||||
|
||||
This executor is inserted into the workflow graph by builders when
|
||||
`.with_request_info()` is called. It intercepts AgentExecutorRequest messages
|
||||
BEFORE the agent runs and pauses the workflow via `ctx.request_info()` with
|
||||
an AgentInputRequest.
|
||||
|
||||
When a response is received, the response handler injects the input
|
||||
as a user message into the conversation and forwards the request to the agent.
|
||||
|
||||
The optional `agent_filter` parameter allows limiting which agents trigger the pause.
|
||||
If the target agent's ID is not in the filter set, the request is forwarded
|
||||
without pausing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
executor_id: str | None = None,
|
||||
agent_filter: set[str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the request info interceptor executor.
|
||||
|
||||
Args:
|
||||
executor_id: ID for this executor. If None, generates a unique ID
|
||||
using the format "request_info_interceptor-<uuid4>".
|
||||
agent_filter: Optional set of agent/executor IDs to filter on.
|
||||
If provided, only requests to these agents trigger a pause.
|
||||
If None (default), all requests trigger a pause.
|
||||
"""
|
||||
if executor_id is None:
|
||||
executor_id = f"{DEFAULT_REQUEST_INFO_ID}-{uuid.uuid4().hex[:8]}"
|
||||
super().__init__(executor_id)
|
||||
self._agent_filter = agent_filter
|
||||
|
||||
def _should_pause_for_agent(self, agent_id: str | None) -> bool:
|
||||
"""Check if we should pause for the given agent ID."""
|
||||
if self._agent_filter is None:
|
||||
return True
|
||||
if agent_id is None:
|
||||
return False
|
||||
# Check both the full ID and any name portion after a prefix
|
||||
# e.g., "groupchat_agent:writer" should match filter "writer"
|
||||
if agent_id in self._agent_filter:
|
||||
return True
|
||||
# Extract name from prefixed IDs like "groupchat_agent:writer" or "request_info:writer"
|
||||
if ":" in agent_id:
|
||||
name_part = agent_id.split(":", 1)[1]
|
||||
if name_part in self._agent_filter:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _extract_agent_name_from_executor_id(self) -> str | None:
|
||||
"""Extract the agent name from this interceptor's executor ID.
|
||||
|
||||
The interceptor ID is typically "request_info:<agent_name>", so we
|
||||
extract the agent name to determine which agent we're intercepting for.
|
||||
"""
|
||||
if ":" in self.id:
|
||||
return self.id.split(":", 1)[1]
|
||||
return None
|
||||
|
||||
@handler
|
||||
async def intercept_agent_request(
|
||||
self,
|
||||
request: AgentExecutorRequest,
|
||||
ctx: WorkflowContext[AgentExecutorRequest, Any],
|
||||
) -> None:
|
||||
"""Intercept request before agent runs and pause for human input.
|
||||
|
||||
Pauses the workflow and emits a RequestInfoEvent with the current
|
||||
conversation for steering. If an agent filter is configured and this
|
||||
agent is not in the filter, the request is forwarded without pausing.
|
||||
|
||||
Args:
|
||||
request: The request about to be sent to the agent
|
||||
ctx: Workflow context for requesting info
|
||||
"""
|
||||
# Determine the target agent from our executor ID
|
||||
target_agent = self._extract_agent_name_from_executor_id()
|
||||
|
||||
# Check if we should pause for this agent
|
||||
if not self._should_pause_for_agent(target_agent):
|
||||
logger.debug(f"Skipping request_info pause for agent {target_agent} (not in filter)")
|
||||
await ctx.send_message(request)
|
||||
return
|
||||
|
||||
conversation = list(request.messages or [])
|
||||
|
||||
input_request = AgentInputRequest(
|
||||
target_agent_id=target_agent,
|
||||
conversation=conversation,
|
||||
instruction=None, # Could be extended to include manager instruction
|
||||
metadata={"_original_request": request, "_input_type": "AgentExecutorRequest"},
|
||||
)
|
||||
await ctx.request_info(input_request, str)
|
||||
|
||||
@handler
|
||||
async def intercept_conversation(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
ctx: WorkflowContext[list[ChatMessage], Any],
|
||||
) -> None:
|
||||
"""Intercept conversation before agent runs (used by SequentialBuilder).
|
||||
|
||||
SequentialBuilder passes list[ChatMessage] directly to agents. This handler
|
||||
intercepts that flow and pauses for human input.
|
||||
|
||||
Args:
|
||||
messages: The conversation about to be sent to the agent
|
||||
ctx: Workflow context for requesting info
|
||||
"""
|
||||
# Determine the target agent from our executor ID
|
||||
target_agent = self._extract_agent_name_from_executor_id()
|
||||
|
||||
# Check if we should pause for this agent
|
||||
if not self._should_pause_for_agent(target_agent):
|
||||
logger.debug(f"Skipping request_info pause for agent {target_agent} (not in filter)")
|
||||
await ctx.send_message(messages)
|
||||
return
|
||||
|
||||
input_request = AgentInputRequest(
|
||||
target_agent_id=target_agent,
|
||||
conversation=list(messages),
|
||||
instruction=None,
|
||||
metadata={"_original_messages": messages, "_input_type": "list[ChatMessage]"},
|
||||
)
|
||||
await ctx.request_info(input_request, str)
|
||||
|
||||
@handler
|
||||
async def intercept_concurrent_requests(
|
||||
self,
|
||||
requests: list[AgentExecutorRequest],
|
||||
ctx: WorkflowContext[list[AgentExecutorRequest], Any],
|
||||
) -> None:
|
||||
"""Intercept requests before concurrent agents run.
|
||||
|
||||
This handler is used by ConcurrentBuilder to get human input before
|
||||
all parallel agents execute.
|
||||
|
||||
Args:
|
||||
requests: List of requests for all concurrent agents
|
||||
ctx: Workflow context for requesting info
|
||||
"""
|
||||
# Combine conversations for display
|
||||
combined_conversation: list[ChatMessage] = []
|
||||
if requests:
|
||||
combined_conversation = list(requests[0].messages or [])
|
||||
|
||||
input_request = AgentInputRequest(
|
||||
target_agent_id=None, # Multiple agents
|
||||
conversation=combined_conversation,
|
||||
instruction=None,
|
||||
metadata={"_original_requests": requests},
|
||||
)
|
||||
await ctx.request_info(input_request, str)
|
||||
|
||||
@response_handler
|
||||
async def handle_input_response(
|
||||
self,
|
||||
original_request: AgentInputRequest,
|
||||
# TODO(@moonbox3): Extend to support other content types
|
||||
response: str,
|
||||
ctx: WorkflowContext[AgentExecutorRequest | list[ChatMessage], Any],
|
||||
) -> None:
|
||||
"""Handle the human input and forward the modified request to the agent.
|
||||
|
||||
Injects the response as a user message into the conversation
|
||||
and forwards the modified request to the agent.
|
||||
|
||||
Args:
|
||||
original_request: The AgentInputRequest that triggered the pause
|
||||
response: The human input text
|
||||
ctx: Workflow context for continuing the workflow
|
||||
|
||||
TODO: Consider having each orchestration implement its own response handler
|
||||
for more specialized behavior.
|
||||
"""
|
||||
human_message = ChatMessage(role=Role.USER, text=response)
|
||||
|
||||
# Handle concurrent case (list of AgentExecutorRequest)
|
||||
original_requests: list[AgentExecutorRequest] | None = original_request.metadata.get("_original_requests")
|
||||
if original_requests is not None:
|
||||
updated_requests: list[AgentExecutorRequest] = []
|
||||
for orig_req in original_requests:
|
||||
messages = list(orig_req.messages or [])
|
||||
messages.append(human_message)
|
||||
updated_requests.append(
|
||||
AgentExecutorRequest(
|
||||
messages=messages,
|
||||
should_respond=orig_req.should_respond,
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Human input received for concurrent workflow, "
|
||||
f"continuing with {len(updated_requests)} updated requests"
|
||||
)
|
||||
await ctx.send_message(updated_requests) # type: ignore[arg-type]
|
||||
return
|
||||
|
||||
# Handle list[ChatMessage] case (SequentialBuilder)
|
||||
original_messages: list[ChatMessage] | None = original_request.metadata.get("_original_messages")
|
||||
if original_messages is not None:
|
||||
messages = list(original_messages)
|
||||
messages.append(human_message)
|
||||
|
||||
logger.debug(
|
||||
f"Human input received for agent {original_request.target_agent_id}, "
|
||||
f"forwarding conversation with steering context"
|
||||
)
|
||||
await ctx.send_message(messages)
|
||||
return
|
||||
|
||||
# Handle AgentExecutorRequest case (GroupChatBuilder)
|
||||
orig_request: AgentExecutorRequest | None = original_request.metadata.get("_original_request")
|
||||
if orig_request is not None:
|
||||
messages = list(orig_request.messages or [])
|
||||
messages.append(human_message)
|
||||
|
||||
updated_request = AgentExecutorRequest(
|
||||
messages=messages,
|
||||
should_respond=orig_request.should_respond,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Human input received for agent {original_request.target_agent_id}, "
|
||||
f"forwarding request with steering context"
|
||||
)
|
||||
await ctx.send_message(updated_request)
|
||||
return
|
||||
|
||||
logger.error("Input response handler missing original request/messages in metadata")
|
||||
raise RuntimeError("Missing original request or messages in AgentInputRequest metadata")
|
||||
@@ -140,6 +140,9 @@ class ParticipantRegistry:
|
||||
|
||||
Provides a clean interface for the common pattern of mapping participant names
|
||||
to executor IDs and tracking which are agents vs custom executors.
|
||||
|
||||
Tracks both entry IDs (where to send requests) and exit IDs (where responses
|
||||
come from) to support pipeline configurations where these differ.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -154,19 +157,26 @@ class ParticipantRegistry:
|
||||
*,
|
||||
entry_id: str,
|
||||
is_agent: bool,
|
||||
exit_id: str | None = None,
|
||||
) -> None:
|
||||
"""Register a participant's routing information.
|
||||
|
||||
Args:
|
||||
name: Participant name
|
||||
entry_id: Executor ID for this participant's entry point
|
||||
entry_id: Executor ID for this participant's entry point (where to send)
|
||||
is_agent: Whether this is an AgentExecutor (True) or custom Executor (False)
|
||||
exit_id: Executor ID for this participant's exit point (where responses come from).
|
||||
If None, defaults to entry_id (single-executor pipeline).
|
||||
"""
|
||||
self._participant_entry_ids[name] = entry_id
|
||||
actual_exit_id = exit_id if exit_id is not None else entry_id
|
||||
|
||||
if is_agent:
|
||||
self._agent_executor_ids[name] = entry_id
|
||||
# Map both entry and exit IDs to participant name for response routing
|
||||
self._executor_id_to_participant[entry_id] = name
|
||||
if actual_exit_id != entry_id:
|
||||
self._executor_id_to_participant[actual_exit_id] = name
|
||||
else:
|
||||
self._non_agent_participants.add(name)
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ confusion and to mirror how the concurrent builder uses explicit dispatcher/aggr
|
||||
""" # noqa: E501
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentProtocol, ChatMessage
|
||||
@@ -52,6 +52,7 @@ from ._executor import (
|
||||
handler,
|
||||
)
|
||||
from ._message_utils import normalize_messages_input
|
||||
from ._orchestration_request_info import RequestInfoInterceptor
|
||||
from ._workflow import Workflow
|
||||
from ._workflow_builder import WorkflowBuilder
|
||||
from ._workflow_context import WorkflowContext
|
||||
@@ -71,14 +72,8 @@ class _InputToConversation(Executor):
|
||||
await ctx.send_message(normalize_messages_input(message))
|
||||
|
||||
@handler
|
||||
async def from_messages(
|
||||
self,
|
||||
messages: list[str | ChatMessage],
|
||||
ctx: WorkflowContext[list[ChatMessage]],
|
||||
) -> None:
|
||||
# Make a copy to avoid mutation downstream
|
||||
normalized = normalize_messages_input(messages)
|
||||
await ctx.send_message(list(normalized))
|
||||
async def from_messages(self, messages: list[str | ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
await ctx.send_message(normalize_messages_input(messages))
|
||||
|
||||
|
||||
class _ResponseToConversation(Executor):
|
||||
@@ -103,7 +98,10 @@ class _EndWithConversation(Executor):
|
||||
class SequentialBuilder:
|
||||
r"""High-level builder for sequential agent/executor workflows with shared context.
|
||||
|
||||
- `participants([...])` accepts a list of AgentProtocol (recommended) or Executor
|
||||
- `participants([...])` accepts a list of AgentProtocol (recommended) or Executor instances
|
||||
- `register_participants([...])` accepts a list of factories for AgentProtocol (recommended)
|
||||
or Executor factories
|
||||
- Executors must define a handler that consumes list[ChatMessage] and sends out a list[ChatMessage]
|
||||
- The workflow wires participants in order, passing a list[ChatMessage] down the chain
|
||||
- Agents append their assistant messages to the conversation
|
||||
- Custom executors can transform/summarize and return a list[ChatMessage]
|
||||
@@ -115,15 +113,51 @@ class SequentialBuilder:
|
||||
|
||||
from agent_framework import SequentialBuilder
|
||||
|
||||
# With agent instances
|
||||
workflow = SequentialBuilder().participants([agent1, agent2, summarizer_exec]).build()
|
||||
|
||||
# With agent factories
|
||||
workflow = (
|
||||
SequentialBuilder().register_participants([create_agent1, create_agent2, create_summarizer_exec]).build()
|
||||
)
|
||||
|
||||
# Enable checkpoint persistence
|
||||
workflow = SequentialBuilder().participants([agent1, agent2]).with_checkpointing(storage).build()
|
||||
|
||||
# Enable request info for mid-workflow feedback (pauses before each agent)
|
||||
workflow = SequentialBuilder().participants([agent1, agent2]).with_request_info().build()
|
||||
|
||||
# Enable request info only for specific agents
|
||||
workflow = (
|
||||
SequentialBuilder()
|
||||
.participants([agent1, agent2, agent3])
|
||||
.with_request_info(agents=[agent2]) # Only pause before agent2
|
||||
.build()
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._participants: list[AgentProtocol | Executor] = []
|
||||
self._participant_factories: list[Callable[[], AgentProtocol | Executor]] = []
|
||||
self._checkpoint_storage: CheckpointStorage | None = None
|
||||
self._request_info_enabled: bool = False
|
||||
self._request_info_filter: set[str] | None = None
|
||||
|
||||
def register_participants(
|
||||
self,
|
||||
participant_factories: Sequence[Callable[[], AgentProtocol | Executor]],
|
||||
) -> "SequentialBuilder":
|
||||
"""Register participant factories for this sequential workflow."""
|
||||
if self._participants:
|
||||
raise ValueError(
|
||||
"Cannot mix .participants([...]) and .register_participants() in the same builder instance."
|
||||
)
|
||||
|
||||
if not participant_factories:
|
||||
raise ValueError("participant_factories cannot be empty")
|
||||
|
||||
self._participant_factories = list(participant_factories)
|
||||
return self
|
||||
|
||||
def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "SequentialBuilder":
|
||||
"""Define the ordered participants for this sequential workflow.
|
||||
@@ -131,6 +165,11 @@ class SequentialBuilder:
|
||||
Accepts AgentProtocol instances (auto-wrapped as AgentExecutor) or Executor instances.
|
||||
Raises if empty or duplicates are provided for clarity.
|
||||
"""
|
||||
if self._participant_factories:
|
||||
raise ValueError(
|
||||
"Cannot mix .participants([...]) and .register_participants() in the same builder instance."
|
||||
)
|
||||
|
||||
if not participants:
|
||||
raise ValueError("participants cannot be empty")
|
||||
|
||||
@@ -157,19 +196,70 @@ class SequentialBuilder:
|
||||
self._checkpoint_storage = checkpoint_storage
|
||||
return self
|
||||
|
||||
def with_request_info(
|
||||
self,
|
||||
*,
|
||||
agents: Sequence[str | AgentProtocol | Executor] | None = None,
|
||||
) -> "SequentialBuilder":
|
||||
"""Enable request info before agents run in the workflow.
|
||||
|
||||
When enabled, the workflow pauses before each agent runs, emitting
|
||||
a RequestInfoEvent that allows the caller to review the conversation and
|
||||
optionally inject guidance before the agent responds. The caller provides
|
||||
input via the standard response_handler/request_info pattern.
|
||||
|
||||
Args:
|
||||
agents: Optional filter - only pause before these specific agents/executors.
|
||||
Accepts agent names (str), agent instances, or executor instances.
|
||||
If None (default), pauses before every agent.
|
||||
|
||||
Returns:
|
||||
self: The builder instance for fluent chaining.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Pause before all agents
|
||||
workflow = SequentialBuilder().participants([a1, a2]).with_request_info().build()
|
||||
|
||||
# Pause only before specific agents
|
||||
workflow = (
|
||||
SequentialBuilder()
|
||||
.participants([drafter, reviewer, finalizer])
|
||||
.with_request_info(agents=[reviewer]) # Only pause before reviewer
|
||||
.build()
|
||||
)
|
||||
"""
|
||||
from ._orchestration_request_info import resolve_request_info_filter
|
||||
|
||||
self._request_info_enabled = True
|
||||
self._request_info_filter = resolve_request_info_filter(list(agents) if agents else None)
|
||||
return self
|
||||
|
||||
def build(self) -> Workflow:
|
||||
"""Build and validate the sequential workflow.
|
||||
|
||||
Wiring pattern:
|
||||
- _InputToConversation normalizes the initial input into list[ChatMessage]
|
||||
- For each participant in order:
|
||||
- If Agent (or AgentExecutor): pass conversation to the agent, then convert response
|
||||
to conversation via _ResponseToConversation
|
||||
- If Agent (or AgentExecutor): pass conversation to the agent, then optionally
|
||||
route through a request info interceptor, then convert response to conversation
|
||||
via _ResponseToConversation
|
||||
- Else (custom Executor): pass conversation directly to the executor
|
||||
- _EndWithConversation yields the final conversation and the workflow becomes idle
|
||||
"""
|
||||
if not self._participants:
|
||||
raise ValueError("No participants provided. Call .participants([...]) first.")
|
||||
if not self._participants and not self._participant_factories:
|
||||
raise ValueError(
|
||||
"No participants or participant factories provided to the builder. "
|
||||
"Use .participants([...]) or .ss([...])."
|
||||
)
|
||||
|
||||
if self._participants and self._participant_factories:
|
||||
# Defensive strategy: this should never happen due to checks in respective methods
|
||||
raise ValueError(
|
||||
"Cannot mix .participants([...]) and .register_participants() in the same builder instance."
|
||||
)
|
||||
|
||||
# Internal nodes
|
||||
input_conv = _InputToConversation(id="input-conversation")
|
||||
@@ -181,22 +271,38 @@ class SequentialBuilder:
|
||||
# Start of the chain is the input normalizer
|
||||
prior: Executor | AgentProtocol = input_conv
|
||||
|
||||
for p in self._participants:
|
||||
# Agent-like branch: either explicitly an AgentExecutor or any non-AgentExecutor
|
||||
if not (isinstance(p, Executor) and not isinstance(p, AgentExecutor)):
|
||||
# input conversation -> (agent) -> response -> conversation
|
||||
builder.add_edge(prior, p)
|
||||
# Give the adapter a deterministic, self-describing id
|
||||
label: str
|
||||
label = p.id if isinstance(p, Executor) else getattr(p, "name", None) or p.__class__.__name__
|
||||
participants: list[Executor | AgentProtocol] = []
|
||||
if self._participant_factories:
|
||||
for factory in self._participant_factories:
|
||||
p = factory()
|
||||
participants.append(p)
|
||||
else:
|
||||
participants = self._participants
|
||||
|
||||
for p in participants:
|
||||
if isinstance(p, (AgentProtocol, AgentExecutor)):
|
||||
label = p.id if isinstance(p, AgentExecutor) else p.display_name
|
||||
|
||||
if self._request_info_enabled:
|
||||
# Insert request info interceptor BEFORE the agent
|
||||
interceptor = RequestInfoInterceptor(
|
||||
executor_id=f"request_info:{label}",
|
||||
agent_filter=self._request_info_filter,
|
||||
)
|
||||
builder.add_edge(prior, interceptor)
|
||||
builder.add_edge(interceptor, p)
|
||||
else:
|
||||
builder.add_edge(prior, p)
|
||||
|
||||
resp_to_conv = _ResponseToConversation(id=f"to-conversation:{label}")
|
||||
builder.add_edge(p, resp_to_conv)
|
||||
prior = resp_to_conv
|
||||
elif isinstance(p, Executor):
|
||||
# Custom executor operates on list[ChatMessage]
|
||||
# If the executor doesn't handle list[ChatMessage] correctly, validation will fail
|
||||
builder.add_edge(prior, p)
|
||||
prior = p
|
||||
else: # pragma: no cover - defensive
|
||||
else:
|
||||
raise TypeError(f"Unsupported participant type: {type(p).__name__}")
|
||||
|
||||
# Terminate with the final conversation
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from dataclasses import fields, is_dataclass
|
||||
from types import UnionType
|
||||
from typing import Any, TypeVar, Union, cast, get_args, get_origin
|
||||
|
||||
@@ -10,67 +9,6 @@ logger = logging.getLogger(__name__)
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def _coerce_to_type(value: Any, target_type: type[T]) -> T | None:
|
||||
"""Best-effort conversion of value into target_type.
|
||||
|
||||
Args:
|
||||
value: The value to convert (can be dict, dataclass, or object with __dict__)
|
||||
target_type: The target type to convert to
|
||||
|
||||
Returns:
|
||||
Instance of target_type if conversion succeeds, None otherwise
|
||||
"""
|
||||
if isinstance(value, target_type):
|
||||
return value # type: ignore[return-value]
|
||||
|
||||
# Convert dataclass instances or objects with __dict__ into dict first
|
||||
value_as_dict: dict[str, Any]
|
||||
if not isinstance(value, dict):
|
||||
if is_dataclass(value):
|
||||
value_as_dict = {f.name: getattr(value, f.name) for f in fields(value)}
|
||||
else:
|
||||
value_dict = getattr(value, "__dict__", None)
|
||||
if isinstance(value_dict, dict):
|
||||
value_as_dict = cast(dict[str, Any], value_dict)
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
value_as_dict = cast(dict[str, Any], value)
|
||||
|
||||
# Try to construct the target type from the dict
|
||||
ctor_kwargs: dict[str, Any] = dict(value_as_dict)
|
||||
|
||||
if is_dataclass(target_type):
|
||||
field_names = {f.name for f in fields(target_type)}
|
||||
ctor_kwargs = {k: v for k, v in value_as_dict.items() if k in field_names}
|
||||
|
||||
try:
|
||||
return target_type(**ctor_kwargs) # type: ignore[call-arg,return-value]
|
||||
except TypeError as exc:
|
||||
logger.debug(f"_coerce_to_type could not call {target_type.__name__}(**..): {exc}")
|
||||
except Exception as exc: # pragma: no cover - unexpected constructor failure
|
||||
logger.warning(
|
||||
f"_coerce_to_type encountered unexpected error calling {target_type.__name__} constructor: {exc}"
|
||||
)
|
||||
|
||||
# Fallback: try to create instance without __init__ and set attributes
|
||||
try:
|
||||
instance = object.__new__(target_type)
|
||||
except Exception as exc: # pragma: no cover - pathological type
|
||||
logger.debug(f"_coerce_to_type could not allocate {target_type.__name__} without __init__: {exc}")
|
||||
return None
|
||||
|
||||
for key, val in value_as_dict.items():
|
||||
try:
|
||||
setattr(instance, key, val)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
f"_coerce_to_type could not set {target_type.__name__}.{key} during fallback assignment: {exc}"
|
||||
)
|
||||
continue
|
||||
return instance # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_instance_of(data: Any, target_type: type | UnionType | Any) -> bool:
|
||||
"""Check if the data is an instance of the target type.
|
||||
|
||||
|
||||
@@ -374,7 +374,7 @@ class WorkflowBuilder:
|
||||
)
|
||||
"""
|
||||
if name in self._executor_registry:
|
||||
raise ValueError(f"An executor factory with the name '{name}' is already registered.")
|
||||
raise ValueError(f"An agent factory with the name '{name}' is already registered.")
|
||||
|
||||
def wrapped_factory() -> AgentExecutor:
|
||||
agent = factory_func()
|
||||
@@ -1148,21 +1148,29 @@ class WorkflowBuilder:
|
||||
if isinstance(self._start_executor, Executor):
|
||||
start_executor = self._start_executor
|
||||
|
||||
executors: dict[str, Executor] = {}
|
||||
# Maps registered factory names to created executor instances for edge resolution
|
||||
factory_name_to_instance: dict[str, Executor] = {}
|
||||
# Maps executor IDs to created executor instances to prevent duplicates
|
||||
executor_id_to_instance: dict[str, Executor] = {}
|
||||
deferred_edge_groups: list[EdgeGroup] = []
|
||||
for name, exec_factory in self._executor_registry.items():
|
||||
instance = exec_factory()
|
||||
if instance.id in executor_id_to_instance:
|
||||
raise ValueError(f"Executor with ID '{instance.id}' has already been created.")
|
||||
executor_id_to_instance[instance.id] = instance
|
||||
|
||||
if isinstance(self._start_executor, str) and name == self._start_executor:
|
||||
start_executor = instance
|
||||
|
||||
# All executors will get their own internal edge group for receiving system messages
|
||||
deferred_edge_groups.append(InternalEdgeGroup(instance.id)) # type: ignore[call-arg]
|
||||
executors[name] = instance
|
||||
factory_name_to_instance[name] = instance
|
||||
|
||||
def _get_executor(name: str) -> Executor:
|
||||
"""Helper to get executor by the registered name. Raises if not found."""
|
||||
if name not in executors:
|
||||
raise ValueError(f"Executor with name '{name}' has not been registered.")
|
||||
return executors[name]
|
||||
if name not in factory_name_to_instance:
|
||||
raise ValueError(f"Factory '{name}' has not been registered.")
|
||||
return factory_name_to_instance[name]
|
||||
|
||||
for registration in self._edge_registry:
|
||||
match registration:
|
||||
@@ -1179,7 +1187,7 @@ class WorkflowBuilder:
|
||||
cases_converted: list[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault] = []
|
||||
for case in cases:
|
||||
if not isinstance(case.target, str):
|
||||
raise ValueError("Switch case target must be a registered executor name (str) if deferred.")
|
||||
raise ValueError("Switch case target must be a registered factory name (str) if deferred.")
|
||||
target_exec = _get_executor(case.target)
|
||||
if isinstance(case, Default):
|
||||
cases_converted.append(SwitchCaseEdgeGroupDefault(target_id=target_exec.id))
|
||||
@@ -1201,7 +1209,7 @@ class WorkflowBuilder:
|
||||
if start_executor is None:
|
||||
raise ValueError("Failed to resolve starting executor from registered factories.")
|
||||
|
||||
return start_executor, list(executors.values()), deferred_edge_groups
|
||||
return start_executor, list(executor_id_to_instance.values()), deferred_edge_groups
|
||||
|
||||
def build(self) -> Workflow:
|
||||
"""Build and return the constructed workflow.
|
||||
|
||||
@@ -521,9 +521,9 @@ class ObservabilitySettings(AFBaseSettings):
|
||||
logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
|
||||
should_add_console_exporter = False
|
||||
if should_add_console_exporter:
|
||||
from opentelemetry.sdk._logs.export import ConsoleLogExporter
|
||||
from opentelemetry.sdk._logs.export import ConsoleLogRecordExporter
|
||||
|
||||
logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter()))
|
||||
logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogRecordExporter()))
|
||||
|
||||
# Attach a handler with the provider to the root logger
|
||||
logger = logging.getLogger()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, MutableSequence, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from itertools import chain
|
||||
from typing import Any, TypeVar
|
||||
from typing import Any, TypeVar, cast
|
||||
|
||||
from openai import AsyncOpenAI, BadRequestError
|
||||
from openai.types.responses.file_search_tool_param import FileSearchToolParam
|
||||
@@ -199,7 +199,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
return response_format, prepared_text
|
||||
|
||||
if isinstance(response_format, Mapping):
|
||||
format_config = self._convert_response_format(response_format)
|
||||
format_config = self._convert_response_format(cast("Mapping[str, Any]", response_format))
|
||||
if prepared_text is None:
|
||||
prepared_text = {}
|
||||
elif "format" in prepared_text and prepared_text["format"] != format_config:
|
||||
@@ -212,20 +212,21 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
def _convert_response_format(self, response_format: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Convert Chat style response_format into Responses text format config."""
|
||||
if "format" in response_format and isinstance(response_format["format"], Mapping):
|
||||
return dict(response_format["format"])
|
||||
return dict(cast("Mapping[str, Any]", response_format["format"]))
|
||||
|
||||
format_type = response_format.get("type")
|
||||
if format_type == "json_schema":
|
||||
schema_section = response_format.get("json_schema", response_format)
|
||||
if not isinstance(schema_section, Mapping):
|
||||
raise ServiceInvalidRequestError("json_schema response_format must be a mapping.")
|
||||
schema = schema_section.get("schema")
|
||||
schema_section_typed = cast("Mapping[str, Any]", schema_section)
|
||||
schema: Any = schema_section_typed.get("schema")
|
||||
if schema is None:
|
||||
raise ServiceInvalidRequestError("json_schema response_format requires a schema.")
|
||||
name = (
|
||||
schema_section.get("name")
|
||||
or schema_section.get("title")
|
||||
or (schema.get("title") if isinstance(schema, Mapping) else None)
|
||||
name: str = str(
|
||||
schema_section_typed.get("name")
|
||||
or schema_section_typed.get("title")
|
||||
or (cast("Mapping[str, Any]", schema).get("title") if isinstance(schema, Mapping) else None)
|
||||
or "response"
|
||||
)
|
||||
format_config: dict[str, Any] = {
|
||||
@@ -532,12 +533,13 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
"text": content.text,
|
||||
},
|
||||
}
|
||||
if content.additional_properties is not None:
|
||||
if status := content.additional_properties.get("status"):
|
||||
props: dict[str, Any] | None = getattr(content, "additional_properties", None)
|
||||
if props:
|
||||
if status := props.get("status"):
|
||||
ret["status"] = status
|
||||
if reasoning_text := content.additional_properties.get("reasoning_text"):
|
||||
if reasoning_text := props.get("reasoning_text"):
|
||||
ret["content"] = {"type": "reasoning_text", "text": reasoning_text}
|
||||
if encrypted_content := content.additional_properties.get("encrypted_content"):
|
||||
if encrypted_content := props.get("encrypted_content"):
|
||||
ret["encrypted_content"] = encrypted_content
|
||||
return ret
|
||||
case DataContent() | UriContent():
|
||||
@@ -824,7 +826,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
"raw_representation": response,
|
||||
}
|
||||
|
||||
conversation_id = self.get_conversation_id(response, chat_options.store)
|
||||
conversation_id = self.get_conversation_id(response, chat_options.store) # type: ignore[reportArgumentType]
|
||||
|
||||
if conversation_id:
|
||||
args["conversation_id"] = conversation_id
|
||||
@@ -911,6 +913,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
metadata.update(self._get_metadata_from_response(event_part))
|
||||
case "refusal":
|
||||
contents.append(TextContent(text=event_part.refusal, raw_representation=event))
|
||||
case _:
|
||||
pass
|
||||
case "response.output_text.delta":
|
||||
contents.append(TextContent(text=event.delta, raw_representation=event))
|
||||
metadata.update(self._get_metadata_from_response(event))
|
||||
@@ -1032,6 +1036,60 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
raw_representation=event,
|
||||
)
|
||||
)
|
||||
case "response.output_text.annotation.added":
|
||||
# Handle streaming text annotations (file citations, file paths, etc.)
|
||||
annotation: Any = event.annotation
|
||||
|
||||
def _get_ann_value(key: str) -> Any:
|
||||
"""Extract value from annotation (dict or object)."""
|
||||
if isinstance(annotation, dict):
|
||||
return cast("dict[str, Any]", annotation).get(key)
|
||||
return getattr(annotation, key, None)
|
||||
|
||||
ann_type = _get_ann_value("type")
|
||||
ann_file_id = _get_ann_value("file_id")
|
||||
if ann_type == "file_path":
|
||||
if ann_file_id:
|
||||
contents.append(
|
||||
HostedFileContent(
|
||||
file_id=str(ann_file_id),
|
||||
additional_properties={
|
||||
"annotation_index": event.annotation_index,
|
||||
"index": _get_ann_value("index"),
|
||||
},
|
||||
raw_representation=event,
|
||||
)
|
||||
)
|
||||
elif ann_type == "file_citation":
|
||||
if ann_file_id:
|
||||
contents.append(
|
||||
HostedFileContent(
|
||||
file_id=str(ann_file_id),
|
||||
additional_properties={
|
||||
"annotation_index": event.annotation_index,
|
||||
"filename": _get_ann_value("filename"),
|
||||
"index": _get_ann_value("index"),
|
||||
},
|
||||
raw_representation=event,
|
||||
)
|
||||
)
|
||||
elif ann_type == "container_file_citation":
|
||||
if ann_file_id:
|
||||
contents.append(
|
||||
HostedFileContent(
|
||||
file_id=str(ann_file_id),
|
||||
additional_properties={
|
||||
"annotation_index": event.annotation_index,
|
||||
"container_id": _get_ann_value("container_id"),
|
||||
"filename": _get_ann_value("filename"),
|
||||
"start_index": _get_ann_value("start_index"),
|
||||
"end_index": _get_ann_value("end_index"),
|
||||
},
|
||||
raw_representation=event,
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.debug("Unparsed annotation type in streaming: %s", ann_type)
|
||||
case _:
|
||||
logger.debug("Unparsed event of type: %s: %s", event.type, event)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -35,7 +35,7 @@ dependencies = [
|
||||
# connectors and functions
|
||||
"openai>=1.99.0",
|
||||
"azure-identity>=1,<2",
|
||||
"mcp[ws]>=1.13",
|
||||
"mcp[ws]>=1.23",
|
||||
"packaging>=24.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
from mcp import types
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.shared.exceptions import McpError
|
||||
from pydantic import AnyUrl, ValidationError
|
||||
from pydantic import AnyUrl, BaseModel, ValidationError
|
||||
|
||||
from agent_framework import (
|
||||
ChatMessage,
|
||||
@@ -357,122 +357,360 @@ def test_chat_message_to_mcp_types():
|
||||
assert isinstance(mcp_contents[1], types.ImageContent)
|
||||
|
||||
|
||||
def test_get_input_model_from_mcp_tool():
|
||||
"""Test creation of input model from MCP tool."""
|
||||
tool = types.Tool(
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {"param1": {"type": "string"}, "param2": {"type": "number"}},
|
||||
"required": ["param1"],
|
||||
},
|
||||
)
|
||||
model = _get_input_model_from_mcp_tool(tool)
|
||||
|
||||
# Create an instance to verify the model works
|
||||
instance = model(param1="test", param2=42)
|
||||
assert instance.param1 == "test"
|
||||
assert instance.param2 == 42
|
||||
|
||||
# Test validation
|
||||
with pytest.raises(ValidationError): # Missing required param1
|
||||
model(param2=42)
|
||||
|
||||
|
||||
def test_get_input_model_from_mcp_tool_with_nested_object():
|
||||
"""Test creation of input model from MCP tool with nested object property."""
|
||||
tool = types.Tool(
|
||||
name="get_customer_detail",
|
||||
description="Get customer details",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {"customer_id": {"type": "integer"}},
|
||||
"required": ["customer_id"],
|
||||
@pytest.mark.parametrize(
|
||||
"test_id,input_schema,valid_data,expected_values,invalid_data,validation_check",
|
||||
[
|
||||
# Basic types with required/optional fields
|
||||
(
|
||||
"basic_types",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"param1": {"type": "string"}, "param2": {"type": "number"}},
|
||||
"required": ["param1"],
|
||||
},
|
||||
{"param1": "test", "param2": 42},
|
||||
{"param1": "test", "param2": 42},
|
||||
{"param2": 42}, # Missing required param1
|
||||
None,
|
||||
),
|
||||
# Nested object
|
||||
(
|
||||
"nested_object",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"properties": {"customer_id": {"type": "integer"}},
|
||||
"required": ["customer_id"],
|
||||
}
|
||||
},
|
||||
"required": ["params"],
|
||||
},
|
||||
{"params": {"customer_id": 251}},
|
||||
{"params.customer_id": 251},
|
||||
{"params": {}}, # Missing required customer_id
|
||||
lambda instance: isinstance(instance.params, BaseModel),
|
||||
),
|
||||
# $ref resolution
|
||||
(
|
||||
"ref_schema",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"params": {"$ref": "#/$defs/CustomerIdParam"}},
|
||||
"required": ["params"],
|
||||
"$defs": {
|
||||
"CustomerIdParam": {
|
||||
"type": "object",
|
||||
"properties": {"customer_id": {"type": "integer"}},
|
||||
"required": ["customer_id"],
|
||||
}
|
||||
},
|
||||
},
|
||||
{"params": {"customer_id": 251}},
|
||||
{"params.customer_id": 251},
|
||||
{"params": {}}, # Missing required customer_id
|
||||
lambda instance: isinstance(instance.params, BaseModel),
|
||||
),
|
||||
# Array of strings (typed)
|
||||
(
|
||||
"array_of_strings",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"description": "List of tags",
|
||||
"items": {"type": "string"},
|
||||
}
|
||||
},
|
||||
"required": ["tags"],
|
||||
},
|
||||
{"tags": ["tag1", "tag2", "tag3"]},
|
||||
{"tags": ["tag1", "tag2", "tag3"]},
|
||||
None, # No validation error test for this case
|
||||
None,
|
||||
),
|
||||
# Array of integers (typed)
|
||||
(
|
||||
"array_of_integers",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"numbers": {
|
||||
"type": "array",
|
||||
"description": "List of integers",
|
||||
"items": {"type": "integer"},
|
||||
}
|
||||
},
|
||||
"required": ["numbers"],
|
||||
},
|
||||
{"numbers": [1, 2, 3]},
|
||||
{"numbers": [1, 2, 3]},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Array of objects (complex nested)
|
||||
(
|
||||
"array_of_objects",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"users": {
|
||||
"type": "array",
|
||||
"description": "List of users",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer", "description": "User ID"},
|
||||
"name": {"type": "string", "description": "User name"},
|
||||
},
|
||||
"required": ["id", "name"],
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["users"],
|
||||
},
|
||||
{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]},
|
||||
{"users[0].id": 1, "users[0].name": "Alice", "users[1].id": 2, "users[1].name": "Bob"},
|
||||
{"users": [{"id": 1}]}, # Missing required 'name'
|
||||
lambda instance: all(isinstance(user, BaseModel) for user in instance.users),
|
||||
),
|
||||
# Deeply nested objects (3+ levels)
|
||||
(
|
||||
"deeply_nested",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date_range": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {"type": "string"},
|
||||
"end": {"type": "string"},
|
||||
},
|
||||
"required": ["start", "end"],
|
||||
},
|
||||
"categories": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["date_range"],
|
||||
}
|
||||
},
|
||||
"required": ["filters"],
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
{
|
||||
"query": {
|
||||
"filters": {
|
||||
"date_range": {"start": "2024-01-01", "end": "2024-12-31"},
|
||||
"categories": ["tech", "science"],
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["params"],
|
||||
},
|
||||
)
|
||||
model = _get_input_model_from_mcp_tool(tool)
|
||||
{
|
||||
"query.filters.date_range.start": "2024-01-01",
|
||||
"query.filters.date_range.end": "2024-12-31",
|
||||
"query.filters.categories": ["tech", "science"],
|
||||
},
|
||||
{"query": {"filters": {"date_range": {}}}}, # Missing required start and end
|
||||
None,
|
||||
),
|
||||
# Complex $ref with nested structure
|
||||
(
|
||||
"ref_nested_structure",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"order": {"$ref": "#/$defs/OrderParams"}},
|
||||
"required": ["order"],
|
||||
"$defs": {
|
||||
"OrderParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customer": {"$ref": "#/$defs/Customer"},
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/OrderItem"}},
|
||||
},
|
||||
"required": ["customer", "items"],
|
||||
},
|
||||
"Customer": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "integer"}, "email": {"type": "string"}},
|
||||
"required": ["id", "email"],
|
||||
},
|
||||
"OrderItem": {
|
||||
"type": "object",
|
||||
"properties": {"product_id": {"type": "string"}, "quantity": {"type": "integer"}},
|
||||
"required": ["product_id", "quantity"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"order": {
|
||||
"customer": {"id": 123, "email": "test@example.com"},
|
||||
"items": [{"product_id": "prod1", "quantity": 2}],
|
||||
}
|
||||
},
|
||||
{
|
||||
"order.customer.id": 123,
|
||||
"order.customer.email": "test@example.com",
|
||||
"order.items[0].product_id": "prod1",
|
||||
"order.items[0].quantity": 2,
|
||||
},
|
||||
{"order": {"customer": {"id": 123}, "items": []}}, # Missing email
|
||||
lambda instance: isinstance(instance.order.customer, BaseModel),
|
||||
),
|
||||
# Mixed types (primitives, arrays, nested objects)
|
||||
(
|
||||
"mixed_types",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"simple_string": {"type": "string"},
|
||||
"simple_number": {"type": "integer"},
|
||||
"string_array": {"type": "array", "items": {"type": "string"}},
|
||||
"nested_config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"options": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["enabled"],
|
||||
},
|
||||
},
|
||||
"required": ["simple_string", "nested_config"],
|
||||
},
|
||||
{
|
||||
"simple_string": "test",
|
||||
"simple_number": 42,
|
||||
"string_array": ["a", "b"],
|
||||
"nested_config": {"enabled": True, "options": ["opt1", "opt2"]},
|
||||
},
|
||||
{
|
||||
"simple_string": "test",
|
||||
"simple_number": 42,
|
||||
"string_array": ["a", "b"],
|
||||
"nested_config.enabled": True,
|
||||
"nested_config.options": ["opt1", "opt2"],
|
||||
},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Empty schema (no properties)
|
||||
(
|
||||
"empty_schema",
|
||||
{"type": "object", "properties": {}},
|
||||
{},
|
||||
{},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# All primitive types
|
||||
(
|
||||
"all_primitives",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"string_field": {"type": "string"},
|
||||
"integer_field": {"type": "integer"},
|
||||
"number_field": {"type": "number"},
|
||||
"boolean_field": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
{"string_field": "test", "integer_field": 42, "number_field": 3.14, "boolean_field": True},
|
||||
{"string_field": "test", "integer_field": 42, "number_field": 3.14, "boolean_field": True},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Edge case: unresolvable $ref (fallback to dict)
|
||||
(
|
||||
"unresolvable_ref",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"data": {"$ref": "#/$defs/NonExistent"}},
|
||||
"$defs": {},
|
||||
},
|
||||
{"data": {"key": "value"}},
|
||||
{"data": {"key": "value"}},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Edge case: array without items schema (fallback to bare list)
|
||||
(
|
||||
"array_no_items",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"items": {"type": "array"}},
|
||||
},
|
||||
{"items": [1, "two", 3.0]},
|
||||
{"items": [1, "two", 3.0]},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
# Edge case: object without properties (fallback to dict)
|
||||
(
|
||||
"object_no_properties",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"config": {"type": "object"}},
|
||||
},
|
||||
{"config": {"arbitrary": "data", "nested": {"key": "value"}}},
|
||||
{"config": {"arbitrary": "data", "nested": {"key": "value"}}},
|
||||
None,
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_input_model_from_mcp_tool_parametrized(
|
||||
test_id, input_schema, valid_data, expected_values, invalid_data, validation_check
|
||||
):
|
||||
"""Parametrized test for JSON schema to Pydantic model conversion.
|
||||
|
||||
# Create an instance to verify the model works with nested objects
|
||||
instance = model(params={"customer_id": 251})
|
||||
assert instance.params == {"customer_id": 251}
|
||||
assert isinstance(instance.params, dict)
|
||||
This test covers various edge cases including:
|
||||
- Basic types with required/optional fields
|
||||
- Nested objects
|
||||
- $ref resolution
|
||||
- Typed arrays (strings, integers, objects)
|
||||
- Deeply nested structures
|
||||
- Complex $ref with nested structures
|
||||
- Mixed types
|
||||
|
||||
# Verify model_dump produces the correct nested structure
|
||||
dumped = instance.model_dump()
|
||||
assert dumped == {"params": {"customer_id": 251}}
|
||||
|
||||
|
||||
def test_get_input_model_from_mcp_tool_with_ref_schema():
|
||||
"""Test creation of input model from MCP tool with $ref schema.
|
||||
|
||||
This simulates a FastMCP tool that uses Pydantic models with $ref in the schema.
|
||||
The schema should be resolved and nested objects should be preserved.
|
||||
To add a new test case, add a tuple to the parametrize decorator with:
|
||||
- test_id: A descriptive name for the test case
|
||||
- input_schema: The JSON schema (inputSchema dict)
|
||||
- valid_data: Valid data to instantiate the model
|
||||
- expected_values: Dict of expected values (supports dot notation for nested access)
|
||||
- invalid_data: Invalid data to test validation errors (None to skip)
|
||||
- validation_check: Optional callable to perform additional validation checks
|
||||
"""
|
||||
# This is similar to what FastMCP generates when you have:
|
||||
# async def get_customer_detail(params: CustomerIdParam) -> CustomerDetail
|
||||
tool = types.Tool(
|
||||
name="get_customer_detail",
|
||||
description="Get customer details",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {"params": {"$ref": "#/$defs/CustomerIdParam"}},
|
||||
"required": ["params"],
|
||||
"$defs": {
|
||||
"CustomerIdParam": {
|
||||
"type": "object",
|
||||
"properties": {"customer_id": {"type": "integer"}},
|
||||
"required": ["customer_id"],
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
tool = types.Tool(name="test_tool", description="A test tool", inputSchema=input_schema)
|
||||
model = _get_input_model_from_mcp_tool(tool)
|
||||
|
||||
# Create an instance to verify the model works with $ref schemas
|
||||
instance = model(params={"customer_id": 251})
|
||||
assert instance.params == {"customer_id": 251}
|
||||
assert isinstance(instance.params, dict)
|
||||
# Test valid data
|
||||
instance = model(**valid_data)
|
||||
|
||||
# Verify model_dump produces the correct nested structure
|
||||
dumped = instance.model_dump()
|
||||
assert dumped == {"params": {"customer_id": 251}}
|
||||
# Check expected values
|
||||
for field_path, expected_value in expected_values.items():
|
||||
# Support dot notation and array indexing for nested access
|
||||
current = instance
|
||||
parts = field_path.replace("]", "").replace("[", ".").split(".")
|
||||
for part in parts:
|
||||
current = current[int(part)] if part.isdigit() else getattr(current, part)
|
||||
assert current == expected_value, f"Field {field_path} = {current}, expected {expected_value}"
|
||||
|
||||
# Run additional validation checks if provided
|
||||
if validation_check:
|
||||
assert validation_check(instance), f"Validation check failed for {test_id}"
|
||||
|
||||
def test_get_input_model_from_mcp_tool_with_simple_array():
|
||||
"""Test array with simple items schema (items schema should be preserved in json_schema_extra)."""
|
||||
tool = types.Tool(
|
||||
name="simple_array_tool",
|
||||
description="Tool with simple array",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"description": "List of tags",
|
||||
"items": {"type": "string"}, # Simple string array
|
||||
}
|
||||
},
|
||||
"required": ["tags"],
|
||||
},
|
||||
)
|
||||
model = _get_input_model_from_mcp_tool(tool)
|
||||
|
||||
# Create an instance
|
||||
instance = model(tags=["tag1", "tag2", "tag3"])
|
||||
assert instance.tags == ["tag1", "tag2", "tag3"]
|
||||
|
||||
# Verify JSON schema still preserves items for simple types
|
||||
json_schema = model.model_json_schema()
|
||||
tags_property = json_schema["properties"]["tags"]
|
||||
assert "items" in tags_property
|
||||
assert tags_property["items"]["type"] == "string"
|
||||
# Test invalid data if provided
|
||||
if invalid_data is not None:
|
||||
with pytest.raises(ValidationError):
|
||||
model(**invalid_data)
|
||||
|
||||
|
||||
def test_get_input_model_from_mcp_prompt():
|
||||
|
||||
@@ -993,6 +993,110 @@ def test_streaming_response_basic_structure() -> None:
|
||||
assert response.raw_representation is mock_event
|
||||
|
||||
|
||||
def test_streaming_annotation_added_with_file_path() -> None:
|
||||
"""Test streaming annotation added event with file_path type extracts HostedFileContent."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_text.annotation.added"
|
||||
mock_event.annotation_index = 0
|
||||
mock_event.annotation = {
|
||||
"type": "file_path",
|
||||
"file_id": "file-abc123",
|
||||
"index": 42,
|
||||
}
|
||||
|
||||
response = client._create_streaming_response_content(mock_event, chat_options, function_call_ids)
|
||||
|
||||
assert len(response.contents) == 1
|
||||
content = response.contents[0]
|
||||
assert isinstance(content, HostedFileContent)
|
||||
assert content.file_id == "file-abc123"
|
||||
assert content.additional_properties is not None
|
||||
assert content.additional_properties.get("annotation_index") == 0
|
||||
assert content.additional_properties.get("index") == 42
|
||||
|
||||
|
||||
def test_streaming_annotation_added_with_file_citation() -> None:
|
||||
"""Test streaming annotation added event with file_citation type extracts HostedFileContent."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_text.annotation.added"
|
||||
mock_event.annotation_index = 1
|
||||
mock_event.annotation = {
|
||||
"type": "file_citation",
|
||||
"file_id": "file-xyz789",
|
||||
"filename": "sample.txt",
|
||||
"index": 15,
|
||||
}
|
||||
|
||||
response = client._create_streaming_response_content(mock_event, chat_options, function_call_ids)
|
||||
|
||||
assert len(response.contents) == 1
|
||||
content = response.contents[0]
|
||||
assert isinstance(content, HostedFileContent)
|
||||
assert content.file_id == "file-xyz789"
|
||||
assert content.additional_properties is not None
|
||||
assert content.additional_properties.get("filename") == "sample.txt"
|
||||
assert content.additional_properties.get("index") == 15
|
||||
|
||||
|
||||
def test_streaming_annotation_added_with_container_file_citation() -> None:
|
||||
"""Test streaming annotation added event with container_file_citation type."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_text.annotation.added"
|
||||
mock_event.annotation_index = 2
|
||||
mock_event.annotation = {
|
||||
"type": "container_file_citation",
|
||||
"file_id": "file-container123",
|
||||
"container_id": "container-456",
|
||||
"filename": "data.csv",
|
||||
"start_index": 10,
|
||||
"end_index": 50,
|
||||
}
|
||||
|
||||
response = client._create_streaming_response_content(mock_event, chat_options, function_call_ids)
|
||||
|
||||
assert len(response.contents) == 1
|
||||
content = response.contents[0]
|
||||
assert isinstance(content, HostedFileContent)
|
||||
assert content.file_id == "file-container123"
|
||||
assert content.additional_properties is not None
|
||||
assert content.additional_properties.get("container_id") == "container-456"
|
||||
assert content.additional_properties.get("filename") == "data.csv"
|
||||
assert content.additional_properties.get("start_index") == 10
|
||||
assert content.additional_properties.get("end_index") == 50
|
||||
|
||||
|
||||
def test_streaming_annotation_added_with_unknown_type() -> None:
|
||||
"""Test streaming annotation added event with unknown type is ignored."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.type = "response.output_text.annotation.added"
|
||||
mock_event.annotation_index = 0
|
||||
mock_event.annotation = {
|
||||
"type": "url_citation",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
|
||||
response = client._create_streaming_response_content(mock_event, chat_options, function_call_ids)
|
||||
|
||||
# url_citation should not produce HostedFileContent
|
||||
assert len(response.contents) == 0
|
||||
|
||||
|
||||
def test_service_response_exception_includes_original_error_details() -> None:
|
||||
"""Test that ServiceResponseException messages include original error details in the new format."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
AgentExecutorRequest,
|
||||
@@ -52,6 +53,55 @@ def test_concurrent_builder_rejects_duplicate_executors() -> None:
|
||||
ConcurrentBuilder().participants([a, b])
|
||||
|
||||
|
||||
def test_concurrent_builder_rejects_duplicate_executors_from_factories() -> None:
|
||||
"""Test that duplicate executor IDs from factories are detected at build time."""
|
||||
|
||||
def create_dup1() -> Executor:
|
||||
return _FakeAgentExec("dup", "A")
|
||||
|
||||
def create_dup2() -> Executor:
|
||||
return _FakeAgentExec("dup", "B") # same executor id
|
||||
|
||||
builder = ConcurrentBuilder().register_participants([create_dup1, create_dup2])
|
||||
with pytest.raises(ValueError, match="Executor with ID 'dup' has already been created."):
|
||||
builder.build()
|
||||
|
||||
|
||||
def test_concurrent_builder_rejects_mixed_participants_and_factories() -> None:
|
||||
"""Test that mixing .participants() and .register_participants() raises an error."""
|
||||
# Case 1: participants first, then register_participants
|
||||
with pytest.raises(ValueError, match="Cannot mix .participants"):
|
||||
(
|
||||
ConcurrentBuilder()
|
||||
.participants([_FakeAgentExec("a", "A")])
|
||||
.register_participants([lambda: _FakeAgentExec("b", "B")])
|
||||
)
|
||||
|
||||
# Case 2: register_participants first, then participants
|
||||
with pytest.raises(ValueError, match="Cannot mix .participants"):
|
||||
(
|
||||
ConcurrentBuilder()
|
||||
.register_participants([lambda: _FakeAgentExec("a", "A")])
|
||||
.participants([_FakeAgentExec("b", "B")])
|
||||
)
|
||||
|
||||
|
||||
def test_concurrent_builder_rejects_multiple_calls_to_participants() -> None:
|
||||
"""Test that multiple calls to .participants() raises an error."""
|
||||
with pytest.raises(ValueError, match=r"participants\(\) has already been called"):
|
||||
(ConcurrentBuilder().participants([_FakeAgentExec("a", "A")]).participants([_FakeAgentExec("b", "B")]))
|
||||
|
||||
|
||||
def test_concurrent_builder_rejects_multiple_calls_to_register_participants() -> None:
|
||||
"""Test that multiple calls to .register_participants() raises an error."""
|
||||
with pytest.raises(ValueError, match=r"register_participants\(\) has already been called"):
|
||||
(
|
||||
ConcurrentBuilder()
|
||||
.register_participants([lambda: _FakeAgentExec("a", "A")])
|
||||
.register_participants([lambda: _FakeAgentExec("b", "B")])
|
||||
)
|
||||
|
||||
|
||||
async def test_concurrent_default_aggregator_emits_single_user_and_assistants() -> None:
|
||||
# Three synthetic agent executors
|
||||
e1 = _FakeAgentExec("agentA", "Alpha")
|
||||
@@ -159,6 +209,138 @@ def test_concurrent_custom_aggregator_uses_callback_name_for_id() -> None:
|
||||
assert aggregator.id == "summarize"
|
||||
|
||||
|
||||
async def test_concurrent_with_aggregator_executor_instance() -> None:
|
||||
"""Test with_aggregator using an Executor instance (not factory)."""
|
||||
|
||||
class CustomAggregator(Executor):
|
||||
@handler
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
|
||||
texts: list[str] = []
|
||||
for r in results:
|
||||
msgs: list[ChatMessage] = r.agent_run_response.messages
|
||||
texts.append(msgs[-1].text if msgs else "")
|
||||
await ctx.yield_output(" & ".join(sorted(texts)))
|
||||
|
||||
e1 = _FakeAgentExec("agentA", "One")
|
||||
e2 = _FakeAgentExec("agentB", "Two")
|
||||
|
||||
aggregator_instance = CustomAggregator(id="instance_aggregator")
|
||||
wf = ConcurrentBuilder().participants([e1, e2]).with_aggregator(aggregator_instance).build()
|
||||
|
||||
completed = False
|
||||
output: str | None = None
|
||||
async for ev in wf.run_stream("prompt: instance test"):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = cast(str, ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
assert completed
|
||||
assert output is not None
|
||||
assert isinstance(output, str)
|
||||
assert output == "One & Two"
|
||||
|
||||
|
||||
async def test_concurrent_with_aggregator_executor_factory() -> None:
|
||||
"""Test with_aggregator using an Executor factory."""
|
||||
|
||||
class CustomAggregator(Executor):
|
||||
@handler
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
|
||||
texts: list[str] = []
|
||||
for r in results:
|
||||
msgs: list[ChatMessage] = r.agent_run_response.messages
|
||||
texts.append(msgs[-1].text if msgs else "")
|
||||
await ctx.yield_output(" | ".join(sorted(texts)))
|
||||
|
||||
e1 = _FakeAgentExec("agentA", "One")
|
||||
e2 = _FakeAgentExec("agentB", "Two")
|
||||
|
||||
wf = (
|
||||
ConcurrentBuilder()
|
||||
.participants([e1, e2])
|
||||
.register_aggregator(lambda: CustomAggregator(id="custom_aggregator"))
|
||||
.build()
|
||||
)
|
||||
|
||||
completed = False
|
||||
output: str | None = None
|
||||
async for ev in wf.run_stream("prompt: factory test"):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = cast(str, ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
assert completed
|
||||
assert output is not None
|
||||
assert isinstance(output, str)
|
||||
assert output == "One | Two"
|
||||
|
||||
|
||||
async def test_concurrent_with_aggregator_executor_factory_with_default_id() -> None:
|
||||
"""Test with_aggregator using an Executor class directly as factory (with default __init__ parameters)."""
|
||||
|
||||
class CustomAggregator(Executor):
|
||||
def __init__(self, id: str = "default_aggregator") -> None:
|
||||
super().__init__(id)
|
||||
|
||||
@handler
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
|
||||
texts: list[str] = []
|
||||
for r in results:
|
||||
msgs: list[ChatMessage] = r.agent_run_response.messages
|
||||
texts.append(msgs[-1].text if msgs else "")
|
||||
await ctx.yield_output(" | ".join(sorted(texts)))
|
||||
|
||||
e1 = _FakeAgentExec("agentA", "One")
|
||||
e2 = _FakeAgentExec("agentB", "Two")
|
||||
|
||||
wf = ConcurrentBuilder().participants([e1, e2]).register_aggregator(CustomAggregator).build()
|
||||
|
||||
completed = False
|
||||
output: str | None = None
|
||||
async for ev in wf.run_stream("prompt: factory test"):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = cast(str, ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
assert completed
|
||||
assert output is not None
|
||||
assert isinstance(output, str)
|
||||
assert output == "One | Two"
|
||||
|
||||
|
||||
def test_concurrent_builder_rejects_multiple_calls_to_with_aggregator() -> None:
|
||||
"""Test that multiple calls to .with_aggregator() raises an error."""
|
||||
|
||||
def summarize(results: list[AgentExecutorResponse]) -> str: # type: ignore[override]
|
||||
return str(len(results))
|
||||
|
||||
with pytest.raises(ValueError, match=r"with_aggregator\(\) has already been called"):
|
||||
(ConcurrentBuilder().with_aggregator(summarize).with_aggregator(summarize))
|
||||
|
||||
|
||||
def test_concurrent_builder_rejects_multiple_calls_to_register_aggregator() -> None:
|
||||
"""Test that multiple calls to .register_aggregator() raises an error."""
|
||||
|
||||
class CustomAggregator(Executor):
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError, match=r"register_aggregator\(\) has already been called"):
|
||||
(
|
||||
ConcurrentBuilder()
|
||||
.register_aggregator(lambda: CustomAggregator(id="agg1"))
|
||||
.register_aggregator(lambda: CustomAggregator(id="agg2"))
|
||||
)
|
||||
|
||||
|
||||
async def test_concurrent_checkpoint_resume_round_trip() -> None:
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
@@ -278,3 +460,92 @@ async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
|
||||
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
|
||||
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
|
||||
|
||||
|
||||
def test_concurrent_builder_rejects_empty_participant_factories() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
ConcurrentBuilder().register_participants([])
|
||||
|
||||
|
||||
async def test_concurrent_builder_reusable_after_build_with_participants() -> None:
|
||||
"""Test that the builder can be reused to build multiple identical workflows with participants()."""
|
||||
e1 = _FakeAgentExec("agentA", "One")
|
||||
e2 = _FakeAgentExec("agentB", "Two")
|
||||
|
||||
builder = ConcurrentBuilder().participants([e1, e2])
|
||||
|
||||
builder.build()
|
||||
|
||||
assert builder._participants[0] is e1 # type: ignore
|
||||
assert builder._participants[1] is e2 # type: ignore
|
||||
assert builder._participant_factories == [] # type: ignore
|
||||
|
||||
|
||||
async def test_concurrent_builder_reusable_after_build_with_factories() -> None:
|
||||
"""Test that the builder can be reused to build multiple workflows with register_participants()."""
|
||||
call_count = 0
|
||||
|
||||
def create_agent_executor_a() -> Executor:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return _FakeAgentExec("agentA", "One")
|
||||
|
||||
def create_agent_executor_b() -> Executor:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return _FakeAgentExec("agentB", "Two")
|
||||
|
||||
builder = ConcurrentBuilder().register_participants([create_agent_executor_a, create_agent_executor_b])
|
||||
|
||||
# Build the first workflow
|
||||
wf1 = builder.build()
|
||||
|
||||
assert builder._participants == [] # type: ignore
|
||||
assert len(builder._participant_factories) == 2 # type: ignore
|
||||
assert call_count == 2
|
||||
|
||||
# Build the second workflow
|
||||
wf2 = builder.build()
|
||||
assert call_count == 4
|
||||
|
||||
# Verify that the two workflows have different executor instances
|
||||
assert wf1.executors["agentA"] is not wf2.executors["agentA"]
|
||||
assert wf1.executors["agentB"] is not wf2.executors["agentB"]
|
||||
|
||||
|
||||
async def test_concurrent_with_register_participants() -> None:
|
||||
"""Test workflow creation using register_participants with factories."""
|
||||
|
||||
def create_agent1() -> Executor:
|
||||
return _FakeAgentExec("agentA", "Alpha")
|
||||
|
||||
def create_agent2() -> Executor:
|
||||
return _FakeAgentExec("agentB", "Beta")
|
||||
|
||||
def create_agent3() -> Executor:
|
||||
return _FakeAgentExec("agentC", "Gamma")
|
||||
|
||||
wf = ConcurrentBuilder().register_participants([create_agent1, create_agent2, create_agent3]).build()
|
||||
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run_stream("test prompt"):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = cast(list[ChatMessage], ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
assert completed
|
||||
assert output is not None
|
||||
messages: list[ChatMessage] = output
|
||||
|
||||
# Expect one user message + one assistant message per participant
|
||||
assert len(messages) == 1 + 3
|
||||
assert messages[0].role == Role.USER
|
||||
assert "test prompt" in messages[0].text
|
||||
|
||||
assistant_texts = {m.text for m in messages[1:]}
|
||||
assert assistant_texts == {"Alpha", "Beta", "Gamma"}
|
||||
assert all(m.role == Role.ASSISTANT for m in messages[1:])
|
||||
|
||||
@@ -1082,3 +1082,106 @@ def test_set_manager_builds_with_agent_manager() -> None:
|
||||
|
||||
assert isinstance(orchestrator, GroupChatOrchestratorExecutor)
|
||||
assert orchestrator._is_manager_agent()
|
||||
|
||||
|
||||
async def test_group_chat_with_request_info_filtering():
|
||||
"""Test that with_request_info(agents=[...]) only pauses before specified agents run."""
|
||||
from agent_framework import AgentInputRequest, RequestInfoEvent
|
||||
|
||||
# Create agents - we want to verify only beta triggers pause
|
||||
alpha = StubAgent("alpha", "response from alpha")
|
||||
beta = StubAgent("beta", "response from beta")
|
||||
|
||||
# Manager that selects alpha first, then beta, then finishes
|
||||
call_count = 0
|
||||
|
||||
async def selector(state: GroupChatStateSnapshot) -> str | None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return "alpha"
|
||||
if call_count == 2:
|
||||
return "beta"
|
||||
return None
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_select_speakers_func(selector, display_name="manager", final_message="done")
|
||||
.participants(alpha=alpha, beta=beta)
|
||||
.with_request_info(agents=["beta"]) # Only pause before beta runs
|
||||
.build()
|
||||
)
|
||||
|
||||
# Run until we get a request info event (should be before beta, not alpha)
|
||||
request_events: list[RequestInfoEvent] = []
|
||||
async for event in workflow.run_stream("test task"):
|
||||
if isinstance(event, RequestInfoEvent) and isinstance(event.data, AgentInputRequest):
|
||||
request_events.append(event)
|
||||
# Don't break - let stream complete naturally when paused
|
||||
|
||||
# Should have exactly one request event before beta
|
||||
assert len(request_events) == 1
|
||||
request_event = request_events[0]
|
||||
|
||||
# The target agent should be beta's executor ID (groupchat_agent:beta)
|
||||
assert request_event.data.target_agent_id is not None
|
||||
assert "beta" in request_event.data.target_agent_id
|
||||
|
||||
# Continue the workflow with a response
|
||||
outputs: list[WorkflowOutputEvent] = []
|
||||
async for event in workflow.send_responses_streaming({request_event.request_id: "continue please"}):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
outputs.append(event)
|
||||
|
||||
# Workflow should complete
|
||||
assert len(outputs) == 1
|
||||
|
||||
|
||||
async def test_group_chat_with_request_info_no_filter_pauses_all():
|
||||
"""Test that with_request_info() without agents pauses before all participants."""
|
||||
from agent_framework import AgentInputRequest, RequestInfoEvent
|
||||
|
||||
# Create agents
|
||||
alpha = StubAgent("alpha", "response from alpha")
|
||||
|
||||
# Manager selects alpha then finishes
|
||||
call_count = 0
|
||||
|
||||
async def selector(state: GroupChatStateSnapshot) -> str | None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return "alpha"
|
||||
return None
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_select_speakers_func(selector, display_name="manager", final_message="done")
|
||||
.participants(alpha=alpha)
|
||||
.with_request_info() # No filter - pause for all
|
||||
.build()
|
||||
)
|
||||
|
||||
# Run until we get a request info event
|
||||
request_events: list[RequestInfoEvent] = []
|
||||
async for event in workflow.run_stream("test task"):
|
||||
if isinstance(event, RequestInfoEvent) and isinstance(event.data, AgentInputRequest):
|
||||
request_events.append(event)
|
||||
break
|
||||
|
||||
# Should pause before alpha
|
||||
assert len(request_events) == 1
|
||||
assert request_events[0].data.target_agent_id is not None
|
||||
assert "alpha" in request_events[0].data.target_agent_id
|
||||
|
||||
|
||||
def test_group_chat_builder_with_request_info_returns_self():
|
||||
"""Test that with_request_info() returns self for method chaining."""
|
||||
builder = GroupChatBuilder()
|
||||
result = builder.with_request_info()
|
||||
assert result is builder
|
||||
|
||||
# Also test with agents parameter
|
||||
builder2 = GroupChatBuilder()
|
||||
result2 = builder2.with_request_info(agents=["test"])
|
||||
assert result2 is builder2
|
||||
|
||||
@@ -687,6 +687,54 @@ async def test_tool_choice_preserved_from_agent_config():
|
||||
assert str(last_tool_choice) == "required", f"Expected 'required', got {last_tool_choice}"
|
||||
|
||||
|
||||
async def test_handoff_builder_with_request_info():
|
||||
"""Test that HandoffBuilder supports request info via with_request_info()."""
|
||||
from agent_framework import AgentInputRequest, RequestInfoEvent
|
||||
|
||||
# Create test agents
|
||||
coordinator = _RecordingAgent(name="coordinator")
|
||||
specialist = _RecordingAgent(name="specialist")
|
||||
|
||||
# Build workflow with request info enabled
|
||||
workflow = (
|
||||
HandoffBuilder(participants=[coordinator, specialist])
|
||||
.set_coordinator("coordinator")
|
||||
.with_termination_condition(lambda conv: len([m for m in conv if m.role == Role.USER]) >= 1)
|
||||
.with_request_info()
|
||||
.build()
|
||||
)
|
||||
|
||||
# Run workflow until it pauses for request info
|
||||
request_event: RequestInfoEvent | None = None
|
||||
async for event in workflow.run_stream("Hello"):
|
||||
if isinstance(event, RequestInfoEvent) and isinstance(event.data, AgentInputRequest):
|
||||
request_event = event
|
||||
|
||||
# Verify request info was emitted
|
||||
assert request_event is not None, "Request info should have been emitted"
|
||||
assert isinstance(request_event.data, AgentInputRequest)
|
||||
|
||||
# Provide response and continue
|
||||
output_events: list[WorkflowOutputEvent] = []
|
||||
async for event in workflow.send_responses_streaming({request_event.request_id: "approved"}):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
output_events.append(event)
|
||||
|
||||
# Verify we got output events
|
||||
assert len(output_events) > 0, "Should produce output events after response"
|
||||
|
||||
|
||||
async def test_handoff_builder_with_request_info_method_chaining():
|
||||
"""Test that with_request_info returns self for method chaining."""
|
||||
coordinator = _RecordingAgent(name="coordinator")
|
||||
|
||||
builder = HandoffBuilder(participants=[coordinator])
|
||||
result = builder.with_request_info()
|
||||
|
||||
assert result is builder, "with_request_info should return self for chaining"
|
||||
assert builder._request_info_enabled is True # type: ignore
|
||||
|
||||
|
||||
async def test_return_to_previous_state_serialization():
|
||||
"""Test that return_to_previous state is properly serialized/deserialized for checkpointing."""
|
||||
from agent_framework._workflows._handoff import _HandoffCoordinator # type: ignore[reportPrivateUsage]
|
||||
|
||||
@@ -857,3 +857,22 @@ async def test_magentic_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
|
||||
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
|
||||
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
|
||||
|
||||
|
||||
def test_magentic_builder_does_not_have_human_input_hook():
|
||||
"""Test that MagenticBuilder does not expose with_human_input_hook (uses specialized HITL instead).
|
||||
|
||||
Magentic uses specialized human intervention mechanisms:
|
||||
- with_plan_review() for plan approval
|
||||
- with_human_input_on_stall() for stall intervention
|
||||
- Tool approval via FunctionApprovalRequestContent
|
||||
|
||||
These emit MagenticHumanInterventionRequest events with structured decision options.
|
||||
"""
|
||||
builder = MagenticBuilder()
|
||||
|
||||
# MagenticBuilder should NOT have the generic human input hook mixin
|
||||
assert not hasattr(builder, "with_human_input_hook"), (
|
||||
"MagenticBuilder should not have with_human_input_hook - "
|
||||
"use with_plan_review() or with_human_input_on_stall() instead"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for request info support in high-level builders."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from agent_framework import (
|
||||
AgentInputRequest,
|
||||
AgentProtocol,
|
||||
AgentResponseReviewRequest,
|
||||
ChatMessage,
|
||||
RequestInfoInterceptor,
|
||||
Role,
|
||||
)
|
||||
from agent_framework._workflows._executor import Executor, handler
|
||||
from agent_framework._workflows._orchestration_request_info import resolve_request_info_filter
|
||||
from agent_framework._workflows._workflow_context import WorkflowContext
|
||||
|
||||
|
||||
class DummyExecutor(Executor):
|
||||
"""Dummy executor with a handler for testing."""
|
||||
|
||||
@handler
|
||||
async def handle(self, data: str, ctx: WorkflowContext[Any, Any]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class TestResolveRequestInfoFilter:
|
||||
"""Tests for resolve_request_info_filter function."""
|
||||
|
||||
def test_returns_none_for_none_input(self):
|
||||
"""Test that None input returns None (no filtering)."""
|
||||
result = resolve_request_info_filter(None)
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_empty_list(self):
|
||||
"""Test that empty list returns None."""
|
||||
result = resolve_request_info_filter([])
|
||||
assert result is None
|
||||
|
||||
def test_resolves_string_names(self):
|
||||
"""Test resolving string agent names."""
|
||||
result = resolve_request_info_filter(["agent1", "agent2"])
|
||||
assert result == {"agent1", "agent2"}
|
||||
|
||||
def test_resolves_executor_ids(self):
|
||||
"""Test resolving Executor instances by ID."""
|
||||
exec1 = DummyExecutor(id="executor1")
|
||||
exec2 = DummyExecutor(id="executor2")
|
||||
|
||||
result = resolve_request_info_filter([exec1, exec2])
|
||||
assert result == {"executor1", "executor2"}
|
||||
|
||||
def test_resolves_agent_names(self):
|
||||
"""Test resolving AgentProtocol-like objects by name attribute."""
|
||||
agent1 = MagicMock(spec=AgentProtocol)
|
||||
agent1.name = "writer"
|
||||
agent2 = MagicMock(spec=AgentProtocol)
|
||||
agent2.name = "reviewer"
|
||||
|
||||
result = resolve_request_info_filter([agent1, agent2])
|
||||
assert result == {"writer", "reviewer"}
|
||||
|
||||
def test_mixed_types(self):
|
||||
"""Test resolving a mix of strings, agents, and executors."""
|
||||
agent = MagicMock(spec=AgentProtocol)
|
||||
agent.name = "writer"
|
||||
executor = DummyExecutor(id="custom_exec")
|
||||
|
||||
result = resolve_request_info_filter(["manual_name", agent, executor])
|
||||
assert result == {"manual_name", "writer", "custom_exec"}
|
||||
|
||||
def test_skips_agent_without_name(self):
|
||||
"""Test that agents without names are skipped."""
|
||||
agent_with_name = MagicMock(spec=AgentProtocol)
|
||||
agent_with_name.name = "valid"
|
||||
agent_without_name = MagicMock(spec=AgentProtocol)
|
||||
agent_without_name.name = None
|
||||
|
||||
result = resolve_request_info_filter([agent_with_name, agent_without_name])
|
||||
assert result == {"valid"}
|
||||
|
||||
|
||||
class TestAgentInputRequest:
|
||||
"""Tests for AgentInputRequest dataclass (formerly AgentResponseReviewRequest)."""
|
||||
|
||||
def test_create_request(self):
|
||||
"""Test creating an AgentInputRequest with all fields."""
|
||||
conversation = [ChatMessage(role=Role.USER, text="Hello")]
|
||||
request = AgentInputRequest(
|
||||
target_agent_id="test_agent",
|
||||
conversation=conversation,
|
||||
instruction="Review this",
|
||||
metadata={"key": "value"},
|
||||
)
|
||||
|
||||
assert request.target_agent_id == "test_agent"
|
||||
assert request.conversation == conversation
|
||||
assert request.instruction == "Review this"
|
||||
assert request.metadata == {"key": "value"}
|
||||
|
||||
def test_create_request_defaults(self):
|
||||
"""Test creating an AgentInputRequest with default values."""
|
||||
request = AgentInputRequest(target_agent_id="test_agent")
|
||||
|
||||
assert request.target_agent_id == "test_agent"
|
||||
assert request.conversation == []
|
||||
assert request.instruction is None
|
||||
assert request.metadata == {}
|
||||
|
||||
def test_backward_compatibility_alias(self):
|
||||
"""Test that AgentResponseReviewRequest is an alias for AgentInputRequest."""
|
||||
assert AgentResponseReviewRequest is AgentInputRequest
|
||||
|
||||
|
||||
class TestRequestInfoInterceptor:
|
||||
"""Tests for RequestInfoInterceptor executor."""
|
||||
|
||||
def test_interceptor_creation_generates_unique_id(self):
|
||||
"""Test creating a RequestInfoInterceptor generates unique IDs."""
|
||||
interceptor1 = RequestInfoInterceptor()
|
||||
interceptor2 = RequestInfoInterceptor()
|
||||
assert interceptor1.id.startswith("request_info_interceptor-")
|
||||
assert interceptor2.id.startswith("request_info_interceptor-")
|
||||
assert interceptor1.id != interceptor2.id
|
||||
|
||||
def test_interceptor_with_custom_id(self):
|
||||
"""Test creating a RequestInfoInterceptor with custom ID."""
|
||||
interceptor = RequestInfoInterceptor(executor_id="custom_review")
|
||||
assert interceptor.id == "custom_review"
|
||||
|
||||
def test_interceptor_with_agent_filter(self):
|
||||
"""Test creating a RequestInfoInterceptor with agent filter."""
|
||||
agent_filter = {"agent1", "agent2"}
|
||||
interceptor = RequestInfoInterceptor(
|
||||
executor_id="filtered_review",
|
||||
agent_filter=agent_filter,
|
||||
)
|
||||
assert interceptor.id == "filtered_review"
|
||||
assert interceptor._agent_filter == agent_filter
|
||||
|
||||
def test_should_pause_for_agent_no_filter(self):
|
||||
"""Test that interceptor pauses for all agents when no filter is set."""
|
||||
interceptor = RequestInfoInterceptor()
|
||||
assert interceptor._should_pause_for_agent("any_agent") is True
|
||||
assert interceptor._should_pause_for_agent("another_agent") is True
|
||||
assert interceptor._should_pause_for_agent(None) is True
|
||||
|
||||
def test_should_pause_for_agent_with_filter(self):
|
||||
"""Test that interceptor only pauses for agents in the filter."""
|
||||
agent_filter = {"writer", "reviewer"}
|
||||
interceptor = RequestInfoInterceptor(agent_filter=agent_filter)
|
||||
|
||||
assert interceptor._should_pause_for_agent("writer") is True
|
||||
assert interceptor._should_pause_for_agent("reviewer") is True
|
||||
assert interceptor._should_pause_for_agent("drafter") is False
|
||||
assert interceptor._should_pause_for_agent(None) is False
|
||||
|
||||
def test_should_pause_for_agent_with_prefixed_id(self):
|
||||
"""Test that filter matches agent names in prefixed executor IDs."""
|
||||
agent_filter = {"writer"}
|
||||
interceptor = RequestInfoInterceptor(agent_filter=agent_filter)
|
||||
|
||||
# Should match the name portion after the colon
|
||||
assert interceptor._should_pause_for_agent("groupchat_agent:writer") is True
|
||||
assert interceptor._should_pause_for_agent("request_info:writer") is True
|
||||
assert interceptor._should_pause_for_agent("groupchat_agent:editor") is False
|
||||
@@ -15,6 +15,7 @@ from agent_framework import (
|
||||
Role,
|
||||
SequentialBuilder,
|
||||
TextContent,
|
||||
TypeCompatibilityError,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
@@ -58,11 +59,43 @@ class _SummarizerExec(Executor):
|
||||
await ctx.send_message(list(conversation) + [summary])
|
||||
|
||||
|
||||
class _InvalidExecutor(Executor):
|
||||
"""Invalid executor that does not have a handler that accepts a list of chat messages"""
|
||||
|
||||
@handler
|
||||
async def summarize(self, conversation: list[str], ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_sequential_builder_rejects_empty_participants() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
SequentialBuilder().participants([])
|
||||
|
||||
|
||||
def test_sequential_builder_rejects_empty_participant_factories() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
SequentialBuilder().register_participants([])
|
||||
|
||||
|
||||
def test_sequential_builder_rejects_mixing_participants_and_factories() -> None:
|
||||
"""Test that mixing .participants() and .register_participants() raises an error."""
|
||||
a1 = _EchoAgent(id="agent1", name="A1")
|
||||
|
||||
# Try .participants() then .register_participants()
|
||||
with pytest.raises(ValueError, match="Cannot mix"):
|
||||
SequentialBuilder().participants([a1]).register_participants([lambda: _EchoAgent(id="agent2", name="A2")])
|
||||
|
||||
# Try .register_participants() then .participants()
|
||||
with pytest.raises(ValueError, match="Cannot mix"):
|
||||
SequentialBuilder().register_participants([lambda: _EchoAgent(id="agent1", name="A1")]).participants([a1])
|
||||
|
||||
|
||||
def test_sequential_builder_validation_rejects_invalid_executor() -> None:
|
||||
"""Test that adding an invalid executor to the builder raises an error."""
|
||||
with pytest.raises(TypeCompatibilityError):
|
||||
SequentialBuilder().participants([_EchoAgent(id="agent1", name="A1"), _InvalidExecutor(id="invalid")]).build()
|
||||
|
||||
|
||||
async def test_sequential_agents_append_to_context() -> None:
|
||||
a1 = _EchoAgent(id="agent1", name="A1")
|
||||
a2 = _EchoAgent(id="agent2", name="A2")
|
||||
@@ -91,6 +124,37 @@ async def test_sequential_agents_append_to_context() -> None:
|
||||
assert "A2 reply" in msgs[2].text
|
||||
|
||||
|
||||
async def test_sequential_register_participants_with_agent_factories() -> None:
|
||||
"""Test that register_participants works with agent factories."""
|
||||
|
||||
def create_agent1() -> _EchoAgent:
|
||||
return _EchoAgent(id="agent1", name="A1")
|
||||
|
||||
def create_agent2() -> _EchoAgent:
|
||||
return _EchoAgent(id="agent2", name="A2")
|
||||
|
||||
wf = SequentialBuilder().register_participants([create_agent1, create_agent2]).build()
|
||||
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run_stream("hello factories"):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = ev.data
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
assert completed
|
||||
assert output is not None
|
||||
assert isinstance(output, list)
|
||||
msgs: list[ChatMessage] = output
|
||||
assert len(msgs) == 3
|
||||
assert msgs[0].role == Role.USER and "hello factories" in msgs[0].text
|
||||
assert msgs[1].role == Role.ASSISTANT and "A1 reply" in msgs[1].text
|
||||
assert msgs[2].role == Role.ASSISTANT and "A2 reply" in msgs[2].text
|
||||
|
||||
|
||||
async def test_sequential_with_custom_executor_summary() -> None:
|
||||
a1 = _EchoAgent(id="agent1", name="A1")
|
||||
summarizer = _SummarizerExec(id="summarizer")
|
||||
@@ -103,7 +167,7 @@ async def test_sequential_with_custom_executor_summary() -> None:
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = ev.data # type: ignore[assignment]
|
||||
output = ev.data
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
@@ -117,6 +181,37 @@ async def test_sequential_with_custom_executor_summary() -> None:
|
||||
assert msgs[2].role == Role.ASSISTANT and msgs[2].text.startswith("Summary of users:")
|
||||
|
||||
|
||||
async def test_sequential_register_participants_mixed_agents_and_executors() -> None:
|
||||
"""Test register_participants with both agent and executor factories."""
|
||||
|
||||
def create_agent() -> _EchoAgent:
|
||||
return _EchoAgent(id="agent1", name="A1")
|
||||
|
||||
def create_summarizer() -> _SummarizerExec:
|
||||
return _SummarizerExec(id="summarizer")
|
||||
|
||||
wf = SequentialBuilder().register_participants([create_agent, create_summarizer]).build()
|
||||
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run_stream("topic Y"):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = ev.data
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
assert completed
|
||||
assert output is not None
|
||||
msgs: list[ChatMessage] = output
|
||||
# Expect: [user, A1 reply, summary]
|
||||
assert len(msgs) == 3
|
||||
assert msgs[0].role == Role.USER and "topic Y" in msgs[0].text
|
||||
assert msgs[1].role == Role.ASSISTANT and "A1 reply" in msgs[1].text
|
||||
assert msgs[2].role == Role.ASSISTANT and msgs[2].text.startswith("Summary of users:")
|
||||
|
||||
|
||||
async def test_sequential_checkpoint_resume_round_trip() -> None:
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
@@ -229,3 +324,130 @@ async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
|
||||
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
|
||||
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
|
||||
|
||||
|
||||
async def test_sequential_register_participants_with_checkpointing() -> None:
|
||||
"""Test that checkpointing works with register_participants."""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
def create_agent1() -> _EchoAgent:
|
||||
return _EchoAgent(id="agent1", name="A1")
|
||||
|
||||
def create_agent2() -> _EchoAgent:
|
||||
return _EchoAgent(id="agent2", name="A2")
|
||||
|
||||
wf = SequentialBuilder().register_participants([create_agent1, create_agent2]).with_checkpointing(storage).build()
|
||||
|
||||
baseline_output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run_stream("checkpoint with factories"):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
baseline_output = ev.data
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
assert baseline_output is not None
|
||||
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
assert checkpoints
|
||||
checkpoints.sort(key=lambda cp: cp.timestamp)
|
||||
|
||||
resume_checkpoint = next(
|
||||
(cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
|
||||
checkpoints[-1],
|
||||
)
|
||||
|
||||
wf_resume = (
|
||||
SequentialBuilder().register_participants([create_agent1, create_agent2]).with_checkpointing(storage).build()
|
||||
)
|
||||
|
||||
resumed_output: list[ChatMessage] | None = None
|
||||
async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
resumed_output = ev.data
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
|
||||
WorkflowRunState.IDLE,
|
||||
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
|
||||
):
|
||||
break
|
||||
|
||||
assert resumed_output is not None
|
||||
assert [m.role for m in resumed_output] == [m.role for m in baseline_output]
|
||||
assert [m.text for m in resumed_output] == [m.text for m in baseline_output]
|
||||
|
||||
|
||||
async def test_sequential_register_participants_factories_called_on_build() -> None:
|
||||
"""Test that factories are called during build(), not during register_participants()."""
|
||||
call_count = 0
|
||||
|
||||
def create_agent() -> _EchoAgent:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return _EchoAgent(id=f"agent{call_count}", name=f"A{call_count}")
|
||||
|
||||
builder = SequentialBuilder().register_participants([create_agent, create_agent])
|
||||
|
||||
# Factories should not be called yet
|
||||
assert call_count == 0
|
||||
|
||||
wf = builder.build()
|
||||
|
||||
# Now factories should have been called
|
||||
assert call_count == 2
|
||||
|
||||
# Run the workflow to ensure it works
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run_stream("test factories timing"):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = ev.data # type: ignore[assignment]
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
assert completed
|
||||
assert output is not None
|
||||
msgs: list[ChatMessage] = output
|
||||
# Should have user message + 2 agent replies
|
||||
assert len(msgs) == 3
|
||||
|
||||
|
||||
async def test_sequential_builder_reusable_after_build_with_participants() -> None:
|
||||
"""Test that the builder can be reused to build multiple identical workflows with participants()."""
|
||||
a1 = _EchoAgent(id="agent1", name="A1")
|
||||
a2 = _EchoAgent(id="agent2", name="A2")
|
||||
|
||||
builder = SequentialBuilder().participants([a1, a2])
|
||||
|
||||
# Build first workflow
|
||||
builder.build()
|
||||
|
||||
assert builder._participants[0] is a1 # type: ignore
|
||||
assert builder._participants[1] is a2 # type: ignore
|
||||
assert builder._participant_factories == [] # type: ignore
|
||||
|
||||
|
||||
async def test_sequential_builder_reusable_after_build_with_factories() -> None:
|
||||
"""Test that the builder can be reused to build multiple workflows with register_participants()."""
|
||||
call_count = 0
|
||||
|
||||
def create_agent1() -> _EchoAgent:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return _EchoAgent(id="agent1", name="A1")
|
||||
|
||||
def create_agent2() -> _EchoAgent:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return _EchoAgent(id="agent2", name="A2")
|
||||
|
||||
builder = SequentialBuilder().register_participants([create_agent1, create_agent2])
|
||||
|
||||
# Build first workflow - factories should be called
|
||||
builder.build()
|
||||
|
||||
assert call_count == 2
|
||||
assert builder._participants == [] # type: ignore
|
||||
assert len(builder._participant_factories) == 2 # type: ignore
|
||||
assert builder._participant_factories[0] is create_agent1 # type: ignore
|
||||
assert builder._participant_factories[1] is create_agent2 # type: ignore
|
||||
|
||||
@@ -111,7 +111,8 @@ def test_add_agent_with_custom_parameters():
|
||||
builder = WorkflowBuilder()
|
||||
|
||||
# Add agent with custom parameters
|
||||
result = builder.add_agent(agent, output_response=True, id="my_custom_id")
|
||||
with pytest.deprecated_call():
|
||||
result = builder.add_agent(agent, output_response=True, id="my_custom_id")
|
||||
|
||||
# Verify that add_agent returns the builder for chaining
|
||||
assert result is builder
|
||||
@@ -133,7 +134,8 @@ def test_add_agent_reuses_same_wrapper():
|
||||
builder = WorkflowBuilder()
|
||||
|
||||
# Add agent with specific parameters
|
||||
builder.add_agent(agent, output_response=True, id="agent_exec")
|
||||
with pytest.deprecated_call():
|
||||
builder.add_agent(agent, output_response=True, id="agent_exec")
|
||||
|
||||
# Use the same agent instance in add_edge - should reuse the same wrapper
|
||||
builder.set_start_executor(agent)
|
||||
@@ -158,8 +160,9 @@ def test_add_agent_then_use_in_edges():
|
||||
builder = WorkflowBuilder()
|
||||
|
||||
# Add agents with specific settings
|
||||
builder.add_agent(agent1, output_response=False, id="exec1")
|
||||
builder.add_agent(agent2, output_response=True, id="exec2")
|
||||
with pytest.deprecated_call():
|
||||
builder.add_agent(agent1, output_response=False, id="exec1")
|
||||
builder.add_agent(agent2, output_response=True, id="exec2")
|
||||
|
||||
# Use the same agent instances to create edges
|
||||
workflow = builder.set_start_executor(agent1).add_edge(agent1, agent2).build()
|
||||
@@ -183,7 +186,8 @@ def test_add_agent_without_explicit_id_uses_agent_name():
|
||||
agent = DummyAgent(id="agent_x", name="named_agent")
|
||||
builder = WorkflowBuilder()
|
||||
|
||||
result = builder.add_agent(agent)
|
||||
with pytest.deprecated_call():
|
||||
result = builder.add_agent(agent)
|
||||
|
||||
# Verify that add_agent returns the builder for chaining
|
||||
assert result is builder
|
||||
@@ -203,10 +207,11 @@ def test_add_agent_duplicate_id_raises_error():
|
||||
builder = WorkflowBuilder()
|
||||
|
||||
# Add first agent
|
||||
builder.add_agent(agent1)
|
||||
with pytest.deprecated_call():
|
||||
builder.add_agent(agent1)
|
||||
|
||||
# Adding second agent with same name should raise ValueError
|
||||
with pytest.raises(ValueError, match="Duplicate executor ID"):
|
||||
with pytest.deprecated_call(), pytest.raises(ValueError, match="Duplicate executor ID"):
|
||||
builder.add_agent(agent2)
|
||||
|
||||
|
||||
@@ -288,6 +293,20 @@ def test_register_duplicate_name_raises_error():
|
||||
builder.register_executor(lambda: MockExecutor(id="executor_2"), name="MyExecutor")
|
||||
|
||||
|
||||
def test_register_duplicate_id_raises_error():
|
||||
"""Test that registering duplicate id raises an error."""
|
||||
builder = WorkflowBuilder()
|
||||
|
||||
# Register first executor
|
||||
builder.register_executor(lambda: MockExecutor(id="executor"), name="MyExecutor1")
|
||||
builder.register_executor(lambda: MockExecutor(id="executor"), name="MyExecutor2")
|
||||
builder.set_start_executor("MyExecutor1")
|
||||
|
||||
# Registering second executor with same ID should raise ValueError
|
||||
with pytest.raises(ValueError, match="Executor with ID 'executor' has already been created."):
|
||||
builder.build()
|
||||
|
||||
|
||||
def test_register_agent_basic():
|
||||
"""Test basic agent registration with lazy initialization."""
|
||||
builder = WorkflowBuilder()
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
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 = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
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 = "Mem0 integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
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 = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
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 = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
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 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core[all]==1.0.0b251209",
|
||||
"agent-framework-core[all]==1.0.0b251211",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -25,6 +25,7 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen
|
||||
| [`getting_started/agents/azure_ai_agent/azure_ai_with_azure_ai_search.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_azure_ai_search.py) | Azure AI Agent with Azure AI Search Example |
|
||||
| [`getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding.py) | Azure AI agent with Bing Grounding search for real-time web information |
|
||||
| [`getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter.py) | Azure AI Agent with Code Interpreter Example |
|
||||
| [`getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py) | Azure AI Agent with Code Interpreter File Generation Example |
|
||||
| [`getting_started/agents/azure_ai_agent/azure_ai_with_existing_agent.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_existing_agent.py) | Azure AI Agent with Existing Agent Example |
|
||||
| [`getting_started/agents/azure_ai_agent/azure_ai_with_existing_thread.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_existing_thread.py) | Azure AI Agent with Existing Thread Example |
|
||||
| [`getting_started/agents/azure_ai_agent/azure_ai_with_explicit_settings.py`](./getting_started/agents/azure_ai_agent/azure_ai_with_explicit_settings.py) | Azure AI Agent with Explicit Settings Example |
|
||||
@@ -47,6 +48,7 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen
|
||||
| [`getting_started/agents/azure_ai/azure_ai_with_bing_custom_search.py`](./getting_started/agents/azure_ai/azure_ai_with_bing_custom_search.py) | Azure AI Agent with Bing Custom Search Example |
|
||||
| [`getting_started/agents/azure_ai/azure_ai_with_browser_automation.py`](./getting_started/agents/azure_ai/azure_ai_with_browser_automation.py) | Azure AI Agent with Browser Automation Example |
|
||||
| [`getting_started/agents/azure_ai/azure_ai_with_code_interpreter.py`](./getting_started/agents/azure_ai/azure_ai_with_code_interpreter.py) | Azure AI Agent with Code Interpreter Example |
|
||||
| [`getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_generation.py`](./getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_generation.py) | Azure AI Agent with Code Interpreter File Generation Example |
|
||||
| [`getting_started/agents/azure_ai/azure_ai_with_existing_agent.py`](./getting_started/agents/azure_ai/azure_ai_with_existing_agent.py) | Azure AI Agent with Existing Agent Example |
|
||||
| [`getting_started/agents/azure_ai/azure_ai_with_existing_conversation.py`](./getting_started/agents/azure_ai/azure_ai_with_existing_conversation.py) | Azure AI Agent with Existing Conversation Example |
|
||||
| [`getting_started/agents/azure_ai/azure_ai_with_explicit_settings.py`](./getting_started/agents/azure_ai/azure_ai_with_explicit_settings.py) | Azure AI Agent with Explicit Settings Example |
|
||||
|
||||
@@ -33,7 +33,7 @@ Try to over-document the samples. This includes comments in the code, README.md
|
||||
For the getting started samples and the concept samples, we should have the following:
|
||||
|
||||
1. A README.md file is included in each set of samples that explains the purpose of the samples and the setup required to run them.
|
||||
2. A summary should be included at the top of the file that explains the purpose of the sample and required components/concepts to understand the sample. For example:
|
||||
2. A summary should be included underneath the imports that explains the purpose of the sample and required components/concepts to understand the sample. For example:
|
||||
|
||||
```python
|
||||
'''
|
||||
|
||||
@@ -14,6 +14,7 @@ This folder contains examples demonstrating different ways to create and use age
|
||||
| [`azure_ai_with_bing_custom_search.py`](azure_ai_with_bing_custom_search.py) | Shows how to use Bing Custom Search with Azure AI agents to search custom search instances and provide responses with relevant results. Requires a Bing Custom Search connection and instance configured in your Azure AI project. |
|
||||
| [`azure_ai_with_browser_automation.py`](azure_ai_with_browser_automation.py) | Shows how to use Browser Automation with Azure AI agents to perform automated web browsing tasks and provide responses based on web interactions. Requires a Browser Automation connection configured in your Azure AI project. |
|
||||
| [`azure_ai_with_code_interpreter.py`](azure_ai_with_code_interpreter.py) | Shows how to use the `HostedCodeInterpreterTool` with Azure AI agents to write and execute Python code for mathematical problem solving and data analysis. |
|
||||
| [`azure_ai_with_code_interpreter_file_generation.py`](azure_ai_with_code_interpreter_file_generation.py) | Shows how to retrieve file IDs from code interpreter generated files using both streaming and non-streaming approaches. |
|
||||
| [`azure_ai_with_existing_agent.py`](azure_ai_with_existing_agent.py) | Shows how to work with a pre-existing agent by providing the agent name and version to the Azure AI client. Demonstrates agent reuse patterns for production scenarios. |
|
||||
| [`azure_ai_with_existing_conversation.py`](azure_ai_with_existing_conversation.py) | Demonstrates how to use an existing conversation created on the service side with Azure AI agents. Shows two approaches: specifying conversation ID at the client level and using AgentThread with an existing conversation ID. |
|
||||
| [`azure_ai_with_application_endpoint.py`](azure_ai_with_application_endpoint.py) | Demonstrates calling the Azure AI application-scoped endpoint. |
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import (
|
||||
CitationAnnotation,
|
||||
HostedCodeInterpreterTool,
|
||||
HostedFileContent,
|
||||
TextContent,
|
||||
)
|
||||
from agent_framework._agents import AgentRunResponseUpdate
|
||||
from agent_framework.azure import AzureAIClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
"""
|
||||
Azure AI V2 Code Interpreter File Generation Sample
|
||||
|
||||
This sample demonstrates how the V2 AzureAIClient handles file annotations
|
||||
when code interpreter generates text files. It shows both non-streaming
|
||||
and streaming approaches to verify file ID extraction.
|
||||
"""
|
||||
|
||||
QUERY = (
|
||||
"Write a simple Python script that creates a text file called 'sample.txt' containing "
|
||||
"'Hello from the code interpreter!' and save it to disk."
|
||||
)
|
||||
|
||||
|
||||
async def test_non_streaming() -> None:
|
||||
"""Test non-streaming response - should have annotations on TextContent."""
|
||||
print("=== Testing Non-Streaming Response ===")
|
||||
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AzureAIClient(credential=credential).create_agent(
|
||||
name="V2CodeInterpreterFileAgent",
|
||||
instructions="You are a helpful assistant that can write and execute Python code to create files.",
|
||||
tools=HostedCodeInterpreterTool(),
|
||||
) as agent,
|
||||
):
|
||||
print(f"User: {QUERY}\n")
|
||||
|
||||
result = await agent.run(QUERY)
|
||||
print(f"Agent: {result.text}\n")
|
||||
|
||||
# Check for annotations in the response
|
||||
annotations_found: list[str] = []
|
||||
# AgentRunResponse has messages property, which contains ChatMessage objects
|
||||
for message in result.messages:
|
||||
for content in message.contents:
|
||||
if isinstance(content, TextContent) and content.annotations:
|
||||
for annotation in content.annotations:
|
||||
if isinstance(annotation, CitationAnnotation) and annotation.file_id:
|
||||
annotations_found.append(annotation.file_id)
|
||||
print(f"Found file annotation: file_id={annotation.file_id}")
|
||||
|
||||
if annotations_found:
|
||||
print(f"SUCCESS: Found {len(annotations_found)} file annotation(s)")
|
||||
else:
|
||||
print("WARNING: No file annotations found in non-streaming response")
|
||||
|
||||
|
||||
async def test_streaming() -> None:
|
||||
"""Test streaming response - check if file content is captured via HostedFileContent."""
|
||||
print("\n=== Testing Streaming Response ===")
|
||||
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AzureAIClient(credential=credential).create_agent(
|
||||
name="V2CodeInterpreterFileAgentStreaming",
|
||||
instructions="You are a helpful assistant that can write and execute Python code to create files.",
|
||||
tools=HostedCodeInterpreterTool(),
|
||||
) as agent,
|
||||
):
|
||||
print(f"User: {QUERY}\n")
|
||||
annotations_found: list[str] = []
|
||||
text_chunks: list[str] = []
|
||||
file_ids_found: list[str] = []
|
||||
|
||||
async for update in agent.run_stream(QUERY):
|
||||
if isinstance(update, AgentRunResponseUpdate):
|
||||
for content in update.contents:
|
||||
if isinstance(content, TextContent):
|
||||
if content.text:
|
||||
text_chunks.append(content.text)
|
||||
if content.annotations:
|
||||
for annotation in content.annotations:
|
||||
if isinstance(annotation, CitationAnnotation) and annotation.file_id:
|
||||
annotations_found.append(annotation.file_id)
|
||||
print(f"Found streaming annotation: file_id={annotation.file_id}")
|
||||
elif isinstance(content, HostedFileContent):
|
||||
file_ids_found.append(content.file_id)
|
||||
print(f"Found streaming HostedFileContent: file_id={content.file_id}")
|
||||
|
||||
print(f"\nAgent response: {''.join(text_chunks)[:200]}...")
|
||||
|
||||
if annotations_found or file_ids_found:
|
||||
total = len(annotations_found) + len(file_ids_found)
|
||||
print(f"SUCCESS: Found {total} file reference(s) in streaming")
|
||||
else:
|
||||
print("WARNING: No file annotations found in streaming response")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
print("AzureAIClient Code Interpreter File Generation Test\n")
|
||||
await test_non_streaming()
|
||||
await test_streaming()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -9,6 +9,7 @@ This folder contains examples demonstrating different ways to create and use age
|
||||
| [`azure_ai_basic.py`](azure_ai_basic.py) | The simplest way to create an agent using `ChatAgent` with `AzureAIAgentClient`. It automatically handles all configuration using environment variables. |
|
||||
| [`azure_ai_with_bing_custom_search.py`](azure_ai_with_bing_custom_search.py) | Shows how to use Bing Custom Search with Azure AI agents to find real-time information from the web using custom search configurations. Demonstrates how to set up and use HostedWebSearchTool with custom search instances. |
|
||||
| [`azure_ai_with_bing_grounding.py`](azure_ai_with_bing_grounding.py) | Shows how to use Bing Grounding search with Azure AI agents to find real-time information from the web. Demonstrates web search capabilities with proper source citations and comprehensive error handling. |
|
||||
| [`azure_ai_with_code_interpreter_file_generation.py`](azure_ai_with_code_interpreter_file_generation.py) | Shows how to retrieve file IDs from code interpreter generated files using both streaming and non-streaming approaches. |
|
||||
| [`azure_ai_with_code_interpreter.py`](azure_ai_with_code_interpreter.py) | Shows how to use the HostedCodeInterpreterTool with Azure AI agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. |
|
||||
| [`azure_ai_with_existing_agent.py`](azure_ai_with_existing_agent.py) | Shows how to work with a pre-existing agent by providing the agent ID to the Azure AI chat client. This example also demonstrates proper cleanup of manually created agents. |
|
||||
| [`azure_ai_with_existing_thread.py`](azure_ai_with_existing_thread.py) | Shows how to work with a pre-existing thread by providing the thread ID to the Azure AI chat client. This example also demonstrates proper cleanup of manually created threads. |
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import AgentRunResponseUpdate, ChatAgent, HostedCodeInterpreterTool, HostedFileContent
|
||||
from agent_framework.azure import AzureAIAgentClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
"""
|
||||
Azure AI Agent Code Interpreter File Generation Example
|
||||
|
||||
This sample demonstrates using HostedCodeInterpreterTool with AzureAIAgentClient
|
||||
to generate a text file and then retrieve it.
|
||||
|
||||
The test flow:
|
||||
1. Create an agent with code interpreter tool
|
||||
2. Ask the agent to generate a txt file using Python code
|
||||
3. Capture the file_id from HostedFileContent in the response
|
||||
4. Retrieve the file using the agents_client.files API
|
||||
"""
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Test file generation and retrieval with code interpreter."""
|
||||
|
||||
async with AzureCliCredential() as credential:
|
||||
client = AzureAIAgentClient(credential=credential)
|
||||
|
||||
try:
|
||||
async with ChatAgent(
|
||||
chat_client=client,
|
||||
instructions=(
|
||||
"You are a Python code execution assistant. "
|
||||
"ALWAYS use the code interpreter tool to execute Python code when asked to create files. "
|
||||
"Write actual Python code to create files, do not just describe what you would do."
|
||||
),
|
||||
tools=[HostedCodeInterpreterTool()],
|
||||
) as agent:
|
||||
# Be very explicit about wanting code execution and a download link
|
||||
query = (
|
||||
"Use the code interpreter to execute this Python code and then provide me "
|
||||
"with a download link for the generated file:\n"
|
||||
"```python\n"
|
||||
"with open('/mnt/data/sample.txt', 'w') as f:\n"
|
||||
" f.write('Hello, World! This is a test file.')\n"
|
||||
"'/mnt/data/sample.txt'\n" # Return the path so it becomes downloadable
|
||||
"```"
|
||||
)
|
||||
print(f"User: {query}\n")
|
||||
print("=" * 60)
|
||||
|
||||
# Collect file_ids from the response
|
||||
file_ids: list[str] = []
|
||||
|
||||
async for chunk in agent.run_stream(query):
|
||||
if not isinstance(chunk, AgentRunResponseUpdate):
|
||||
continue
|
||||
|
||||
for content in chunk.contents:
|
||||
if content.type == "text":
|
||||
print(content.text, end="", flush=True)
|
||||
elif content.type == "hosted_file":
|
||||
if isinstance(content, HostedFileContent):
|
||||
file_ids.append(content.file_id)
|
||||
print(f"\n[File generated: {content.file_id}]")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
|
||||
# Attempt to retrieve discovered files
|
||||
if file_ids:
|
||||
print(f"\nAttempting to retrieve {len(file_ids)} file(s):")
|
||||
for file_id in file_ids:
|
||||
try:
|
||||
file_info = await client.agents_client.files.get(file_id)
|
||||
print(f" File {file_id}: Retrieved successfully")
|
||||
print(f" Filename: {file_info.filename}")
|
||||
print(f" Purpose: {file_info.purpose}")
|
||||
print(f" Bytes: {file_info.bytes}")
|
||||
except Exception as e:
|
||||
print(f" File {file_id}: FAILED to retrieve - {e}")
|
||||
else:
|
||||
print("No file IDs were captured from the response.")
|
||||
|
||||
# List all files to see if any exist
|
||||
print("\nListing all files in the agent service:")
|
||||
try:
|
||||
files_list = await client.agents_client.files.list()
|
||||
count = 0
|
||||
for file_info in files_list.data:
|
||||
count += 1
|
||||
print(f" - {file_info.id}: {file_info.filename} ({file_info.purpose})")
|
||||
if count == 0:
|
||||
print(" No files found.")
|
||||
except Exception as e:
|
||||
print(f" Failed to list files: {e}")
|
||||
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -78,9 +78,22 @@ Once comfortable with these, explore the rest of the samples below.
|
||||
|
||||
| Sample | File | Concepts |
|
||||
|---|---|---|
|
||||
| Human-In-The-Loop (Guessing Game) | [human-in-the-loop/guessing_game_with_human_input.py](./human-in-the-loop/guessing_game_with_human_input.py) | Interactive request/response prompts with a human |
|
||||
| Azure Agents Tool Feedback Loop | [agents/azure_chat_agents_tool_calls_with_feedback.py](./agents/azure_chat_agents_tool_calls_with_feedback.py) | Two-agent workflow that streams tool calls and pauses for human guidance between passes |
|
||||
| Human-In-The-Loop (Guessing Game) | [human-in-the-loop/guessing_game_with_human_input.py](./human-in-the-loop/guessing_game_with_human_input.py) | Interactive request/response prompts with a human via `ctx.request_info()` |
|
||||
| Agents with Approval Requests in Workflows | [human-in-the-loop/agents_with_approval_requests.py](./human-in-the-loop/agents_with_approval_requests.py) | Agents that create approval requests during workflow execution and wait for human approval to proceed |
|
||||
| SequentialBuilder Request Info | [human-in-the-loop/sequential_request_info.py](./human-in-the-loop/sequential_request_info.py) | Request info for agent responses mid-workflow using `.with_request_info()` on SequentialBuilder |
|
||||
| ConcurrentBuilder Request Info | [human-in-the-loop/concurrent_request_info.py](./human-in-the-loop/concurrent_request_info.py) | Review concurrent agent outputs before aggregation using `.with_request_info()` on ConcurrentBuilder |
|
||||
| GroupChatBuilder Request Info | [human-in-the-loop/group_chat_request_info.py](./human-in-the-loop/group_chat_request_info.py) | Steer group discussions with periodic guidance using `.with_request_info()` on GroupChatBuilder |
|
||||
|
||||
|
||||
### tool-approval
|
||||
|
||||
Tool approval samples demonstrate using `@ai_function(approval_mode="always_require")` to gate sensitive tool executions with human approval. These work with the high-level builder APIs.
|
||||
|
||||
| Sample | File | Concepts |
|
||||
|---|---|---|
|
||||
| SequentialBuilder Tool Approval | [tool-approval/sequential_builder_tool_approval.py](./tool-approval/sequential_builder_tool_approval.py) | Sequential workflow with tool approval gates for sensitive operations |
|
||||
| ConcurrentBuilder Tool Approval | [tool-approval/concurrent_builder_tool_approval.py](./tool-approval/concurrent_builder_tool_approval.py) | Concurrent workflow with tool approvals across parallel agents |
|
||||
| GroupChatBuilder Tool Approval | [tool-approval/group_chat_builder_tool_approval.py](./tool-approval/group_chat_builder_tool_approval.py) | Group chat workflow with tool approval for multi-agent collaboration |
|
||||
|
||||
### observability
|
||||
|
||||
@@ -97,6 +110,7 @@ For additional observability samples in Agent Framework, see the [observability
|
||||
| Concurrent Orchestration (Default Aggregator) | [orchestration/concurrent_agents.py](./orchestration/concurrent_agents.py) | Fan-out to multiple agents; fan-in with default aggregator returning combined ChatMessages |
|
||||
| Concurrent Orchestration (Custom Aggregator) | [orchestration/concurrent_custom_aggregator.py](./orchestration/concurrent_custom_aggregator.py) | Override aggregator via callback; summarize results with an LLM |
|
||||
| Concurrent Orchestration (Custom Agent Executors) | [orchestration/concurrent_custom_agent_executors.py](./orchestration/concurrent_custom_agent_executors.py) | Child executors own ChatAgents; concurrent fan-out/fan-in via ConcurrentBuilder |
|
||||
| Concurrent Orchestration (Participant Factory) | [orchestration/concurrent_participant_factory.py](./orchestration/concurrent_participant_factory.py) | Use participant factories for state isolation between workflow instances |
|
||||
| Group Chat with Agent Manager | [orchestration/group_chat_agent_manager.py](./orchestration/group_chat_agent_manager.py) | Agent-based manager using `set_manager()` to select next speaker |
|
||||
| Group Chat Philosophical Debate | [orchestration/group_chat_philosophical_debate.py](./orchestration/group_chat_philosophical_debate.py) | Agent manager moderates long-form, multi-round debate across diverse participants |
|
||||
| Group Chat with Simple Function Selector | [orchestration/group_chat_simple_selector.py](./orchestration/group_chat_simple_selector.py) | Group chat with a simple function selector for next speaker |
|
||||
@@ -111,6 +125,7 @@ For additional observability samples in Agent Framework, see the [observability
|
||||
| Magentic + Checkpoint Resume | [orchestration/magentic_checkpoint.py](./orchestration/magentic_checkpoint.py) | Resume Magentic orchestration from saved checkpoints |
|
||||
| Sequential Orchestration (Agents) | [orchestration/sequential_agents.py](./orchestration/sequential_agents.py) | Chain agents sequentially with shared conversation context |
|
||||
| Sequential Orchestration (Custom Executor) | [orchestration/sequential_custom_executors.py](./orchestration/sequential_custom_executors.py) | Mix agents with a summarizer that appends a compact summary |
|
||||
| Sequential Orchestration (Participant Factories) | [orchestration/sequential_participant_factory.py](./orchestration/sequential_participant_factory.py) | Use participant factories for state isolation between workflow instances |
|
||||
|
||||
**Magentic checkpointing tip**: Treat `MagenticBuilder.participants` keys as stable identifiers. When resuming from a checkpoint, the rebuilt workflow must reuse the same participant names; otherwise the checkpoint cannot be applied and the run will fail fast.
|
||||
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Sample: Request Info with ConcurrentBuilder
|
||||
|
||||
This sample demonstrates using the `.with_request_info()` method to pause a
|
||||
ConcurrentBuilder workflow AFTER all parallel agents complete but BEFORE
|
||||
aggregation, allowing human review and modification of the combined results.
|
||||
|
||||
Purpose:
|
||||
Show how to use the request info API that pauses after concurrent agents run,
|
||||
allowing review and steering of results before they are aggregated.
|
||||
|
||||
Demonstrate:
|
||||
- Configuring request info with `.with_request_info()`
|
||||
- Reviewing outputs from multiple concurrent agents
|
||||
- Injecting human guidance after agents execute but before aggregation
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables
|
||||
- Authentication via azure-identity (run az login before executing)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
AgentInputRequest,
|
||||
ChatMessage,
|
||||
ConcurrentBuilder,
|
||||
RequestInfoEvent,
|
||||
Role,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
)
|
||||
from agent_framework._workflows._agent_executor import AgentExecutorResponse
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
# Store chat client at module level for aggregator access
|
||||
_chat_client: AzureOpenAIChatClient | None = None
|
||||
|
||||
|
||||
async def aggregate_with_synthesis(results: list[AgentExecutorResponse]) -> Any:
|
||||
"""Custom aggregator that synthesizes concurrent agent outputs using an LLM.
|
||||
|
||||
This aggregator extracts the outputs from each parallel agent and uses the
|
||||
chat client to create a unified summary, incorporating any human feedback
|
||||
that was injected into the conversation.
|
||||
|
||||
Args:
|
||||
results: List of responses from all concurrent agents
|
||||
|
||||
Returns:
|
||||
The synthesized summary text
|
||||
"""
|
||||
if not _chat_client:
|
||||
return "Error: Chat client not initialized"
|
||||
|
||||
# Extract each agent's final output
|
||||
expert_sections: list[str] = []
|
||||
human_guidance = ""
|
||||
|
||||
for r in results:
|
||||
try:
|
||||
messages = getattr(r.agent_run_response, "messages", [])
|
||||
final_text = messages[-1].text if messages and hasattr(messages[-1], "text") else "(no content)"
|
||||
expert_sections.append(f"{getattr(r, 'executor_id', 'analyst')}:\n{final_text}")
|
||||
|
||||
# Check for human feedback in the conversation (will be last user message if present)
|
||||
if r.full_conversation:
|
||||
for msg in reversed(r.full_conversation):
|
||||
if msg.role == Role.USER and msg.text and "perspectives" not in msg.text.lower():
|
||||
human_guidance = msg.text
|
||||
break
|
||||
except Exception:
|
||||
expert_sections.append(f"{getattr(r, 'executor_id', 'analyst')}: (error extracting output)")
|
||||
|
||||
# Build prompt with human guidance if provided
|
||||
guidance_text = f"\n\nHuman guidance: {human_guidance}" if human_guidance else ""
|
||||
|
||||
system_msg = ChatMessage(
|
||||
Role.SYSTEM,
|
||||
text=(
|
||||
"You are a synthesis expert. Consolidate the following analyst perspectives "
|
||||
"into one cohesive, balanced summary (3-4 sentences). If human guidance is provided, "
|
||||
"prioritize aspects as directed."
|
||||
),
|
||||
)
|
||||
user_msg = ChatMessage(Role.USER, text="\n\n".join(expert_sections) + guidance_text)
|
||||
|
||||
response = await _chat_client.get_response([system_msg, user_msg])
|
||||
return response.messages[-1].text if response.messages else ""
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
global _chat_client
|
||||
_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
|
||||
# Create agents that analyze from different perspectives
|
||||
technical_analyst = _chat_client.create_agent(
|
||||
name="technical_analyst",
|
||||
instructions=(
|
||||
"You are a technical analyst. When given a topic, provide a technical "
|
||||
"perspective focusing on implementation details, performance, and architecture. "
|
||||
"Keep your analysis to 2-3 sentences."
|
||||
),
|
||||
)
|
||||
|
||||
business_analyst = _chat_client.create_agent(
|
||||
name="business_analyst",
|
||||
instructions=(
|
||||
"You are a business analyst. When given a topic, provide a business "
|
||||
"perspective focusing on ROI, market impact, and strategic value. "
|
||||
"Keep your analysis to 2-3 sentences."
|
||||
),
|
||||
)
|
||||
|
||||
user_experience_analyst = _chat_client.create_agent(
|
||||
name="ux_analyst",
|
||||
instructions=(
|
||||
"You are a UX analyst. When given a topic, provide a user experience "
|
||||
"perspective focusing on usability, accessibility, and user satisfaction. "
|
||||
"Keep your analysis to 2-3 sentences."
|
||||
),
|
||||
)
|
||||
|
||||
# Build workflow with request info enabled and custom aggregator
|
||||
workflow = (
|
||||
ConcurrentBuilder()
|
||||
.participants([technical_analyst, business_analyst, user_experience_analyst])
|
||||
.with_aggregator(aggregate_with_synthesis)
|
||||
.with_request_info()
|
||||
.build()
|
||||
)
|
||||
|
||||
# Run the workflow with human-in-the-loop
|
||||
pending_responses: dict[str, str] | None = None
|
||||
workflow_complete = False
|
||||
|
||||
print("Starting multi-perspective analysis workflow...")
|
||||
print("=" * 60)
|
||||
|
||||
while not workflow_complete:
|
||||
# Run or continue the workflow
|
||||
stream = (
|
||||
workflow.send_responses_streaming(pending_responses)
|
||||
if pending_responses
|
||||
else workflow.run_stream("Analyze the impact of large language models on software development.")
|
||||
)
|
||||
|
||||
pending_responses = None
|
||||
|
||||
# Process events
|
||||
async for event in stream:
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if isinstance(event.data, AgentInputRequest):
|
||||
# Display pre-execution context for steering concurrent agents
|
||||
print("\n" + "-" * 40)
|
||||
print("INPUT REQUESTED (BEFORE CONCURRENT AGENTS)")
|
||||
print("-" * 40)
|
||||
print(f"About to call agents: {event.data.target_agent_id}")
|
||||
print("Conversation context:")
|
||||
recent = (
|
||||
event.data.conversation[-2:] if len(event.data.conversation) > 2 else event.data.conversation
|
||||
)
|
||||
for msg in recent:
|
||||
role = msg.role.value if msg.role else "unknown"
|
||||
text = (msg.text or "")[:150]
|
||||
print(f" [{role}]: {text}...")
|
||||
print("-" * 40)
|
||||
|
||||
# Get human input to steer all agents
|
||||
user_input = input("Your guidance for the analysts (or 'skip' to continue): ") # noqa: ASYNC250
|
||||
if user_input.lower() == "skip":
|
||||
user_input = "Please analyze objectively from your unique perspective."
|
||||
|
||||
pending_responses = {event.request_id: user_input}
|
||||
print("(Resuming workflow...)")
|
||||
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
print("\n" + "=" * 60)
|
||||
print("WORKFLOW COMPLETE")
|
||||
print("=" * 60)
|
||||
print("Aggregated output:")
|
||||
# Custom aggregator returns a string
|
||||
if event.data:
|
||||
print(event.data)
|
||||
workflow_complete = True
|
||||
|
||||
elif isinstance(event, WorkflowStatusEvent):
|
||||
if event.state == WorkflowRunState.IDLE:
|
||||
workflow_complete = True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Sample: Request Info with GroupChatBuilder
|
||||
|
||||
This sample demonstrates using the `.with_request_info()` method to pause a
|
||||
GroupChatBuilder workflow BEFORE specific participants speak. By using the
|
||||
`agents=` filter parameter, you can target only certain participants rather
|
||||
than pausing before every turn.
|
||||
|
||||
Purpose:
|
||||
Show how to use the request info API with selective filtering to pause before
|
||||
specific participants speak, allowing human input to steer their response.
|
||||
|
||||
Demonstrate:
|
||||
- Configuring request info with `.with_request_info(agents=[...])`
|
||||
- Using agent filtering to reduce interruptions
|
||||
- Steering agent behavior with pre-agent human input
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables
|
||||
- Authentication via azure-identity (run az login before executing)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import (
|
||||
AgentInputRequest,
|
||||
AgentRunUpdateEvent,
|
||||
ChatMessage,
|
||||
GroupChatBuilder,
|
||||
RequestInfoEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
|
||||
# Create agents for a group discussion
|
||||
optimist = chat_client.create_agent(
|
||||
name="optimist",
|
||||
instructions=(
|
||||
"You are an optimistic team member. You see opportunities and potential "
|
||||
"in ideas. Engage constructively with the discussion, building on others' "
|
||||
"points while maintaining a positive outlook. Keep responses to 2-3 sentences."
|
||||
),
|
||||
)
|
||||
|
||||
pragmatist = chat_client.create_agent(
|
||||
name="pragmatist",
|
||||
instructions=(
|
||||
"You are a pragmatic team member. You focus on practical implementation "
|
||||
"and realistic timelines. Sometimes you disagree with overly optimistic views. "
|
||||
"Keep responses to 2-3 sentences."
|
||||
),
|
||||
)
|
||||
|
||||
creative = chat_client.create_agent(
|
||||
name="creative",
|
||||
instructions=(
|
||||
"You are a creative team member. You propose innovative solutions and "
|
||||
"think outside the box. You may suggest alternatives to conventional approaches. "
|
||||
"Keep responses to 2-3 sentences."
|
||||
),
|
||||
)
|
||||
|
||||
# Manager orchestrates the discussion
|
||||
manager = chat_client.create_agent(
|
||||
name="manager",
|
||||
instructions=(
|
||||
"You are a discussion manager coordinating a team conversation between optimist, "
|
||||
"pragmatist, and creative. Your job is to select who speaks next.\n\n"
|
||||
"RULES:\n"
|
||||
"1. Rotate through ALL participants - do not favor any single participant\n"
|
||||
"2. Each participant should speak at least once before any participant speaks twice\n"
|
||||
"3. If human feedback redirects the topic, acknowledge it and continue rotating\n"
|
||||
"4. Continue for at least 5 participant turns before concluding\n"
|
||||
"5. Do NOT select the same participant twice in a row"
|
||||
),
|
||||
)
|
||||
|
||||
# Build workflow with request info enabled
|
||||
# Using agents= filter to only pause before pragmatist speaks (not every turn)
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
.set_manager(manager=manager, display_name="Discussion Manager")
|
||||
.participants([optimist, pragmatist, creative])
|
||||
.with_max_rounds(6)
|
||||
.with_request_info(agents=[pragmatist]) # Only pause before pragmatist speaks
|
||||
.build()
|
||||
)
|
||||
|
||||
# Run the workflow with human-in-the-loop
|
||||
pending_responses: dict[str, str] | None = None
|
||||
workflow_complete = False
|
||||
current_agent: str | None = None # Track current streaming agent
|
||||
|
||||
print("Starting group discussion workflow...")
|
||||
print("=" * 60)
|
||||
|
||||
while not workflow_complete:
|
||||
# Run or continue the workflow
|
||||
stream = (
|
||||
workflow.send_responses_streaming(pending_responses)
|
||||
if pending_responses
|
||||
else workflow.run_stream(
|
||||
"Discuss how our team should approach adopting AI tools for productivity. "
|
||||
"Consider benefits, risks, and implementation strategies."
|
||||
)
|
||||
)
|
||||
|
||||
pending_responses = None
|
||||
|
||||
# Process events
|
||||
async for event in stream:
|
||||
if isinstance(event, AgentRunUpdateEvent):
|
||||
# Show all agent responses as they stream
|
||||
if event.data and event.data.text:
|
||||
agent_name = event.data.author_name or "unknown"
|
||||
# Print agent name header only when agent changes
|
||||
if agent_name != current_agent:
|
||||
current_agent = agent_name
|
||||
print(f"\n[{agent_name}]: ", end="", flush=True)
|
||||
print(event.data.text, end="", flush=True)
|
||||
|
||||
elif isinstance(event, RequestInfoEvent):
|
||||
current_agent = None # Reset for next agent
|
||||
if isinstance(event.data, AgentInputRequest):
|
||||
# Display pre-agent context for human input
|
||||
print("\n" + "-" * 40)
|
||||
print("INPUT REQUESTED")
|
||||
print(f"About to call agent: {event.data.target_agent_id}")
|
||||
print("-" * 40)
|
||||
print("Conversation context:")
|
||||
recent = (
|
||||
event.data.conversation[-3:] if len(event.data.conversation) > 3 else event.data.conversation
|
||||
)
|
||||
for msg in recent:
|
||||
role = msg.role.value if msg.role else "unknown"
|
||||
text = (msg.text or "")[:100]
|
||||
print(f" [{role}]: {text}...")
|
||||
print("-" * 40)
|
||||
|
||||
# Get human input to steer the agent
|
||||
user_input = input("Steer the discussion (or 'skip' to continue): ") # noqa: ASYNC250
|
||||
if user_input.lower() == "skip":
|
||||
user_input = "Please continue the discussion naturally."
|
||||
|
||||
pending_responses = {event.request_id: user_input}
|
||||
print("(Resuming discussion...)")
|
||||
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
print("\n" + "=" * 60)
|
||||
print("DISCUSSION COMPLETE")
|
||||
print("=" * 60)
|
||||
print("Final conversation:")
|
||||
if event.data:
|
||||
messages: list[ChatMessage] = event.data[-4:]
|
||||
for msg in messages:
|
||||
role = msg.role.value if msg.role else "unknown"
|
||||
text = (msg.text or "")[:200]
|
||||
print(f"[{role}]: {text}...")
|
||||
workflow_complete = True
|
||||
|
||||
elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
workflow_complete = True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Sample: Request Info with SequentialBuilder
|
||||
|
||||
This sample demonstrates using the `.with_request_info()` method to pause a
|
||||
SequentialBuilder workflow BEFORE each agent runs, allowing external input
|
||||
(e.g., human steering) before the agent responds.
|
||||
|
||||
Purpose:
|
||||
Show how to use the request info API that pauses before every agent response,
|
||||
using the standard request_info pattern for consistency.
|
||||
|
||||
Demonstrate:
|
||||
- Configuring request info with `.with_request_info()`
|
||||
- Handling RequestInfoEvent with AgentInputRequest data
|
||||
- Injecting responses back into the workflow via send_responses_streaming
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables
|
||||
- Authentication via azure-identity (run az login before executing)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import (
|
||||
AgentInputRequest,
|
||||
ChatMessage,
|
||||
RequestInfoEvent,
|
||||
SequentialBuilder,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
|
||||
# Create agents for a sequential document review workflow
|
||||
drafter = chat_client.create_agent(
|
||||
name="drafter",
|
||||
instructions=("You are a document drafter. When given a topic, create a brief draft (2-3 sentences)."),
|
||||
)
|
||||
|
||||
editor = chat_client.create_agent(
|
||||
name="editor",
|
||||
instructions=(
|
||||
"You are an editor. Review the draft and suggest improvements. "
|
||||
"Incorporate any human feedback that was provided."
|
||||
),
|
||||
)
|
||||
|
||||
finalizer = chat_client.create_agent(
|
||||
name="finalizer",
|
||||
instructions=(
|
||||
"You are a finalizer. Take the edited content and create a polished final version. "
|
||||
"Incorporate any additional feedback provided."
|
||||
),
|
||||
)
|
||||
|
||||
# Build workflow with request info enabled (pauses before each agent)
|
||||
workflow = SequentialBuilder().participants([drafter, editor, finalizer]).with_request_info().build()
|
||||
|
||||
# Run the workflow with request info handling
|
||||
pending_responses: dict[str, str] | None = None
|
||||
workflow_complete = False
|
||||
|
||||
print("Starting document review workflow...")
|
||||
print("=" * 60)
|
||||
|
||||
while not workflow_complete:
|
||||
# Run or continue the workflow
|
||||
stream = (
|
||||
workflow.send_responses_streaming(pending_responses)
|
||||
if pending_responses
|
||||
else workflow.run_stream("Write a brief introduction to artificial intelligence.")
|
||||
)
|
||||
|
||||
pending_responses = None
|
||||
|
||||
# Process events
|
||||
async for event in stream:
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if isinstance(event.data, AgentInputRequest):
|
||||
# Display pre-agent context for steering
|
||||
print("\n" + "-" * 40)
|
||||
print("REQUEST INFO: INPUT REQUESTED")
|
||||
print(f"About to call agent: {event.data.target_agent_id}")
|
||||
print("-" * 40)
|
||||
print("Conversation context:")
|
||||
recent = (
|
||||
event.data.conversation[-2:] if len(event.data.conversation) > 2 else event.data.conversation
|
||||
)
|
||||
for msg in recent:
|
||||
role = msg.role.value if msg.role else "unknown"
|
||||
text = (msg.text or "")[:150]
|
||||
print(f" [{role}]: {text}...")
|
||||
print("-" * 40)
|
||||
|
||||
# Get input to steer the agent
|
||||
user_input = input("Your guidance (or 'skip' to continue): ") # noqa: ASYNC250
|
||||
if user_input.lower() == "skip":
|
||||
user_input = "Please continue naturally."
|
||||
|
||||
pending_responses = {event.request_id: user_input}
|
||||
print("(Resuming workflow...)")
|
||||
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
print("\n" + "=" * 60)
|
||||
print("WORKFLOW COMPLETE")
|
||||
print("=" * 60)
|
||||
print("Final output:")
|
||||
if event.data:
|
||||
messages: list[ChatMessage] = event.data[-3:]
|
||||
for msg in messages:
|
||||
role = msg.role.value if msg.role else "unknown"
|
||||
print(f"[{role}]: {msg.text}")
|
||||
workflow_complete = True
|
||||
|
||||
elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
workflow_complete = True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+1
-1
@@ -17,7 +17,7 @@ to synthesize a concise, consolidated summary from the experts' outputs.
|
||||
The workflow completes when all participants become idle.
|
||||
|
||||
Demonstrates:
|
||||
- ConcurrentBuilder().participants([...]).with_custom_aggregator(callback)
|
||||
- ConcurrentBuilder().participants([...]).with_aggregator(callback)
|
||||
- Fan-out to agents and fan-in at an aggregator
|
||||
- Aggregation implemented via an LLM call (chat_client.get_response)
|
||||
- Workflow output yielded with the synthesized summary string
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Never
|
||||
|
||||
from agent_framework import (
|
||||
ChatAgent,
|
||||
ChatMessage,
|
||||
ConcurrentBuilder,
|
||||
Executor,
|
||||
Role,
|
||||
Workflow,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
"""
|
||||
Sample: Concurrent Orchestration with participant factories and Custom Aggregator
|
||||
|
||||
Build a concurrent workflow with ConcurrentBuilder that fans out one prompt to
|
||||
multiple domain agents and fans in their responses.
|
||||
|
||||
Override the default aggregator with a custom Executor class that uses
|
||||
AzureOpenAIChatClient.get_response() to synthesize a concise, consolidated summary
|
||||
from the experts' outputs.
|
||||
|
||||
All participants and the aggregator are created via factory functions that return
|
||||
their respective ChatAgent or Executor instances.
|
||||
|
||||
Using participant factories allows you to set up proper state isolation between workflow
|
||||
instances created by the same builder. This is particularly useful when you need to handle
|
||||
requests or tasks in parallel with stateful participants.
|
||||
|
||||
Demonstrates:
|
||||
- ConcurrentBuilder().register_participants([...]).with_aggregator(callback)
|
||||
- Fan-out to agents and fan-in at an aggregator
|
||||
- Aggregation implemented via an LLM call (chat_client.get_response)
|
||||
- Workflow output yielded with the synthesized summary string
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI configured for AzureOpenAIChatClient (az login + required env vars)
|
||||
"""
|
||||
|
||||
|
||||
def create_researcher() -> ChatAgent:
|
||||
"""Factory function to create a researcher agent instance."""
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
|
||||
instructions=(
|
||||
"You're an expert market and product researcher. Given a prompt, provide concise, factual insights,"
|
||||
" opportunities, and risks."
|
||||
),
|
||||
name="researcher",
|
||||
)
|
||||
|
||||
|
||||
def create_marketer() -> ChatAgent:
|
||||
"""Factory function to create a marketer agent instance."""
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
|
||||
instructions=(
|
||||
"You're a creative marketing strategist. Craft compelling value propositions and target messaging"
|
||||
" aligned to the prompt."
|
||||
),
|
||||
name="marketer",
|
||||
)
|
||||
|
||||
|
||||
def create_legal() -> ChatAgent:
|
||||
"""Factory function to create a legal/compliance agent instance."""
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
|
||||
instructions=(
|
||||
"You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns"
|
||||
" based on the prompt."
|
||||
),
|
||||
name="legal",
|
||||
)
|
||||
|
||||
|
||||
class SummarizationExecutor(Executor):
|
||||
"""Custom aggregator executor that synthesizes expert outputs into a concise summary."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="summarization_executor")
|
||||
self.chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
|
||||
@handler
|
||||
async def summarize_results(self, results: list[Any], ctx: WorkflowContext[Never, str]) -> None:
|
||||
expert_sections: list[str] = []
|
||||
for r in results:
|
||||
try:
|
||||
messages = getattr(r.agent_run_response, "messages", [])
|
||||
final_text = messages[-1].text if messages and hasattr(messages[-1], "text") else "(no content)"
|
||||
expert_sections.append(f"{getattr(r, 'executor_id', 'expert')}:\n{final_text}")
|
||||
except Exception as e:
|
||||
expert_sections.append(f"{getattr(r, 'executor_id', 'expert')}: (error: {type(e).__name__}: {e})")
|
||||
|
||||
# Ask the model to synthesize a concise summary of the experts' outputs
|
||||
system_msg = ChatMessage(
|
||||
Role.SYSTEM,
|
||||
text=(
|
||||
"You are a helpful assistant that consolidates multiple domain expert outputs "
|
||||
"into one cohesive, concise summary with clear takeaways. Keep it under 200 words."
|
||||
),
|
||||
)
|
||||
user_msg = ChatMessage(Role.USER, text="\n\n".join(expert_sections))
|
||||
|
||||
response = await self.chat_client.get_response([system_msg, user_msg])
|
||||
|
||||
await ctx.yield_output(response.messages[-1].text if response.messages else "")
|
||||
|
||||
|
||||
async def run_workflow(workflow: Workflow, query: str) -> None:
|
||||
events = await workflow.run(query)
|
||||
outputs = events.get_outputs()
|
||||
|
||||
if outputs:
|
||||
print(outputs[0]) # Get the first (and typically only) output
|
||||
else:
|
||||
raise RuntimeError("No outputs received from the workflow.")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# Create a concurrent builder with participant factories and a custom aggregator
|
||||
# - register_participants([...]) accepts factory functions that return
|
||||
# AgentProtocol (agents) or Executor instances.
|
||||
# - register_aggregator(...) takes a factory function that returns an Executor instance.
|
||||
concurrent_builder = (
|
||||
ConcurrentBuilder()
|
||||
.register_participants([create_researcher, create_marketer, create_legal])
|
||||
.register_aggregator(SummarizationExecutor)
|
||||
)
|
||||
|
||||
# Build workflow_a
|
||||
workflow_a = concurrent_builder.build()
|
||||
|
||||
# Run workflow_a
|
||||
# Context is maintained across runs
|
||||
print("=== First Run on workflow_a ===")
|
||||
await run_workflow(workflow_a, "We are launching a new budget-friendly electric bike for urban commuters.")
|
||||
print("\n=== Second Run on workflow_a ===")
|
||||
await run_workflow(workflow_a, "Refine your response to focus on the California market.")
|
||||
|
||||
# Build workflow_b
|
||||
# This will create new instances of all participants and the aggregator
|
||||
# The agents will also get new threads
|
||||
workflow_b = concurrent_builder.build()
|
||||
# Run workflow_b
|
||||
# Context is not maintained across instances
|
||||
# Should not expect mentions of electric bikes in the results
|
||||
print("\n=== First Run on workflow_b ===")
|
||||
await run_workflow(workflow_b, "Refine your response to focus on the California market.")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
=== First Run on workflow_a ===
|
||||
The budget-friendly electric bike market is poised for significant growth, driven by urbanization, ...
|
||||
|
||||
=== Second Run on workflow_a ===
|
||||
Launching a budget-friendly electric bike in California presents significant opportunities, driven ...
|
||||
|
||||
=== First Run on workflow_b ===
|
||||
To successfully penetrate the California market, consider these tailored strategies focused on ...
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Handoff Workflow with Code Interpreter File Generation Sample
|
||||
|
||||
This sample demonstrates retrieving file IDs from code interpreter output
|
||||
in a handoff workflow context. A triage agent routes to a code specialist
|
||||
that generates a text file, and we verify the file_id is captured correctly
|
||||
from the streaming AgentRunUpdateEvent events.
|
||||
|
||||
Verifies GitHub issue #2718: files generated by code interpreter in
|
||||
HandoffBuilder workflows can be properly retrieved.
|
||||
|
||||
Toggle USE_V2_CLIENT to switch between:
|
||||
- V1: AzureAIAgentClient (azure-ai-agents SDK)
|
||||
- V2: AzureAIClient (azure-ai-projects 2.x with Responses API)
|
||||
|
||||
IMPORTANT: When using V2 AzureAIClient with HandoffBuilder, each agent must
|
||||
have its own client instance. The V2 client binds to a single server-side
|
||||
agent name, so sharing a client between agents causes routing issues.
|
||||
|
||||
Prerequisites:
|
||||
- `az login` (Azure CLI authentication)
|
||||
- V1: AZURE_AI_AGENT_PROJECT_CONNECTION_STRING
|
||||
- V2: AZURE_AI_PROJECT_ENDPOINT, AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from agent_framework import (
|
||||
AgentRunUpdateEvent,
|
||||
ChatAgent,
|
||||
HandoffBuilder,
|
||||
HandoffUserInputRequest,
|
||||
HostedCodeInterpreterTool,
|
||||
HostedFileContent,
|
||||
RequestInfoEvent,
|
||||
TextContent,
|
||||
WorkflowEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
)
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
# Toggle between V1 (AzureAIAgentClient) and V2 (AzureAIClient)
|
||||
USE_V2_CLIENT = False
|
||||
|
||||
|
||||
async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
|
||||
"""Collect all events from an async stream."""
|
||||
return [event async for event in stream]
|
||||
|
||||
|
||||
def _handle_events(events: list[WorkflowEvent]) -> tuple[list[RequestInfoEvent], list[str]]:
|
||||
"""Process workflow events and extract file IDs and pending requests.
|
||||
|
||||
Returns:
|
||||
Tuple of (pending_requests, file_ids_found)
|
||||
"""
|
||||
requests: list[RequestInfoEvent] = []
|
||||
file_ids: list[str] = []
|
||||
|
||||
for event in events:
|
||||
if isinstance(event, WorkflowStatusEvent):
|
||||
if event.state in {WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS}:
|
||||
print(f"[status] {event.state.name}")
|
||||
|
||||
elif isinstance(event, RequestInfoEvent):
|
||||
if isinstance(event.data, HandoffUserInputRequest):
|
||||
print("\n=== Conversation So Far ===")
|
||||
for msg in event.data.conversation:
|
||||
speaker = msg.author_name or msg.role.value
|
||||
text = msg.text or ""
|
||||
txt = text[:200] + "..." if len(text) > 200 else text
|
||||
print(f"- {speaker}: {txt}")
|
||||
print("===========================\n")
|
||||
requests.append(event)
|
||||
|
||||
elif isinstance(event, AgentRunUpdateEvent):
|
||||
update = event.data
|
||||
if update is None:
|
||||
continue
|
||||
for content in update.contents:
|
||||
if isinstance(content, HostedFileContent):
|
||||
file_ids.append(content.file_id)
|
||||
print(f"[Found HostedFileContent: file_id={content.file_id}]")
|
||||
elif isinstance(content, TextContent) and content.annotations:
|
||||
for annotation in content.annotations:
|
||||
if hasattr(annotation, "file_id") and annotation.file_id:
|
||||
file_ids.append(annotation.file_id)
|
||||
print(f"[Found file annotation: file_id={annotation.file_id}]")
|
||||
|
||||
return requests, file_ids
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def create_agents_v1(credential: AzureCliCredential) -> AsyncIterator[tuple[ChatAgent, ChatAgent]]:
|
||||
"""Create agents using V1 AzureAIAgentClient."""
|
||||
from agent_framework.azure import AzureAIAgentClient
|
||||
|
||||
async with AzureAIAgentClient(credential=credential) as client:
|
||||
triage = client.create_agent(
|
||||
name="triage_agent",
|
||||
instructions=(
|
||||
"You are a triage agent. Route code-related requests to the code_specialist. "
|
||||
"When the user asks to create or generate files, hand off to code_specialist "
|
||||
"by calling handoff_to_code_specialist."
|
||||
),
|
||||
)
|
||||
|
||||
code_specialist = client.create_agent(
|
||||
name="code_specialist",
|
||||
instructions=(
|
||||
"You are a Python code specialist. Use the code interpreter to execute Python code "
|
||||
"and create files when requested. Always save files to /mnt/data/ directory."
|
||||
),
|
||||
tools=[HostedCodeInterpreterTool()],
|
||||
)
|
||||
|
||||
yield triage, code_specialist
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def create_agents_v2(credential: AzureCliCredential) -> AsyncIterator[tuple[ChatAgent, ChatAgent]]:
|
||||
"""Create agents using V2 AzureAIClient.
|
||||
|
||||
Each agent needs its own client instance because the V2 client binds
|
||||
to a single server-side agent name.
|
||||
"""
|
||||
from agent_framework.azure import AzureAIClient
|
||||
|
||||
async with (
|
||||
AzureAIClient(credential=credential) as triage_client,
|
||||
AzureAIClient(credential=credential) as code_client,
|
||||
):
|
||||
triage = triage_client.create_agent(
|
||||
name="TriageAgent",
|
||||
instructions=(
|
||||
"You are a triage agent. Your ONLY job is to route requests to the appropriate specialist. "
|
||||
"For code or file creation requests, call handoff_to_CodeSpecialist immediately. "
|
||||
"Do NOT try to complete tasks yourself. Just hand off."
|
||||
),
|
||||
)
|
||||
|
||||
code_specialist = code_client.create_agent(
|
||||
name="CodeSpecialist",
|
||||
instructions=(
|
||||
"You are a Python code specialist. You have access to a code interpreter tool. "
|
||||
"Use the code interpreter to execute Python code and create files. "
|
||||
"Always save files to /mnt/data/ directory. "
|
||||
"Do NOT discuss handoffs or routing - just complete the coding task directly."
|
||||
),
|
||||
tools=[HostedCodeInterpreterTool()],
|
||||
)
|
||||
|
||||
yield triage, code_specialist
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run a simple handoff workflow with code interpreter file generation."""
|
||||
client_version = "V2 (AzureAIClient)" if USE_V2_CLIENT else "V1 (AzureAIAgentClient)"
|
||||
print(f"=== Handoff Workflow with Code Interpreter File Generation [{client_version}] ===\n")
|
||||
|
||||
async with AzureCliCredential() as credential:
|
||||
create_agents = create_agents_v2 if USE_V2_CLIENT else create_agents_v1
|
||||
|
||||
async with create_agents(credential) as (triage, code_specialist):
|
||||
workflow = (
|
||||
HandoffBuilder()
|
||||
.participants([triage, code_specialist])
|
||||
.set_coordinator(triage)
|
||||
.with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 2)
|
||||
.build()
|
||||
)
|
||||
|
||||
user_inputs = [
|
||||
"Please create a text file called hello.txt with 'Hello from handoff workflow!' inside it.",
|
||||
"exit",
|
||||
]
|
||||
input_index = 0
|
||||
all_file_ids: list[str] = []
|
||||
|
||||
print(f"User: {user_inputs[0]}")
|
||||
events = await _drain(workflow.run_stream(user_inputs[0]))
|
||||
requests, file_ids = _handle_events(events)
|
||||
all_file_ids.extend(file_ids)
|
||||
input_index += 1
|
||||
|
||||
while requests:
|
||||
request = requests[0]
|
||||
if input_index >= len(user_inputs):
|
||||
break
|
||||
user_input = user_inputs[input_index]
|
||||
print(f"\nUser: {user_input}")
|
||||
|
||||
responses = {request.request_id: user_input}
|
||||
events = await _drain(workflow.send_responses_streaming(responses))
|
||||
requests, file_ids = _handle_events(events)
|
||||
all_file_ids.extend(file_ids)
|
||||
input_index += 1
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
if all_file_ids:
|
||||
print(f"SUCCESS: Found {len(all_file_ids)} file ID(s) in handoff workflow:")
|
||||
for fid in all_file_ids:
|
||||
print(f" - {fid}")
|
||||
else:
|
||||
print("WARNING: No file IDs captured from the handoff workflow.")
|
||||
print("=" * 50)
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
User: Please create a text file called hello.txt with 'Hello from handoff workflow!' inside it.
|
||||
[Found HostedFileContent: file_id=assistant-JT1sA...]
|
||||
|
||||
=== Conversation So Far ===
|
||||
- user: Please create a text file called hello.txt with 'Hello from handoff workflow!' inside it.
|
||||
- triage_agent: I am handing off your request to create the text file "hello.txt" with the specified content to the code specialist. They will assist you shortly.
|
||||
- code_specialist: The file "hello.txt" has been created with the content "Hello from handoff workflow!". You can download it using the link below:
|
||||
|
||||
[hello.txt](sandbox:/mnt/data/hello.txt)
|
||||
===========================
|
||||
|
||||
[status] IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
User: exit
|
||||
[status] IDLE
|
||||
|
||||
==================================================
|
||||
SUCCESS: Found 1 file ID(s) in handoff workflow:
|
||||
- assistant-JT1sA...
|
||||
==================================================
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+2
-3
@@ -13,7 +13,6 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from typing_extensions import Never
|
||||
|
||||
"""
|
||||
Sample: Sequential workflow mixing agents and a custom summarizer executor
|
||||
@@ -42,12 +41,12 @@ class Summarizer(Executor):
|
||||
"""Simple summarizer: consumes full conversation and appends an assistant summary."""
|
||||
|
||||
@handler
|
||||
async def summarize(self, conversation: list[ChatMessage], ctx: WorkflowContext[Never, list[ChatMessage]]) -> None:
|
||||
async def summarize(self, conversation: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
users = sum(1 for m in conversation if m.role == Role.USER)
|
||||
assistants = sum(1 for m in conversation if m.role == Role.ASSISTANT)
|
||||
summary = ChatMessage(role=Role.ASSISTANT, text=f"Summary -> users:{users} assistants:{assistants}")
|
||||
final_conversation = list(conversation) + [summary]
|
||||
await ctx.yield_output(final_conversation)
|
||||
await ctx.send_message(final_conversation)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import (
|
||||
ChatAgent,
|
||||
ChatMessage,
|
||||
Executor,
|
||||
Role,
|
||||
SequentialBuilder,
|
||||
Workflow,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
"""
|
||||
Sample: Sequential workflow with participant factories
|
||||
|
||||
This sample demonstrates how to create a sequential workflow with participant factories.
|
||||
|
||||
Using participant factories allows you to set up proper state isolation between workflow
|
||||
instances created by the same builder. This is particularly useful when you need to handle
|
||||
requests or tasks in parallel with stateful participants.
|
||||
|
||||
In this example, we create a sequential workflow with two participants: an accumulator
|
||||
and a content producer. The accumulator is stateful and maintains a list of all messages it has
|
||||
received. Context is maintained across runs of the same workflow instance but not across different
|
||||
workflow instances.
|
||||
"""
|
||||
|
||||
|
||||
class Accumulate(Executor):
|
||||
"""Simple accumulator.
|
||||
|
||||
Accumulates all messages from the conversation and prints them out.
|
||||
"""
|
||||
|
||||
def __init__(self, id: str):
|
||||
super().__init__(id)
|
||||
# Some internal state to accumulate messages
|
||||
self._accumulated: list[str] = []
|
||||
|
||||
@handler
|
||||
async def accumulate(self, conversation: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
self._accumulated.extend([msg.text for msg in conversation])
|
||||
print(f"Number of queries received so far: {len(self._accumulated)}")
|
||||
await ctx.send_message(conversation)
|
||||
|
||||
|
||||
def create_agent() -> ChatAgent:
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
|
||||
instructions="Produce a concise paragraph answering the user's request.",
|
||||
name="ContentProducer",
|
||||
)
|
||||
|
||||
|
||||
async def run_workflow(workflow: Workflow, query: str) -> None:
|
||||
events = await workflow.run(query)
|
||||
outputs = events.get_outputs()
|
||||
|
||||
if outputs:
|
||||
messages: list[ChatMessage] = outputs[0]
|
||||
for message in messages:
|
||||
name = message.author_name or ("assistant" if message.role == Role.ASSISTANT else "user")
|
||||
print(f"{name}: {message.text}")
|
||||
else:
|
||||
raise RuntimeError("No outputs received from the workflow.")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 1) Create a builder with participant factories
|
||||
builder = SequentialBuilder().register_participants([
|
||||
lambda: Accumulate("accumulator"),
|
||||
create_agent,
|
||||
])
|
||||
# 2) Build workflow_a
|
||||
workflow_a = builder.build()
|
||||
|
||||
# 3) Run workflow_a
|
||||
# Context is maintained across runs
|
||||
print("=== First Run on workflow_a ===")
|
||||
await run_workflow(workflow_a, "Why is the sky blue?")
|
||||
print("\n=== Second Run on workflow_a ===")
|
||||
await run_workflow(workflow_a, "Repeat my previous question.")
|
||||
|
||||
# 4) Build workflow_b
|
||||
# This will create a new instance of the accumulator and content producer
|
||||
# using the same workflow builder
|
||||
workflow_b = builder.build()
|
||||
|
||||
# 5) Run workflow_b
|
||||
# Context is not maintained across instances
|
||||
print("\n=== First Run on workflow_b ===")
|
||||
await run_workflow(workflow_b, "Repeat my previous question.")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
=== First Run on workflow_a ===
|
||||
Number of queries received so far: 1
|
||||
user: Why is the sky blue?
|
||||
ContentProducer: The sky appears blue due to a phenomenon called Rayleigh scattering.
|
||||
When sunlight enters the Earth's atmosphere, it collides with gases
|
||||
and particles, scattering shorter wavelengths of light (blue and violet)
|
||||
more than the longer wavelengths (red and yellow). Although violet light
|
||||
is scattered even more than blue, our eyes are more sensitive to blue
|
||||
light, and some violet light is absorbed by the ozone layer. As a result,
|
||||
we perceive the sky as predominantly blue during the day.
|
||||
|
||||
=== Second Run on workflow_a ===
|
||||
Number of queries received so far: 2
|
||||
user: Repeat my previous question.
|
||||
ContentProducer: Why is the sky blue?
|
||||
|
||||
=== First Run on workflow_b ===
|
||||
Number of queries received so far: 1
|
||||
user: Repeat my previous question.
|
||||
ContentProducer: I'm sorry, but I can't repeat your previous question as I don't have
|
||||
access to your past queries. However, feel free to ask anything again,
|
||||
and I'll be happy to help!
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import (
|
||||
ChatMessage,
|
||||
ConcurrentBuilder,
|
||||
FunctionApprovalRequestContent,
|
||||
FunctionApprovalResponseContent,
|
||||
RequestInfoEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
ai_function,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
"""
|
||||
Sample: Concurrent Workflow with Tool Approval Requests
|
||||
|
||||
This sample demonstrates how to use ConcurrentBuilder with tools that require human
|
||||
approval before execution. Multiple agents run in parallel, and any tool requiring
|
||||
approval will pause the workflow until the human responds.
|
||||
|
||||
This sample works as follows:
|
||||
1. A ConcurrentBuilder workflow is created with two agents running in parallel.
|
||||
2. One agent has a tool requiring approval (financial transaction).
|
||||
3. The other agent has only non-approval tools (market data lookup).
|
||||
4. Both agents receive the same task and work concurrently.
|
||||
5. When the financial agent tries to execute a trade, it triggers an approval request.
|
||||
6. The sample simulates human approval and the workflow completes.
|
||||
7. Results from both agents are aggregated and output.
|
||||
|
||||
Purpose:
|
||||
Show how tool call approvals work in parallel execution scenarios where only some
|
||||
agents have sensitive tools.
|
||||
|
||||
Demonstrate:
|
||||
- Combining agents with and without approval-required tools in concurrent workflows.
|
||||
- Handling RequestInfoEvent during concurrent agent execution.
|
||||
- Understanding that approval pauses only the agent that triggered it, not all agents.
|
||||
|
||||
Prerequisites:
|
||||
- OpenAI or Azure OpenAI configured with the required environment variables.
|
||||
- Basic familiarity with ConcurrentBuilder and streaming workflow events.
|
||||
"""
|
||||
|
||||
|
||||
# 1. Define tools for the research agent (no approval required)
|
||||
@ai_function
|
||||
def get_stock_price(symbol: Annotated[str, "The stock ticker symbol"]) -> str:
|
||||
"""Get the current stock price for a given symbol."""
|
||||
# Mock data for demonstration
|
||||
prices = {"AAPL": 175.50, "GOOGL": 140.25, "MSFT": 378.90, "AMZN": 178.75}
|
||||
price = prices.get(symbol.upper(), 100.00)
|
||||
return f"{symbol.upper()}: ${price:.2f}"
|
||||
|
||||
|
||||
@ai_function
|
||||
def get_market_sentiment(symbol: Annotated[str, "The stock ticker symbol"]) -> str:
|
||||
"""Get market sentiment analysis for a stock."""
|
||||
# Mock sentiment data
|
||||
return f"Market sentiment for {symbol.upper()}: Bullish (72% positive mentions in last 24h)"
|
||||
|
||||
|
||||
# 2. Define tools for the trading agent (approval required for trades)
|
||||
@ai_function(approval_mode="always_require")
|
||||
def execute_trade(
|
||||
symbol: Annotated[str, "The stock ticker symbol"],
|
||||
action: Annotated[str, "Either 'buy' or 'sell'"],
|
||||
quantity: Annotated[int, "Number of shares to trade"],
|
||||
) -> str:
|
||||
"""Execute a stock trade. Requires human approval due to financial impact."""
|
||||
return f"Trade executed: {action.upper()} {quantity} shares of {symbol.upper()}"
|
||||
|
||||
|
||||
@ai_function
|
||||
def get_portfolio_balance() -> str:
|
||||
"""Get current portfolio balance and available funds."""
|
||||
return "Portfolio: $50,000 invested, $10,000 cash available"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 3. Create two agents with different tool sets
|
||||
chat_client = OpenAIChatClient()
|
||||
|
||||
research_agent = chat_client.create_agent(
|
||||
name="ResearchAgent",
|
||||
instructions=(
|
||||
"You are a market research analyst. Analyze stock data and provide "
|
||||
"recommendations based on price and sentiment. Do not execute trades."
|
||||
),
|
||||
tools=[get_stock_price, get_market_sentiment],
|
||||
)
|
||||
|
||||
trading_agent = chat_client.create_agent(
|
||||
name="TradingAgent",
|
||||
instructions=(
|
||||
"You are a trading assistant. When asked to buy or sell shares, you MUST "
|
||||
"call the execute_trade function to complete the transaction. Check portfolio "
|
||||
"balance first, then execute the requested trade."
|
||||
),
|
||||
tools=[get_portfolio_balance, execute_trade],
|
||||
)
|
||||
|
||||
# 4. Build a concurrent workflow with both agents
|
||||
# ConcurrentBuilder requires at least 2 participants for fan-out
|
||||
workflow = ConcurrentBuilder().participants([research_agent, trading_agent]).build()
|
||||
|
||||
# 5. Start the workflow - both agents will process the same task in parallel
|
||||
print("Starting concurrent workflow with tool approval...")
|
||||
print("Two agents will analyze MSFT - one for research, one for trading.")
|
||||
print("-" * 60)
|
||||
|
||||
# Phase 1: Run workflow and collect all events (stream ends at IDLE or IDLE_WITH_PENDING_REQUESTS)
|
||||
request_info_events: list[RequestInfoEvent] = []
|
||||
workflow_completed_without_approvals = False
|
||||
async for event in workflow.run_stream("Analyze MSFT stock and if sentiment is positive, buy 10 shares."):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
request_info_events.append(event)
|
||||
if isinstance(event.data, FunctionApprovalRequestContent):
|
||||
print(f"\nApproval requested for tool: {event.data.function_call.name}")
|
||||
print(f" Arguments: {event.data.function_call.arguments}")
|
||||
elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
workflow_completed_without_approvals = True
|
||||
|
||||
# 6. Handle approval requests (if any)
|
||||
if request_info_events:
|
||||
responses: dict[str, FunctionApprovalResponseContent] = {}
|
||||
for request_event in request_info_events:
|
||||
if isinstance(request_event.data, FunctionApprovalRequestContent):
|
||||
print(f"\nSimulating human approval for: {request_event.data.function_call.name}")
|
||||
# Create approval response
|
||||
responses[request_event.request_id] = request_event.data.create_response(approved=True)
|
||||
|
||||
if responses:
|
||||
# Phase 2: Send all approvals and continue workflow
|
||||
output: list[ChatMessage] | None = None
|
||||
async for event in workflow.send_responses_streaming(responses):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
output = event.data
|
||||
|
||||
if output:
|
||||
print("\n" + "-" * 60)
|
||||
print("Workflow completed. Aggregated results from both agents:")
|
||||
for msg in output:
|
||||
if hasattr(msg, "author_name") and msg.author_name:
|
||||
print(f"\n[{msg.author_name}]:")
|
||||
text = msg.text[:300] + "..." if len(msg.text) > 300 else msg.text
|
||||
if text:
|
||||
print(f" {text}")
|
||||
elif workflow_completed_without_approvals:
|
||||
print("\nWorkflow completed without requiring approvals.")
|
||||
print("(The trading agent may have only checked balance without executing a trade)")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
Starting concurrent workflow with tool approval...
|
||||
Two agents will analyze MSFT - one for research, one for trading.
|
||||
------------------------------------------------------------
|
||||
|
||||
Approval requested for tool: execute_trade
|
||||
Arguments: {"symbol": "MSFT", "action": "buy", "quantity": 10}
|
||||
Simulating human approval for: execute_trade
|
||||
|
||||
------------------------------------------------------------
|
||||
Workflow completed. Aggregated results from both agents:
|
||||
|
||||
[ResearchAgent]:
|
||||
MSFT is currently trading at $175.50 with bullish market sentiment
|
||||
(72% positive mentions). Based on the positive sentiment, this could
|
||||
be a good opportunity to consider buying.
|
||||
|
||||
[TradingAgent]:
|
||||
I've checked your portfolio balance ($10,000 cash available) and
|
||||
executed the trade: BUY 10 shares of MSFT at approximately $175.50
|
||||
per share, totaling ~$1,755.
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import (
|
||||
FunctionApprovalRequestContent,
|
||||
GroupChatBuilder,
|
||||
GroupChatStateSnapshot,
|
||||
RequestInfoEvent,
|
||||
ai_function,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
"""
|
||||
Sample: Group Chat Workflow with Tool Approval Requests
|
||||
|
||||
This sample demonstrates how to use GroupChatBuilder with tools that require human
|
||||
approval before execution. A group of specialized agents collaborate on a task, and
|
||||
sensitive tool calls trigger human-in-the-loop approval.
|
||||
|
||||
This sample works as follows:
|
||||
1. A GroupChatBuilder workflow is created with multiple specialized agents.
|
||||
2. A selector function determines which agent speaks next based on conversation state.
|
||||
3. Agents collaborate on a software deployment task.
|
||||
4. When the deployment agent tries to deploy to production, it triggers an approval request.
|
||||
5. The sample simulates human approval and the workflow completes.
|
||||
|
||||
Purpose:
|
||||
Show how tool call approvals integrate with multi-agent group chat workflows where
|
||||
different agents have different levels of tool access.
|
||||
|
||||
Demonstrate:
|
||||
- Using set_select_speakers_func with agents that have approval-required tools.
|
||||
- Handling RequestInfoEvent in group chat scenarios.
|
||||
- Multi-round group chat with tool approval interruption and resumption.
|
||||
|
||||
Prerequisites:
|
||||
- OpenAI or Azure OpenAI configured with the required environment variables.
|
||||
- Basic familiarity with GroupChatBuilder and streaming workflow events.
|
||||
"""
|
||||
|
||||
|
||||
# 1. Define tools for different agents
|
||||
@ai_function
|
||||
def run_tests(test_suite: Annotated[str, "Name of the test suite to run"]) -> str:
|
||||
"""Run automated tests for the application."""
|
||||
return f"Test suite '{test_suite}' completed: 47 passed, 0 failed, 0 skipped"
|
||||
|
||||
|
||||
@ai_function
|
||||
def check_staging_status() -> str:
|
||||
"""Check the current status of the staging environment."""
|
||||
return "Staging environment: Healthy, Version 2.3.0 deployed, All services running"
|
||||
|
||||
|
||||
@ai_function(approval_mode="always_require")
|
||||
def deploy_to_production(
|
||||
version: Annotated[str, "The version to deploy"],
|
||||
components: Annotated[str, "Comma-separated list of components to deploy"],
|
||||
) -> str:
|
||||
"""Deploy specified components to production. Requires human approval."""
|
||||
return f"Production deployment complete: Version {version}, Components: {components}"
|
||||
|
||||
|
||||
@ai_function
|
||||
def create_rollback_plan(version: Annotated[str, "The version being deployed"]) -> str:
|
||||
"""Create a rollback plan for the deployment."""
|
||||
return (
|
||||
f"Rollback plan created for version {version}: "
|
||||
"Automated rollback to v2.2.0 if health checks fail within 5 minutes"
|
||||
)
|
||||
|
||||
|
||||
# 2. Define the speaker selector function
|
||||
def select_next_speaker(state: GroupChatStateSnapshot) -> str | None:
|
||||
"""Select the next speaker based on the conversation flow.
|
||||
|
||||
This simple selector follows a predefined flow:
|
||||
1. QA Engineer runs tests
|
||||
2. DevOps Engineer checks staging and creates rollback plan
|
||||
3. DevOps Engineer deploys to production (triggers approval)
|
||||
"""
|
||||
round_index: int = state["round_index"]
|
||||
|
||||
# Define the conversation flow
|
||||
speaker_order: list[str] = [
|
||||
"QAEngineer", # Round 0: Run tests
|
||||
"DevOpsEngineer", # Round 1: Check staging, create rollback
|
||||
"DevOpsEngineer", # Round 2: Deploy to production (approval required)
|
||||
]
|
||||
|
||||
if round_index >= len(speaker_order):
|
||||
return None # End the conversation
|
||||
|
||||
return speaker_order[round_index]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 3. Create specialized agents
|
||||
chat_client = OpenAIChatClient()
|
||||
|
||||
qa_engineer = chat_client.create_agent(
|
||||
name="QAEngineer",
|
||||
instructions=(
|
||||
"You are a QA engineer responsible for running tests before deployment. "
|
||||
"Run the appropriate test suites and report results clearly."
|
||||
),
|
||||
tools=[run_tests],
|
||||
)
|
||||
|
||||
devops_engineer = chat_client.create_agent(
|
||||
name="DevOpsEngineer",
|
||||
instructions=(
|
||||
"You are a DevOps engineer responsible for deployments. First check staging "
|
||||
"status and create a rollback plan, then proceed with production deployment. "
|
||||
"Always ensure safety measures are in place before deploying."
|
||||
),
|
||||
tools=[check_staging_status, create_rollback_plan, deploy_to_production],
|
||||
)
|
||||
|
||||
# 4. Build a group chat workflow with the selector function
|
||||
workflow = (
|
||||
GroupChatBuilder()
|
||||
# Optionally, use `.set_manager(...)` to customize the group chat manager
|
||||
.set_select_speakers_func(select_next_speaker)
|
||||
.participants([qa_engineer, devops_engineer])
|
||||
.with_max_rounds(5)
|
||||
.build()
|
||||
)
|
||||
|
||||
# 5. Start the workflow
|
||||
print("Starting group chat workflow for software deployment...")
|
||||
print("Agents: QA Engineer, DevOps Engineer")
|
||||
print("-" * 60)
|
||||
|
||||
# Phase 1: Run workflow and collect all events (stream ends at IDLE or IDLE_WITH_PENDING_REQUESTS)
|
||||
request_info_events: list[RequestInfoEvent] = []
|
||||
async for event in workflow.run_stream(
|
||||
"We need to deploy version 2.4.0 to production. Please coordinate the deployment."
|
||||
):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
request_info_events.append(event)
|
||||
if isinstance(event.data, FunctionApprovalRequestContent):
|
||||
print("\n[APPROVAL REQUIRED]")
|
||||
print(f" Tool: {event.data.function_call.name}")
|
||||
print(f" Arguments: {event.data.function_call.arguments}")
|
||||
|
||||
# 6. Handle approval requests
|
||||
if request_info_events:
|
||||
for request_event in request_info_events:
|
||||
if isinstance(request_event.data, FunctionApprovalRequestContent):
|
||||
print("\n" + "=" * 60)
|
||||
print("Human review required for production deployment!")
|
||||
print("In a real scenario, you would review the deployment details here.")
|
||||
print("Simulating approval for demo purposes...")
|
||||
print("=" * 60)
|
||||
|
||||
# Create approval response
|
||||
approval_response = request_event.data.create_response(approved=True)
|
||||
|
||||
# Phase 2: Send approval and continue workflow
|
||||
async for _ in workflow.send_responses_streaming({request_event.request_id: approval_response}):
|
||||
pass # Consume all events
|
||||
|
||||
print("\n" + "-" * 60)
|
||||
print("Deployment workflow completed successfully!")
|
||||
print("All agents have finished their tasks.")
|
||||
else:
|
||||
print("\nWorkflow completed without requiring production deployment approval.")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
Starting group chat workflow for software deployment...
|
||||
Agents: QA Engineer, DevOps Engineer
|
||||
------------------------------------------------------------
|
||||
|
||||
[QAEngineer]: Running the integration test suite to verify the application
|
||||
before deployment... Test suite 'integration' completed: 47 passed, 0 failed.
|
||||
All tests passing - ready for deployment.
|
||||
|
||||
[DevOpsEngineer]: Checking staging environment status... Staging is healthy
|
||||
with version 2.3.0. Creating rollback plan for version 2.4.0... Rollback plan
|
||||
created with automated rollback to v2.2.0 if health checks fail.
|
||||
|
||||
[APPROVAL REQUIRED]
|
||||
Tool: deploy_to_production
|
||||
Arguments: {"version": "2.4.0", "components": "api,web,worker"}
|
||||
|
||||
============================================================
|
||||
Human review required for production deployment!
|
||||
In a real scenario, you would review the deployment details here.
|
||||
Simulating approval for demo purposes...
|
||||
============================================================
|
||||
|
||||
[DevOpsEngineer]: Production deployment complete! Version 2.4.0 has been
|
||||
successfully deployed with components: api, web, worker.
|
||||
|
||||
------------------------------------------------------------
|
||||
Deployment workflow completed successfully!
|
||||
All agents have finished their tasks.
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import (
|
||||
ChatMessage,
|
||||
FunctionApprovalRequestContent,
|
||||
RequestInfoEvent,
|
||||
SequentialBuilder,
|
||||
WorkflowOutputEvent,
|
||||
ai_function,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
"""
|
||||
Sample: Sequential Workflow with Tool Approval Requests
|
||||
|
||||
This sample demonstrates how to use SequentialBuilder with tools that require human
|
||||
approval before execution. The approval flow uses the existing @ai_function decorator
|
||||
with approval_mode="always_require" to trigger human-in-the-loop interactions.
|
||||
|
||||
This sample works as follows:
|
||||
1. A SequentialBuilder workflow is created with a single agent that has tools requiring approval.
|
||||
2. The agent receives a user task and determines it needs to call a sensitive tool.
|
||||
3. The tool call triggers a FunctionApprovalRequestContent, pausing the workflow.
|
||||
4. The sample simulates human approval by responding to the RequestInfoEvent.
|
||||
5. Once approved, the tool executes and the agent completes its response.
|
||||
6. The workflow outputs the final conversation with all messages.
|
||||
|
||||
Purpose:
|
||||
Show how tool call approvals integrate seamlessly with SequentialBuilder without
|
||||
requiring any additional builder configuration.
|
||||
|
||||
Demonstrate:
|
||||
- Using @ai_function(approval_mode="always_require") for sensitive operations.
|
||||
- Handling RequestInfoEvent with FunctionApprovalRequestContent in sequential workflows.
|
||||
- Resuming workflow execution after approval via send_responses_streaming.
|
||||
|
||||
Prerequisites:
|
||||
- OpenAI or Azure OpenAI configured with the required environment variables.
|
||||
- Basic familiarity with SequentialBuilder and streaming workflow events.
|
||||
"""
|
||||
|
||||
|
||||
# 1. Define tools - one requiring approval, one that doesn't
|
||||
@ai_function(approval_mode="always_require")
|
||||
def execute_database_query(
|
||||
query: Annotated[str, "The SQL query to execute against the production database"],
|
||||
) -> str:
|
||||
"""Execute a SQL query against the production database. Requires human approval."""
|
||||
# In a real implementation, this would execute the query
|
||||
return f"Query executed successfully. Results: 3 rows affected by '{query}'"
|
||||
|
||||
|
||||
@ai_function
|
||||
def get_database_schema() -> str:
|
||||
"""Get the current database schema. Does not require approval."""
|
||||
return """
|
||||
Tables:
|
||||
- users (id, name, email, created_at)
|
||||
- orders (id, user_id, total, status, created_at)
|
||||
- products (id, name, price, stock)
|
||||
"""
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 2. Create the agent with tools (approval mode is set per-tool via decorator)
|
||||
chat_client = OpenAIChatClient()
|
||||
database_agent = chat_client.create_agent(
|
||||
name="DatabaseAgent",
|
||||
instructions=(
|
||||
"You are a database assistant. You can view the database schema and execute "
|
||||
"queries. Always check the schema before running queries. Be careful with "
|
||||
"queries that modify data."
|
||||
),
|
||||
tools=[get_database_schema, execute_database_query],
|
||||
)
|
||||
|
||||
# 3. Build a sequential workflow with the agent
|
||||
workflow = SequentialBuilder().participants([database_agent]).build()
|
||||
|
||||
# 4. Start the workflow with a user task
|
||||
print("Starting sequential workflow with tool approval...")
|
||||
print("-" * 60)
|
||||
|
||||
# Phase 1: Run workflow and collect all events (stream ends at IDLE or IDLE_WITH_PENDING_REQUESTS)
|
||||
request_info_events: list[RequestInfoEvent] = []
|
||||
async for event in workflow.run_stream(
|
||||
"Check the schema and then update all orders with status 'pending' to 'processing'"
|
||||
):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
request_info_events.append(event)
|
||||
if isinstance(event.data, FunctionApprovalRequestContent):
|
||||
print(f"\nApproval requested for tool: {event.data.function_call.name}")
|
||||
print(f" Arguments: {event.data.function_call.arguments}")
|
||||
|
||||
# 5. Handle approval requests
|
||||
if request_info_events:
|
||||
for request_event in request_info_events:
|
||||
if isinstance(request_event.data, FunctionApprovalRequestContent):
|
||||
# In a real application, you would prompt the user here
|
||||
print("\nSimulating human approval (auto-approving for demo)...")
|
||||
|
||||
# Create approval response
|
||||
approval_response = request_event.data.create_response(approved=True)
|
||||
|
||||
# Phase 2: Send approval and continue workflow
|
||||
output: list[ChatMessage] | None = None
|
||||
async for event in workflow.send_responses_streaming({request_event.request_id: approval_response}):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
output = event.data
|
||||
|
||||
if output:
|
||||
print("\n" + "-" * 60)
|
||||
print("Workflow completed. Final conversation:")
|
||||
for msg in output:
|
||||
role = msg.role.value if hasattr(msg.role, "value") else msg.role
|
||||
text = msg.text[:200] + "..." if len(msg.text) > 200 else msg.text
|
||||
print(f" [{role}]: {text}")
|
||||
else:
|
||||
print("No approval requests were generated (schema check may have been sufficient).")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
Starting sequential workflow with tool approval...
|
||||
------------------------------------------------------------
|
||||
|
||||
Approval requested for tool: execute_database_query
|
||||
Arguments: {"query": "UPDATE orders SET status = 'processing' WHERE status = 'pending'"}
|
||||
|
||||
Simulating human approval (auto-approving for demo)...
|
||||
|
||||
------------------------------------------------------------
|
||||
Workflow completed. Final conversation:
|
||||
[user]: Check the schema and then update all orders with status 'pending' to 'processing'
|
||||
[assistant]: I've checked the schema and executed the update query. The query
|
||||
"UPDATE orders SET status = 'processing' WHERE status = 'pending'"
|
||||
was executed successfully, affecting 3 rows.
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Generated
+17
-17
@@ -89,7 +89,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -156,7 +156,7 @@ docs = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-a2a"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/a2a" }
|
||||
dependencies = [
|
||||
{ name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -171,7 +171,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/ag-ui" }
|
||||
dependencies = [
|
||||
{ name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -201,7 +201,7 @@ provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-anthropic"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/anthropic" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -216,7 +216,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-azure-ai"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/azure-ai" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -235,7 +235,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-azure-ai-search"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/azure-ai-search" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -250,7 +250,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-azurefunctions"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/azurefunctions" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -275,7 +275,7 @@ dev = [{ name = "types-python-dateutil", specifier = ">=2.9.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-chatkit"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/chatkit" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -290,7 +290,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-copilotstudio"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/copilotstudio" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -305,7 +305,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-core"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/core" }
|
||||
dependencies = [
|
||||
{ name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -356,7 +356,7 @@ requires-dist = [
|
||||
{ name = "agent-framework-purview", marker = "extra == 'all'", editable = "packages/purview" },
|
||||
{ name = "agent-framework-redis", marker = "extra == 'all'", editable = "packages/redis" },
|
||||
{ name = "azure-identity", specifier = ">=1,<2" },
|
||||
{ name = "mcp", extras = ["ws"], specifier = ">=1.13" },
|
||||
{ name = "mcp", extras = ["ws"], specifier = ">=1.23" },
|
||||
{ name = "openai", specifier = ">=1.99.0" },
|
||||
{ name = "opentelemetry-api", specifier = ">=1.39.0" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.39.0" },
|
||||
@@ -371,7 +371,7 @@ provides-extras = ["all"]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-declarative"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/declarative" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -396,7 +396,7 @@ dev = [{ name = "types-pyyaml" }]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-devui"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/devui" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -430,7 +430,7 @@ provides-extras = ["dev", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-lab"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/lab" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -521,7 +521,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-mem0"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/mem0" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -536,7 +536,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-purview"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/purview" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -553,7 +553,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-redis"
|
||||
version = "1.0.0b251209"
|
||||
version = "1.0.0b251211"
|
||||
source = { editable = "packages/redis" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
|
||||
Reference in New Issue
Block a user