Compare commits

...
Author SHA1 Message Date
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Chris
16230d3b20 Bump actions/checkout from 5 to 6 (#2404)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-12-11 18:43:52 +00:00
Dmytro StrukandGitHub 8d53b20026 Python: Updated package versions (#2784)
* Updated package versions

* Small fix
2025-12-11 18:39:08 +00:00
Eduard van ValkenburgandGitHub c376868ec9 Python: added more complete parsing for mcp tool arguments (#2756)
* added more complete parsing for mcp tool arguments

* fixed mypy

* added nonlocal model counter, and some fixes

* fixes in naming logic

* extracted json parsing function, added parametrized test and checked coverage
2025-12-11 17:24:08 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8bb9927f3c Bump Azure.AI.AgentServer.AgentFramework from 1.0.0-beta.4 to 1.0.0-beta.5 (#2778)
---
updated-dependencies:
- dependency-name: Azure.AI.AgentServer.AgentFramework
  dependency-version: 1.0.0-beta.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Azure.AI.AgentServer.AgentFramework
  dependency-version: 1.0.0-beta.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Azure.AI.AgentServer.AgentFramework
  dependency-version: 1.0.0-beta.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-11 14:08:20 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
194486c4cc Bump Azure.Identity from 1.17.0 to 1.17.1 (#2780)
---
updated-dependencies:
- dependency-name: Azure.Identity
  dependency-version: 1.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Azure.Identity
  dependency-version: 1.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Azure.Identity
  dependency-version: 1.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Azure.Identity
  dependency-version: 1.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-11 11:04:22 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0413f4220a Bump AWSSDK.Extensions.Bedrock.MEAI from 4.0.4.7 to 4.0.4.11 (#2777)
---
updated-dependencies:
- dependency-name: AWSSDK.Extensions.Bedrock.MEAI
  dependency-version: 4.0.4.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-11 11:01:19 +00:00
CopilotGitHubrogerbarretoCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
67e83042cf .NET: Add Conversation State Sample (Step05) (#2697)
* Initial plan

* Add Agent_OpenAI_Step05_Conversation sample for conversation state management

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Update Program.cs comment to accurately describe the sample

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Update the code to use the ConversationClient more in line with the samples in OpenAI

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Changing sample to use ChatClientAgent and conversationId in GetNewThread

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-11 10:58:42 +00:00
5da1c2fd4c code ql sm04598 (#2723)
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
2025-12-11 10:34:58 +00:00
SergeyMenshykhandGitHub 989b6ebe71 .NET: [BREAKING] Prevent nulls in AIAgent property (#2719)
* prevent nulls in AIAgent property

* address feedback
2025-12-11 09:50:25 +00:00
Evan MattsonandGitHub 3481914981 Capture file IDs from code interpreter in streaming responses (#2741) 2025-12-11 07:30:19 +00:00
KurtandGitHub 4c6a5d4aa1 Python: fix: GroupChat ManagerSelectionResponse JSON Schema for OpenAI Structured Outpu… (#2750)
* fix: ManagerSelectionResponse JSON Schema for OpenAI Structured Output Strict Mode

* refactor: install pre-commit then commit again
2025-12-11 02:34:12 +00:00
Tao ChenandGitHub 191779ce80 Python: Add factory pattern to concurrent orchestration builder (#2738)
* Add factory pattern to concurrent orchestration builder

* Update readme

* Address AI comments

* Fix unit tests

* Fix import

* Prevent multiple calls to set participants or factories

* Add comments

* Mitigate warnings

* Fix mypy

* Address comments

* Address Copilot comments

* Fix tests
2025-12-11 01:23:28 +00:00
CopilotGitHubstephentoubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Roger Barreto
638fbb5f03 Update Microsoft.Extensions.AI.* packages to 10.1.0 (#2735)
* Initial plan

* Update Microsoft.Extensions.AI dependencies to latest versions (10.1.0)

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2025-12-10 19:04:05 +00:00
Tao ChenandGitHub 523305ac62 Python: Add factory pattern to sequential orchestration builder (#2710)
* Add factory pattern to sequential orchestration builder

* Use temp list to avoid override

* Add sample and some other fixes

* Fix comments

* Small fix

* Update readme
2025-12-10 18:16:10 +00:00
CopilotGitHublarohracopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Laveesh Rohra
3f4eeb00be Python: Change DurableAIAgent log level from warning to debug when invoked without thread (#2736)
* Initial plan

* Change logger.warning to logger.debug for missing thread

Co-authored-by: larohra <41490930+larohra@users.noreply.github.com>

* Fix test to use getMessage() method for log records

Co-authored-by: larohra <41490930+larohra@users.noreply.github.com>

* Remove test for logging change per review feedback

Co-authored-by: larohra <41490930+larohra@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: larohra <41490930+larohra@users.noreply.github.com>
Co-authored-by: Laveesh Rohra <larohra@microsoft.com>
2025-12-10 17:57:08 +00:00
Evan MattsonandGitHub b378ca75d1 Python: extend HITL support for all orchestration patterns (#2620)
* Support HITL for orchestration patterns

* Cleanup around naming

* Fix typing issues

* Clean up

* Naming clean up

* Updates to HITL to make it cleaner

* Rename human input hook to orchestration request info

* Clean up per PR feedback
2025-12-10 15:59:29 +00:00
98 changed files with 4939 additions and 478 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
persist-credentials: false
+2 -2
View File
@@ -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: |
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-22.04
# check out the latest version of the code
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
persist-credentials: false
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python and install the project
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
outputs:
pythonChanges: ${{ steps.filter.outputs.python}}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
@@ -59,7 +59,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
outputs:
pythonChanges: ${{ steps.filter.outputs.python}}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
@@ -75,7 +75,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -135,7 +135,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
@@ -19,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:
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
env:
UV_PYTHON: "3.10"
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
# Save the PR number to a file since the workflow_run event
# in the coverage report workflow does not have access to it
- name: Save PR number
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
run:
working-directory: python
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
+11 -11
View File
@@ -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" />
+1
View File
@@ -129,6 +129,7 @@
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj" />
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/Agent_OpenAI_Step03_CreateFromChatClient.csproj" />
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient.csproj" />
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Agent_OpenAI_Step05_Conversation.csproj" />
</Folder>
<Folder Name="/Samples/Purview/" />
<Folder Name="/Samples/Purview/AgentWithPurview/">
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to maintain conversation state using the OpenAIResponseClientAgent
// and AgentThread. By passing the same thread to multiple agent invocations, the agent
// automatically maintains the conversation history, allowing the AI model to understand
// context from previous exchanges.
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
using OpenAI.Conversations;
string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set.");
string model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o-mini";
// Create a ConversationClient directly from OpenAIClient
OpenAIClient openAIClient = new(apiKey);
ConversationClient conversationClient = openAIClient.GetConversationClient();
// Create an agent directly from the 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();
@@ -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>
@@ -276,15 +276,9 @@ public sealed class BasicStreamingTests : IAsyncDisposable
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")]
internal sealed class FakeChatClientAgent : AIAgent
{
public FakeChatClientAgent()
{
this.Id = "fake-agent";
this.Description = "A fake agent for testing";
}
protected override string? IdCore => "fake-agent";
public override string Id { get; }
public override string? Description { get; }
public override string? Description => "A fake agent for testing";
public override AgentThread GetNewThread()
{
@@ -350,15 +344,9 @@ internal sealed class FakeChatClientAgent : AIAgent
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")]
internal sealed class FakeMultiMessageAgent : AIAgent
{
public FakeMultiMessageAgent()
{
this.Id = "fake-multi-message-agent";
this.Description = "A fake agent that sends multiple messages for testing";
}
protected override string? IdCore => "fake-multi-message-agent";
public override string Id { get; }
public override string? Description { get; }
public override string? Description => "A fake agent that sends multiple messages for testing";
public override AgentThread GetNewThread()
{
@@ -421,7 +421,7 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
private sealed class MultiResponseAgent : AIAgent
{
public override string Id => "multi-response-agent";
protected override string? IdCore => "multi-response-agent";
public override string? Description => "Agent that produces multiple text chunks";
@@ -510,7 +510,7 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
private sealed class TestAgent : AIAgent
{
public override string Id => "test-agent";
protected override string? IdCore => "test-agent";
public override string? Description => "Test agent";
@@ -57,7 +57,7 @@ internal sealed class HelloAgent(string id = nameof(HelloAgent)) : AIAgent
public const string Greeting = "Hello World!";
public const string DefaultId = nameof(HelloAgent);
public override string Id => id;
protected override string? IdCore => id;
public override string? Name => id;
public override AgentThread GetNewThread()
@@ -19,7 +19,7 @@ public class SpecializedExecutorSmokeTests
{
public class TestAIAgent(List<ChatMessage>? messages = null, string? id = null, string? name = null) : AIAgent
{
public override string Id => id ?? base.Id;
protected override string? IdCore => id;
public override string? Name => name;
public static List<ChatMessage> ToChatMessages(params string[] messages)
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal class TestEchoAgent(string? id = null, string? name = null, string? prefix = null) : AIAgent
{
public override string Id => id ?? base.Id;
protected override string? IdCore => id;
public override string? Name => name ?? base.Name;
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
+21 -1
View File
@@ -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
+1 -1
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251209"
version = "1.0.0b251211"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+15 -85
View File
@@ -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:
+146 -23
View File
@@ -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)
+2 -2
View File
@@ -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",
]
+345 -107
View File
@@ -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()
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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]
+2
View File
@@ -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 |
+1 -1
View File
@@ -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. |
@@ -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. |
@@ -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.
@@ -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())
@@ -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())
@@ -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())
@@ -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
@@ -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())
@@ -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())
@@ -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:
@@ -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())
@@ -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())
@@ -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())
@@ -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())
+17 -17
View File
@@ -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'" },