Merge branch 'main' into feature-xunit3-mtp-upgrade

This commit is contained in:
westey
2026-02-25 13:31:08 +00:00
committed by GitHub
Unverified
56 changed files with 9101 additions and 813 deletions
+1 -5
View File
@@ -1,10 +1,6 @@
{
"name": "C# (.NET)",
//"image": "mcr.microsoft.com/devcontainers/dotnet",
// Workaround for https://github.com/devcontainers/images/issues/1752
"build": {
"dockerfile": "dotnet.Dockerfile"
},
"image": "mcr.microsoft.com/devcontainers/dotnet",
"features": {
"ghcr.io/devcontainers/features/azure-cli:1.2.9": {},
"ghcr.io/devcontainers/features/github-cli:1": {
-5
View File
@@ -1,5 +0,0 @@
FROM mcr.microsoft.com/devcontainers/universal:latest
# Remove Yarn repository with expired GPG key to prevent apt-get update failures
# Tracking issue: https://github.com/devcontainers/images/issues/1752
RUN rm -f /etc/apt/sources.list.d/yarn.list
+1
View File
@@ -183,6 +183,7 @@
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step18_FileSearch/FoundryAgents_Step18_FileSearch.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step19_OpenAPITools/FoundryAgents_Step19_OpenAPITools.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step21_BingCustomSearch/FoundryAgents_Step21_BingCustomSearch.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step25_WebSearch/FoundryAgents_Step25_WebSearch.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step26_MemorySearch/FoundryAgents_Step26_MemorySearch.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/FoundryAgents_Evaluations_Step01_RedTeaming.csproj" />
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);CA1812;CS8321</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.AI.Projects" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use Bing Custom Search Tool with AI Agents.
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI.Responses;
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
string connectionId = Environment.GetEnvironmentVariable("BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID is not set.");
string instanceName = Environment.GetEnvironmentVariable("BING_CUSTOM_SEARCH_INSTANCE_NAME") ?? throw new InvalidOperationException("BING_CUSTOM_SEARCH_INSTANCE_NAME is not set.");
const string AgentInstructions = """
You are a helpful agent that can use Bing Custom Search tools to assist users.
Use the available Bing Custom Search tools to answer questions and perform tasks.
""";
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Bing Custom Search tool parameters shared by both options
BingCustomSearchToolParameters bingCustomSearchToolParameters = new([
new BingCustomSearchConfiguration(connectionId, instanceName)
]);
AIAgent agent = await CreateAgentWithMEAIAsync();
// AIAgent agent = await CreateAgentWithNativeSDKAsync();
Console.WriteLine($"Created agent: {agent.Name}");
// Run the agent with a search query
AgentResponse response = await agent.RunAsync("Search for the latest news about Microsoft AI");
Console.WriteLine("\n=== Agent Response ===");
foreach (var message in response.Messages)
{
Console.WriteLine(message.Text);
}
// Cleanup by deleting the agent
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
Console.WriteLine($"\nDeleted agent: {agent.Name}");
// --- Agent Creation Options ---
// Option 1 - Using AsAITool wrapping for the ResponseTool returned by AgentTool.CreateBingCustomSearchTool (MEAI + AgentFramework)
async Task<AIAgent> CreateAgentWithMEAIAsync()
{
return await aiProjectClient.CreateAIAgentAsync(
model: deploymentName,
name: "BingCustomSearchAgent-MEAI",
instructions: AgentInstructions,
tools: [((ResponseTool)AgentTool.CreateBingCustomSearchTool(bingCustomSearchToolParameters)).AsAITool()]);
}
// Option 2 - Using PromptAgentDefinition with AgentTool.CreateBingCustomSearchTool (Native SDK)
async Task<AIAgent> CreateAgentWithNativeSDKAsync()
{
return await aiProjectClient.CreateAIAgentAsync(
name: "BingCustomSearchAgent-NATIVE",
creationOptions: new AgentVersionCreationOptions(
new PromptAgentDefinition(model: deploymentName)
{
Instructions = AgentInstructions,
Tools = {
(ResponseTool)AgentTool.CreateBingCustomSearchTool(bingCustomSearchToolParameters),
}
})
);
}
@@ -0,0 +1,63 @@
# Using Bing Custom Search with AI Agents
This sample demonstrates how to use the Bing Custom Search tool with AI agents to perform customized web searches.
## What this sample demonstrates
- Creating agents with Bing Custom Search capabilities
- Configuring custom search instances via connection ID and instance name
- Two agent creation approaches: MEAI abstraction (Option 1) and Native SDK (Option 2)
- Running search queries through the agent
- Managing agent lifecycle (creation and deletion)
## Agent creation options
This sample provides two approaches for creating agents with Bing Custom Search:
- **Option 1 - MEAI + AgentFramework**: Uses the Agent Framework `ResponseTool` wrapped with `AsAITool()` to call the `CreateAIAgentAsync` overload that accepts `tools:[]`, while still relying on the same underlying Azure AI Projects SDK types as Option 2.
- **Option 2 - Native SDK**: Uses `PromptAgentDefinition` with `AgentVersionCreationOptions` to create the agent directly with the Azure AI Projects SDK types.
Both options produce the same result. Toggle between them by commenting/uncommenting the corresponding `CreateAgentWith*Async` call in `Program.cs`.
## Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 10 SDK or later
- Azure Foundry service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
- A Bing Custom Search resource configured in Azure and connected to your Foundry project
**Note**: This demo uses Azure Default credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource.
Set the following environment variables:
```powershell
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
$env:BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID="/subscriptions/<sub-id>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<account>/projects/<project>/connections/<connection-name>"
$env:BING_CUSTOM_SEARCH_INSTANCE_NAME="your-configuration-name"
```
### Finding the connection ID and instance name
- **Connection ID**: The full ARM resource path including the `/projects/<name>/connections/<connection-name>` segment. Find the connection name in your Foundry project under **Management center****Connected resources**.
- **Instance Name**: The **configuration name** from the Bing Custom Search resource (Azure portal → your Bing Custom Search resource → **Configurations**). This is _not_ the Azure resource name.
## Run the sample
Navigate to the FoundryAgents sample directory and run:
```powershell
cd dotnet/samples/GettingStarted/FoundryAgents
dotnet run --project .\FoundryAgents_Step21_BingCustomSearch
```
## Expected behavior
The sample will:
1. Create an agent with Bing Custom Search tool capabilities
2. Run the agent with a search query about Microsoft AI
3. Display the search results returned by the agent
4. Clean up resources by deleting the agent
@@ -58,6 +58,7 @@ Before you begin, ensure you have the following prerequisites:
|[Using plugins](./FoundryAgents_Step13_Plugins/)|This sample demonstrates how to use plugins with a Foundry agent|
|[Code interpreter](./FoundryAgents_Step14_CodeInterpreter/)|This sample demonstrates how to use the code interpreter tool with a Foundry agent|
|[Computer use](./FoundryAgents_Step15_ComputerUse/)|This sample demonstrates how to use computer use capabilities with a Foundry agent|
|[Bing Custom Search](./FoundryAgents_Step21_BingCustomSearch/)|This sample demonstrates how to use Bing Custom Search tool with a Foundry agent|
|[Web search](./FoundryAgents_Step25_WebSearch/)|This sample demonstrates how to use the Responses API web search tool with a Foundry agent|
|[Memory search](./FoundryAgents_Step26_MemorySearch/)|This sample demonstrates how to use memory search tool with a Foundry agent|
|[File search](./FoundryAgents_Step18_FileSearch/)|This sample demonstrates how to use the file search tool with a Foundry agent|
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using A2A;
using A2A.AspNetCore;
using Microsoft.Agents.AI;
@@ -10,12 +11,14 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.AspNetCore.Builder;
/// <summary>
/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
{
/// <summary>
@@ -33,6 +36,20 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
=> endpoints.MapA2A(agentBuilder, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2A(agentBuilder.Name, path, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -43,6 +60,21 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path)
=> endpoints.MapA2A(agentName, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -109,6 +141,37 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard)
=> endpoints.MapA2A(agentName, path, agentCard, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2A(agentBuilder.Name, path, agentCard, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, agentCard, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -144,10 +207,28 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, agentCard, configureTaskManager);
return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, agentRunMode);
}
/// <summary>
@@ -160,6 +241,17 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
=> endpoints.MapA2A(agent, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode agentRunMode)
=> endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -169,13 +261,25 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore);
var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode);
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
configureTaskManager(taskManager);
@@ -198,6 +302,23 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard)
=> endpoints.MapA2A(agent, path, agentCard, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode agentRunMode)
=> endpoints.MapA2A(agent, path, agentCard, _ => { }, agentRunMode);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -213,13 +334,31 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory);
var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: agentRunMode);
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
configureTaskManager(taskManager);
@@ -8,6 +8,12 @@
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A.AspNetCore" />
</ItemGroup>
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Provides JSON serialization options for A2A Hosting APIs to support AOT and trimming.
/// </summary>
public static class A2AHostingJsonUtilities
{
/// <summary>
/// Gets the default <see cref="JsonSerializerOptions"/> instance used for A2A Hosting serialization.
/// </summary>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
private static JsonSerializerOptions CreateDefaultOptions()
{
JsonSerializerOptions options = new(global::A2A.A2AJsonUtilities.DefaultOptions);
// Chain in the resolvers from both AgentAbstractionsJsonUtilities and the A2A SDK context.
// AgentAbstractionsJsonUtilities is first to ensure M.E.AI types (e.g. ResponseContinuationToken)
// are handled via its resolver, followed by the A2A SDK resolver for protocol types.
options.TypeInfoResolverChain.Clear();
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.TypeInfoResolverChain.Add(global::A2A.A2AJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.MakeReadOnly();
return options;
}
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using A2A;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Provides context for a custom A2A run mode decision.
/// </summary>
public sealed class A2ARunDecisionContext
{
internal A2ARunDecisionContext(MessageSendParams messageSendParams)
{
this.MessageSendParams = messageSendParams;
}
/// <summary>
/// Gets the parameters of the incoming A2A message that triggered this run.
/// </summary>
public MessageSendParams MessageSendParams { get; }
}
@@ -1,19 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.Converters;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Provides extension methods for attaching A2A (Agent2Agent) messaging capabilities to an <see cref="AIAgent"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class AIAgentExtensions
{
// Metadata key used to store continuation tokens for long-running background operations
// in the AgentTask.Metadata dictionary, persisted by the task store.
private const string ContinuationTokenMetadataKey = "__a2a__continuationToken";
/// <summary>
/// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified <see cref="AIAgent"/>.
/// </summary>
@@ -21,49 +31,45 @@ public static class AIAgentExtensions
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
/// <param name="agentSessionStore">The store to store session contents and metadata.</param>
/// <param name="runMode">Controls the response behavior of the agent run.</param>
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
/// <returns>The configured <see cref="TaskManager"/>.</returns>
public static ITaskManager MapA2A(
this AIAgent agent,
ITaskManager? taskManager = null,
ILoggerFactory? loggerFactory = null,
AgentSessionStore? agentSessionStore = null)
AgentSessionStore? agentSessionStore = null,
AgentRunMode? runMode = null,
JsonSerializerOptions? jsonSerializerOptions = null)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(agent.Name);
runMode ??= AgentRunMode.DisallowBackground;
var hostAgent = new AIHostAgent(
innerAgent: agent,
sessionStore: agentSessionStore ?? new NoopAgentSessionStore());
taskManager ??= new TaskManager();
taskManager.OnMessageReceived += OnMessageReceivedAsync;
// Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent.
JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions;
// OnMessageReceived handles both message-only and task-based flows.
// The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set,
// so we consolidate all initial message handling here and return either
// an AgentMessage or AgentTask depending on the agent response.
// When the agent returns a ContinuationToken (long-running operation), a task is
// created for stateful tracking. Otherwise a lightweight AgentMessage is returned.
// See https://github.com/a2aproject/a2a-dotnet/issues/275
taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, runMode, taskManager, continuationTokenJsonOptions, ct);
// Task flow for subsequent updates and cancellations
taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct);
taskManager.OnTaskCancelled += OnTaskCancelledAsync;
return taskManager;
async Task<A2AResponse> OnMessageReceivedAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken)
{
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
var options = messageSendParams.Metadata is not { Count: > 0 }
? null
: new AgentRunOptions { AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
var response = await hostAgent.RunAsync(
messageSendParams.ToChatMessages(),
session: session,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
var parts = response.Messages.ToParts();
return new AgentMessage
{
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = MessageRole.Agent,
Parts = parts,
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
}
}
/// <summary>
@@ -74,15 +80,19 @@ public static class AIAgentExtensions
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
/// <param name="agentSessionStore">The store to store session contents and metadata.</param>
/// <param name="runMode">Controls the response behavior of the agent run.</param>
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
/// <returns>The configured <see cref="TaskManager"/>.</returns>
public static ITaskManager MapA2A(
this AIAgent agent,
AgentCard agentCard,
ITaskManager? taskManager = null,
ILoggerFactory? loggerFactory = null,
AgentSessionStore? agentSessionStore = null)
AgentSessionStore? agentSessionStore = null,
AgentRunMode? runMode = null,
JsonSerializerOptions? jsonSerializerOptions = null)
{
taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore);
taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, runMode, jsonSerializerOptions);
taskManager.OnAgentCardQuery += (context, query) =>
{
@@ -97,4 +107,203 @@ public static class AIAgentExtensions
};
return taskManager;
}
private static async Task<A2AResponse> OnMessageReceivedAsync(
MessageSendParams messageSendParams,
AIHostAgent hostAgent,
AgentRunMode runMode,
ITaskManager taskManager,
JsonSerializerOptions continuationTokenJsonOptions,
CancellationToken cancellationToken)
{
// AIAgent does not support resuming from arbitrary prior tasks.
// Throw explicitly so the client gets a clear error rather than a response
// that silently ignores the referenced task context.
// Follow-ups on the *same* task are handled via OnTaskUpdated instead.
if (messageSendParams.Message.ReferenceTaskIds is { Count: > 0 })
{
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context. Use OnTaskUpdated for follow-ups on the same task.");
}
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
// Decide whether to run in background based on user preferences and agent capabilities
var decisionContext = new A2ARunDecisionContext(messageSendParams);
var allowBackgroundResponses = await runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
var options = messageSendParams.Metadata is not { Count: > 0 }
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
var response = await hostAgent.RunAsync(
messageSendParams.ToChatMessages(),
session: session,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
if (response.ContinuationToken is null)
{
return CreateMessageFromResponse(contextId, response);
}
var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false);
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
return agentTask;
}
private static async Task OnTaskUpdatedAsync(
AgentTask agentTask,
AIHostAgent hostAgent,
ITaskManager taskManager,
JsonSerializerOptions continuationTokenJsonOptions,
CancellationToken cancellationToken)
{
var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
try
{
// Discard any stale continuation token — the incoming user message supersedes
// any previous background operation. AF agents don't support updating existing
// background responses (long-running operations); we start a fresh run from the
// existing session using the full chat history (which includes the new message).
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false);
var response = await hostAgent.RunAsync(
ExtractChatMessagesFromTaskHistory(agentTask),
session: session,
options: new AgentRunOptions { AllowBackgroundResponses = true },
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
if (response.ContinuationToken is not null)
{
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
}
else
{
await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception)
{
await taskManager.UpdateStatusAsync(
agentTask.Id,
TaskState.Failed,
final: true,
cancellationToken: cancellationToken).ConfigureAwait(false);
throw;
}
}
private static Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken)
{
// Remove the continuation token from metadata if present.
// The task has already been marked as cancelled by the TaskManager.
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
return Task.CompletedTask;
}
private static AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) =>
new()
{
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = MessageRole.Agent,
Parts = response.Messages.ToParts(),
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
// Task outputs should be returned as artifacts rather than messages:
// https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts
private static Artifact CreateArtifactFromResponse(AgentResponse response) =>
new()
{
ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
Parts = response.Messages.ToParts(),
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
private static async Task<AgentTask> InitializeTaskAsync(
string contextId,
AgentMessage originalMessage,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false);
// Add the original user message to the task history.
// The A2A SDK does this internally when it creates tasks via OnTaskCreated.
agentTask.History ??= [];
agentTask.History.Add(originalMessage);
// Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false);
return agentTask;
}
private static void StoreContinuationToken(
AgentTask agentTask,
ResponseContinuationToken token,
JsonSerializerOptions continuationTokenJsonOptions)
{
// Serialize the continuation token into the task's metadata so it survives
// across requests and is cleaned up with the task itself.
agentTask.Metadata ??= [];
agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement(
token,
continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
}
private static async Task TransitionToWorkingAsync(
string taskId,
string contextId,
AgentResponse response,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
// Include any intermediate progress messages from the response as a status message.
AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null;
await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static async Task CompleteWithArtifactAsync(
string taskId,
AgentResponse response,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
var artifact = CreateArtifactFromResponse(response);
await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false);
await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask agentTask)
{
if (agentTask.History is not { Count: > 0 })
{
return [];
}
var chatMessages = new List<ChatMessage>(agentTask.History.Count);
foreach (var message in agentTask.History)
{
chatMessages.Add(message.ToChatMessage());
}
return chatMessages;
}
}
@@ -0,0 +1,105 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Specifies how the A2A hosting layer determines whether to run <see cref="AIAgent"/> in background or not.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public sealed class AgentRunMode : IEquatable<AgentRunMode>
{
private const string MessageValue = "message";
private const string TaskValue = "task";
private const string DynamicValue = "dynamic";
private readonly string _value;
private readonly Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? _runInBackground;
private AgentRunMode(string value, Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? runInBackground = null)
{
this._value = value;
this._runInBackground = runInBackground;
}
/// <summary>
/// Dissallows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>false</c>.
/// In the A2A protocol terminology will make responses be returned as <c>AgentMessage</c>.
/// </summary>
public static AgentRunMode DisallowBackground => new(MessageValue);
/// <summary>
/// Allows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>true</c>.
/// In the A2A protocol terminology will make responses be returned as <c>AgentTask</c> if the agent supports background responses, and as <c>AgentMessage</c> otherwise.
/// </summary>
public static AgentRunMode AllowBackgroundIfSupported => new(TaskValue);
/// <summary>
/// The agent run mode is decided by the supplied <paramref name="runInBackground"/> delegate.
/// The delegate receives an <see cref="A2ARunDecisionContext"/> with the incoming
/// message and returns a boolean specifying whether to run the agent in background mode.
/// <see langword="true"/> indicates that the agent should run in background mode and return an
/// <c>AgentTask</c> if the agent supports background mode; otherwise, it returns an <c>AgentMessage</c>
/// if the mode is not supported. <see langword="false"/> indicates that the agent should run in
/// non-background mode and return an <c>AgentMessage</c>.
/// </summary>
/// <param name="runInBackground">
/// An async delegate that decides whether the response should be wrapped in an <c>AgentTask</c>.
/// </param>
public static AgentRunMode AllowBackgroundWhen(Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>> runInBackground)
{
ArgumentNullException.ThrowIfNull(runInBackground);
return new(DynamicValue, runInBackground);
}
/// <summary>
/// Determines whether the agent response should be returned as an <c>AgentTask</c>.
/// </summary>
internal ValueTask<bool> ShouldRunInBackgroundAsync(A2ARunDecisionContext context, CancellationToken cancellationToken)
{
if (string.Equals(this._value, MessageValue, StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult(false);
}
if (string.Equals(this._value, TaskValue, StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult(true);
}
// Dynamic: delegate to custom callback.
if (this._runInBackground is not null)
{
return this._runInBackground(context, cancellationToken);
}
// No delegate provided — fall back to "message" behavior.
return ValueTask.FromResult(true);
}
/// <inheritdoc/>
public bool Equals(AgentRunMode? other) =>
other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode);
/// <inheritdoc/>
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this._value);
/// <inheritdoc/>
public override string ToString() => this._value;
/// <summary>Determines whether two <see cref="AgentRunMode"/> instances are equal.</summary>
public static bool operator ==(AgentRunMode? left, AgentRunMode? right) =>
left?.Equals(right) ?? right is null;
/// <summary>Determines whether two <see cref="AgentRunMode"/> instances are not equal.</summary>
public static bool operator !=(AgentRunMode? left, AgentRunMode? right) =>
!(left == right);
}
@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Text.Json;
using A2A;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
@@ -37,7 +36,7 @@ internal static class AdditionalPropertiesDictionaryExtensions
continue;
}
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
}
return metadata;
@@ -10,6 +10,8 @@
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -111,7 +111,9 @@ internal static class JsonDocumentExtensions
VariableType? currentType =
element.ValueKind switch
{
JsonValueKind.Object => VariableType.Record(targetType.Schema?.Select(kvp => (kvp.Key, kvp.Value)) ?? []),
JsonValueKind.Object => targetType.HasSchema
? VariableType.Record(targetType.Schema!.Select(kvp => (kvp.Key, kvp.Value)))
: VariableType.RecordType,
JsonValueKind.String => typeof(string),
JsonValueKind.True => typeof(bool),
JsonValueKind.False => typeof(bool),
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -18,10 +19,11 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
public sealed class AIAgentExtensionsTests
{
/// <summary>
/// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync are null.
/// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync have
/// AllowBackgroundResponses enabled and no AdditionalProperties.
/// </summary>
[Fact]
public async Task MapA2A_WhenMetadataIsNull_PassesNullOptionsToRunAsync()
public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
@@ -35,7 +37,9 @@ public sealed class AIAgentExtensionsTests
});
// Assert
Assert.Null(capturedOptions);
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
Assert.Null(capturedOptions.AdditionalProperties);
}
/// <summary>
@@ -68,11 +72,11 @@ public sealed class AIAgentExtensionsTests
}
/// <summary>
/// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync is null
/// because the ToAdditionalProperties extension method returns null for empty dictionaries.
/// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync have
/// AllowBackgroundResponses enabled and no AdditionalProperties.
/// </summary>
[Fact]
public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesNullOptionsToRunAsync()
public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
@@ -86,7 +90,9 @@ public sealed class AIAgentExtensionsTests
});
// Assert
Assert.Null(capturedOptions);
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
Assert.Null(capturedOptions.AdditionalProperties);
}
/// <summary>
@@ -171,6 +177,590 @@ public sealed class AIAgentExtensionsTests
Assert.Null(agentMessage.Metadata);
}
/// <summary>
/// Verifies that when runMode is Message, the result is always an AgentMessage even when
/// the agent would otherwise support background responses.
/// </summary>
[Fact]
public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options)
.Object.MapA2A(runMode: AgentRunMode.DisallowBackground);
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
Assert.IsType<AgentMessage>(a2aResponse);
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
}
/// <summary>
/// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
/// the result is an AgentMessage because the response type is determined solely by ContinuationToken presence.
/// </summary>
[Fact]
public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
Assert.IsType<AgentMessage>(a2aResponse);
Assert.NotNull(capturedOptions);
Assert.True(capturedOptions.AllowBackgroundResponses);
}
/// <summary>
/// Verifies that a custom Dynamic delegate returning false produces an AgentMessage
/// even when the agent completes immediately (no ContinuationToken).
/// </summary>
[Fact]
public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsAgentMessageAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]);
ITaskManager taskManager = CreateAgentMockWithResponse(response)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)));
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
Assert.IsType<AgentMessage>(a2aResponse);
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
/// <summary>
/// Verifies that when the agent returns a ContinuationToken, an AgentTask in Working state is returned.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasContinuationToken_ReturnsAgentTaskInWorkingStateAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
}
/// <summary>
/// Verifies that when the agent returns a ContinuationToken, the returned task includes
/// intermediate messages from the initial response in its status message.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasContinuationToken_TaskStatusHasIntermediateMessageAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.Status.Message);
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(agentTask.Status.Message.Parts));
Assert.Equal("Starting work...", textPart.Text);
}
/// <summary>
/// Verifies that when the agent returns a ContinuationToken, the continuation token
/// is serialized into the AgentTask.Metadata for persistence.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasContinuationToken_StoresTokenInTaskMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.Metadata);
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
}
/// <summary>
/// Verifies that when a task is created (Working or Completed), the original user message
/// is added to the task history, matching the A2A SDK's behavior when it creates tasks internally.
/// </summary>
[Fact]
public async Task MapA2A_WhenTaskIsCreated_OriginalMessageIsInHistoryAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
AgentMessage originalMessage = new() { MessageId = "user-msg-1", Role = MessageRole.User, Parts = [new TextPart { Text = "Do something" }] };
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = originalMessage
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.History);
Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-1" && m.Role == MessageRole.User);
}
/// <summary>
/// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
/// the returned AgentMessage preserves the original context ID.
/// </summary>
[Fact]
public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageWithContextIdAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]);
ITaskManager taskManager = CreateAgentMockWithResponse(response)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
AgentMessage originalMessage = new() { MessageId = "user-msg-2", ContextId = "ctx-123", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] };
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = originalMessage
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Equal("ctx-123", agentMessage.ContextId);
}
/// <summary>
/// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token
/// and the agent returns a completed response (null ContinuationToken), the task is updated to Completed.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCompletedAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithSequentialResponses(
// First call: return response with ContinuationToken (long-running)
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call (via OnTaskUpdated): return completed response
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]),
ref callCount);
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — trigger OnMessageReceived to create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
// Act — invoke OnTaskUpdated to check on the background operation
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
// Assert — task should now be completed
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Completed, updatedTask.Status.State);
Assert.NotNull(updatedTask.Artifacts);
Artifact artifact = Assert.Single(updatedTask.Artifacts);
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
Assert.Equal("Done!", textPart.Text);
}
/// <summary>
/// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token
/// and the agent returns another ContinuationToken, the task stays in Working state.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskRemainsWorkingAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithSequentialResponses(
// First call: return response with ContinuationToken
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call (via OnTaskUpdated): still working, return another token
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
{
ContinuationToken = CreateTestContinuationToken()
},
ref callCount);
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — trigger OnMessageReceived to create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — invoke OnTaskUpdated; agent still working
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
// Assert — task should still be in Working state
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Working, updatedTask.Status.State);
}
/// <summary>
/// Verifies the full lifecycle: agent starts background work, first poll returns still working,
/// second poll returns completed.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
return invocation switch
{
// First call: start background work
1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call: still working
2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Third call: done
_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "All done!")])
};
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Do work" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
// Act — first poll: still working
AgentTask? currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(currentTask);
await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(currentTask);
Assert.Equal(TaskState.Working, currentTask.Status.State);
// Act — second poll: completed
await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(currentTask);
Assert.Equal(TaskState.Completed, currentTask.Status.State);
// Assert — final output as artifact
Assert.NotNull(currentTask.Artifacts);
Artifact artifact = Assert.Single(currentTask.Artifacts);
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
Assert.Equal("All done!", textPart.Text);
}
/// <summary>
/// Verifies that when the agent throws during a background operation poll,
/// the task is updated to Failed state.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
if (invocation == 1)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
};
}
throw new InvalidOperationException("Agent failed");
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — poll the task; agent throws
await Assert.ThrowsAsync<InvalidOperationException>(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
// Assert — task should be Failed
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Failed, updatedTask.Status.State);
}
/// <summary>
/// Verifies that in Task mode with a ContinuationToken, the result is an AgentTask in Working state.
/// </summary>
[Fact]
public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Working on it...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
Assert.NotNull(agentTask.Metadata);
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
}
/// <summary>
/// Verifies that when the agent returns a ContinuationToken with no progress messages,
/// the task transitions to Working state with a null status message.
/// </summary>
[Fact]
public async Task MapA2A_WhenContinuationTokenWithNoMessages_TaskStatusHasNullMessageAsync()
{
// Arrange
AgentResponse response = new([])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
Assert.Null(agentTask.Status.Message);
}
/// <summary>
/// Verifies that when OnTaskUpdated is invoked on a completed task with a follow-up message
/// and no continuation token in metadata, the task processes history and completes with a new artifact.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryAndCompletesAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
return invocation switch
{
// First call: create a task with ContinuationToken
1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call (via OnTaskUpdated): complete the background operation
2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]),
// Third call (follow-up via OnTaskUpdated): complete follow-up
_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")])
};
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create a working task (with continuation token)
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — first OnTaskUpdated: completes the background operation
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
agentTask = (await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None))!;
Assert.Equal(TaskState.Completed, agentTask.Status.State);
// Simulate a follow-up message by adding it to history and re-submitting via OnTaskUpdated
agentTask.History ??= [];
agentTask.History.Add(new AgentMessage { MessageId = "follow-up", Role = MessageRole.User, Parts = [new TextPart { Text = "Follow up" }] });
// Act — invoke OnTaskUpdated without a continuation token in metadata
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
// Assert
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Completed, updatedTask.Status.State);
Assert.NotNull(updatedTask.Artifacts);
Assert.Equal(2, updatedTask.Artifacts.Count);
Artifact artifact = updatedTask.Artifacts[1];
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
Assert.Equal("Follow-up done!", textPart.Text);
}
/// <summary>
/// Verifies that when a task is cancelled, the continuation token is removed from metadata.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskCancelled_RemovesContinuationTokenFromMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act — create a working task with a continuation token
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.Metadata);
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
// Act — cancel the task
await taskManager.CancelTaskAsync(new TaskIdParams { Id = agentTask.Id }, CancellationToken.None);
// Assert — continuation token should be removed from metadata
Assert.False(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
}
/// <summary>
/// Verifies that when the agent throws an OperationCanceledException during a poll,
/// it is re-thrown without marking the task as Failed.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenOperationCancelled_DoesNotMarkFailedAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
if (invocation == 1)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
};
}
throw new OperationCanceledException("Cancelled");
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — poll the task; agent throws OperationCanceledException
await Assert.ThrowsAsync<OperationCanceledException>(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
// Assert — task should still be Working, not Failed
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Working, updatedTask.Status.State);
}
/// <summary>
/// Verifies that when the incoming message has a ContextId, it is used for the task
/// rather than generating a new one.
/// </summary>
[Fact]
public async Task MapA2A_WhenMessageHasContextId_UsesProvidedContextIdAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage
{
MessageId = "test-id",
ContextId = "my-context-123",
Role = MessageRole.User,
Parts = [new TextPart { Text = "Hello" }]
}
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Equal("my-context-123", agentMessage.ContextId);
}
#pragma warning restore MEAI001
private static Mock<AIAgent> CreateAgentMock(Action<AgentRunOptions?> optionsCallback)
{
Mock<AIAgent> agentMock = new() { CallBase = true };
@@ -220,5 +810,57 @@ public sealed class AIAgentExtensionsTests
return await handler.Invoke(messageSendParams, CancellationToken.None);
}
private static async Task InvokeOnTaskUpdatedAsync(ITaskManager taskManager, AgentTask agentTask)
{
Func<AgentTask, CancellationToken, Task>? handler = taskManager.OnTaskUpdated;
Assert.NotNull(handler);
await handler.Invoke(agentTask, CancellationToken.None);
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
private static ResponseContinuationToken CreateTestContinuationToken()
{
return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 });
}
#pragma warning restore MEAI001
private static Mock<AIAgent> CreateAgentMockWithSequentialResponses(
AgentResponse firstResponse,
AgentResponse secondResponse,
ref int callCount)
{
return CreateAgentMockWithCallCount(ref callCount, invocation =>
invocation == 1 ? firstResponse : secondResponse);
}
private static Mock<AIAgent> CreateAgentMockWithCallCount(
ref int callCount,
Func<int, AgentResponse> responseFactory)
{
// Use a StrongBox to allow the lambda to capture a mutable reference
StrongBox<int> callCountBox = new(callCount);
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new TestAgentSession());
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
int currentCall = Interlocked.Increment(ref callCountBox.Value);
return responseFactory(currentCall);
});
return agentMock;
}
private sealed class TestAgentSession : AgentSession;
}
@@ -384,4 +384,79 @@ public sealed class JsonDocumentExtensionsTests
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseList(typeof(int[])));
}
/// <summary>
/// Regression test for #4195: When a JSON object contains an array of objects
/// and is parsed with <c>VariableType.RecordType</c> (no schema), the nested
/// object properties must be preserved. Before the fix, DetermineElementType()
/// created an empty-schema VariableType, causing ParseRecord to take the
/// ParseSchema path (zero fields) and return empty dictionaries.
/// </summary>
[Fact]
public void ParseRecord_ObjectWithArrayOfObjects_NoSchema_PreservesNestedProperties()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
{
"items": [
{ "name": "Alice", "role": "Engineer" },
{ "name": "Bob", "role": "Designer" },
{ "name": "Carol", "role": "PM" }
]
}
""");
// Act
Dictionary<string, object?> result = document.ParseRecord(VariableType.RecordType);
// Assert
Assert.True(result.ContainsKey("items"));
List<object?> items = Assert.IsType<List<object?>>(result["items"]);
Assert.Equal(3, items.Count);
Dictionary<string, object?> first = Assert.IsType<Dictionary<string, object?>>(items[0]);
Assert.Equal("Alice", first["name"]);
Assert.Equal("Engineer", first["role"]);
Dictionary<string, object?> second = Assert.IsType<Dictionary<string, object?>>(items[1]);
Assert.Equal("Bob", second["name"]);
Assert.Equal("Designer", second["role"]);
Dictionary<string, object?> third = Assert.IsType<Dictionary<string, object?>>(items[2]);
Assert.Equal("Carol", third["name"]);
Assert.Equal("PM", third["role"]);
}
/// <summary>
/// Regression test for #4195: When a JSON array of objects is parsed directly
/// via <c>ParseList</c> with <c>VariableType.ListType</c> (no schema), all
/// object properties must be preserved in each element.
/// </summary>
[Fact]
public void ParseList_ArrayOfObjects_NoSchema_PreservesProperties()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[
{ "name": "Alice", "role": "Engineer" },
{ "name": "Bob", "role": "Designer" }
]
""");
// Act
List<object?> result = document.ParseList(VariableType.ListType);
// Assert
Assert.Equal(2, result.Count);
Dictionary<string, object?> first = Assert.IsType<Dictionary<string, object?>>(result[0]);
Assert.Equal("Alice", first["name"]);
Assert.Equal("Engineer", first["role"]);
Dictionary<string, object?> second = Assert.IsType<Dictionary<string, object?>>(result[1]);
Assert.Equal("Bob", second["name"]);
Assert.Equal("Designer", second["role"]);
}
}
@@ -13,7 +13,7 @@ import sys
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Annotation, Content, Message, SupportsGetEmbeddings
from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext
from agent_framework._settings import SecretString, load_settings
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
@@ -47,8 +47,12 @@ if TYPE_CHECKING:
from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseMessage,
KnowledgeBaseMessageImageContent,
KnowledgeBaseMessageImageContentImage,
KnowledgeBaseMessageTextContent,
KnowledgeBaseReference,
KnowledgeBaseRetrievalRequest,
KnowledgeBaseRetrievalResponse,
KnowledgeRetrievalIntent,
KnowledgeRetrievalSemanticIntent,
)
@@ -78,8 +82,12 @@ try:
from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseMessage,
KnowledgeBaseMessageImageContent,
KnowledgeBaseMessageImageContentImage,
KnowledgeBaseMessageTextContent,
KnowledgeBaseReference,
KnowledgeBaseRetrievalRequest,
KnowledgeBaseRetrievalResponse,
KnowledgeRetrievalIntent,
KnowledgeRetrievalSemanticIntent,
)
@@ -154,7 +162,9 @@ class AzureAISearchContextProvider(BaseContextProvider):
top_k: int = 5,
semantic_configuration_name: str | None = None,
vector_field_name: str | None = None,
embedding_function: Callable[[str], Awaitable[list[float]]] | None = None,
embedding_function: Callable[[str], Awaitable[list[float]]]
| SupportsGetEmbeddings[str, list[float], Any]
| None = None,
context_prompt: str | None = None,
azure_openai_resource_url: str | None = None,
model_deployment_name: str | None = None,
@@ -181,7 +191,7 @@ class AzureAISearchContextProvider(BaseContextProvider):
top_k: Maximum number of documents to retrieve. Default: 5.
semantic_configuration_name: Name of semantic configuration in the index.
vector_field_name: Name of the vector field in the index.
embedding_function: Async function to generate embeddings.
embedding_function: Async function to generate embeddings or a SupportsGetEmbeddings instance.
context_prompt: Custom prompt to prepend to retrieved context.
azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base.
model_deployment_name: Model deployment name in Azure OpenAI.
@@ -309,9 +319,20 @@ class AzureAISearchContextProvider(BaseContextProvider):
exc_tb: Any,
) -> None:
"""Async context manager exit - cleanup clients."""
await self.close()
async def close(self) -> None:
"""Close all the open clients."""
if self._retrieval_client is not None:
await self._retrieval_client.close()
self._retrieval_client = None
self._knowledge_base_initialized = False
if self._search_client is not None:
await self._search_client.close()
self._search_client = None
if self._index_client is not None:
await self._index_client.close()
self._index_client = None
# -- Hooks pattern ---------------------------------------------------------
@@ -326,32 +347,23 @@ class AzureAISearchContextProvider(BaseContextProvider):
"""Retrieve relevant context from Azure AI Search and add to session context."""
messages_list = list(context.input_messages)
def get_role_value(role: str | Any) -> str:
return role.value if hasattr(role, "value") else str(role)
filtered_messages = [
msg
for msg in messages_list
if msg and msg.text and msg.text.strip() and get_role_value(msg.role) in ["user", "assistant"]
msg for msg in messages_list if msg and msg.text and msg.text.strip() and msg.role in ["user", "assistant"]
]
if not filtered_messages:
return
if self.mode == "semantic":
query = "\n".join(msg.text for msg in filtered_messages)
search_result_parts = await self._semantic_search(query)
result_messages = await self._semantic_search(query)
else:
recent_messages = filtered_messages[-self.agentic_message_history_count :]
search_result_parts = await self._agentic_search(recent_messages)
result_messages = await self._agentic_search(recent_messages)
if not search_result_parts:
if not result_messages:
return
context_messages = [Message(role="user", text=self.context_prompt)]
context_messages.extend([Message(role="user", text=part) for part in search_result_parts])
context.extend_messages(self.source_id, context_messages)
# -- Internal methods (ported from AzureAISearchContextProvider) -----------
context.extend_messages(self.source_id, [Message(role="user", text=self.context_prompt), *result_messages])
def _find_vector_fields(self, index: Any) -> list[str]:
"""Find all fields that can store vectors."""
@@ -432,7 +444,7 @@ class AzureAISearchContextProvider(BaseContextProvider):
self._auto_discovered_vector_field = True
async def _semantic_search(self, query: str) -> list[str]:
async def _semantic_search(self, query: str) -> list[Message]:
"""Perform semantic hybrid search."""
await self._auto_discover_vector_field()
@@ -440,14 +452,14 @@ class AzureAISearchContextProvider(BaseContextProvider):
if self.vector_field_name:
vector_k = max(self.top_k, 50) if self.semantic_configuration_name else self.top_k
if self._use_vectorizable_query:
vector_queries = [
VectorizableTextQuery(text=query, k_nearest_neighbors=vector_k, fields=self.vector_field_name)
]
vector_queries = [VectorizableTextQuery(text=query, k=vector_k, fields=self.vector_field_name)]
elif self.embedding_function:
query_vector = await self.embedding_function(query)
vector_queries = [
VectorizedQuery(vector=query_vector, k_nearest_neighbors=vector_k, fields=self.vector_field_name)
]
if isinstance(self.embedding_function, SupportsGetEmbeddings):
embeddings = await self.embedding_function.get_embeddings([query]) # type: ignore[reportUnknownVariableType]
query_vector: list[float] = embeddings[0].vector # type: ignore[reportUnknownVariableType]
else:
query_vector = await self.embedding_function(query)
vector_queries = [VectorizedQuery(vector=query_vector, k=vector_k, fields=self.vector_field_name)]
search_params: dict[str, Any] = {"search_text": query, "top": self.top_k}
if vector_queries:
@@ -461,13 +473,13 @@ class AzureAISearchContextProvider(BaseContextProvider):
raise RuntimeError("Search client is not initialized.")
results = await self._search_client.search(**search_params) # type: ignore[reportUnknownVariableType]
formatted_results: list[str] = []
result_messages: list[Message] = []
async for doc in results: # type: ignore[reportUnknownVariableType]
doc_id = doc.get("id") or doc.get("@search.id") # type: ignore[reportUnknownVariableType]
doc_text: str = self._extract_document_text(doc, doc_id=doc_id) # type: ignore[reportUnknownArgumentType]
if doc_text:
formatted_results.append(doc_text) # type: ignore[reportUnknownArgumentType]
return formatted_results
result_messages.append(Message(role="user", text=doc_text)) # type: ignore[reportUnknownArgumentType]
return result_messages
async def _ensure_knowledge_base(self) -> None:
"""Ensure Knowledge Base and knowledge source are created or use existing KB."""
@@ -550,7 +562,7 @@ class AzureAISearchContextProvider(BaseContextProvider):
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
async def _agentic_search(self, messages: list[Message]) -> list[str]:
async def _agentic_search(self, messages: list[Message]) -> list[Message]:
"""Perform agentic retrieval with multi-hop reasoning."""
await self._ensure_knowledge_base()
@@ -577,14 +589,7 @@ class AzureAISearchContextProvider(BaseContextProvider):
include_activity=True,
)
else:
kb_messages = [
KnowledgeBaseMessage(
role=msg.role if hasattr(msg.role, "value") else str(msg.role),
content=[KnowledgeBaseMessageTextContent(text=msg.text)],
)
for msg in messages
if msg.text
]
kb_messages = self._prepare_messages_for_kb_search(messages)
retrieval_request = KnowledgeBaseRetrievalRequest(
messages=kb_messages,
retrieval_reasoning_effort=reasoning_effort,
@@ -596,17 +601,136 @@ class AzureAISearchContextProvider(BaseContextProvider):
raise RuntimeError("Retrieval client not initialized.")
retrieval_result = await self._retrieval_client.retrieve(retrieval_request=retrieval_request)
if retrieval_result.response and len(retrieval_result.response) > 0:
assistant_message = retrieval_result.response[-1]
if assistant_message.content:
answer_parts: list[str] = []
for content_item in assistant_message.content:
if isinstance(content_item, KnowledgeBaseMessageTextContent) and content_item.text:
answer_parts.append(content_item.text)
if answer_parts:
return answer_parts
return self._parse_messages_from_kb_response(retrieval_result)
return ["No results found from Knowledge Base."]
@staticmethod
def _prepare_messages_for_kb_search(messages: list[Message]) -> list[KnowledgeBaseMessage]:
"""Convert framework Messages to KnowledgeBaseMessages for agentic retrieval.
Handles text and image content types. Other content types (function calls,
errors, etc.) are skipped.
Args:
messages: Framework messages to convert.
Returns:
List of KnowledgeBaseMessage objects suitable for retrieval requests.
"""
kb_messages: list[KnowledgeBaseMessage] = []
for msg in messages:
kb_content: list[KnowledgeBaseMessageTextContent | KnowledgeBaseMessageImageContent] = []
if msg.contents:
for content in msg.contents:
match content.type:
case "text" if content.text:
kb_content.append(KnowledgeBaseMessageTextContent(text=content.text))
case "uri" | "data" if (
content.uri and content.media_type and content.media_type.startswith("image/")
):
kb_content.append(
KnowledgeBaseMessageImageContent(
image=KnowledgeBaseMessageImageContentImage(url=content.uri),
)
)
elif msg.text:
kb_content.append(KnowledgeBaseMessageTextContent(text=msg.text))
if kb_content:
kb_messages.append(KnowledgeBaseMessage(role=msg.role, content=kb_content)) # type: ignore[arg-type]
return kb_messages
@staticmethod
def _parse_references_to_annotations(references: list[KnowledgeBaseReference] | None) -> list[Annotation]:
"""Convert Knowledge Base references to framework Annotations.
Captures all available fields from each reference subtype: URLs, doc keys,
reranker scores, source data, and the raw reference object itself.
Args:
references: The references from a Knowledge Base retrieval response.
Returns:
List of citation Annotations.
"""
if not references:
return []
annotations: list[Annotation] = []
for ref in references:
url: str | None = None
for attr in ("url", "blob_url", "doc_url", "web_url"):
url = getattr(ref, attr, None)
if url:
break
annotation = Annotation(
type="citation",
url=url or "",
title=getattr(ref, "title", None) or ref.id,
)
extra: dict[str, Any] = {
"reference_id": ref.id,
"reference_type": getattr(ref, "type", None),
"activity_source": ref.activity_source,
}
if ref.reranker_score is not None:
extra["reranker_score"] = ref.reranker_score
if ref.source_data:
extra["source_data"] = ref.source_data
doc_key = getattr(ref, "doc_key", None)
if doc_key:
extra["doc_key"] = doc_key
if ref.additional_properties:
extra["sdk_additional_properties"] = ref.additional_properties
sensitivity_info = getattr(ref, "search_sensitivity_label_info", None)
if sensitivity_info:
extra["sensitivity_label"] = {
"display_name": sensitivity_info.display_name,
"sensitivity_label_id": sensitivity_info.sensitivity_label_id,
"is_encrypted": sensitivity_info.is_encrypted,
}
annotation["additional_properties"] = extra
annotation["raw_representation"] = ref
annotations.append(annotation)
return annotations
@staticmethod
def _parse_messages_from_kb_response(retrieval_result: KnowledgeBaseRetrievalResponse) -> list[Message]:
"""Convert a Knowledge Base retrieval response to framework Messages.
Each KnowledgeBaseMessage becomes a Message. References from the response
are converted to Annotations and attached to content items.
Args:
retrieval_result: The full retrieval response including messages and references.
Returns:
List of Messages, or a single default Message if no results found.
"""
if not retrieval_result.response:
return [Message(role="assistant", text="No results found from Knowledge Base.")]
annotations = AzureAISearchContextProvider._parse_references_to_annotations(retrieval_result.references)
result_messages: list[Message] = []
for kb_msg in retrieval_result.response:
if not kb_msg.content:
continue
contents: list[Content] = []
for item in kb_msg.content:
if isinstance(item, KnowledgeBaseMessageTextContent) and item.text:
contents.append(Content.from_text(item.text))
elif isinstance(item, KnowledgeBaseMessageImageContent) and item.image and item.image.url:
contents.append(Content.from_uri(uri=item.image.url, media_type="image/png"))
if contents:
if annotations:
for c in contents:
c.annotations = annotations
result_messages.append(Message(role=kb_msg.role or "assistant", contents=contents))
if not result_messages:
return [Message(role="assistant", text="No results found from Knowledge Base.")]
return result_messages
def _extract_document_text(self, doc: dict[str, Any], doc_id: str | None = None) -> str:
"""Extract readable text from a search document with optional citation."""
@@ -6,7 +6,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import pytest
from agent_framework import Message
from agent_framework import Content, Message
from agent_framework._sessions import AgentSession, SessionContext
from agent_framework.exceptions import SettingNotFoundError
from azure.core.credentials import AzureKeyCredential
@@ -720,7 +720,7 @@ class TestSemanticSearch:
results = await provider._semantic_search("test query")
assert len(results) == 1
assert "result text" in results[0]
assert "result text" in results[0].text
call_kwargs = mock_client.search.call_args[1]
assert call_kwargs["search_text"] == "test query"
@@ -746,7 +746,11 @@ class TestSemanticSearch:
provider = _make_provider()
provider._use_vectorizable_query = False
provider.vector_field_name = "embedding"
provider.embedding_function = AsyncMock(return_value=[0.1, 0.2, 0.3])
async def _embed(query: str) -> list[float]:
return [0.1, 0.2, 0.3]
provider.embedding_function = _embed
mock_client = AsyncMock()
async def _search(**kwargs):
@@ -757,7 +761,6 @@ class TestSemanticSearch:
results = await provider._semantic_search("embed query")
assert len(results) == 1
provider.embedding_function.assert_awaited_once_with("embed query")
call_kwargs = mock_client.search.call_args[1]
assert "vector_queries" in call_kwargs
@@ -1100,9 +1103,11 @@ class TestAgenticSearch:
mock_content = Mock()
mock_content.text = "Answer text"
mock_message = Mock()
mock_message.role = "assistant"
mock_message.content = [mock_content]
mock_result = Mock()
mock_result.response = [mock_message]
mock_result.references = None
mock_retrieval = AsyncMock()
mock_retrieval.retrieve = AsyncMock(return_value=mock_result)
@@ -1115,7 +1120,9 @@ class TestAgenticSearch:
):
results = await provider._agentic_search([Message(role="user", contents=["test query"])])
assert results == ["Answer text"]
assert len(results) == 1
assert results[0].text == "Answer text"
assert results[0].role == "assistant"
async def test_non_minimal_reasoning_uses_messages(self) -> None:
provider = _make_provider()
@@ -1126,9 +1133,11 @@ class TestAgenticSearch:
mock_content = Mock()
mock_content.text = "Medium answer"
mock_message = Mock()
mock_message.role = "assistant"
mock_message.content = [mock_content]
mock_result = Mock()
mock_result.response = [mock_message]
mock_result.references = None
mock_retrieval = AsyncMock()
mock_retrieval.retrieve = AsyncMock(return_value=mock_result)
@@ -1143,7 +1152,8 @@ class TestAgenticSearch:
Message(role="assistant", contents=["answer"]),
])
assert results == ["Medium answer"]
assert len(results) == 1
assert results[0].text == "Medium answer"
mock_retrieval.retrieve.assert_awaited_once()
async def test_no_response_returns_default_message(self) -> None:
@@ -1154,13 +1164,15 @@ class TestAgenticSearch:
mock_result = Mock()
mock_result.response = []
mock_result.references = None
mock_retrieval = AsyncMock()
mock_retrieval.retrieve = AsyncMock(return_value=mock_result)
provider._retrieval_client = mock_retrieval
results = await provider._agentic_search([Message(role="user", contents=["query"])])
assert results == ["No results found from Knowledge Base."]
assert len(results) == 1
assert results[0].text == "No results found from Knowledge Base."
async def test_empty_content_returns_default_message(self) -> None:
provider = _make_provider()
@@ -1172,13 +1184,15 @@ class TestAgenticSearch:
mock_message.content = None
mock_result = Mock()
mock_result.response = [mock_message]
mock_result.references = None
mock_retrieval = AsyncMock()
mock_retrieval.retrieve = AsyncMock(return_value=mock_result)
provider._retrieval_client = mock_retrieval
results = await provider._agentic_search([Message(role="user", contents=["query"])])
assert results == ["No results found from Knowledge Base."]
assert len(results) == 1
assert results[0].text == "No results found from Knowledge Base."
async def test_answer_synthesis_output_mode(self) -> None:
provider = _make_provider()
@@ -1190,9 +1204,11 @@ class TestAgenticSearch:
mock_content = Mock()
mock_content.text = "Synthesized answer"
mock_message = Mock()
mock_message.role = "assistant"
mock_message.content = [mock_content]
mock_result = Mock()
mock_result.response = [mock_message]
mock_result.references = None
mock_retrieval = AsyncMock()
mock_retrieval.retrieve = AsyncMock(return_value=mock_result)
@@ -1204,7 +1220,8 @@ class TestAgenticSearch:
):
results = await provider._agentic_search([Message(role="user", contents=["query"])])
assert results == ["Synthesized answer"]
assert len(results) == 1
assert results[0].text == "Synthesized answer"
async def test_content_without_text_excluded(self) -> None:
provider = _make_provider()
@@ -1217,9 +1234,11 @@ class TestAgenticSearch:
mock_content_no_text = Mock()
mock_content_no_text.text = None
mock_message = Mock()
mock_message.role = "assistant"
mock_message.content = [mock_content_no_text, mock_content_with_text]
mock_result = Mock()
mock_result.response = [mock_message]
mock_result.references = None
mock_retrieval = AsyncMock()
mock_retrieval.retrieve = AsyncMock(return_value=mock_result)
@@ -1231,7 +1250,8 @@ class TestAgenticSearch:
):
results = await provider._agentic_search([Message(role="user", contents=["query"])])
assert results == ["Good content"]
assert len(results) == 1
assert results[0].text == "Good content"
async def test_none_response_returns_default_message(self) -> None:
provider = _make_provider()
@@ -1241,13 +1261,355 @@ class TestAgenticSearch:
mock_result = Mock()
mock_result.response = None
mock_result.references = None
mock_retrieval = AsyncMock()
mock_retrieval.retrieve = AsyncMock(return_value=mock_result)
provider._retrieval_client = mock_retrieval
results = await provider._agentic_search([Message(role="user", contents=["query"])])
assert results == ["No results found from Knowledge Base."]
assert len(results) == 1
assert results[0].text == "No results found from Knowledge Base."
# -- before_run: agentic mode --------------------------------------------------
# -- _prepare_messages_for_kb_search / _parse_content_from_kb_response --------
class TestPrepareMessagesForKbSearch:
"""Tests for _prepare_messages_for_kb_search."""
def test_text_only_messages(self) -> None:
messages = [
Message(role="user", contents=["hello"]),
Message(role="assistant", contents=["world"]),
]
result = AzureAISearchContextProvider._prepare_messages_for_kb_search(messages)
assert len(result) == 2
assert result[0].role == "user"
assert result[1].role == "assistant"
# Verify content is KnowledgeBaseMessageTextContent
from azure.search.documents.knowledgebases.models import KnowledgeBaseMessageTextContent
assert isinstance(result[0].content[0], KnowledgeBaseMessageTextContent)
assert result[0].content[0].text == "hello"
def test_image_uri_content(self) -> None:
from agent_framework import Content
img = Content.from_uri(uri="https://example.com/photo.png", media_type="image/png")
messages = [Message(role="user", contents=[img])]
result = AzureAISearchContextProvider._prepare_messages_for_kb_search(messages)
assert len(result) == 1
from azure.search.documents.knowledgebases.models import KnowledgeBaseMessageImageContent
assert isinstance(result[0].content[0], KnowledgeBaseMessageImageContent)
assert result[0].content[0].image.url == "https://example.com/photo.png"
def test_mixed_text_and_image_content(self) -> None:
from agent_framework import Content
text = Content.from_text("describe this image")
img = Content.from_uri(uri="https://example.com/img.jpg", media_type="image/jpeg")
messages = [Message(role="user", contents=[text, img])]
result = AzureAISearchContextProvider._prepare_messages_for_kb_search(messages)
assert len(result) == 1
assert len(result[0].content) == 2
def test_skips_non_text_non_image_content(self) -> None:
from agent_framework import Content
error = Content.from_error(message="oops")
messages = [Message(role="user", contents=[error])]
result = AzureAISearchContextProvider._prepare_messages_for_kb_search(messages)
assert len(result) == 0 # message had no usable content
def test_skips_empty_text(self) -> None:
from agent_framework import Content
empty = Content.from_text("")
messages = [Message(role="user", contents=[empty])]
result = AzureAISearchContextProvider._prepare_messages_for_kb_search(messages)
assert len(result) == 0
def test_fallback_to_msg_text_when_no_contents(self) -> None:
msg = Message(role="user", text="fallback text")
result = AzureAISearchContextProvider._prepare_messages_for_kb_search([msg])
assert len(result) == 1
assert result[0].content[0].text == "fallback text"
def test_data_uri_image(self) -> None:
from agent_framework import Content
img = Content.from_data(data=b"\x89PNG", media_type="image/png")
messages = [Message(role="user", contents=[img])]
result = AzureAISearchContextProvider._prepare_messages_for_kb_search(messages)
assert len(result) == 1
from azure.search.documents.knowledgebases.models import KnowledgeBaseMessageImageContent
assert isinstance(result[0].content[0], KnowledgeBaseMessageImageContent)
def test_non_image_uri_skipped(self) -> None:
from agent_framework import Content
pdf = Content.from_uri(uri="https://example.com/doc.pdf", media_type="application/pdf")
messages = [Message(role="user", contents=[pdf])]
result = AzureAISearchContextProvider._prepare_messages_for_kb_search(messages)
assert len(result) == 0
class TestParseReferencesToAnnotations:
"""Tests for _parse_references_to_annotations."""
def test_none_references(self) -> None:
result = AzureAISearchContextProvider._parse_references_to_annotations(None)
assert result == []
def test_empty_references(self) -> None:
result = AzureAISearchContextProvider._parse_references_to_annotations([])
assert result == []
def test_search_index_reference_captures_doc_key(self) -> None:
from azure.search.documents.knowledgebases.models import KnowledgeBaseSearchIndexReference
ref = KnowledgeBaseSearchIndexReference(id="ref-1", activity_source=0, doc_key="doc-1")
result = AzureAISearchContextProvider._parse_references_to_annotations([ref])
assert len(result) == 1
assert result[0]["type"] == "citation"
assert result[0]["title"] == "ref-1"
extra = result[0]["additional_properties"]
assert extra["reference_id"] == "ref-1"
assert extra["reference_type"] == "searchIndex"
assert extra["activity_source"] == 0
assert extra["doc_key"] == "doc-1"
def test_web_reference_with_url_and_title(self) -> None:
from azure.search.documents.knowledgebases.models import KnowledgeBaseWebReference
ref = KnowledgeBaseWebReference(
id="ref-2", activity_source=0, url="https://example.com/page", title="Example Page"
)
result = AzureAISearchContextProvider._parse_references_to_annotations([ref])
assert len(result) == 1
assert result[0]["url"] == "https://example.com/page"
assert result[0]["title"] == "Example Page"
assert result[0]["additional_properties"]["reference_type"] == "web"
def test_blob_reference_extracts_blob_url(self) -> None:
from azure.search.documents.knowledgebases.models import KnowledgeBaseAzureBlobReference
ref = KnowledgeBaseAzureBlobReference(
id="ref-3", activity_source=0, blob_url="https://storage.blob.core.windows.net/doc.pdf"
)
result = AzureAISearchContextProvider._parse_references_to_annotations([ref])
assert result[0]["url"] == "https://storage.blob.core.windows.net/doc.pdf"
assert result[0]["additional_properties"]["reference_type"] == "azureBlob"
def test_source_data_and_reranker_score(self) -> None:
from azure.search.documents.knowledgebases.models import KnowledgeBaseSearchIndexReference
ref = KnowledgeBaseSearchIndexReference(
id="ref-4", activity_source=0, source_data={"chunk": "some text"}, reranker_score=0.95
)
result = AzureAISearchContextProvider._parse_references_to_annotations([ref])
extra = result[0]["additional_properties"]
assert extra["source_data"] == {"chunk": "some text"}
assert extra["reranker_score"] == 0.95
def test_raw_representation_stores_original_ref(self) -> None:
from azure.search.documents.knowledgebases.models import KnowledgeBaseSearchIndexReference
ref = KnowledgeBaseSearchIndexReference(id="ref-5", activity_source=0)
result = AzureAISearchContextProvider._parse_references_to_annotations([ref])
assert result[0]["raw_representation"] is ref
def test_remote_sharepoint_captures_sensitivity_label(self) -> None:
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseRemoteSharePointReference,
SharePointSensitivityLabelInfo,
)
label = SharePointSensitivityLabelInfo(
display_name="Confidential", sensitivity_label_id="lbl-1", is_encrypted=True
)
ref = KnowledgeBaseRemoteSharePointReference(
id="ref-6", activity_source=0, web_url="https://sp.example.com/doc", search_sensitivity_label_info=label
)
result = AzureAISearchContextProvider._parse_references_to_annotations([ref])
assert result[0]["url"] == "https://sp.example.com/doc"
sl = result[0]["additional_properties"]["sensitivity_label"]
assert sl["display_name"] == "Confidential"
assert sl["sensitivity_label_id"] == "lbl-1"
assert sl["is_encrypted"] is True
def test_multiple_references(self) -> None:
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseSearchIndexReference,
KnowledgeBaseWebReference,
)
refs = [
KnowledgeBaseSearchIndexReference(id="ref-a", activity_source=0),
KnowledgeBaseWebReference(id="ref-b", activity_source=1, url="https://example.com"),
]
result = AzureAISearchContextProvider._parse_references_to_annotations(refs)
assert len(result) == 2
assert result[0]["additional_properties"]["activity_source"] == 0
assert result[1]["additional_properties"]["activity_source"] == 1
class TestParseMessagesFromKbResponse:
"""Tests for _parse_messages_from_kb_response."""
def test_converts_all_messages(self) -> None:
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseMessage,
KnowledgeBaseMessageTextContent,
KnowledgeBaseRetrievalResponse,
)
response = KnowledgeBaseRetrievalResponse(
response=[
KnowledgeBaseMessage(role="user", content=[KnowledgeBaseMessageTextContent(text="q")]),
KnowledgeBaseMessage(role="assistant", content=[KnowledgeBaseMessageTextContent(text="answer")]),
],
references=None,
)
result = AzureAISearchContextProvider._parse_messages_from_kb_response(response)
assert len(result) == 2
assert result[0].role == "user"
assert result[0].text == "q"
assert result[1].role == "assistant"
assert result[1].text == "answer"
def test_none_response_returns_default(self) -> None:
from azure.search.documents.knowledgebases.models import KnowledgeBaseRetrievalResponse
response = KnowledgeBaseRetrievalResponse(response=None, references=None)
result = AzureAISearchContextProvider._parse_messages_from_kb_response(response)
assert len(result) == 1
assert result[0].text == "No results found from Knowledge Base."
def test_empty_response_returns_default(self) -> None:
from azure.search.documents.knowledgebases.models import KnowledgeBaseRetrievalResponse
response = KnowledgeBaseRetrievalResponse(response=[], references=None)
result = AzureAISearchContextProvider._parse_messages_from_kb_response(response)
assert len(result) == 1
assert result[0].text == "No results found from Knowledge Base."
def test_image_content(self) -> None:
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseMessage,
KnowledgeBaseMessageImageContent,
KnowledgeBaseMessageImageContentImage,
KnowledgeBaseRetrievalResponse,
)
response = KnowledgeBaseRetrievalResponse(
response=[
KnowledgeBaseMessage(
role="assistant",
content=[
KnowledgeBaseMessageImageContent(
image=KnowledgeBaseMessageImageContentImage(url="https://img.example.com/a.png")
)
],
),
],
references=None,
)
result = AzureAISearchContextProvider._parse_messages_from_kb_response(response)
assert len(result) == 1
assert result[0].contents[0].type == "uri"
assert result[0].contents[0].uri == "https://img.example.com/a.png"
def test_mixed_text_and_image_content(self) -> None:
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseMessage,
KnowledgeBaseMessageImageContent,
KnowledgeBaseMessageImageContentImage,
KnowledgeBaseMessageTextContent,
KnowledgeBaseRetrievalResponse,
)
response = KnowledgeBaseRetrievalResponse(
response=[
KnowledgeBaseMessage(
role="assistant",
content=[
KnowledgeBaseMessageTextContent(text="description"),
KnowledgeBaseMessageImageContent(
image=KnowledgeBaseMessageImageContentImage(url="https://img.example.com/b.png")
),
],
),
],
references=None,
)
result = AzureAISearchContextProvider._parse_messages_from_kb_response(response)
assert len(result) == 1
assert len(result[0].contents) == 2
assert result[0].contents[0].type == "text"
assert result[0].contents[1].type == "uri"
def test_references_become_annotations(self) -> None:
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseMessage,
KnowledgeBaseMessageTextContent,
KnowledgeBaseRetrievalResponse,
KnowledgeBaseWebReference,
)
response = KnowledgeBaseRetrievalResponse(
response=[
KnowledgeBaseMessage(role="assistant", content=[KnowledgeBaseMessageTextContent(text="answer")]),
],
references=[
KnowledgeBaseWebReference(
id="ref-1", activity_source=0, url="https://example.com", title="Example"
),
],
)
result = AzureAISearchContextProvider._parse_messages_from_kb_response(response)
assert len(result) == 1
annotations = result[0].contents[0].annotations
assert annotations is not None
assert len(annotations) == 1
assert annotations[0]["type"] == "citation"
assert annotations[0]["url"] == "https://example.com"
assert annotations[0]["title"] == "Example"
def test_multiple_messages_with_references(self) -> None:
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseMessage,
KnowledgeBaseMessageTextContent,
KnowledgeBaseRetrievalResponse,
KnowledgeBaseSearchIndexReference,
)
response = KnowledgeBaseRetrievalResponse(
response=[
KnowledgeBaseMessage(role="user", content=[KnowledgeBaseMessageTextContent(text="q")]),
KnowledgeBaseMessage(
role="assistant",
content=[
KnowledgeBaseMessageTextContent(text="part1"),
KnowledgeBaseMessageTextContent(text="part2"),
],
),
],
references=[KnowledgeBaseSearchIndexReference(id="doc-1", activity_source=0)],
)
result = AzureAISearchContextProvider._parse_messages_from_kb_response(response)
assert len(result) == 2
# All content items get annotations
for msg in result:
for c in msg.contents:
assert c.annotations is not None
assert len(c.annotations) == 1
# -- before_run: agentic mode --------------------------------------------------
@@ -1266,9 +1628,11 @@ class TestBeforeRunAgentic:
mock_content = Mock()
mock_content.text = "agentic result"
mock_message = Mock()
mock_message.role = "assistant"
mock_message.content = [mock_content]
mock_result = Mock()
mock_result.response = [mock_message]
mock_result.references = None
mock_retrieval = AsyncMock()
mock_retrieval.retrieve = AsyncMock(return_value=mock_result)
@@ -59,6 +59,7 @@ from ._sessions import (
register_state_type,
)
from ._settings import SecretString, load_settings
from ._skills import FileAgentSkillsProvider
from ._telemetry import (
AGENT_FRAMEWORK_USER_AGENT,
APP_INFO,
@@ -233,6 +234,7 @@ __all__ = [
"Executor",
"FanInEdgeGroup",
"FanOutEdgeGroup",
"FileAgentSkillsProvider",
"FileCheckpointStorage",
"FinalT",
"FinishReason",
@@ -0,0 +1,597 @@
# Copyright (c) Microsoft. All rights reserved.
"""File-based Agent Skills provider for the agent framework.
This module implements the progressive disclosure pattern from the
`Agent Skills specification <https://agentskills.io/>`_:
1. **Advertise** — skill names and descriptions are injected into the system prompt.
2. **Load** — the full SKILL.md body is returned via the ``load_skill`` tool.
3. **Read resources** — supplementary files are read from disk on demand via
the ``read_skill_resource`` tool.
Skills are discovered by searching configured directories for ``SKILL.md`` files.
Referenced resources are validated at initialization; invalid skills are excluded
and logged.
**Security:** this provider only reads static content. Skill metadata is XML-escaped
before prompt embedding, and resource reads are guarded against path traversal and
symlink escape. Only use skills from trusted sources.
"""
from __future__ import annotations
import logging
import os
import re
from collections.abc import Sequence
from dataclasses import dataclass, field
from html import escape as xml_escape
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Any, ClassVar, Final
from ._sessions import BaseContextProvider
from ._tools import FunctionTool
if TYPE_CHECKING:
from ._agents import SupportsAgentRun
from ._sessions import AgentSession, SessionContext
logger = logging.getLogger(__name__)
# region Constants
SKILL_FILE_NAME: Final[str] = "SKILL.md"
MAX_SEARCH_DEPTH: Final[int] = 2
MAX_NAME_LENGTH: Final[int] = 64
MAX_DESCRIPTION_LENGTH: Final[int] = 1024
# endregion
# region Compiled regex patterns (ported from .NET FileAgentSkillLoader)
# Matches YAML frontmatter delimited by "---" lines.
# The \uFEFF? prefix allows an optional UTF-8 BOM.
_FRONTMATTER_RE = re.compile(
r"\A\uFEFF?---\s*$(.+?)^---\s*$",
re.MULTILINE | re.DOTALL,
)
# Matches resource file references in skill markdown. Group 1 = relative file path.
# Supports two forms:
# 1. Markdown links: [text](path/file.ext)
# 2. Backtick-quoted paths: `path/file.ext`
# Supports optional ./ or ../ prefixes; excludes URLs (no ":" in the path character class).
_RESOURCE_LINK_RE = re.compile(
r"(?:\[.*?\]\(|`)(\.?\.?/?[\w][\w\-./]*\.\w+)(?:\)|`)",
)
# Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value,
# Group 3 = unquoted value.
_YAML_KV_RE = re.compile(
r"^\s*(\w+)\s*:\s*(?:[\"'](.+?)[\"']|(.+?))\s*$",
re.MULTILINE,
)
# Validates skill names: lowercase letters, numbers, hyphens only;
# must not start or end with a hyphen.
_VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$")
_DEFAULT_SKILLS_INSTRUCTION_PROMPT = """\
You have access to skills containing domain-specific knowledge and capabilities.
Each skill provides specialized instructions, reference documents, and assets for specific tasks.
<available_skills>
{0}
</available_skills>
When a task aligns with a skill's domain:
1. Use `load_skill` to retrieve the skill's instructions
2. Follow the provided guidance
3. Use `read_skill_resource` to read any references or other files mentioned by the skill,
always using the full path as written (e.g. `references/FAQ.md`, not just `FAQ.md`)
Only load what is needed, when it is needed."""
# endregion
# region Private data classes
@dataclass
class _SkillFrontmatter:
"""Parsed YAML frontmatter from a SKILL.md file."""
name: str
description: str
@dataclass
class _FileAgentSkill:
"""Represents a loaded Agent Skill discovered from a filesystem directory."""
frontmatter: _SkillFrontmatter
body: str
source_path: str
resource_names: list[str] = field(default_factory=list)
# endregion
# region Private module-level functions (skill discovery, parsing, security)
def _normalize_resource_path(path: str) -> str:
"""Normalize a relative resource path.
Replaces backslashes with forward slashes and removes leading ``./`` prefixes
so that ``./refs/doc.md`` and ``refs/doc.md`` are treated as the same resource.
"""
return PurePosixPath(path.replace("\\", "/")).as_posix()
def _extract_resource_paths(content: str) -> list[str]:
"""Extract deduplicated resource paths from markdown link syntax."""
seen: set[str] = set()
paths: list[str] = []
for match in _RESOURCE_LINK_RE.finditer(content):
normalized = _normalize_resource_path(match.group(1))
lower = normalized.lower()
if lower not in seen:
seen.add(lower)
paths.append(normalized)
return paths
def _is_path_within_directory(full_path: str, directory_path: str) -> bool:
"""Check that *full_path* is under *directory_path*.
Uses :meth:`pathlib.Path.is_relative_to` for cross-platform comparison,
which handles case sensitivity correctly per platform.
"""
try:
return Path(full_path).is_relative_to(directory_path)
except (ValueError, OSError):
return False
def _has_symlink_in_path(full_path: str, directory_path: str) -> bool:
"""Check whether any segment in *full_path* below *directory_path* is a symlink.
Precondition: *full_path* must start with *directory_path*. Callers are
expected to verify containment via :func:`_is_path_within_directory` before
invoking this function.
"""
dir_path = Path(directory_path)
try:
relative = Path(full_path).relative_to(dir_path)
except ValueError as exc:
raise ValueError(
f"full_path {full_path!r} does not start with directory_path {directory_path!r}"
) from exc
current = dir_path
for part in relative.parts:
current = current / part
if current.is_symlink():
return True
return False
def _try_parse_skill_document(
content: str,
skill_file_path: str,
) -> tuple[_SkillFrontmatter, str] | None:
"""Parse a SKILL.md file into frontmatter and body.
Returns:
A ``(frontmatter, body)`` tuple on success, or ``None`` if parsing fails.
"""
match = _FRONTMATTER_RE.search(content)
if not match:
logger.error("SKILL.md at '%s' does not contain valid YAML frontmatter delimited by '---'", skill_file_path)
return None
yaml_content = match.group(1).strip()
name: str | None = None
description: str | None = None
for kv_match in _YAML_KV_RE.finditer(yaml_content):
key = kv_match.group(1)
value = kv_match.group(2) if kv_match.group(2) is not None else kv_match.group(3)
if key.lower() == "name":
name = value
elif key.lower() == "description":
description = value
if not name or not name.strip():
logger.error("SKILL.md at '%s' is missing a 'name' field in frontmatter", skill_file_path)
return None
if len(name) > MAX_NAME_LENGTH or not _VALID_NAME_RE.match(name):
logger.error(
"SKILL.md at '%s' has an invalid 'name' value: Must be %d characters or fewer, "
"using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen.",
skill_file_path,
MAX_NAME_LENGTH,
)
return None
if not description or not description.strip():
logger.error("SKILL.md at '%s' is missing a 'description' field in frontmatter", skill_file_path)
return None
if len(description) > MAX_DESCRIPTION_LENGTH:
logger.error(
"SKILL.md at '%s' has an invalid 'description' value: Must be %d characters or fewer.",
skill_file_path,
MAX_DESCRIPTION_LENGTH,
)
return None
body = content[match.end() :].lstrip()
return _SkillFrontmatter(name, description), body
def _validate_resources(
skill_dir_path: str,
resource_names: list[str],
skill_name: str,
) -> bool:
"""Validate that all resource paths exist and are safe."""
skill_dir = Path(skill_dir_path).absolute()
for resource_name in resource_names:
resource_path = Path(os.path.normpath(skill_dir / resource_name))
if not _is_path_within_directory(str(resource_path), str(skill_dir)):
logger.warning(
"Excluding skill '%s': resource '%s' references a path outside the skill directory",
skill_name,
resource_name,
)
return False
if not resource_path.is_file():
logger.warning(
"Excluding skill '%s': referenced resource '%s' does not exist",
skill_name,
resource_name,
)
return False
if _has_symlink_in_path(str(resource_path), str(skill_dir)):
logger.warning(
"Excluding skill '%s': resource '%s' is a symlink that resolves outside the skill directory",
skill_name,
resource_name,
)
return False
return True
def _parse_skill_file(skill_dir_path: str) -> _FileAgentSkill | None:
"""Parse a SKILL.md file from the given directory."""
skill_file = Path(skill_dir_path) / SKILL_FILE_NAME
try:
content = skill_file.read_text(encoding="utf-8")
except OSError:
logger.error("Failed to read SKILL.md at '%s'", skill_file)
return None
result = _try_parse_skill_document(content, str(skill_file))
if result is None:
return None
frontmatter, body = result
resource_names = _extract_resource_paths(body)
if not _validate_resources(skill_dir_path, resource_names, frontmatter.name):
return None
return _FileAgentSkill(
frontmatter=frontmatter,
body=body,
source_path=skill_dir_path,
resource_names=resource_names,
)
def _search_directories_for_skills(
directory: str,
results: list[str],
current_depth: int,
) -> None:
"""Recursively search for SKILL.md files up to *MAX_SEARCH_DEPTH*."""
dir_path = Path(directory)
if (dir_path / SKILL_FILE_NAME).is_file():
results.append(str(dir_path.absolute()))
if current_depth >= MAX_SEARCH_DEPTH:
return
try:
entries = list(dir_path.iterdir())
except OSError:
return
for entry in entries:
if entry.is_dir():
_search_directories_for_skills(str(entry), results, current_depth + 1)
def _discover_skill_directories(skill_paths: Sequence[str]) -> list[str]:
"""Discover all directories containing SKILL.md files."""
discovered: list[str] = []
for root_dir in skill_paths:
if not root_dir or not root_dir.strip() or not Path(root_dir).is_dir():
continue
_search_directories_for_skills(root_dir, discovered, current_depth=0)
return discovered
def _discover_and_load_skills(skill_paths: Sequence[str]) -> dict[str, _FileAgentSkill]:
"""Discover and load all valid skills from the given paths."""
skills: dict[str, _FileAgentSkill] = {}
discovered = _discover_skill_directories(skill_paths)
logger.info("Discovered %d potential skills", len(discovered))
for skill_path in discovered:
skill = _parse_skill_file(skill_path)
if skill is None:
continue
if skill.frontmatter.name in skills:
existing = skills[skill.frontmatter.name]
logger.warning(
"Duplicate skill name '%s': skill from '%s' skipped in favor of existing skill from '%s'",
skill.frontmatter.name,
skill_path,
existing.source_path,
)
continue
skills[skill.frontmatter.name] = skill
logger.info("Loaded skill: %s", skill.frontmatter.name)
logger.info("Successfully loaded %d skills", len(skills))
return skills
def _read_skill_resource(skill: _FileAgentSkill, resource_name: str) -> str:
"""Read a resource file from disk with path traversal and symlink guards.
Args:
skill: The skill that owns the resource.
resource_name: Relative path of the resource within the skill directory.
Returns:
The UTF-8 text content of the resource file.
Raises:
ValueError: The resource is not registered, resolves outside the skill
directory, or does not exist.
"""
resource_name = _normalize_resource_path(resource_name)
# Find the registered resource name with the original casing so the
# file path is correct on case-sensitive filesystems.
registered_name: str | None = None
for r in skill.resource_names:
if r.lower() == resource_name.lower():
registered_name = r
break
if registered_name is None:
raise ValueError(f"Resource '{resource_name}' not found in skill '{skill.frontmatter.name}'.")
full_path = os.path.normpath(Path(skill.source_path) / registered_name)
source_dir = str(Path(skill.source_path).absolute())
if not _is_path_within_directory(full_path, source_dir):
raise ValueError(f"Resource file '{resource_name}' references a path outside the skill directory.")
if not Path(full_path).is_file():
raise ValueError(f"Resource file '{resource_name}' not found in skill '{skill.frontmatter.name}'.")
if _has_symlink_in_path(full_path, source_dir):
raise ValueError(f"Resource file '{resource_name}' is a symlink that resolves outside the skill directory.")
logger.info("Reading resource '%s' from skill '%s'", resource_name, skill.frontmatter.name)
return Path(full_path).read_text(encoding="utf-8")
def _build_skills_instruction_prompt(
prompt_template: str | None,
skills: dict[str, _FileAgentSkill],
) -> str | None:
"""Build the system prompt advertising available skills."""
template = _DEFAULT_SKILLS_INSTRUCTION_PROMPT
if prompt_template is not None:
# Validate that the custom template contains a valid {0} placeholder
try:
prompt_template.format("")
template = prompt_template
except (KeyError, IndexError) as exc:
raise ValueError(
"The provided skills_instruction_prompt is not a valid format string. "
"It must contain a '{0}' placeholder and escape any literal '{' or '}' "
"by doubling them ('{{' or '}}')."
) from exc
if not skills:
return None
lines: list[str] = []
# Sort by name for deterministic output
for skill in sorted(skills.values(), key=lambda s: s.frontmatter.name):
lines.append(" <skill>")
lines.append(f" <name>{xml_escape(skill.frontmatter.name)}</name>")
lines.append(f" <description>{xml_escape(skill.frontmatter.description)}</description>")
lines.append(" </skill>")
return template.format("\n".join(lines))
# endregion
# region Public API
class FileAgentSkillsProvider(BaseContextProvider):
"""A context provider that discovers and exposes Agent Skills from filesystem directories.
This provider implements the progressive disclosure pattern from the
`Agent Skills specification <https://agentskills.io/>`_:
1. **Advertise** — skill names and descriptions are injected into the system prompt
(~100 tokens per skill).
2. **Load** — the full SKILL.md body is returned via the ``load_skill`` tool.
3. **Read resources** — supplementary files are read on demand via the
``read_skill_resource`` tool.
Skills are discovered by searching the configured directories for ``SKILL.md`` files.
Referenced resources are validated at initialization; invalid skills are excluded and
logged.
**Security:** this provider only reads static content. Skill metadata is XML-escaped
before prompt embedding, and resource reads are guarded against path traversal and
symlink escape. Only use skills from trusted sources.
Args:
skill_paths: A single path or sequence of paths to search. Each can be an
individual skill folder (containing a SKILL.md file) or a parent folder
with skill subdirectories.
Keyword Args:
skills_instruction_prompt: A custom system prompt template for advertising
skills. Use ``{0}`` as the placeholder for the generated skills list.
When ``None``, a default template is used.
source_id: Unique identifier for this provider instance.
logger: Optional logger instance. When ``None``, uses the module logger.
"""
DEFAULT_SOURCE_ID: ClassVar[str] = "file_agent_skills"
def __init__(
self,
skill_paths: str | Path | Sequence[str | Path],
*,
skills_instruction_prompt: str | None = None,
source_id: str | None = None,
) -> None:
"""Initialize the FileAgentSkillsProvider.
Args:
skill_paths: A single path or sequence of paths to search for skills.
Keyword Args:
skills_instruction_prompt: Custom system prompt template with ``{0}`` placeholder.
source_id: Unique identifier for this provider instance.
"""
super().__init__(source_id or self.DEFAULT_SOURCE_ID)
resolved_paths: Sequence[str] = [str(skill_paths)] if isinstance(skill_paths, (str, Path)) else [str(p) for p in skill_paths]
self._skills = _discover_and_load_skills(resolved_paths)
self._skills_instruction_prompt = _build_skills_instruction_prompt(skills_instruction_prompt, self._skills)
self._tools = [
FunctionTool(
name="load_skill",
description="Loads the full instructions for a specific skill.",
func=self._load_skill,
input_model={
"type": "object",
"properties": {
"skill_name": {"type": "string", "description": "The name of the skill to load."},
},
"required": ["skill_name"],
},
),
FunctionTool(
name="read_skill_resource",
description="Reads a file associated with a skill, such as references or assets.",
func=self._read_skill_resource,
input_model={
"type": "object",
"properties": {
"skill_name": {"type": "string", "description": "The name of the skill."},
"resource_name": {
"type": "string",
"description": "The relative path of the resource file.",
},
},
"required": ["skill_name", "resource_name"],
},
),
]
async def before_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Inject skill instructions and tools into the session context.
When skills are available, adds the skills instruction prompt and
``load_skill`` / ``read_skill_resource`` tools.
"""
if not self._skills:
return
if self._skills_instruction_prompt:
context.extend_instructions(self.source_id, self._skills_instruction_prompt)
context.extend_tools(self.source_id, self._tools)
def _load_skill(self, skill_name: str) -> str:
"""Load the full instructions for a specific skill.
Args:
skill_name: The name of the skill to load.
Returns:
The skill body text, or an error message if not found.
"""
if not skill_name or not skill_name.strip():
return "Error: Skill name cannot be empty."
skill = self._skills.get(skill_name)
if skill is None:
return f"Error: Skill '{skill_name}' not found."
logger.info("Loading skill: %s", skill_name)
return skill.body
def _read_skill_resource(self, skill_name: str, resource_name: str) -> str:
"""Read a file associated with a skill.
Args:
skill_name: The name of the skill.
resource_name: The relative path of the resource file.
Returns:
The resource file content, or an error message if not found.
"""
if not skill_name or not skill_name.strip():
return "Error: Skill name cannot be empty."
if not resource_name or not resource_name.strip():
return "Error: Resource name cannot be empty."
skill = self._skills.get(skill_name)
if skill is None:
return f"Error: Skill '{skill_name}' not found."
try:
return _read_skill_resource(skill, resource_name)
except Exception:
logger.exception("Failed to read resource '%s' from skill '%s'", resource_name, skill_name)
return f"Error: Failed to read resource '{resource_name}' from skill '{skill_name}'."
# endregion
+22 -18
View File
@@ -2164,15 +2164,11 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
# Error threshold reached: force a final non-tool turn so
# function_call_output items are submitted before exit.
mutable_options["tool_choice"] = "none"
elif (
max_function_calls is not None
and total_function_calls >= max_function_calls
):
elif max_function_calls is not None and total_function_calls >= max_function_calls:
# Best-effort limit: checked after each batch of parallel calls completes,
# so the current batch always runs to completion even if it overshoots.
logger.info(
"Maximum function calls reached (%d/%d). "
"Stopping further function calls for this request.",
"Maximum function calls reached (%d/%d). Stopping further function calls for this request.",
total_function_calls,
max_function_calls,
)
@@ -2196,9 +2192,15 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
prepped_messages.extend(response.messages)
continue
if response is not None:
return response
# Loop exhausted all iterations (or function invocation disabled).
# Make a final model call with tool_choice="none" so the model
# produces a plain text answer instead of leaving orphaned
# function_call items without matching results.
if response is not None and self.function_invocation_configuration["enabled"]:
logger.info(
"Maximum iterations reached (%d). Requesting final response without tools.",
self.function_invocation_configuration["max_iterations"],
)
mutable_options["tool_choice"] = "none"
response = await super_get_response(
messages=prepped_messages,
@@ -2302,15 +2304,11 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
mutable_options["tool_choice"] = "none"
elif result["action"] != "continue":
return
elif (
max_function_calls is not None
and total_function_calls >= max_function_calls
):
elif max_function_calls is not None and total_function_calls >= max_function_calls:
# Best-effort limit: checked after each batch of parallel calls completes,
# so the current batch always runs to completion even if it overshoots.
logger.info(
"Maximum function calls reached (%d/%d). "
"Stopping further function calls for this request.",
"Maximum function calls reached (%d/%d). Stopping further function calls for this request.",
total_function_calls,
max_function_calls,
)
@@ -2333,9 +2331,15 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
prepped_messages.extend(response.messages)
continue
if response is not None:
return
# Loop exhausted all iterations (or function invocation disabled).
# Make a final model call with tool_choice="none" so the model
# produces a plain text answer instead of leaving orphaned
# function_call items without matching results.
if response is not None and self.function_invocation_configuration["enabled"]:
logger.info(
"Maximum iterations reached (%d). Requesting final response without tools.",
self.function_invocation_configuration["max_iterations"],
)
mutable_options["tool_choice"] = "none"
inner_stream = await _ensure_response_stream(
super_get_response(
@@ -158,7 +158,28 @@ class Runner:
self._running = False
async def _run_iteration(self) -> None:
async def _deliver_messages(source_executor_id: str, messages: list[WorkflowMessage]) -> None:
"""Run a single iteration of the workflow.
Messages are delivered through edge runners. A source executor may have multiple outgoing edge
runners. All edge runners run concurrently, but messages sent through the same edge runner are
delivered in the order they were sent to preserve message ordering guarantees per edge.
What this means in practice:
- A message from a source to multiple target is delivered to all targets concurrently.
- Multiple messages from a source to the same target are delivered in the order they were sent.
- Multiple messages from different sources to the same target can be delivered to the target one
at a time in any order, because true parallelism is not realized in Python.
- Multiple message from different sources to different targets are delivered concurrently to all
targets, assuming each message is targeting a unique target, or it falls back to the previous
rules if there are multiple messages targeting the same target.
- Special case: if using a fan-out edge runner (or derived edge runner that replicates messages
to multiple targets such as multi-selection or switch-case) to send messages to targets from
a source by specifying the target, the messages will be delivered to the specified targets
in the order they were sent. This is because all messages go through the same edge runner instance
which preserves message order.
"""
async def _deliver_messages(source_executor_id: str, source_messages: list[WorkflowMessage]) -> None:
"""Outer loop to concurrently deliver messages from all sources to their targets."""
async def _deliver_message_inner(edge_runner: EdgeRunner, message: WorkflowMessage) -> bool:
@@ -172,13 +193,20 @@ class Runner:
logger.debug(f"No outgoing edges found for executor {source_executor_id}; dropping messages.")
return
for message in messages:
# Deliver a message through all edge runners associated with the source executor concurrently.
tasks = [_deliver_message_inner(edge_runner, message) for edge_runner in associated_edge_runners]
await asyncio.gather(*tasks)
async def _deliver_messages_for_edge_runner(edge_runner: EdgeRunner) -> None:
# Preserve message order per edge runner (and therefore per routed target path)
# while still allowing parallelism across different edge runners.
for message in source_messages:
await _deliver_message_inner(edge_runner, message)
messages = await self._ctx.drain_messages()
tasks = [_deliver_messages(source_executor_id, messages) for source_executor_id, messages in messages.items()]
tasks = [_deliver_messages_for_edge_runner(edge_runner) for edge_runner in associated_edge_runners]
await asyncio.gather(*tasks)
message_batches = await self._ctx.drain_messages()
tasks = [
_deliver_messages(source_executor_id, source_messages)
for source_executor_id, source_messages in message_batches.items()
]
await asyncio.gather(*tasks)
async def _create_checkpoint_if_enabled(self, previous_checkpoint_id: CheckpointID | None) -> CheckpointID | None:
+2 -1
View File
@@ -34,7 +34,8 @@ dependencies = [
# connectors and functions
"openai>=1.99.0",
"azure-identity>=1,<2",
"azure-ai-projects >= 2.0.0b3",
# Pinned to 2.0.0b3 - breaking changes in 2.0.0b4, unpin once upgrades complete
"azure-ai-projects == 2.0.0b3",
"mcp[ws]>=1.24.0,<2",
"packaging>=24.1",
]
@@ -831,8 +831,6 @@ async def test_rejection_result_uses_function_call_id(chat_client_base: Supports
assert "rejected" in rejection_result.result.lower()
@pytest.mark.skip(reason="Failsafe behavior with max_iterations needs investigation in unified API")
@pytest.mark.skip(reason="Failsafe behavior with max_iterations needs investigation in unified API")
async def test_max_iterations_limit(chat_client_base: SupportsChatGetResponse):
"""Test that MAX_ITERATIONS in additional_properties limits function call loops."""
exec_counter = 0
@@ -880,6 +878,256 @@ async def test_max_iterations_limit(chat_client_base: SupportsChatGetResponse):
assert response.messages[-1].text == "I broke out of the function invocation loop..." # Failsafe response
async def test_max_iterations_no_orphaned_function_calls(chat_client_base: SupportsChatGetResponse):
"""When max_iterations is reached, verify the returned response has no orphaned
FunctionCallContent (i.e., every function_call has a matching function_result).
"""
exec_counter = 0
@tool(name="test_function", approval_mode="never_require")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
# Model keeps requesting tool calls on every iteration
chat_client_base.run_responses = [
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="call_1", name="test_function", arguments='{"arg1": "v1"}')
],
)
),
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="call_2", name="test_function", arguments='{"arg1": "v2"}')
],
)
),
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="call_3", name="test_function", arguments='{"arg1": "v3"}')
],
)
),
]
chat_client_base.function_invocation_configuration["max_iterations"] = 2
response = await chat_client_base.get_response(
[Message(role="user", text="hello")],
options={"tool_choice": "auto", "tools": [ai_func]},
)
# Collect all function_call and function_result call_ids from response
all_call_ids = set()
all_result_ids = set()
for msg in response.messages:
for content in msg.contents:
if content.type == "function_call":
all_call_ids.add(content.call_id)
elif content.type == "function_result":
all_result_ids.add(content.call_id)
orphaned_calls = all_call_ids - all_result_ids
assert not orphaned_calls, (
f"Response contains orphaned FunctionCallContent without matching "
f"FunctionResultContent: {orphaned_calls}."
)
async def test_max_iterations_makes_final_toolchoice_none_call(chat_client_base: SupportsChatGetResponse):
"""When max_iterations is reached, verify a final model call is made with
tool_choice='none' to produce a clean text response.
"""
exec_counter = 0
@tool(name="test_function", approval_mode="never_require")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.run_responses = [
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="call_1", name="test_function", arguments='{"arg1": "v1"}')
],
)
),
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="call_2", name="test_function", arguments='{"arg1": "v2"}')
],
)
),
# This response should be reached via failsafe (tool_choice="none")
ChatResponse(messages=Message(role="assistant", text="Final answer after giving up on tools.")),
]
chat_client_base.function_invocation_configuration["max_iterations"] = 1
response = await chat_client_base.get_response(
[Message(role="user", text="hello")],
options={"tool_choice": "auto", "tools": [ai_func]},
)
assert exec_counter == 1, f"Expected 1 function execution, got {exec_counter}"
# The response should end with a plain text message (from the failsafe call)
last_msg = response.messages[-1]
has_function_calls = any(c.type == "function_call" for c in last_msg.contents)
assert not has_function_calls, (
f"Last message in response still contains function_call items. "
f"Expected a clean text response after max_iterations failsafe. "
f"Got message with role={last_msg.role}, contents={[c.type for c in last_msg.contents]}"
)
# The mock client returns "I broke out of the function invocation loop..."
# when tool_choice="none"
assert last_msg.text == "I broke out of the function invocation loop...", (
f"Expected failsafe text response, got: {last_msg.text!r}"
)
async def test_max_iterations_preserves_all_fcc_messages(chat_client_base: SupportsChatGetResponse):
"""When max_iterations is reached and a final response is produced, all
intermediate function call/result messages should be included.
"""
exec_counter = 0
@tool(name="test_function", approval_mode="never_require")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Result {exec_counter}"
# Two iterations of function calls, then failsafe
chat_client_base.run_responses = [
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="call_1", name="test_function", arguments='{"arg1": "v1"}')
],
)
),
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="call_2", name="test_function", arguments='{"arg1": "v2"}')
],
)
),
ChatResponse(messages=Message(role="assistant", text="Done")),
]
chat_client_base.function_invocation_configuration["max_iterations"] = 2
response = await chat_client_base.get_response(
[Message(role="user", text="hello")],
options={"tool_choice": "auto", "tools": [ai_func]},
)
assert exec_counter == 2, f"Expected 2 function executions, got {exec_counter}"
# All function calls from both iterations should be present in the response
all_call_ids = set()
all_result_ids = set()
for msg in response.messages:
for content in msg.contents:
if content.type == "function_call":
all_call_ids.add(content.call_id)
elif content.type == "function_result":
all_result_ids.add(content.call_id)
assert "call_1" in all_call_ids, "First iteration's function call missing from response"
assert "call_2" in all_call_ids, "Second iteration's function call missing from response"
assert all_call_ids == all_result_ids, (
f"Mismatched function calls and results. Calls: {all_call_ids}, Results: {all_result_ids}"
)
async def test_max_iterations_thread_integrity_with_agent(chat_client_base: SupportsChatGetResponse):
"""Verify that agent.run() does not produce orphaned function calls after
max_iterations, which would corrupt the thread and cause API errors on the
next call.
"""
@tool(name="browser_snapshot", approval_mode="never_require")
def browser_snapshot(url: str) -> str:
return f"Screenshot of {url}"
# Model keeps requesting tool calls on every iteration.
# The failsafe call (with tool_choice="none") after the loop is handled
# automatically by the mock client, which returns a hardcoded text response
# when tool_choice="none" (see conftest.py ChatClientBase.get_response).
chat_client_base.run_responses = [
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(
call_id="call_abc", name="browser_snapshot", arguments='{"url": "https://example.com"}'
)
],
)
),
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(
call_id="call_xyz", name="browser_snapshot", arguments='{"url": "https://test.com"}'
)
],
)
),
]
chat_client_base.function_invocation_configuration["max_iterations"] = 2
agent = Agent(
client=chat_client_base,
name="test-agent",
tools=[browser_snapshot],
)
response = await agent.run(
"Take screenshots",
options={"tool_choice": "auto"},
)
# Check for orphaned function calls in the response messages
all_call_ids = set()
all_result_ids = set()
for msg in response.messages:
for content in msg.contents:
if content.type == "function_call":
all_call_ids.add(content.call_id)
elif content.type == "function_result":
all_result_ids.add(content.call_id)
orphaned_calls = all_call_ids - all_result_ids
assert not orphaned_calls, (
f"Response contains orphaned function calls {orphaned_calls}. "
f"This would cause API errors on the next call."
)
@pytest.mark.parametrize("max_iterations", [10])
async def test_max_function_calls_limits_parallel_invocations(chat_client_base: SupportsChatGetResponse):
"""Test that max_function_calls caps total function invocations across iterations with parallel calls."""
@@ -2248,7 +2496,6 @@ async def test_streaming_approval_request_generated(chat_client_base: SupportsCh
assert exec_counter == 0 # Function not executed yet due to approval requirement
@pytest.mark.skip(reason="Failsafe behavior with max_iterations needs investigation in unified API")
async def test_streaming_max_iterations_limit(chat_client_base: SupportsChatGetResponse):
"""Test that MAX_ITERATIONS in streaming mode limits function call loops."""
exec_counter = 0
@@ -0,0 +1,697 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for file-based Agent Skills provider."""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from agent_framework import FileAgentSkillsProvider, SessionContext
from agent_framework._skills import (
_build_skills_instruction_prompt,
_discover_and_load_skills,
_extract_resource_paths,
_FileAgentSkill,
_has_symlink_in_path,
_normalize_resource_path,
_read_skill_resource,
_SkillFrontmatter,
_try_parse_skill_document,
)
def _symlinks_supported(tmp: Path) -> bool:
"""Return True if the current platform/environment supports symlinks."""
test_target = tmp / "_symlink_test_target"
test_link = tmp / "_symlink_test_link"
try:
test_target.write_text("test", encoding="utf-8")
test_link.symlink_to(test_target)
return True
except (OSError, NotImplementedError):
return False
finally:
test_link.unlink(missing_ok=True)
test_target.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_skill(
base: Path,
name: str,
description: str = "A test skill.",
body: str = "# Instructions\nDo the thing.",
*,
extra_frontmatter: str = "",
resources: dict[str, str] | None = None,
) -> Path:
"""Create a skill directory with SKILL.md and optional resource files."""
skill_dir = base / name
skill_dir.mkdir(parents=True, exist_ok=True)
frontmatter = f"---\nname: {name}\ndescription: {description}\n{extra_frontmatter}---\n"
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(frontmatter + body, encoding="utf-8")
if resources:
for rel_path, content in resources.items():
res_file = skill_dir / rel_path
res_file.parent.mkdir(parents=True, exist_ok=True)
res_file.write_text(content, encoding="utf-8")
return skill_dir
# ---------------------------------------------------------------------------
# Tests: module-level helper functions
# ---------------------------------------------------------------------------
class TestNormalizeResourcePath:
"""Tests for _normalize_resource_path."""
def test_strips_dot_slash_prefix(self) -> None:
assert _normalize_resource_path("./refs/doc.md") == "refs/doc.md"
def test_replaces_backslashes(self) -> None:
assert _normalize_resource_path("refs\\doc.md") == "refs/doc.md"
def test_strips_dot_slash_and_replaces_backslashes(self) -> None:
assert _normalize_resource_path(".\\refs\\doc.md") == "refs/doc.md"
def test_no_change_for_clean_path(self) -> None:
assert _normalize_resource_path("refs/doc.md") == "refs/doc.md"
class TestExtractResourcePaths:
"""Tests for _extract_resource_paths."""
def test_extracts_markdown_links(self) -> None:
content = "See [doc](refs/FAQ.md) and [template](assets/template.md)."
paths = _extract_resource_paths(content)
assert paths == ["refs/FAQ.md", "assets/template.md"]
def test_deduplicates_case_insensitive(self) -> None:
content = "See [a](refs/FAQ.md) and [b](refs/faq.md)."
paths = _extract_resource_paths(content)
assert len(paths) == 1
def test_normalizes_dot_slash_prefix(self) -> None:
content = "See [doc](./refs/FAQ.md)."
paths = _extract_resource_paths(content)
assert paths == ["refs/FAQ.md"]
def test_ignores_urls(self) -> None:
content = "See [link](https://example.com/doc.md)."
paths = _extract_resource_paths(content)
assert paths == []
def test_empty_content(self) -> None:
assert _extract_resource_paths("") == []
def test_extracts_backtick_quoted_paths(self) -> None:
content = "Use the template at `assets/template.md` and the script `./scripts/run.py`."
paths = _extract_resource_paths(content)
assert paths == ["assets/template.md", "scripts/run.py"]
def test_deduplicates_across_link_and_backtick(self) -> None:
content = "See [doc](refs/FAQ.md) and also `refs/FAQ.md`."
paths = _extract_resource_paths(content)
assert len(paths) == 1
class TestTryParseSkillDocument:
"""Tests for _try_parse_skill_document."""
def test_valid_skill(self) -> None:
content = "---\nname: test-skill\ndescription: A test skill.\n---\n# Body\nInstructions here."
result = _try_parse_skill_document(content, "test.md")
assert result is not None
frontmatter, body = result
assert frontmatter.name == "test-skill"
assert frontmatter.description == "A test skill."
assert "Instructions here." in body
def test_quoted_values(self) -> None:
content = "---\nname: \"test-skill\"\ndescription: 'A test skill.'\n---\nBody."
result = _try_parse_skill_document(content, "test.md")
assert result is not None
assert result[0].name == "test-skill"
assert result[0].description == "A test skill."
def test_utf8_bom(self) -> None:
content = "\ufeff---\nname: test-skill\ndescription: A test skill.\n---\nBody."
result = _try_parse_skill_document(content, "test.md")
assert result is not None
assert result[0].name == "test-skill"
def test_missing_frontmatter(self) -> None:
content = "# Just a markdown file\nNo frontmatter here."
result = _try_parse_skill_document(content, "test.md")
assert result is None
def test_missing_name(self) -> None:
content = "---\ndescription: A test skill.\n---\nBody."
result = _try_parse_skill_document(content, "test.md")
assert result is None
def test_missing_description(self) -> None:
content = "---\nname: test-skill\n---\nBody."
result = _try_parse_skill_document(content, "test.md")
assert result is None
def test_invalid_name_uppercase(self) -> None:
content = "---\nname: Test-Skill\ndescription: A test skill.\n---\nBody."
result = _try_parse_skill_document(content, "test.md")
assert result is None
def test_invalid_name_starts_with_hyphen(self) -> None:
content = "---\nname: -test-skill\ndescription: A test skill.\n---\nBody."
result = _try_parse_skill_document(content, "test.md")
assert result is None
def test_invalid_name_ends_with_hyphen(self) -> None:
content = "---\nname: test-skill-\ndescription: A test skill.\n---\nBody."
result = _try_parse_skill_document(content, "test.md")
assert result is None
def test_name_too_long(self) -> None:
long_name = "a" * 65
content = f"---\nname: {long_name}\ndescription: A test skill.\n---\nBody."
result = _try_parse_skill_document(content, "test.md")
assert result is None
def test_description_too_long(self) -> None:
long_desc = "a" * 1025
content = f"---\nname: test-skill\ndescription: {long_desc}\n---\nBody."
result = _try_parse_skill_document(content, "test.md")
assert result is None
def test_extra_metadata_ignored(self) -> None:
content = "---\nname: test-skill\ndescription: A test skill.\nauthor: someone\nversion: 1.0\n---\nBody."
result = _try_parse_skill_document(content, "test.md")
assert result is not None
assert result[0].name == "test-skill"
# ---------------------------------------------------------------------------
# Tests: skill discovery and loading
# ---------------------------------------------------------------------------
class TestDiscoverAndLoadSkills:
"""Tests for _discover_and_load_skills."""
def test_discovers_valid_skill(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
skills = _discover_and_load_skills([str(tmp_path)])
assert "my-skill" in skills
assert skills["my-skill"].frontmatter.name == "my-skill"
def test_discovers_nested_skills(self, tmp_path: Path) -> None:
skills_dir = tmp_path / "skills"
_write_skill(skills_dir, "skill-a")
_write_skill(skills_dir, "skill-b")
skills = _discover_and_load_skills([str(skills_dir)])
assert len(skills) == 2
assert "skill-a" in skills
assert "skill-b" in skills
def test_skips_invalid_skill(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "bad-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("No frontmatter here.", encoding="utf-8")
skills = _discover_and_load_skills([str(tmp_path)])
assert len(skills) == 0
def test_deduplicates_skill_names(self, tmp_path: Path) -> None:
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
_write_skill(dir1, "my-skill", body="First")
_write_skill(dir2, "my-skill", body="Second")
skills = _discover_and_load_skills([str(dir1), str(dir2)])
assert len(skills) == 1
assert skills["my-skill"].body == "First"
def test_empty_directory(self, tmp_path: Path) -> None:
skills = _discover_and_load_skills([str(tmp_path)])
assert len(skills) == 0
def test_nonexistent_directory(self) -> None:
skills = _discover_and_load_skills(["/nonexistent/path"])
assert len(skills) == 0
def test_multiple_paths(self, tmp_path: Path) -> None:
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
_write_skill(dir1, "skill-a")
_write_skill(dir2, "skill-b")
skills = _discover_and_load_skills([str(dir1), str(dir2)])
assert len(skills) == 2
def test_depth_limit(self, tmp_path: Path) -> None:
# Depth 0: tmp_path itself
# Depth 1: tmp_path/level1
# Depth 2: tmp_path/level1/level2 (should be found)
# Depth 3: tmp_path/level1/level2/level3 (should NOT be found)
deep = tmp_path / "level1" / "level2" / "level3"
deep.mkdir(parents=True)
(deep / "SKILL.md").write_text("---\nname: deep-skill\ndescription: Too deep.\n---\nBody.", encoding="utf-8")
skills = _discover_and_load_skills([str(tmp_path)])
assert "deep-skill" not in skills
def test_skill_with_resources(self, tmp_path: Path) -> None:
_write_skill(
tmp_path,
"my-skill",
body="See [doc](refs/FAQ.md).",
resources={"refs/FAQ.md": "FAQ content"},
)
skills = _discover_and_load_skills([str(tmp_path)])
assert "my-skill" in skills
assert skills["my-skill"].resource_names == ["refs/FAQ.md"]
def test_excludes_skill_with_missing_resource(self, tmp_path: Path) -> None:
_write_skill(
tmp_path,
"my-skill",
body="See [doc](refs/MISSING.md).",
)
skills = _discover_and_load_skills([str(tmp_path)])
assert len(skills) == 0
def test_excludes_skill_with_path_traversal_resource(self, tmp_path: Path) -> None:
_write_skill(
tmp_path,
"my-skill",
body="See [doc](../secret.md).",
resources={}, # resource points outside
)
# Create the file outside the skill directory
(tmp_path / "secret.md").write_text("secret", encoding="utf-8")
skills = _discover_and_load_skills([str(tmp_path)])
assert len(skills) == 0
# ---------------------------------------------------------------------------
# Tests: read_skill_resource
# ---------------------------------------------------------------------------
class TestReadSkillResource:
"""Tests for _read_skill_resource."""
def test_reads_valid_resource(self, tmp_path: Path) -> None:
_write_skill(
tmp_path,
"my-skill",
body="See [doc](refs/FAQ.md).",
resources={"refs/FAQ.md": "FAQ content here"},
)
skills = _discover_and_load_skills([str(tmp_path)])
content = _read_skill_resource(skills["my-skill"], "refs/FAQ.md")
assert content == "FAQ content here"
def test_normalizes_dot_slash(self, tmp_path: Path) -> None:
_write_skill(
tmp_path,
"my-skill",
body="See [doc](refs/FAQ.md).",
resources={"refs/FAQ.md": "FAQ content"},
)
skills = _discover_and_load_skills([str(tmp_path)])
content = _read_skill_resource(skills["my-skill"], "./refs/FAQ.md")
assert content == "FAQ content"
def test_unregistered_resource_raises(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
skills = _discover_and_load_skills([str(tmp_path)])
with pytest.raises(ValueError, match="not found in skill"):
_read_skill_resource(skills["my-skill"], "nonexistent.md")
def test_case_insensitive_lookup_uses_registered_casing(self, tmp_path: Path) -> None:
_write_skill(
tmp_path,
"my-skill",
body="See [doc](refs/FAQ.md).",
resources={"refs/FAQ.md": "FAQ content"},
)
skills = _discover_and_load_skills([str(tmp_path)])
# Request with different casing; the registered name should be used for the file path
content = _read_skill_resource(skills["my-skill"], "REFS/faq.md")
assert content == "FAQ content"
def test_path_traversal_raises(self, tmp_path: Path) -> None:
skill = _FileAgentSkill(
frontmatter=_SkillFrontmatter("test", "Test skill"),
body="Body",
source_path=str(tmp_path / "skill"),
resource_names=["../secret.md"],
)
(tmp_path / "secret.md").write_text("secret", encoding="utf-8")
with pytest.raises(ValueError, match="outside the skill directory"):
_read_skill_resource(skill, "../secret.md")
def test_similar_prefix_directory_does_not_match(self, tmp_path: Path) -> None:
"""A skill directory named 'skill-a-evil' must not access resources from 'skill-a'."""
skill = _FileAgentSkill(
frontmatter=_SkillFrontmatter("test", "Test skill"),
body="Body",
source_path=str(tmp_path / "skill-a"),
resource_names=["../skill-a-evil/secret.md"],
)
evil_dir = tmp_path / "skill-a-evil"
evil_dir.mkdir()
(evil_dir / "secret.md").write_text("evil", encoding="utf-8")
with pytest.raises(ValueError, match="outside the skill directory"):
_read_skill_resource(skill, "../skill-a-evil/secret.md")
# ---------------------------------------------------------------------------
# Tests: _build_skills_instruction_prompt
# ---------------------------------------------------------------------------
class TestBuildSkillsInstructionPrompt:
"""Tests for _build_skills_instruction_prompt."""
def test_returns_none_for_empty_skills(self) -> None:
assert _build_skills_instruction_prompt(None, {}) is None
def test_default_prompt_contains_skills(self) -> None:
skills = {
"my-skill": _FileAgentSkill(
frontmatter=_SkillFrontmatter("my-skill", "Does stuff."),
body="Body",
source_path="/tmp/skill",
),
}
prompt = _build_skills_instruction_prompt(None, skills)
assert prompt is not None
assert "<name>my-skill</name>" in prompt
assert "<description>Does stuff.</description>" in prompt
assert "load_skill" in prompt
def test_skills_sorted_alphabetically(self) -> None:
skills = {
"zebra": _FileAgentSkill(
frontmatter=_SkillFrontmatter("zebra", "Z skill."),
body="Body",
source_path="/tmp/z",
),
"alpha": _FileAgentSkill(
frontmatter=_SkillFrontmatter("alpha", "A skill."),
body="Body",
source_path="/tmp/a",
),
}
prompt = _build_skills_instruction_prompt(None, skills)
assert prompt is not None
alpha_pos = prompt.index("alpha")
zebra_pos = prompt.index("zebra")
assert alpha_pos < zebra_pos
def test_xml_escapes_metadata(self) -> None:
skills = {
"my-skill": _FileAgentSkill(
frontmatter=_SkillFrontmatter("my-skill", 'Uses <tags> & "quotes"'),
body="Body",
source_path="/tmp/skill",
),
}
prompt = _build_skills_instruction_prompt(None, skills)
assert prompt is not None
assert "&lt;tags&gt;" in prompt
assert "&amp;" in prompt
def test_custom_prompt_template(self) -> None:
skills = {
"my-skill": _FileAgentSkill(
frontmatter=_SkillFrontmatter("my-skill", "Does stuff."),
body="Body",
source_path="/tmp/skill",
),
}
custom = "Custom header:\n{0}\nCustom footer."
prompt = _build_skills_instruction_prompt(custom, skills)
assert prompt is not None
assert prompt.startswith("Custom header:")
assert prompt.endswith("Custom footer.")
def test_invalid_prompt_template_raises(self) -> None:
skills = {
"my-skill": _FileAgentSkill(
frontmatter=_SkillFrontmatter("my-skill", "Does stuff."),
body="Body",
source_path="/tmp/skill",
),
}
with pytest.raises(ValueError, match="valid format string"):
_build_skills_instruction_prompt("{invalid}", skills)
# ---------------------------------------------------------------------------
# Tests: FileAgentSkillsProvider
# ---------------------------------------------------------------------------
class TestFileAgentSkillsProvider:
"""Tests for the public FileAgentSkillsProvider class."""
def test_default_source_id(self, tmp_path: Path) -> None:
provider = FileAgentSkillsProvider(str(tmp_path))
assert provider.source_id == "file_agent_skills"
def test_custom_source_id(self, tmp_path: Path) -> None:
provider = FileAgentSkillsProvider(str(tmp_path), source_id="custom")
assert provider.source_id == "custom"
def test_accepts_single_path_string(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
provider = FileAgentSkillsProvider(str(tmp_path))
assert len(provider._skills) == 1
def test_accepts_sequence_of_paths(self, tmp_path: Path) -> None:
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
_write_skill(dir1, "skill-a")
_write_skill(dir2, "skill-b")
provider = FileAgentSkillsProvider([str(dir1), str(dir2)])
assert len(provider._skills) == 2
async def test_before_run_with_skills(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
provider = FileAgentSkillsProvider(str(tmp_path))
context = SessionContext(input_messages=[])
await provider.before_run(
agent=AsyncMock(),
session=AsyncMock(),
context=context,
state={},
)
assert len(context.instructions) == 1
assert "my-skill" in context.instructions[0]
assert len(context.tools) == 2
tool_names = {t.name for t in context.tools}
assert tool_names == {"load_skill", "read_skill_resource"}
async def test_before_run_without_skills(self, tmp_path: Path) -> None:
provider = FileAgentSkillsProvider(str(tmp_path))
context = SessionContext(input_messages=[])
await provider.before_run(
agent=AsyncMock(),
session=AsyncMock(),
context=context,
state={},
)
assert len(context.instructions) == 0
assert len(context.tools) == 0
def test_load_skill_returns_body(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill", body="Skill body content.")
provider = FileAgentSkillsProvider(str(tmp_path))
result = provider._load_skill("my-skill")
assert result == "Skill body content."
def test_load_skill_unknown_returns_error(self, tmp_path: Path) -> None:
provider = FileAgentSkillsProvider(str(tmp_path))
result = provider._load_skill("nonexistent")
assert result.startswith("Error:")
def test_load_skill_empty_name_returns_error(self, tmp_path: Path) -> None:
provider = FileAgentSkillsProvider(str(tmp_path))
result = provider._load_skill("")
assert result.startswith("Error:")
def test_read_skill_resource_returns_content(self, tmp_path: Path) -> None:
_write_skill(
tmp_path,
"my-skill",
body="See [doc](refs/FAQ.md).",
resources={"refs/FAQ.md": "FAQ content"},
)
provider = FileAgentSkillsProvider(str(tmp_path))
result = provider._read_skill_resource("my-skill", "refs/FAQ.md")
assert result == "FAQ content"
def test_read_skill_resource_unknown_skill_returns_error(self, tmp_path: Path) -> None:
provider = FileAgentSkillsProvider(str(tmp_path))
result = provider._read_skill_resource("nonexistent", "file.md")
assert result.startswith("Error:")
def test_read_skill_resource_empty_name_returns_error(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
provider = FileAgentSkillsProvider(str(tmp_path))
result = provider._read_skill_resource("my-skill", "")
assert result.startswith("Error:")
def test_read_skill_resource_unknown_resource_returns_error(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill")
provider = FileAgentSkillsProvider(str(tmp_path))
result = provider._read_skill_resource("my-skill", "nonexistent.md")
assert result.startswith("Error:")
async def test_skills_sorted_in_prompt(self, tmp_path: Path) -> None:
skills_dir = tmp_path / "skills"
_write_skill(skills_dir, "zebra", description="Z skill.")
_write_skill(skills_dir, "alpha", description="A skill.")
provider = FileAgentSkillsProvider(str(skills_dir))
context = SessionContext(input_messages=[])
await provider.before_run(
agent=AsyncMock(),
session=AsyncMock(),
context=context,
state={},
)
prompt = context.instructions[0]
assert prompt.index("alpha") < prompt.index("zebra")
async def test_xml_escaping_in_prompt(self, tmp_path: Path) -> None:
_write_skill(tmp_path, "my-skill", description="Uses <tags> & stuff")
provider = FileAgentSkillsProvider(str(tmp_path))
context = SessionContext(input_messages=[])
await provider.before_run(
agent=AsyncMock(),
session=AsyncMock(),
context=context,
state={},
)
prompt = context.instructions[0]
assert "&lt;tags&gt;" in prompt
assert "&amp;" in prompt
# ---------------------------------------------------------------------------
# Tests: symlink detection (_has_symlink_in_path and end-to-end guards)
# ---------------------------------------------------------------------------
@pytest.fixture()
def _requires_symlinks(tmp_path: Path) -> None:
"""Skip the test if the platform does not support symlinks."""
if not _symlinks_supported(tmp_path):
pytest.skip("Symlinks not supported on this platform/environment")
@pytest.mark.usefixtures("_requires_symlinks")
class TestSymlinkDetection:
"""Tests for _has_symlink_in_path and the symlink guards in validation/read."""
def test_detects_symlinked_file(self, tmp_path: Path) -> None:
"""A symlink to a file outside the directory should be detected."""
skill_dir = tmp_path / "skill"
skill_dir.mkdir()
outside_file = tmp_path / "secret.txt"
outside_file.write_text("secret", encoding="utf-8")
symlink_path = skill_dir / "link.txt"
symlink_path.symlink_to(outside_file)
full_path = str(symlink_path)
directory_path = str(skill_dir) + os.sep
assert _has_symlink_in_path(full_path, directory_path) is True
def test_detects_symlinked_directory(self, tmp_path: Path) -> None:
"""A symlink to a directory outside should be detected for paths through it."""
skill_dir = tmp_path / "skill"
skill_dir.mkdir()
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
(outside_dir / "data.txt").write_text("data", encoding="utf-8")
symlink_dir = skill_dir / "linked-dir"
symlink_dir.symlink_to(outside_dir)
full_path = str(skill_dir / "linked-dir" / "data.txt")
directory_path = str(skill_dir) + os.sep
assert _has_symlink_in_path(full_path, directory_path) is True
def test_returns_false_for_regular_files(self, tmp_path: Path) -> None:
"""Regular (non-symlinked) files should not be flagged."""
skill_dir = tmp_path / "skill"
skill_dir.mkdir()
regular_file = skill_dir / "doc.txt"
regular_file.write_text("content", encoding="utf-8")
full_path = str(regular_file)
directory_path = str(skill_dir) + os.sep
assert _has_symlink_in_path(full_path, directory_path) is False
def test_validate_resources_rejects_symlinked_resource(self, tmp_path: Path) -> None:
"""_discover_and_load_skills should exclude a skill whose resource is a symlink."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
outside_file = tmp_path / "secret.md"
outside_file.write_text("secret content", encoding="utf-8")
# Create SKILL.md referencing a resource
(skill_dir / "SKILL.md").write_text(
"---\nname: my-skill\ndescription: A test skill.\n---\nSee [doc](refs/leak.md).\n",
encoding="utf-8",
)
refs_dir = skill_dir / "refs"
refs_dir.mkdir()
(refs_dir / "leak.md").symlink_to(outside_file)
skills = _discover_and_load_skills([str(tmp_path)])
assert "my-skill" not in skills
def test_read_skill_resource_rejects_symlinked_resource(self, tmp_path: Path) -> None:
"""_read_skill_resource should raise ValueError for a symlinked resource."""
skill_dir = tmp_path / "skill"
skill_dir.mkdir()
outside_file = tmp_path / "secret.md"
outside_file.write_text("secret content", encoding="utf-8")
refs_dir = skill_dir / "refs"
refs_dir.mkdir()
(refs_dir / "leak.md").symlink_to(outside_file)
skill = _FileAgentSkill(
frontmatter=_SkillFrontmatter("test", "Test skill"),
body="See [doc](refs/leak.md).",
source_path=str(skill_dir),
resource_names=["refs/leak.md"],
)
with pytest.raises(ValueError, match="symlink"):
_read_skill_resource(skill, "refs/leak.md")
@@ -21,7 +21,7 @@ from agent_framework import (
handler,
)
from agent_framework._workflows._const import EXECUTOR_STATE_KEY
from agent_framework._workflows._edge import SingleEdgeGroup
from agent_framework._workflows._edge import FanOutEdgeGroup, SingleEdgeGroup
from agent_framework._workflows._runner import Runner
from agent_framework._workflows._runner_context import (
InProcRunnerContext,
@@ -150,6 +150,154 @@ async def test_runner_run_until_convergence_not_completed():
assert event.type != "status" or event.state != WorkflowRunState.IDLE
async def test_runner_run_iteration_preserves_message_order_per_edge_runner() -> None:
"""Test that _run_iteration preserves message order to the same target path."""
class RecordingEdgeRunner:
def __init__(self) -> None:
self.received: list[int] = []
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
message_data = message.data
assert isinstance(message_data, MockMessage)
self.received.append(message_data.data)
return True
ctx = InProcRunnerContext()
state = State()
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
edge_runner = RecordingEdgeRunner()
runner._edge_runner_map = {"source": [edge_runner]} # type: ignore[assignment]
for index in range(5):
await ctx.send_message(WorkflowMessage(data=MockMessage(data=index), source_id="source"))
await runner._run_iteration()
assert edge_runner.received == [0, 1, 2, 3, 4]
async def test_runner_run_iteration_delivers_different_edge_runners_concurrently() -> None:
"""Test that different edge runners for the same source are executed concurrently."""
class BlockingEdgeRunner:
def __init__(self) -> None:
self.started = asyncio.Event()
self.release = asyncio.Event()
self.call_count = 0
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
self.call_count += 1
self.started.set()
await self.release.wait()
return True
class ProbeEdgeRunner:
def __init__(self) -> None:
self.probe_completed = asyncio.Event()
self.call_count = 0
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
self.call_count += 1
self.probe_completed.set()
return True
ctx = InProcRunnerContext()
state = State()
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
blocking_edge_runner = BlockingEdgeRunner()
probe_edge_runner = ProbeEdgeRunner()
runner._edge_runner_map = {"source": [blocking_edge_runner, probe_edge_runner]} # type: ignore[assignment]
await ctx.send_message(WorkflowMessage(data=MockMessage(data=1), source_id="source"))
iteration_task = asyncio.create_task(runner._run_iteration())
await blocking_edge_runner.started.wait()
await asyncio.wait_for(probe_edge_runner.probe_completed.wait(), timeout=2.0)
blocking_edge_runner.release.set()
await iteration_task
assert blocking_edge_runner.call_count == 1
assert probe_edge_runner.call_count == 1
async def test_fanout_edge_runner_delivers_to_multiple_targets_concurrently() -> None:
"""Test that FanOutEdgeRunner delivers messages to multiple targets concurrently.
This verifies that when a message is broadcast through a FanOutEdgeGroup (no target_id),
the runner delivers to all targets concurrently rather than sequentially.
"""
class BlockingExecutor(Executor):
"""An executor that blocks until released, used to detect concurrent execution."""
def __init__(self, id: str) -> None:
super().__init__(id=id)
self.started = asyncio.Event()
self.release = asyncio.Event()
self.call_count = 0
@handler
async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
self.call_count += 1
self.started.set()
await self.release.wait()
class ProbeExecutor(Executor):
"""An executor that completes immediately, used to probe concurrent execution."""
def __init__(self, id: str) -> None:
super().__init__(id=id)
self.probe_completed = asyncio.Event()
self.call_count = 0
@handler
async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
self.call_count += 1
self.probe_completed.set()
source = MockExecutor(id="source")
blocking_target = BlockingExecutor(id="blocking_target")
probe_target = ProbeExecutor(id="probe_target")
# FanOutEdgeGroup broadcasts messages to multiple targets
edge_group = FanOutEdgeGroup(source_id=source.id, target_ids=[blocking_target.id, probe_target.id])
executors: dict[str, Executor] = {
source.id: source,
blocking_target.id: blocking_target,
probe_target.id: probe_target,
}
ctx = InProcRunnerContext()
state = State()
runner = Runner([edge_group], executors, state, ctx, "test_name", graph_signature_hash="test_hash")
# Queue a message from source (will be delivered to both targets via FanOut)
await ctx.send_message(WorkflowMessage(data=MockMessage(data=1), source_id=source.id))
iteration_task = asyncio.create_task(runner._run_iteration())
# Wait for the blocking executor to start
await blocking_target.started.wait()
# If FanOut delivers concurrently, the probe should complete while blocking is still waiting
# If sequential, this would timeout because probe wouldn't start until blocking finishes
await asyncio.wait_for(probe_target.probe_completed.wait(), timeout=2.0)
# Release the blocking executor to allow iteration to complete
blocking_target.release.set()
await iteration_task
# Both executors should have been called exactly once
assert blocking_target.call_count == 1
assert probe_target.call_count == 1
async def test_runner_already_running():
"""Test that running the runner while it is already running raises an error."""
executor_a = MockExecutor(id="executor_a")
@@ -34,7 +34,6 @@ from ._executors_agents import (
AgentResult,
ExternalLoopState,
InvokeAzureAgentExecutor,
InvokeToolExecutor,
)
from ._executors_basic import (
BASIC_ACTION_EXECUTORS,
@@ -68,6 +67,17 @@ from ._executors_external_input import (
RequestExternalInputExecutor,
WaitForInputExecutor,
)
from ._executors_tools import (
FUNCTION_TOOL_REGISTRY_KEY,
TOOL_ACTION_EXECUTORS,
TOOL_APPROVAL_STATE_KEY,
BaseToolExecutor,
InvokeFunctionToolExecutor,
ToolApprovalRequest,
ToolApprovalResponse,
ToolApprovalState,
ToolInvocationResult,
)
from ._factory import DeclarativeWorkflowError, WorkflowFactory
from ._state import WorkflowState
@@ -79,6 +89,9 @@ __all__ = [
"CONTROL_FLOW_EXECUTORS",
"DECLARATIVE_STATE_KEY",
"EXTERNAL_INPUT_EXECUTORS",
"FUNCTION_TOOL_REGISTRY_KEY",
"TOOL_ACTION_EXECUTORS",
"TOOL_APPROVAL_STATE_KEY",
"TOOL_REGISTRY_KEY",
"ActionComplete",
"ActionTrigger",
@@ -86,6 +99,7 @@ __all__ = [
"AgentExternalInputResponse",
"AgentResult",
"AppendValueExecutor",
"BaseToolExecutor",
"BreakLoopExecutor",
"ClearAllVariablesExecutor",
"ConfirmationExecutor",
@@ -107,7 +121,7 @@ __all__ = [
"ForeachInitExecutor",
"ForeachNextExecutor",
"InvokeAzureAgentExecutor",
"InvokeToolExecutor",
"InvokeFunctionToolExecutor",
"JoinExecutor",
"LoopControl",
"LoopIterationResult",
@@ -119,6 +133,10 @@ __all__ = [
"SetTextVariableExecutor",
"SetValueExecutor",
"SetVariableExecutor",
"ToolApprovalRequest",
"ToolApprovalResponse",
"ToolApprovalState",
"ToolInvocationResult",
"WaitForInputExecutor",
"WorkflowFactory",
"WorkflowState",
@@ -13,6 +13,7 @@ action definitions and creates a proper workflow graph with:
from __future__ import annotations
import logging
from typing import Any
from agent_framework import (
@@ -38,6 +39,10 @@ from ._executors_control_flow import (
SwitchEvaluatorExecutor,
)
from ._executors_external_input import EXTERNAL_INPUT_EXECUTORS
from ._executors_tools import TOOL_ACTION_EXECUTORS, InvokeFunctionToolExecutor
logger = logging.getLogger(__name__)
# Combined mapping of all action kinds to executor classes
ALL_ACTION_EXECUTORS = {
@@ -45,6 +50,7 @@ ALL_ACTION_EXECUTORS = {
**CONTROL_FLOW_EXECUTORS,
**AGENT_ACTION_EXECUTORS,
**EXTERNAL_INPUT_EXECUTORS,
**TOOL_ACTION_EXECUTORS,
}
# Action kinds that terminate control flow (no fall-through to successor)
@@ -78,6 +84,7 @@ ACTION_REQUIRED_FIELDS: dict[str, list[str]] = {
"RequestHumanInput": ["variable"],
"WaitForHumanInput": ["variable"],
"EmitEvent": ["event"],
"InvokeFunctionTool": ["functionName"],
}
# Alternate field names that satisfy required field requirements
@@ -118,6 +125,7 @@ class DeclarativeWorkflowBuilder:
yaml_definition: dict[str, Any],
workflow_id: str | None = None,
agents: dict[str, Any] | None = None,
tools: dict[str, Any] | None = None,
checkpoint_storage: Any | None = None,
validate: bool = True,
max_iterations: int | None = None,
@@ -128,6 +136,7 @@ class DeclarativeWorkflowBuilder:
yaml_definition: The parsed YAML workflow definition
workflow_id: Optional ID for the workflow (defaults to name from YAML)
agents: Registry of agent instances by name (for InvokeAzureAgent actions)
tools: Registry of tool/function instances by name (for InvokeFunctionTool actions)
checkpoint_storage: Optional checkpoint storage for pause/resume support
validate: Whether to validate the workflow definition before building (default: True)
max_iterations: Maximum runner supersteps. Falls back to the YAML ``maxTurns``
@@ -138,6 +147,7 @@ class DeclarativeWorkflowBuilder:
self._executors: dict[str, Any] = {} # id -> executor
self._action_index = 0 # Counter for generating unique IDs
self._agents = agents or {} # Agent registry for agent executors
self._tools = tools or {} # Tool registry for tool executors
self._checkpoint_storage = checkpoint_storage
self._pending_gotos: list[tuple[Any, str]] = [] # (goto_executor, target_id)
self._validate = validate
@@ -423,8 +433,13 @@ class DeclarativeWorkflowBuilder:
executor_class = ALL_ACTION_EXECUTORS.get(kind)
if executor_class is None:
# Unknown action type - skip with warning
# In production, might want to log this
# Unknown action type - log warning and skip
logger.warning(
"Unknown action kind '%s' encountered at index %d - action will be skipped. Available action kinds: %s",
kind,
self._action_index,
list(ALL_ACTION_EXECUTORS.keys()),
)
return None
# Create the executor with ID
@@ -437,10 +452,12 @@ class DeclarativeWorkflowBuilder:
action_id = f"{parent_id}_{kind}_{self._action_index}" if parent_id else f"{kind}_{self._action_index}"
self._action_index += 1
# Pass agents to agent-related executors
# Pass agents/tools to specialized executors
executor: Any
if kind in ("InvokeAzureAgent",):
executor = InvokeAzureAgentExecutor(action_def, id=action_id, agents=self._agents)
elif kind == "InvokeFunctionTool":
executor = InvokeFunctionToolExecutor(action_def, id=action_id, tools=self._tools)
else:
executor = executor_class(action_def, id=action_id)
self._executors[action_id] = executor
@@ -1019,75 +1019,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
await ctx.send_message(ActionComplete())
class InvokeToolExecutor(DeclarativeActionExecutor):
"""Executor that invokes a registered tool/function.
Tools are simpler than agents - they take input, perform an action,
and return a result synchronously (or with a simple async call).
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the tool invocation."""
state = await self._ensure_state_initialized(ctx, trigger)
tool_name = self._action_def.get("tool") or self._action_def.get("toolName", "")
input_expr = self._action_def.get("input")
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get("resultProperty")
parameters = self._action_def.get("parameters", {})
# Get tools registry
try:
tool_registry: dict[str, Any] | None = ctx.state.get(TOOL_REGISTRY_KEY)
except KeyError:
tool_registry = {}
tool: Any = tool_registry.get(tool_name) if tool_registry else None
if tool is None:
error_msg = f"Tool '{tool_name}' not found in registry"
if output_property:
state.set(output_property, {"error": error_msg})
await ctx.send_message(ActionComplete())
return
# Build parameters
params: dict[str, Any] = {}
for param_name, param_expression in parameters.items():
params[param_name] = state.eval_if_expression(param_expression)
# Add main input if specified
if input_expr:
params["input"] = state.eval_if_expression(input_expr)
try:
# Invoke the tool
if callable(tool):
from inspect import isawaitable
result = tool(**params)
if isawaitable(result):
result = await result
# Store result
if output_property:
state.set(output_property, result)
except Exception as e:
if output_property:
state.set(output_property, {"error": str(e)})
await ctx.send_message(ActionComplete())
return
await ctx.send_message(ActionComplete())
# Mapping of agent action kinds to executor classes
AGENT_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"InvokeAzureAgent": InvokeAzureAgentExecutor,
"InvokeTool": InvokeToolExecutor,
}
@@ -0,0 +1,711 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tool invocation executors for declarative workflows.
Provides base abstractions and concrete executors for invoking various tool types
(functions, APIs, MCP servers, etc.) with support for approval flows and structured output.
This module is designed for extensibility:
- BaseToolExecutor provides common patterns (registry lookup, approval flow, output formatting)
- Concrete executors (InvokeFunctionToolExecutor) implement tool-specific invocation logic
- New tool types can be added by subclassing BaseToolExecutor
"""
import json
import logging
import uuid
from abc import abstractmethod
from dataclasses import dataclass, field
from inspect import isawaitable
from typing import Any
from agent_framework import (
Content,
Message,
WorkflowContext,
handler,
response_handler,
)
from ._declarative_base import (
ActionComplete,
DeclarativeActionExecutor,
DeclarativeWorkflowState,
)
from ._executors_agents import TOOL_REGISTRY_KEY
logger = logging.getLogger(__name__)
# Registry key for function tools in State - reuse existing key so functions registered
# at runtime are discoverable by both agent-based and function-based tool executors.
FUNCTION_TOOL_REGISTRY_KEY = TOOL_REGISTRY_KEY
# State key prefix for storing approval state during yield/resume.
# The executor's ID is appended to create a per-executor key.
TOOL_APPROVAL_STATE_KEY = "_tool_approval_state"
# ============================================================================
# Request/Response Types for Approval Flow
# ============================================================================
@dataclass
class ToolApprovalRequest:
"""Request for approval before invoking a tool.
Emitted when requireApproval=true, signaling that the workflow should yield
and wait for user approval before invoking the tool.
This follows the same pattern as AgentExternalInputRequest from _executors_agents.py,
allowing consistent handling of human-in-loop scenarios across agents and tools.
Attributes:
request_id: Unique identifier for this approval request.
function_name: Evaluated function name to be invoked.
arguments: Evaluated arguments to be passed to the function.
"""
request_id: str
function_name: str
arguments: dict[str, Any]
@dataclass
class ToolApprovalResponse:
"""Response to a ToolApprovalRequest.
Provided by the caller to approve or reject tool invocation.
Attributes:
approved: Whether the tool invocation was approved.
reason: Optional reason for rejection.
"""
approved: bool
reason: str | None = None
# ============================================================================
# State Types for Approval Flow
# ============================================================================
@dataclass
class ToolApprovalState:
"""State saved during approval yield for resumption.
Stored in State under a per-executor key when requireApproval=true.
Retrieved by handle_approval_response() to continue execution.
"""
function_name: str
arguments: dict[str, Any]
output_messages_var: str | None
output_result_var: str | None
auto_send: bool
# ============================================================================
# Result Types
# ============================================================================
@dataclass
class ToolInvocationResult:
"""Result from a tool invocation.
Attributes:
success: Whether the invocation succeeded.
result: The return value from the tool (if successful).
error: Error message (if failed).
messages: Message list format for conversation history.
rejected: Whether the invocation was rejected during approval.
rejection_reason: Reason for rejection.
"""
success: bool
result: Any = None
error: str | None = None
messages: list[Message] = field(default_factory=list)
rejected: bool = False
rejection_reason: str | None = None
# ============================================================================
# Helper Functions
# ============================================================================
def _normalize_variable_path(variable: str) -> str:
"""Normalize variable names to ensure they have a scope prefix.
Args:
variable: Variable name like 'Local.X' or 'weatherResult'
Returns:
The variable path with a scope prefix (defaults to Local if none provided)
"""
if variable.startswith(("Local.", "System.", "Workflow.", "Agent.", "Conversation.")):
return variable
if "." in variable:
return variable
return "Local." + variable
# ============================================================================
# Base Tool Executor (Abstract)
# ============================================================================
class BaseToolExecutor(DeclarativeActionExecutor):
"""Base class for tool invocation executors.
Provides common functionality for all tool-like executors:
- Tool registry lookup (State + WorkflowFactory registration)
- Approval flow (request_info pattern with yield/resume)
- Output formatting (messages as Message list + result variable)
- Error handling (stores error in output, doesn't raise)
Subclasses must implement:
- _invoke_tool(): Perform the actual tool invocation
YAML Schema (common fields):
kind: <ToolKind>
id: unique_id
functionName: function_to_call # required, supports =expression syntax
requireApproval: true # optional, default=false
arguments: # optional dictionary
param1: value1
param2: =Local.dynamicValue
output:
messages: Local.toolCallMessages # Message list
result: Local.toolResult
autoSend: true # optional, default=true
"""
def __init__(
self,
action_def: dict[str, Any],
*,
id: str | None = None,
tools: dict[str, Any] | None = None,
):
"""Initialize the tool executor.
Args:
action_def: The action definition from YAML
id: Optional executor ID
tools: Registry of tool instances by name (from WorkflowFactory)
"""
super().__init__(action_def, id=id)
self._tools = tools or {}
@abstractmethod
async def _invoke_tool(
self,
tool: Any,
function_name: str,
arguments: dict[str, Any],
state: DeclarativeWorkflowState,
) -> Any:
"""Invoke the tool with the given arguments.
Args:
tool: The tool instance to invoke
function_name: Function/method name to call
arguments: Arguments to pass
state: Workflow state
Returns:
The result from the tool invocation
Raises:
Any exception from the tool invocation
"""
pass
def _get_tool(
self,
function_name: str,
ctx: WorkflowContext[Any, Any],
) -> Any | None:
"""Get tool from registry.
Checks both WorkflowFactory registry (self._tools) and State registry.
Args:
function_name: Name of the function
ctx: Workflow context
Returns:
The tool/function, or None if not found
"""
# Check WorkflowFactory registry first (passed in constructor)
tool = self._tools.get(function_name)
if tool is not None:
return tool
# Check State registry (for runtime registration)
try:
tool_registry: dict[str, Any] | None = ctx.state.get(FUNCTION_TOOL_REGISTRY_KEY)
if tool_registry:
return tool_registry.get(function_name)
except KeyError:
logger.debug(
"%s: tool registry key '%s' not found in state "
"(this is normal if tools are only registered via WorkflowFactory)",
self.__class__.__name__,
FUNCTION_TOOL_REGISTRY_KEY,
)
return None
def _get_output_config(self) -> tuple[str | None, str | None, bool]:
"""Parse output configuration from action definition.
Returns:
Tuple of (messages_var, result_var, auto_send)
"""
output_config = self._action_def.get("output", {})
if not isinstance(output_config, dict):
return None, None, True
messages_var = output_config.get("messages")
result_var = output_config.get("result")
auto_send = bool(output_config.get("autoSend", True))
return (
str(messages_var) if messages_var else None,
str(result_var) if result_var else None,
auto_send,
)
def _store_result(
self,
result: ToolInvocationResult,
state: DeclarativeWorkflowState,
messages_var: str | None,
result_var: str | None,
) -> None:
"""Store tool invocation result in workflow state.
Args:
result: The tool invocation result
state: Workflow state
messages_var: Variable path for messages output
result_var: Variable path for result output
"""
# Store messages if variable specified
if messages_var:
path = _normalize_variable_path(messages_var)
state.set(path, result.messages)
# Store result if variable specified
if result_var:
path = _normalize_variable_path(result_var)
if result.rejected:
state.set(
path,
{
"approved": False,
"rejected": True,
"reason": result.rejection_reason,
},
)
elif result.success:
state.set(path, result.result)
else:
state.set(
path,
{
"error": result.error,
},
)
async def _format_messages(
self,
function_name: str,
arguments: dict[str, Any],
result: Any,
) -> list[Message]:
"""Format tool invocation as Message list.
Creates tool call + tool result message pair for conversation history,
following the same format as agent tool calls.
Args:
function_name: Function name invoked
arguments: Arguments passed
result: Result from invocation
Returns:
List of Message objects [tool_call_message, tool_result_message]
"""
call_id = str(uuid.uuid4())
# Safely serialize arguments to JSON
try:
arguments_str = json.dumps(arguments) if isinstance(arguments, dict) else str(arguments)
except (TypeError, ValueError) as e:
logger.warning(f"Failed to serialize arguments to JSON: {e}")
arguments_str = str(arguments)
# Tool call message (from assistant)
tool_call_content = Content.from_function_call(
call_id=call_id,
name=function_name,
arguments=arguments_str,
)
tool_call_message = Message(
role="assistant",
contents=[tool_call_content],
)
# Safely serialize result to JSON
try:
result_str = json.dumps(result) if not isinstance(result, str) else result
except (TypeError, ValueError) as e:
logger.warning(f"Failed to serialize result to JSON: {e}")
result_str = str(result)
tool_result_content = Content.from_function_result(
call_id=call_id,
result=result_str,
)
tool_result_message = Message(
role="tool",
contents=[tool_result_content],
)
return [tool_call_message, tool_result_message]
async def _execute_tool_invocation(
self,
function_name: str,
arguments: dict[str, Any],
state: DeclarativeWorkflowState,
ctx: WorkflowContext[Any, Any],
) -> ToolInvocationResult:
"""Execute the tool invocation.
Args:
function_name: Function to invoke
arguments: Arguments to pass
state: Workflow state
ctx: Workflow context
Returns:
ToolInvocationResult with outcome
"""
# Get tool from registry
tool = self._get_tool(function_name, ctx)
if tool is None:
error_msg = f"Function '{function_name}' not found in registry"
logger.error(f"{self.__class__.__name__}: {error_msg}")
return ToolInvocationResult(
success=False,
error=error_msg,
)
try:
# Invoke the tool (subclass implements this)
result_value = await self._invoke_tool(
tool=tool,
function_name=function_name,
arguments=arguments,
state=state,
)
# Format as messages for conversation history
messages = await self._format_messages(
function_name=function_name,
arguments=arguments,
result=result_value,
)
return ToolInvocationResult(
success=True,
result=result_value,
messages=messages,
)
except Exception as e:
logger.error(
"%s: error invoking function '%s': %s: %s",
self.__class__.__name__,
function_name,
type(e).__name__,
e,
exc_info=True,
)
return ToolInvocationResult(
success=False,
error=f"{type(e).__name__}: {e}",
)
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Handle the tool invocation with optional approval flow.
When requireApproval=true:
1. Saves invocation state to State (keyed by executor ID)
2. Emits ToolApprovalRequest via ctx.request_info()
3. Workflow yields (returns without ActionComplete)
4. Resumes in handle_approval_response() when user responds
"""
state = await self._ensure_state_initialized(ctx, trigger)
# Parse output configuration early so we can store errors
messages_var, result_var, auto_send = self._get_output_config()
# Get and evaluate function name (required)
function_name_expr = self._action_def.get("functionName")
if not function_name_expr:
error_msg = f"Action '{self.id}' is missing required 'functionName' field"
logger.error(f"{self.__class__.__name__}: {error_msg}")
if result_var:
state.set(_normalize_variable_path(result_var), {"error": error_msg})
await ctx.send_message(ActionComplete())
return
function_name = state.eval_if_expression(function_name_expr)
if not function_name:
error_msg = f"Action '{self.id}': functionName expression evaluated to empty"
logger.error(f"{self.__class__.__name__}: {error_msg}")
if result_var:
state.set(_normalize_variable_path(result_var), {"error": error_msg})
await ctx.send_message(ActionComplete())
return
function_name = str(function_name)
# Evaluate arguments
arguments_def = self._action_def.get("arguments", {})
arguments: dict[str, Any] = {}
if arguments_def is not None and not isinstance(arguments_def, dict):
logger.warning(
"%s: 'arguments' must be a dictionary, got %s - ignoring",
self.__class__.__name__,
type(arguments_def).__name__,
)
elif isinstance(arguments_def, dict):
for key, value in arguments_def.items():
arguments[key] = state.eval_if_expression(value)
# Check if approval is required
require_approval = self._action_def.get("requireApproval", False)
if require_approval:
# Save state for resumption (keyed by executor ID to avoid collisions)
approval_state = ToolApprovalState(
function_name=function_name,
arguments=arguments,
output_messages_var=messages_var,
output_result_var=result_var,
auto_send=auto_send,
)
approval_key = f"{TOOL_APPROVAL_STATE_KEY}_{self.id}"
ctx.state.set(approval_key, approval_state)
# Emit approval request - workflow yields here
request = ToolApprovalRequest(
request_id=str(uuid.uuid4()),
function_name=function_name,
arguments=arguments,
)
logger.info(f"{self.__class__.__name__}: requesting approval for '{function_name}'")
await ctx.request_info(request, ToolApprovalResponse)
# Workflow yields - will resume in handle_approval_response
return
# No approval required - invoke directly
result = await self._execute_tool_invocation(
function_name=function_name,
arguments=arguments,
state=state,
ctx=ctx,
)
self._store_result(result, state, messages_var, result_var)
if auto_send and result.success and result.result is not None:
await ctx.yield_output(str(result.result))
await ctx.send_message(ActionComplete())
@response_handler
async def handle_approval_response(
self,
original_request: ToolApprovalRequest,
response: ToolApprovalResponse,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Handle response to a ToolApprovalRequest.
Called when the workflow resumes after yielding for approval.
Either executes the tool (if approved) or stores rejection status.
"""
state = self._get_state(ctx.state)
approval_key = f"{TOOL_APPROVAL_STATE_KEY}_{self.id}"
# Retrieve saved invocation state
try:
approval_state: ToolApprovalState = ctx.state.get(approval_key)
except KeyError:
error_msg = "Approval state not found, cannot resume tool invocation"
logger.error(f"{self.__class__.__name__}: {error_msg}")
# Try to store error - get output config from action def as fallback
_, result_var, _ = self._get_output_config()
if result_var and state:
state.set(_normalize_variable_path(result_var), {"error": error_msg})
await ctx.send_message(ActionComplete())
return
# Clean up approval state
try:
ctx.state.delete(approval_key)
except KeyError:
logger.warning(f"{self.__class__.__name__}: approval state already deleted")
function_name = approval_state.function_name
arguments = approval_state.arguments
messages_var = approval_state.output_messages_var
result_var = approval_state.output_result_var
auto_send = approval_state.auto_send
# Check if approved
if not response.approved:
logger.info(f"{self.__class__.__name__}: tool invocation rejected: {response.reason}")
# Store rejection status (don't raise error)
result = ToolInvocationResult(
success=False,
rejected=True,
rejection_reason=response.reason,
messages=[
Message(
role="assistant",
text=f"Function '{function_name}' was rejected: {response.reason or 'No reason provided'}",
)
],
)
self._store_result(result, state, messages_var, result_var)
await ctx.send_message(ActionComplete())
return
# Approved - execute the invocation
result = await self._execute_tool_invocation(
function_name=function_name,
arguments=arguments,
state=state,
ctx=ctx,
)
self._store_result(result, state, messages_var, result_var)
if auto_send and result.success and result.result is not None:
await ctx.yield_output(str(result.result))
await ctx.send_message(ActionComplete())
# ============================================================================
# Function Tool Executor (Concrete)
# ============================================================================
class InvokeFunctionToolExecutor(BaseToolExecutor):
"""Executor that invokes a Python function as a tool.
This executor supports invoking registered Python functions with:
- Expression evaluation for functionName and arguments
- Optional approval flow (yield/resume pattern)
- Async function support
- Message list output for conversation history
YAML Schema:
kind: InvokeFunctionTool
id: invoke_function_example
functionName: get_weather # required, supports =expression syntax
requireApproval: true # optional, default=false
arguments: # optional dictionary
location: =Local.location
unit: F
output:
messages: Local.weatherToolCallItems # Message list
result: Local.WeatherInfo
autoSend: true # optional, default=true
Tool Registration:
Tools can be registered via:
1. WorkflowFactory.register_tool("name", func) - preferred
2. Setting FUNCTION_TOOL_REGISTRY_KEY in State at runtime
Examples:
.. code-block:: python
from agent_framework_declarative import WorkflowFactory
def get_weather(location: str, unit: str = "F") -> dict:
return {"temp": 72, "unit": unit, "location": location}
async def fetch_data(url: str) -> dict:
# async function example
return {"data": "..."}
factory = (
WorkflowFactory().register_tool("get_weather", get_weather).register_tool("fetch_data", fetch_data)
)
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
"""
async def _invoke_tool(
self,
tool: Any,
function_name: str,
arguments: dict[str, Any],
state: DeclarativeWorkflowState,
) -> Any:
"""Invoke the function tool.
Supports:
- Direct callable functions
- Async functions (via inspect.isawaitable)
Args:
tool: The tool/function to invoke
function_name: Name of the function (for error messages)
arguments: Arguments to pass to the function
state: Workflow state (not used for function tools)
Returns:
The result from the function invocation
Raises:
ValueError: If the tool is not callable
"""
if not callable(tool):
raise ValueError(f"Function '{function_name}' is not callable")
# Invoke the function
result = tool(**arguments)
# Handle async functions
if isawaitable(result):
result = await result
return result
# ============================================================================
# Executor Registry Export
# ============================================================================
TOOL_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"InvokeFunctionTool": InvokeFunctionToolExecutor,
}
@@ -141,6 +141,7 @@ class WorkflowFactory:
self._agent_factory = agent_factory or AgentFactory(env_file_path=env_file)
self._agents: dict[str, SupportsAgentRun | AgentExecutor] = dict(agents) if agents else {}
self._bindings: dict[str, Any] = dict(bindings) if bindings else {}
self._tools: dict[str, Any] = {} # Tool registry for InvokeFunctionTool actions
self._checkpoint_storage = checkpoint_storage
self._max_iterations = max_iterations
@@ -377,12 +378,13 @@ class WorkflowFactory:
if description:
normalized_def["description"] = description
# Build the graph-based workflow, passing agents for InvokeAzureAgent executors
# Build the graph-based workflow, passing agents and tools for specialized executors
try:
graph_builder = DeclarativeWorkflowBuilder(
normalized_def,
workflow_id=name,
agents=agents,
tools=self._tools,
checkpoint_storage=self._checkpoint_storage,
max_iterations=self._max_iterations,
)
@@ -390,9 +392,10 @@ class WorkflowFactory:
except ValueError as e:
raise DeclarativeWorkflowError(f"Failed to build graph-based workflow: {e}") from e
# Store agents and bindings for reference (executors already have them)
# Store agents, bindings, and tools for reference (executors already have them)
workflow._declarative_agents = agents # type: ignore[attr-defined]
workflow._declarative_bindings = self._bindings # type: ignore[attr-defined]
workflow._declarative_tools = self._tools # type: ignore[attr-defined]
# Store input schema if defined in workflow definition
# This allows DevUI to generate proper input forms
@@ -598,9 +601,66 @@ class WorkflowFactory:
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
"""
if not callable(func):
raise TypeError(f"Expected a callable for binding '{name}', got {type(func).__name__}")
self._bindings[name] = func
return self
def register_tool(self, name: str, func: Any) -> WorkflowFactory:
"""Register a function with the factory for use in InvokeFunctionTool actions.
Registered functions are available to InvokeFunctionTool actions by name via the functionName field.
This method supports fluent chaining.
Args:
name: The name to register the function under. Must match the functionName
referenced in InvokeFunctionTool actions.
func: The function to register (can be sync or async).
Returns:
Self for method chaining.
Examples:
.. code-block:: python
from agent_framework_declarative import WorkflowFactory
def get_weather(location: str, unit: str = "F") -> dict:
return {"temp": 72, "unit": unit, "location": location}
async def fetch_data(url: str) -> dict:
# Async function example
return {"data": "..."}
# Register functions for use in InvokeFunctionTool workflow actions
factory = (
WorkflowFactory().register_tool("get_weather", get_weather).register_tool("fetch_data", fetch_data)
)
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
The workflow YAML can then reference these tools:
.. code-block:: yaml
actions:
- kind: InvokeFunctionTool
id: call_weather
functionName: get_weather
arguments:
location: =Local.city
unit: F
output:
result: Local.weatherData
"""
if not callable(func):
raise TypeError(f"Expected a callable for tool '{name}', got {type(func).__name__}")
self._tools[name] = func
return self
def _convert_inputs_to_json_schema(self, inputs_def: dict[str, Any]) -> dict[str, Any]:
"""Convert a declarative inputs definition to JSON Schema.
@@ -961,6 +961,423 @@ tools:
assert mcp_tool.get("project_connection_id") == "my-oauth-connection"
class TestAgentFactoryFilePath:
"""Tests for AgentFactory file path operations."""
def test_create_agent_from_yaml_path_file_not_found(self, tmp_path):
"""Test that nonexistent file raises DeclarativeLoaderError."""
from agent_framework_declarative import AgentFactory
from agent_framework_declarative._loader import DeclarativeLoaderError
factory = AgentFactory()
with pytest.raises(DeclarativeLoaderError, match="YAML file not found"):
factory.create_agent_from_yaml_path(tmp_path / "nonexistent.yaml")
def test_create_agent_from_yaml_path_with_string_path(self, tmp_path):
"""Test create_agent_from_yaml_path accepts string path."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_file = tmp_path / "agent.yaml"
yaml_file.write_text("""
kind: Prompt
name: FileAgent
instructions: Test agent from file
""")
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = factory.create_agent_from_yaml_path(str(yaml_file))
assert agent.name == "FileAgent"
def test_create_agent_from_yaml_path_with_path_object(self, tmp_path):
"""Test create_agent_from_yaml_path accepts Path object."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_file = tmp_path / "agent.yaml"
yaml_file.write_text("""
kind: Prompt
name: PathAgent
instructions: Test agent from Path
""")
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = factory.create_agent_from_yaml_path(yaml_file)
assert agent.name == "PathAgent"
class TestAgentFactoryAsyncMethods:
"""Tests for AgentFactory async methods."""
@pytest.mark.asyncio
async def test_create_agent_from_yaml_path_async_file_not_found(self, tmp_path):
"""Test async version raises DeclarativeLoaderError for nonexistent file."""
from agent_framework_declarative import AgentFactory
from agent_framework_declarative._loader import DeclarativeLoaderError
factory = AgentFactory()
with pytest.raises(DeclarativeLoaderError, match="YAML file not found"):
await factory.create_agent_from_yaml_path_async(tmp_path / "nonexistent.yaml")
@pytest.mark.asyncio
async def test_create_agent_from_yaml_async_with_client(self):
"""Test async creation with pre-configured client."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_content = """
kind: Prompt
name: AsyncAgent
instructions: Test async agent
"""
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = await factory.create_agent_from_yaml_async(yaml_content)
assert agent.name == "AsyncAgent"
@pytest.mark.asyncio
async def test_create_agent_from_dict_async_with_client(self):
"""Test async dict creation with pre-configured client."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
agent_def = {
"kind": "Prompt",
"name": "AsyncDictAgent",
"instructions": "Test async dict agent",
}
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = await factory.create_agent_from_dict_async(agent_def)
assert agent.name == "AsyncDictAgent"
@pytest.mark.asyncio
async def test_create_agent_from_dict_async_invalid_kind_raises(self):
"""Test that async version also raises for non-PromptAgent."""
from agent_framework_declarative import AgentFactory
from agent_framework_declarative._loader import DeclarativeLoaderError
agent_def = {
"kind": "Resource",
"name": "NotAnAgent",
}
factory = AgentFactory()
with pytest.raises(DeclarativeLoaderError, match="Only definitions for a PromptAgent are supported"):
await factory.create_agent_from_dict_async(agent_def)
@pytest.mark.asyncio
async def test_create_agent_from_yaml_path_async_with_string_path(self, tmp_path):
"""Test async version accepts string path."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_file = tmp_path / "async_agent.yaml"
yaml_file.write_text("""
kind: Prompt
name: AsyncPathAgent
instructions: Test async path agent
""")
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = await factory.create_agent_from_yaml_path_async(str(yaml_file))
assert agent.name == "AsyncPathAgent"
class TestAgentFactoryProviderLookup:
"""Tests for provider configuration lookup."""
def test_provider_lookup_error_for_unknown_provider(self):
"""Test that unknown provider raises ProviderLookupError."""
from agent_framework_declarative import AgentFactory
from agent_framework_declarative._loader import ProviderLookupError
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
model:
id: test-model
provider: UnknownProvider
apiType: UnknownApiType
"""
factory = AgentFactory()
with pytest.raises(ProviderLookupError, match="Unsupported provider type"):
factory.create_agent_from_yaml(yaml_content)
def test_additional_mappings_override_default(self):
"""Test that additional_mappings can extend provider configurations."""
from agent_framework_declarative import AgentFactory
# Define a custom provider mapping
custom_mappings = {
"CustomProvider.Chat": {
"package": "agent_framework.openai",
"name": "OpenAIChatClient",
"model_id_field": "model_id",
},
}
factory = AgentFactory(additional_mappings=custom_mappings)
# The custom mapping should be available
assert "CustomProvider.Chat" in factory.additional_mappings
class TestAgentFactoryConnectionHandling:
"""Tests for connection handling in AgentFactory."""
def test_reference_connection_requires_connections_dict(self):
"""Test that ReferenceConnection without connections dict raises."""
from agent_framework_declarative import AgentFactory
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
model:
id: gpt-4
provider: OpenAI
apiType: Chat
connection:
kind: reference
name: my-connection
"""
factory = AgentFactory() # No connections provided
with pytest.raises(ValueError, match="Connections must be provided to resolve ReferenceConnection"):
factory.create_agent_from_yaml(yaml_content)
def test_reference_connection_not_found_raises(self):
"""Test that missing ReferenceConnection raises."""
from agent_framework_declarative import AgentFactory
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
model:
id: gpt-4
provider: OpenAI
apiType: Chat
connection:
kind: reference
name: missing-connection
"""
factory = AgentFactory(connections={"other-connection": "value"})
with pytest.raises(ValueError, match="not found in provided connections"):
factory.create_agent_from_yaml(yaml_content)
def test_model_without_id_uses_provided_client(self):
"""Test that model without id uses the provided chat_client."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
model:
provider: OpenAI
"""
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = factory.create_agent_from_yaml(yaml_content)
assert agent is not None
def test_model_without_id_and_no_client_raises(self):
"""Test that model without id and no client raises."""
from agent_framework_declarative import AgentFactory
from agent_framework_declarative._loader import DeclarativeLoaderError
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
model:
provider: OpenAI
"""
factory = AgentFactory() # No chat_client
with pytest.raises(DeclarativeLoaderError, match="ChatClient must be provided"):
factory.create_agent_from_yaml(yaml_content)
class TestAgentFactoryChatOptions:
"""Tests for chat options parsing."""
def test_parse_chat_options_with_all_fields(self):
"""Test parsing all ModelOptions fields into chat options dict."""
from agent_framework_declarative._loader import AgentFactory
from agent_framework_declarative._models import Model, ModelOptions
factory = AgentFactory()
# Create a Model with all options set
options = ModelOptions(
temperature=0.7,
maxOutputTokens=1000,
topP=0.9,
frequencyPenalty=0.5,
presencePenalty=0.3,
seed=42,
stopSequences=["STOP", "END"],
allowMultipleToolCalls=True,
)
options.additionalProperties["chatToolMode"] = "auto"
model = Model(id="gpt-4", options=options)
# Parse the options
chat_options = factory._parse_chat_options(model)
# Verify all options are parsed correctly
assert chat_options.get("temperature") == 0.7
assert chat_options.get("max_tokens") == 1000
assert chat_options.get("top_p") == 0.9
assert chat_options.get("frequency_penalty") == 0.5
assert chat_options.get("presence_penalty") == 0.3
assert chat_options.get("seed") == 42
assert chat_options.get("stop") == ["STOP", "END"]
assert chat_options.get("allow_multiple_tool_calls") is True
assert chat_options.get("tool_choice") == "auto"
def test_parse_chat_options_empty_model(self):
"""Test that missing model options returns empty dict."""
from agent_framework_declarative._loader import AgentFactory
factory = AgentFactory()
result = factory._parse_chat_options(None)
assert result == {}
def test_parse_chat_options_with_additional_properties(self):
"""Test that additional properties are passed through."""
from agent_framework_declarative._loader import AgentFactory
from agent_framework_declarative._models import Model, ModelOptions
factory = AgentFactory()
# Create a Model with additional properties
options = ModelOptions(temperature=0.5)
options.additionalProperties["customOption"] = "customValue"
model = Model(id="gpt-4", options=options)
# Parse the options
chat_options = factory._parse_chat_options(model)
# Verify additional properties are preserved
assert "additional_chat_options" in chat_options
assert chat_options["additional_chat_options"].get("customOption") == "customValue"
class TestAgentFactoryToolParsing:
"""Tests for tool parsing edge cases."""
def test_parse_tools_returns_none_for_empty_list(self):
"""Test that empty tools list returns None."""
from agent_framework_declarative._loader import AgentFactory
factory = AgentFactory()
result = factory._parse_tools(None)
assert result is None
result = factory._parse_tools([])
assert result is None
def test_parse_function_tool_with_bindings(self):
"""Test parsing FunctionTool with bindings."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
tools:
- kind: function
name: my_function
description: A test function
bindings:
- name: my_binding
"""
def my_function():
return "result"
mock_client = MagicMock()
factory = AgentFactory(client=mock_client, bindings={"my_binding": my_function})
agent = factory.create_agent_from_yaml(yaml_content)
# Should have parsed the tool with binding
tools = agent.default_options.get("tools", [])
assert len(tools) == 1
def test_parse_file_search_tool_with_all_options(self):
"""Test parsing FileSearchTool with ranker and filters."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
tools:
- kind: file_search
name: search
description: Search files
vectorStoreIds:
- vs_123
ranker: semantic
scoreThreshold: 0.8
maximumResultCount: 10
filters:
type: document
"""
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = factory.create_agent_from_yaml(yaml_content)
# Verify a file search tool was parsed
tools = agent.default_options.get("tools", [])
assert len(tools) == 1
def test_parse_unsupported_tool_kind_raises(self):
"""Test that unsupported tool kind raises ValueError."""
from agent_framework_declarative._loader import AgentFactory
from agent_framework_declarative._models import CustomTool
factory = AgentFactory()
custom_tool = CustomTool(kind="custom", name="test")
with pytest.raises(ValueError, match="Unsupported tool kind"):
factory._parse_tool(custom_tool)
class TestProviderResponseFormat:
"""response_format from outputSchema must be passed inside default_options."""
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,15 @@ from agent_framework_declarative._workflows._declarative_base import (
LoopIterationResult,
)
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
_requires_powerfx = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -250,6 +259,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval([1, 2, 3]) # type: ignore[arg-type]
assert result == [1, 2, 3]
@_requires_powerfx
async def test_eval_simple_and_operator(self, mock_state):
"""Test simple And operator evaluation."""
state = DeclarativeWorkflowState(mock_state)
@@ -264,6 +274,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval("=Local.a And Local.b")
assert result is True
@_requires_powerfx
async def test_eval_simple_or_operator(self, mock_state):
"""Test simple Or operator evaluation."""
state = DeclarativeWorkflowState(mock_state)
@@ -278,6 +289,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval("=Local.a Or Local.b")
assert result is False
@_requires_powerfx
async def test_eval_negation(self, mock_state):
"""Test negation (!) evaluation."""
state = DeclarativeWorkflowState(mock_state)
@@ -287,6 +299,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval("=!Local.flag")
assert result is False
@_requires_powerfx
async def test_eval_not_function(self, mock_state):
"""Test Not() function evaluation."""
state = DeclarativeWorkflowState(mock_state)
@@ -296,6 +309,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval("=Not(Local.flag)")
assert result is False
@_requires_powerfx
async def test_eval_comparison_operators(self, mock_state):
"""Test comparison operators."""
state = DeclarativeWorkflowState(mock_state)
@@ -310,6 +324,7 @@ class TestDeclarativeWorkflowStateExtended:
assert state.eval("=Local.x <> Local.y") is True
assert state.eval("=Local.x = 5") is True
@_requires_powerfx
async def test_eval_arithmetic_operators(self, mock_state):
"""Test arithmetic operators."""
state = DeclarativeWorkflowState(mock_state)
@@ -322,6 +337,7 @@ class TestDeclarativeWorkflowStateExtended:
assert state.eval("=Local.x * Local.y") == 30
assert state.eval("=Local.x / Local.y") == pytest.approx(3.333, rel=0.01)
@_requires_powerfx
async def test_eval_string_literal(self, mock_state):
"""Test string literal evaluation."""
state = DeclarativeWorkflowState(mock_state)
@@ -330,6 +346,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval('="hello world"')
assert result == "hello world"
@_requires_powerfx
async def test_eval_float_literal(self, mock_state):
"""Test float literal evaluation."""
from decimal import Decimal
@@ -341,6 +358,7 @@ class TestDeclarativeWorkflowStateExtended:
# Accepts both float (Python fallback) and Decimal (pythonnet/PowerFx)
assert result == 3.14 or result == Decimal("3.14")
@_requires_powerfx
async def test_eval_variable_reference_with_namespace_mappings(self, mock_state):
"""Test variable reference with PowerFx symbols."""
state = DeclarativeWorkflowState(mock_state)
@@ -355,6 +373,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval("=Workflow.Inputs.query")
assert result == "test"
@_requires_powerfx
async def test_eval_if_expression_with_dict(self, mock_state):
"""Test eval_if_expression recursively evaluates dicts."""
state = DeclarativeWorkflowState(mock_state)
@@ -364,6 +383,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval_if_expression({"greeting": "=Local.name", "static": "hello"})
assert result == {"greeting": "Alice", "static": "hello"}
@_requires_powerfx
async def test_eval_if_expression_with_list(self, mock_state):
"""Test eval_if_expression recursively evaluates lists."""
state = DeclarativeWorkflowState(mock_state)
@@ -449,6 +469,7 @@ class TestBasicExecutorsCoverage:
result = state.get("Local.nested")
assert result == 42
@_requires_powerfx
async def test_set_text_variable_executor(self, mock_context, mock_state):
"""Test SetTextVariableExecutor."""
from agent_framework_declarative._workflows._executors_basic import (
@@ -591,6 +612,7 @@ class TestBasicExecutorsCoverage:
mock_context.yield_output.assert_called_once_with("Plain text message")
@_requires_powerfx
async def test_send_activity_with_expression(self, mock_context, mock_state):
"""Test SendActivityExecutor evaluates expressions."""
from agent_framework_declarative._workflows._executors_basic import (
@@ -909,6 +931,7 @@ class TestAgentExecutorsCoverage:
assert result_prop == "Local.result"
assert auto_send is False
@_requires_powerfx
async def test_agent_executor_build_input_text_from_string_messages(self, mock_context, mock_state):
"""Test _build_input_text with string messages expression."""
from agent_framework_declarative._workflows._executors_agents import (
@@ -925,6 +948,7 @@ class TestAgentExecutorsCoverage:
input_text = await executor._build_input_text(state, {}, "=Local.userInput")
assert input_text == "Hello agent!"
@_requires_powerfx
async def test_agent_executor_build_input_text_from_message_list(self, mock_context, mock_state):
"""Test _build_input_text extracts text from message list."""
from agent_framework_declarative._workflows._executors_agents import (
@@ -948,6 +972,7 @@ class TestAgentExecutorsCoverage:
input_text = await executor._build_input_text(state, {}, "=Conversation.messages")
assert input_text == "Last message"
@_requires_powerfx
async def test_agent_executor_build_input_text_from_message_with_text_attr(self, mock_context, mock_state):
"""Test _build_input_text extracts text from message with text attribute."""
from agent_framework_declarative._workflows._executors_agents import (
@@ -1120,83 +1145,6 @@ class TestAgentExecutorsCoverage:
parsed = state.get("Local.Parsed")
assert parsed == {"status": "ok", "count": 42}
async def test_invoke_tool_executor_not_found(self, mock_context, mock_state):
"""Test InvokeToolExecutor when tool not found."""
from agent_framework_declarative._workflows._executors_agents import (
InvokeToolExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "InvokeTool",
"tool": "MissingTool",
"resultProperty": "Local.result",
}
executor = InvokeToolExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
result = state.get("Local.result")
assert result == {"error": "Tool 'MissingTool' not found in registry"}
async def test_invoke_tool_executor_sync_tool(self, mock_context, mock_state):
"""Test InvokeToolExecutor with synchronous tool."""
from agent_framework_declarative._workflows._executors_agents import (
TOOL_REGISTRY_KEY,
InvokeToolExecutor,
)
def my_tool(x: int, y: int) -> int:
return x + y
mock_state._data[TOOL_REGISTRY_KEY] = {"add": my_tool}
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "InvokeTool",
"tool": "add",
"parameters": {"x": 5, "y": 3},
"resultProperty": "Local.result",
}
executor = InvokeToolExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
result = state.get("Local.result")
assert result == 8
async def test_invoke_tool_executor_async_tool(self, mock_context, mock_state):
"""Test InvokeToolExecutor with asynchronous tool."""
from agent_framework_declarative._workflows._executors_agents import (
TOOL_REGISTRY_KEY,
InvokeToolExecutor,
)
async def my_async_tool(input: str) -> str:
return f"Processed: {input}"
mock_state._data[TOOL_REGISTRY_KEY] = {"process": my_async_tool}
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "InvokeTool",
"tool": "process",
"input": "test data",
"resultProperty": "Local.result",
}
executor = InvokeToolExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
result = state.get("Local.result")
assert result == "Processed: test data"
# ---------------------------------------------------------------------------
# Control Flow Executors Tests - Additional coverage
@@ -1206,6 +1154,7 @@ class TestAgentExecutorsCoverage:
class TestControlFlowCoverage:
"""Tests for control flow executors covering uncovered code paths."""
@_requires_powerfx
async def test_foreach_with_source_alias(self, mock_context, mock_state):
"""Test ForeachInitExecutor with 'source' alias (interpreter mode)."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1268,6 +1217,7 @@ class TestControlFlowCoverage:
assert msg.current_index == 1
assert msg.current_item == "b"
@_requires_powerfx
async def test_switch_evaluator_with_value_cases(self, mock_context, mock_state):
"""Test SwitchEvaluatorExecutor with value/cases schema."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1295,6 +1245,7 @@ class TestControlFlowCoverage:
assert msg.matched is True
assert msg.branch_index == 1 # Second case matched
@_requires_powerfx
async def test_switch_evaluator_default_case(self, mock_context, mock_state):
"""Test SwitchEvaluatorExecutor falls through to default."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1554,6 +1505,7 @@ class TestControlFlowCoverage:
# Should NOT send any message
mock_context.send_message.assert_not_called()
@_requires_powerfx
async def test_condition_group_evaluator_first_match(self, mock_context, mock_state):
"""Test ConditionGroupEvaluatorExecutor returns first match."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1579,6 +1531,7 @@ class TestControlFlowCoverage:
assert msg.matched is True
assert msg.branch_index == 1 # Second condition (x > 5) is first match
@_requires_powerfx
async def test_condition_group_evaluator_no_match(self, mock_context, mock_state):
"""Test ConditionGroupEvaluatorExecutor with no matches."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1603,6 +1556,7 @@ class TestControlFlowCoverage:
assert msg.matched is False
assert msg.branch_index == -1
@_requires_powerfx
async def test_condition_group_evaluator_boolean_true_condition(self, mock_context, mock_state):
"""Test ConditionGroupEvaluatorExecutor with boolean True condition."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1626,6 +1580,7 @@ class TestControlFlowCoverage:
assert msg.matched is True
assert msg.branch_index == 1
@_requires_powerfx
async def test_if_condition_evaluator_true(self, mock_context, mock_state):
"""Test IfConditionEvaluatorExecutor with true condition."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1646,6 +1601,7 @@ class TestControlFlowCoverage:
assert msg.matched is True
assert msg.branch_index == 0 # Then branch
@_requires_powerfx
async def test_if_condition_evaluator_false(self, mock_context, mock_state):
"""Test IfConditionEvaluatorExecutor with false condition."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1894,6 +1850,7 @@ class TestHumanInputExecutorsCoverage:
class TestAgentExternalLoopCoverage:
"""Tests for agent executor external loop handling."""
@_requires_powerfx
async def test_agent_executor_with_external_loop(self, mock_context, mock_state):
"""Test agent executor with external loop that triggers."""
from unittest.mock import patch
@@ -1996,39 +1953,13 @@ class TestAgentExternalLoopCoverage:
result = state.get("Local.result")
assert result == "Direct string response"
async def test_invoke_tool_with_error(self, mock_context, mock_state):
"""Test InvokeToolExecutor handles tool errors."""
from agent_framework_declarative._workflows._executors_agents import (
TOOL_REGISTRY_KEY,
InvokeToolExecutor,
)
def failing_tool(**kwargs):
raise ValueError("Tool error")
mock_state._data[TOOL_REGISTRY_KEY] = {"bad_tool": failing_tool}
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "InvokeTool",
"tool": "bad_tool",
"resultProperty": "Local.result",
}
executor = InvokeToolExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
result = state.get("Local.result")
assert result == {"error": "Tool error"}
# ---------------------------------------------------------------------------
# PowerFx Functions Coverage
# ---------------------------------------------------------------------------
@_requires_powerfx
class TestPowerFxFunctionsCoverage:
"""Tests for PowerFx function evaluation coverage."""
@@ -2784,6 +2715,7 @@ class TestBuilderValidation:
assert "activity" in str(exc_info.value)
@_requires_powerfx
class TestExpressionEdgeCases:
"""Tests for expression evaluation edge cases."""
@@ -2808,6 +2740,7 @@ class TestExpressionEdgeCases:
assert result == 42
@_requires_powerfx
class TestLongMessageTextHandling:
"""Tests for handling long MessageText results that exceed PowerFx limits."""
@@ -7,7 +7,16 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from agent_framework_declarative._workflows import (
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
_requires_powerfx = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
from agent_framework_declarative._workflows import ( # noqa: E402
ALL_ACTION_EXECUTORS,
DECLARATIVE_STATE_KEY,
ActionComplete,
@@ -99,6 +108,7 @@ class TestDeclarativeWorkflowState:
result = state.get("Local.items")
assert result == ["first", "second"]
@_requires_powerfx
@pytest.mark.asyncio
async def test_eval_expression(self, mock_state):
"""Test evaluating expressions."""
@@ -196,6 +206,7 @@ class TestDeclarativeActionExecutor:
# Note: ConditionEvaluatorExecutor tests removed - conditions are now evaluated on edges
@_requires_powerfx
async def test_foreach_init_with_items(self, mock_context, mock_state):
"""Test ForeachInitExecutor with items."""
state = DeclarativeWorkflowState(mock_state)
@@ -529,6 +540,7 @@ class TestHumanInputExecutors:
assert "continue" in request.message.lower()
@_requires_powerfx
class TestParseValueExecutor:
"""Tests for the ParseValue action executor."""
@@ -9,13 +9,27 @@ These tests verify:
- Pause/resume capabilities
"""
import sys
import pytest
from agent_framework_declarative._workflows import (
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(
not _powerfx_available or sys.version_info >= (3, 14),
reason="PowerFx engine not available (requires dotnet runtime)",
)
from agent_framework_declarative._workflows import ( # noqa: E402
ActionTrigger,
DeclarativeWorkflowBuilder,
)
from agent_framework_declarative._workflows._factory import WorkflowFactory
from agent_framework_declarative._workflows._factory import WorkflowFactory # noqa: E402
class TestGraphBasedWorkflowExecution:
@@ -237,6 +237,444 @@ class TestCustomFunctionsRegistry:
"Lower",
"Concat",
"Search",
"If",
"Or",
"And",
"Not",
"AgentMessage",
"ForAll",
]
for name in expected:
assert name in CUSTOM_FUNCTIONS
class TestMessageTextEdgeCases:
"""Additional tests for message_text edge cases."""
def test_message_text_dict_with_text_attr_content(self):
"""Test message with content that has text attribute."""
class ContentWithText: # noqa: B903
def __init__(self, text: str):
self.text = text
msg = {"role": "assistant", "content": ContentWithText("Hello from text attr")}
assert message_text(msg) == "Hello from text attr"
def test_message_text_dict_content_non_string(self):
"""Test message with non-string content."""
msg = {"role": "assistant", "content": 42}
assert message_text(msg) == "42"
def test_message_text_list_with_string_items(self):
"""Test message_text with list of strings."""
result = message_text(["Hello", "World"])
assert result == "Hello World"
def test_message_text_list_with_content_objects(self):
"""Test message_text with list items having content attribute."""
class MessageObj: # noqa: B903
def __init__(self, content: str):
self.content = content
msgs = [MessageObj("Hello"), MessageObj("World")]
result = message_text(msgs)
assert result == "Hello World"
def test_message_text_list_with_content_text_attr(self):
"""Test message_text with content having text attribute."""
class ContentWithText: # noqa: B903
def __init__(self, text: str):
self.text = text
class MessageObj:
def __init__(self, content):
self.content = content
msgs = [MessageObj(ContentWithText("Part1")), MessageObj(ContentWithText("Part2"))]
result = message_text(msgs)
assert result == "Part1 Part2"
def test_message_text_list_with_non_string_content(self):
"""Test message_text with non-string content in dicts."""
msgs = [{"content": 123}, {"content": 456}]
result = message_text(msgs)
assert result == "123 456"
def test_message_text_object_with_text_attr(self):
"""Test message_text with object having text attribute."""
class ObjWithText:
text = "Direct text"
result = message_text(ObjWithText())
assert result == "Direct text"
def test_message_text_object_with_content_attr(self):
"""Test message_text with object having content attribute."""
class ObjWithContent:
content = "Direct content"
result = message_text(ObjWithContent())
assert result == "Direct content"
def test_message_text_object_with_non_string_content(self):
"""Test message_text with object having non-string content."""
class ObjWithContent:
content = None
result = message_text(ObjWithContent())
assert result == ""
def test_message_text_list_with_empty_content_object(self):
"""Test message with content object that evaluates to empty."""
class MessageObj:
content = None
result = message_text([MessageObj()])
assert result == ""
class TestAgentMessage:
"""Tests for agent_message function."""
def test_agent_message_creates_dict(self):
"""Test that AgentMessage creates correct dict."""
from agent_framework_declarative._workflows._powerfx_functions import agent_message
msg = agent_message("Hello")
assert msg == {"role": "assistant", "content": "Hello"}
def test_agent_message_with_none(self):
"""Test AgentMessage with None."""
from agent_framework_declarative._workflows._powerfx_functions import agent_message
msg = agent_message(None)
assert msg == {"role": "assistant", "content": ""}
class TestIfFunc:
"""Tests for if_func conditional function."""
def test_if_true_condition(self):
"""Test If with true condition."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(True, "yes", "no") == "yes"
def test_if_false_condition(self):
"""Test If with false condition."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(False, "yes", "no") == "no"
def test_if_truthy_value(self):
"""Test If with truthy value."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(1, "yes", "no") == "yes"
assert if_func("non-empty", "yes", "no") == "yes"
def test_if_falsy_value(self):
"""Test If with falsy value."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(0, "yes", "no") == "no"
assert if_func("", "yes", "no") == "no"
assert if_func(None, "yes", "no") == "no"
def test_if_no_false_value(self):
"""Test If with no false value defaults to None."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(False, "yes") is None
class TestOrFunc:
"""Tests for or_func function."""
def test_or_all_false(self):
"""Test Or with all false values."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func(False, False, False) is False
def test_or_one_true(self):
"""Test Or with one true value."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func(False, True, False) is True
def test_or_all_true(self):
"""Test Or with all true values."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func(True, True, True) is True
def test_or_empty(self):
"""Test Or with no arguments."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func() is False
class TestAndFunc:
"""Tests for and_func function."""
def test_and_all_true(self):
"""Test And with all true values."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func(True, True, True) is True
def test_and_one_false(self):
"""Test And with one false value."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func(True, False, True) is False
def test_and_all_false(self):
"""Test And with all false values."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func(False, False, False) is False
def test_and_empty(self):
"""Test And with no arguments."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func() is True
class TestNotFunc:
"""Tests for not_func function."""
def test_not_true(self):
"""Test Not with true."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(True) is False
def test_not_false(self):
"""Test Not with false."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(False) is True
def test_not_truthy(self):
"""Test Not with truthy values."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(1) is False
assert not_func("text") is False
def test_not_falsy(self):
"""Test Not with falsy values."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(0) is True
assert not_func("") is True
assert not_func(None) is True
class TestIsBlankEdgeCases:
"""Additional tests for is_blank edge cases."""
def test_is_blank_empty_dict(self):
"""Test that empty dict is blank."""
assert is_blank({}) is True
def test_is_blank_non_empty_dict(self):
"""Test that non-empty dict is not blank."""
assert is_blank({"key": "value"}) is False
class TestCountRowsEdgeCases:
"""Additional tests for count_rows edge cases."""
def test_count_rows_dict(self):
"""Test counting dict items."""
assert count_rows({"a": 1, "b": 2, "c": 3}) == 3
def test_count_rows_tuple(self):
"""Test counting tuple items."""
assert count_rows((1, 2, 3, 4)) == 4
def test_count_rows_non_iterable(self):
"""Test counting non-iterable returns 0."""
assert count_rows(42) == 0
assert count_rows("string") == 0
class TestFirstLastEdgeCases:
"""Additional tests for first/last edge cases."""
def test_first_none(self):
"""Test first with None."""
assert first(None) is None
def test_last_none(self):
"""Test last with None."""
assert last(None) is None
def test_first_tuple(self):
"""Test first with tuple."""
assert first((1, 2, 3)) == 1
def test_last_tuple(self):
"""Test last with tuple."""
assert last((1, 2, 3)) == 3
class TestFindEdgeCases:
"""Additional tests for find edge cases."""
def test_find_none_substring(self):
"""Test find with None substring."""
assert find(None, "text") is None
def test_find_none_text(self):
"""Test find with None text."""
assert find("sub", None) is None
def test_find_both_none(self):
"""Test find with both None."""
assert find(None, None) is None
class TestLowerEdgeCases:
"""Additional tests for lower edge cases."""
def test_lower_none(self):
"""Test lower with None."""
assert lower(None) == ""
class TestConcatStrings:
"""Tests for concat_strings function."""
def test_concat_strings_basic(self):
"""Test basic string concatenation."""
from agent_framework_declarative._workflows._powerfx_functions import concat_strings
assert concat_strings("Hello", " ", "World") == "Hello World"
def test_concat_strings_with_none(self):
"""Test concat with None values."""
from agent_framework_declarative._workflows._powerfx_functions import concat_strings
assert concat_strings("Hello", None, "World") == "HelloWorld"
def test_concat_strings_empty(self):
"""Test concat with no arguments."""
from agent_framework_declarative._workflows._powerfx_functions import concat_strings
assert concat_strings() == ""
class TestConcatTextEdgeCases:
"""Additional tests for concat_text edge cases."""
def test_concat_text_none(self):
"""Test concat_text with None."""
assert concat_text(None) == ""
def test_concat_text_non_list(self):
"""Test concat_text with non-list."""
assert concat_text("single value") == "single value"
def test_concat_text_with_field_attr(self):
"""Test concat_text with field as object attribute."""
class Item: # noqa: B903
def __init__(self, name: str):
self.name = name
items = [Item("Alice"), Item("Bob")]
assert concat_text(items, field="name", separator=", ") == "Alice, Bob"
def test_concat_text_with_none_values(self):
"""Test concat_text with None values in list."""
items = [{"name": "Alice"}, {"name": None}, {"name": "Bob"}]
result = concat_text(items, field="name", separator=", ")
assert result == "Alice, , Bob"
class TestForAll:
"""Tests for for_all function."""
def test_for_all_with_list_of_dicts(self):
"""Test ForAll with list of dictionaries."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
items = [{"name": "Alice"}, {"name": "Bob"}]
result = for_all(items, "expression")
assert result == items
def test_for_all_with_non_dict_items(self):
"""Test ForAll with non-dict items."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
items = [1, 2, 3]
result = for_all(items, "expression")
assert result == [1, 2, 3]
def test_for_all_with_none(self):
"""Test ForAll with None."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
assert for_all(None, "expression") == []
def test_for_all_with_non_list(self):
"""Test ForAll with non-list."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
assert for_all("not a list", "expression") == []
def test_for_all_empty_list(self):
"""Test ForAll with empty list."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
assert for_all([], "expression") == []
class TestSearchTableEdgeCases:
"""Additional tests for search_table edge cases."""
def test_search_table_none(self):
"""Test search_table with None."""
assert search_table(None, "value", "column") == []
def test_search_table_non_list(self):
"""Test search_table with non-list."""
assert search_table("not a list", "value", "column") == []
def test_search_table_with_object_attr(self):
"""Test search_table with object attributes."""
class Item: # noqa: B903
def __init__(self, name: str):
self.name = name
items = [Item("Alice"), Item("Bob"), Item("Charlie")]
result = search_table(items, "Bob", "name")
assert len(result) == 1
assert result[0].name == "Bob"
def test_search_table_no_matching_column(self):
"""Test search_table when items don't have the column."""
items = [{"other": "value"}]
result = search_table(items, "value", "name")
assert result == []
def test_search_table_empty_value(self):
"""Test search_table with empty search value."""
items = [{"name": "Alice"}, {"name": "Bob"}]
result = search_table(items, "", "name")
# Empty string matches everything
assert len(result) == 2
@@ -20,7 +20,16 @@ from unittest.mock import MagicMock
import pytest
from agent_framework_declarative._workflows._declarative_base import (
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
from agent_framework_declarative._workflows._declarative_base import ( # noqa: E402
DeclarativeWorkflowState,
)
@@ -9,6 +9,15 @@ from agent_framework_declarative._workflows._factory import (
WorkflowFactory,
)
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
_requires_powerfx = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
class TestWorkflowFactoryValidation:
"""Tests for workflow definition validation."""
@@ -58,6 +67,7 @@ actions:
assert workflow.name == "minimal-workflow"
@_requires_powerfx
class TestWorkflowFactoryExecution:
"""Tests for workflow execution."""
@@ -204,6 +214,7 @@ actions:
assert workflow.name == "file-workflow"
@_requires_powerfx
class TestDisplayNameMetadata:
"""Tests for displayName metadata support."""
@@ -231,3 +242,662 @@ actions:
# Should execute successfully with displayName metadata
assert len(outputs) >= 1
class TestWorkflowFactoryToolRegistration:
"""Tests for tool registration."""
def test_register_tool_basic(self):
"""Test registering a tool."""
def my_tool(x: int) -> int:
return x * 2
factory = WorkflowFactory()
result = factory.register_tool("my_tool", my_tool)
# Should return self for fluent chaining
assert result is factory
assert "my_tool" in factory._tools
assert factory._tools["my_tool"](5) == 10
def test_register_multiple_tools(self):
"""Test registering multiple tools with fluent chaining."""
def add(a: int, b: int) -> int:
return a + b
def multiply(a: int, b: int) -> int:
return a * b
factory = WorkflowFactory().register_tool("add", add).register_tool("multiply", multiply)
assert "add" in factory._tools
assert "multiply" in factory._tools
assert factory._tools["add"](2, 3) == 5
assert factory._tools["multiply"](2, 3) == 6
def test_register_tool_non_callable_raises(self):
"""Test that register_tool raises TypeError for non-callable."""
factory = WorkflowFactory()
with pytest.raises(TypeError, match="Expected a callable for tool"):
factory.register_tool("bad_tool", "not_a_function")
def test_register_binding_non_callable_raises(self):
"""Test that register_binding raises TypeError for non-callable."""
factory = WorkflowFactory()
with pytest.raises(TypeError, match="Expected a callable for binding"):
factory.register_binding("bad_binding", 42)
class TestWorkflowFactoryEdgeCases:
"""Tests for edge cases in workflow factory."""
def test_empty_actions_list(self):
"""Test workflow with empty actions list."""
factory = WorkflowFactory()
with pytest.raises(DeclarativeWorkflowError, match="actions"):
factory.create_workflow_from_yaml("""
name: empty-actions
actions: []
""")
def test_unknown_action_kind(self):
"""Test workflow with unknown action kind."""
factory = WorkflowFactory()
with pytest.raises((DeclarativeWorkflowError, ValueError)):
factory.create_workflow_from_yaml("""
name: unknown-action
actions:
- kind: UnknownActionType
value: test
""")
def test_workflow_with_description(self):
"""Test workflow with description field."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: described-workflow
description: This is a test workflow
actions:
- kind: SetValue
path: Local.x
value: 1
""")
assert workflow is not None
assert workflow.name == "described-workflow"
@_requires_powerfx
@pytest.mark.asyncio
async def test_workflow_with_expression_value(self):
"""Test workflow with expression-based value."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: expression-test
actions:
- kind: SetValue
path: Local.x
value: 5
- kind: SetValue
path: Local.y
value: =Local.x
- kind: SendActivity
activity:
text: =Local.y
""")
result = await workflow.run({})
outputs = result.get_outputs()
assert any("5" in str(o) for o in outputs)
@_requires_powerfx
@pytest.mark.asyncio
async def test_workflow_with_nested_if(self):
"""Test workflow with nested If statements."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: nested-if-test
actions:
- kind: SetValue
path: Local.level
value: 2
- kind: If
condition: true
then:
- kind: If
condition: true
then:
- kind: SendActivity
activity:
text: Nested condition passed
""")
result = await workflow.run({})
outputs = result.get_outputs()
assert any("Nested condition passed" in str(o) for o in outputs)
def test_load_from_string_path(self, tmp_path):
"""Test loading a workflow from a string file path."""
workflow_file = tmp_path / "workflow.yaml"
workflow_file.write_text("""
name: string-path-workflow
actions:
- kind: SetValue
path: Local.loaded
value: true
""")
factory = WorkflowFactory()
# Pass as string instead of Path object
workflow = factory.create_workflow_from_yaml_path(str(workflow_file))
assert workflow is not None
assert workflow.name == "string-path-workflow"
@_requires_powerfx
class TestWorkflowFactorySwitch:
"""Tests for Switch/Case action."""
@pytest.mark.asyncio
async def test_switch_with_matching_case(self):
"""Test Switch with a matching case."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: switch-test
actions:
- kind: SetValue
path: Local.color
value: red
- kind: Switch
value: =Local.color
cases:
- match: red
actions:
- kind: SendActivity
activity:
text: Color is red
- match: blue
actions:
- kind: SendActivity
activity:
text: Color is blue
""")
result = await workflow.run({})
outputs = result.get_outputs()
assert any("Color is red" in str(o) for o in outputs)
@pytest.mark.asyncio
async def test_switch_with_default(self):
"""Test Switch falling through to default."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: switch-default-test
actions:
- kind: SetValue
path: Local.color
value: green
- kind: Switch
value: =Local.color
cases:
- match: red
actions:
- kind: SendActivity
activity:
text: Red
- match: blue
actions:
- kind: SendActivity
activity:
text: Blue
default:
- kind: SendActivity
activity:
text: Unknown color
""")
result = await workflow.run({})
outputs = result.get_outputs()
assert any("Unknown color" in str(o) for o in outputs)
@_requires_powerfx
class TestWorkflowFactoryMultipleActionTypes:
"""Tests for workflows with multiple action types."""
@pytest.mark.asyncio
async def test_set_multiple_variables(self):
"""Test SetMultipleVariables action."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: multi-set-test
actions:
- kind: SetMultipleVariables
variables:
- path: Local.a
value: 1
- path: Local.b
value: 2
- path: Local.c
value: 3
- kind: SendActivity
activity:
text: Done
""")
result = await workflow.run({})
outputs = result.get_outputs()
assert any("Done" in str(o) for o in outputs)
@pytest.mark.asyncio
async def test_append_value(self):
"""Test AppendValue action."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: append-test
actions:
- kind: SetValue
path: Local.list
value: []
- kind: AppendValue
path: Local.list
value: first
- kind: AppendValue
path: Local.list
value: second
- kind: SendActivity
activity:
text: Done
""")
result = await workflow.run({})
outputs = result.get_outputs()
assert any("Done" in str(o) for o in outputs)
@pytest.mark.asyncio
async def test_emit_event(self):
"""Test EmitEvent action."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: emit-event-test
actions:
- kind: EmitEvent
event:
name: test_event
data:
message: Hello
- kind: SendActivity
activity:
text: Event emitted
""")
result = await workflow.run({})
outputs = result.get_outputs()
# Workflow should complete
assert any("Event emitted" in str(o) for o in outputs)
class TestWorkflowFactoryYamlErrors:
"""Tests for YAML parsing error handling."""
def test_invalid_yaml_raises(self):
"""Test that invalid YAML raises DeclarativeWorkflowError."""
factory = WorkflowFactory()
with pytest.raises(DeclarativeWorkflowError, match="Invalid YAML"):
factory.create_workflow_from_yaml("""
name: broken-yaml
actions:
- kind: SetValue
path: Local.x
value: [unclosed bracket
""")
def test_non_dict_workflow_raises(self):
"""Test that non-dict workflow definition raises error."""
factory = WorkflowFactory()
with pytest.raises(DeclarativeWorkflowError, match="must be a dictionary"):
factory.create_workflow_from_yaml("- just a list item")
class TestWorkflowFactoryTriggerFormat:
"""Tests for trigger-based workflow format."""
def test_trigger_based_workflow(self):
"""Test workflow with trigger-based format."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
kind: Workflow
trigger:
kind: OnConversationStart
id: my_trigger
actions:
- kind: SetValue
path: Local.x
value: 1
""")
assert workflow is not None
assert workflow.name == "my_trigger"
def test_trigger_workflow_without_id(self):
"""Test trigger workflow without id uses default name."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
kind: Workflow
trigger:
kind: OnConversationStart
actions:
- kind: SetValue
path: Local.x
value: 1
""")
assert workflow is not None
assert workflow.name == "declarative_workflow"
class TestWorkflowFactoryAgentCreation:
"""Tests for agent creation from definitions."""
def test_agent_creation_with_file_reference(self, tmp_path):
"""Test creating agent from file reference."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
# Create a minimal agent YAML file (using Prompt kind)
agent_file = tmp_path / "test_agent.yaml"
agent_file.write_text("""
kind: Prompt
name: TestAgent
description: A test agent
instructions: You are a test agent.
""")
# Create a mock client and agent factory
mock_client = MagicMock()
mock_agent = MagicMock()
mock_agent.name = "TestAgent"
mock_client.create_agent.return_value = mock_agent
agent_factory = AgentFactory(client=mock_client)
# Create workflow that references the agent
workflow_file = tmp_path / "workflow.yaml"
workflow_file.write_text(f"""
kind: Workflow
agents:
TestAgent:
file: {agent_file.name}
actions:
- kind: SetValue
path: Local.x
value: 1
""")
factory = WorkflowFactory(agent_factory=agent_factory)
workflow = factory.create_workflow_from_yaml_path(workflow_file)
assert workflow is not None
assert "TestAgent" in workflow._declarative_agents
def test_agent_connection_definition_raises(self):
"""Test that connection-based agent definition raises error."""
factory = WorkflowFactory()
with pytest.raises(DeclarativeWorkflowError, match="Connection-based agents"):
factory.create_workflow_from_yaml("""
kind: Workflow
agents:
MyAgent:
connection: azure-connection
actions:
- kind: SetValue
path: Local.x
value: 1
""")
def test_invalid_agent_definition_raises(self):
"""Test that invalid agent definition raises error."""
factory = WorkflowFactory()
with pytest.raises(DeclarativeWorkflowError, match="Invalid agent definition"):
factory.create_workflow_from_yaml("""
kind: Workflow
agents:
MyAgent:
unknown_field: value
actions:
- kind: SetValue
path: Local.x
value: 1
""")
def test_preregistered_agent_not_overwritten(self):
"""Test that pre-registered agents are not overwritten by definitions."""
class MockAgent:
name = "PreregisteredAgent"
factory = WorkflowFactory(agents={"TestAgent": MockAgent()})
workflow = factory.create_workflow_from_yaml("""
kind: Workflow
agents:
TestAgent:
kind: Agent
name: OverrideAttempt
actions:
- kind: SetValue
path: Local.x
value: 1
""")
assert workflow._declarative_agents["TestAgent"].name == "PreregisteredAgent"
class TestWorkflowFactoryInputSchema:
"""Tests for input schema conversion."""
def test_inputs_to_json_schema_basic(self):
"""Test basic input schema conversion."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: input-schema-test
inputs:
name:
type: string
description: The user's name
age:
type: integer
description: The user's age
actions:
- kind: SetValue
path: Local.x
value: 1
""")
schema = workflow.input_schema
assert schema["type"] == "object"
assert "name" in schema["properties"]
assert "age" in schema["properties"]
assert schema["properties"]["name"]["type"] == "string"
assert schema["properties"]["age"]["type"] == "integer"
assert "name" in schema["required"]
assert "age" in schema["required"]
def test_inputs_schema_with_optional_field(self):
"""Test input schema with optional field."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: optional-input-test
inputs:
required_field:
type: string
required: true
optional_field:
type: string
required: false
actions:
- kind: SetValue
path: Local.x
value: 1
""")
schema = workflow.input_schema
assert "required_field" in schema["required"]
assert "optional_field" not in schema["required"]
def test_inputs_schema_with_default_value(self):
"""Test input schema with default value."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: default-input-test
inputs:
greeting:
type: string
default: Hello
actions:
- kind: SetValue
path: Local.x
value: 1
""")
schema = workflow.input_schema
assert schema["properties"]["greeting"]["default"] == "Hello"
def test_inputs_schema_with_enum(self):
"""Test input schema with enum values."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: enum-input-test
inputs:
color:
type: string
enum:
- red
- green
- blue
actions:
- kind: SetValue
path: Local.x
value: 1
""")
schema = workflow.input_schema
assert schema["properties"]["color"]["enum"] == ["red", "green", "blue"]
def test_inputs_schema_type_mappings(self):
"""Test various type mappings in input schema."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: type-mapping-test
inputs:
str_field:
type: str
int_field:
type: int
float_field:
type: float
bool_field:
type: bool
list_field:
type: list
dict_field:
type: dict
actions:
- kind: SetValue
path: Local.x
value: 1
""")
schema = workflow.input_schema
assert schema["properties"]["str_field"]["type"] == "string"
assert schema["properties"]["int_field"]["type"] == "integer"
assert schema["properties"]["float_field"]["type"] == "number"
assert schema["properties"]["bool_field"]["type"] == "boolean"
assert schema["properties"]["list_field"]["type"] == "array"
assert schema["properties"]["dict_field"]["type"] == "object"
def test_inputs_schema_simple_format(self):
"""Test simple input format (field: type)."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: simple-input-test
inputs:
name: string
count: integer
actions:
- kind: SetValue
path: Local.x
value: 1
""")
schema = workflow.input_schema
assert schema["properties"]["name"]["type"] == "string"
assert schema["properties"]["count"]["type"] == "integer"
assert "name" in schema["required"]
assert "count" in schema["required"]
class TestWorkflowFactoryChaining:
"""Tests for fluent method chaining."""
def test_fluent_agent_registration(self):
"""Test fluent agent registration."""
class MockAgent1:
name = "Agent1"
class MockAgent2:
name = "Agent2"
factory = WorkflowFactory().register_agent("agent1", MockAgent1()).register_agent("agent2", MockAgent2())
assert "agent1" in factory._agents
assert "agent2" in factory._agents
def test_fluent_binding_registration(self):
"""Test fluent binding registration."""
def func1():
return 1
def func2():
return 2
factory = WorkflowFactory().register_binding("func1", func1).register_binding("func2", func2)
assert "func1" in factory._bindings
assert "func2" in factory._bindings
def test_fluent_mixed_registration(self):
"""Test mixed fluent registration."""
class MockAgent:
name = "Agent"
def my_tool():
return "tool"
def my_binding():
return "binding"
factory = (
WorkflowFactory()
.register_agent("agent", MockAgent())
.register_tool("tool", my_tool)
.register_binding("binding", my_binding)
)
assert "agent" in factory._agents
assert "tool" in factory._tools
assert "binding" in factory._bindings
@@ -225,6 +225,335 @@ class TestWorkflowStateResetTurn:
assert state.get("Workflow.Outputs.result") == "done"
class TestWorkflowStateEvalSimple:
"""Tests for _eval_simple fallback PowerFx evaluation."""
def test_negation_prefix(self):
"""Test negation with ! prefix."""
state = WorkflowState()
state.set("Local.value", True)
assert state._eval_simple("!Local.value") is False
state.set("Local.value", False)
assert state._eval_simple("!Local.value") is True
def test_not_function(self):
"""Test Not() function."""
state = WorkflowState()
state.set("Local.flag", True)
assert state._eval_simple("Not(Local.flag)") is False
state.set("Local.flag", False)
assert state._eval_simple("Not(Local.flag)") is True
def test_and_operator(self):
"""Test And operator."""
state = WorkflowState()
state.set("Local.a", True)
state.set("Local.b", True)
assert state._eval_simple("Local.a And Local.b") is True
state.set("Local.b", False)
assert state._eval_simple("Local.a And Local.b") is False
def test_or_operator(self):
"""Test Or operator."""
state = WorkflowState()
state.set("Local.a", False)
state.set("Local.b", False)
assert state._eval_simple("Local.a Or Local.b") is False
state.set("Local.b", True)
assert state._eval_simple("Local.a Or Local.b") is True
def test_or_operator_double_pipe(self):
"""Test || operator."""
state = WorkflowState()
state.set("Local.x", False)
state.set("Local.y", True)
assert state._eval_simple("Local.x || Local.y") is True
def test_less_than(self):
"""Test < comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num < 10") is True
assert state._eval_simple("Local.num < 3") is False
def test_greater_than(self):
"""Test > comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num > 3") is True
assert state._eval_simple("Local.num > 10") is False
def test_less_than_or_equal(self):
"""Test <= comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num <= 5") is True
assert state._eval_simple("Local.num <= 4") is False
def test_greater_than_or_equal(self):
"""Test >= comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num >= 5") is True
assert state._eval_simple("Local.num >= 6") is False
def test_not_equal(self):
"""Test <> comparison."""
state = WorkflowState()
state.set("Local.val", "hello")
assert state._eval_simple('Local.val <> "world"') is True
assert state._eval_simple('Local.val <> "hello"') is False
def test_equal(self):
"""Test = comparison."""
state = WorkflowState()
state.set("Local.val", "test")
assert state._eval_simple('Local.val = "test"') is True
assert state._eval_simple('Local.val = "other"') is False
def test_addition_numeric(self):
"""Test + operator with numbers."""
state = WorkflowState()
state.set("Local.a", 3)
state.set("Local.b", 4)
assert state._eval_simple("Local.a + Local.b") == 7.0
def test_addition_string_concat(self):
"""Test + operator falls back to string concat."""
state = WorkflowState()
state.set("Local.a", "hello")
state.set("Local.b", "world")
assert state._eval_simple("Local.a + Local.b") == "helloworld"
def test_addition_with_none(self):
"""Test + treats None as 0."""
state = WorkflowState()
state.set("Local.a", 5)
# Local.b doesn't exist, so it's None
assert state._eval_simple("Local.a + Local.b") == 5.0
def test_subtraction(self):
"""Test - operator."""
state = WorkflowState()
state.set("Local.a", 10)
state.set("Local.b", 3)
assert state._eval_simple("Local.a - Local.b") == 7.0
def test_subtraction_with_none(self):
"""Test - treats None as 0."""
state = WorkflowState()
state.set("Local.a", 5)
assert state._eval_simple("Local.a - Local.missing") == 5.0
def test_multiplication(self):
"""Test * operator."""
state = WorkflowState()
state.set("Local.a", 4)
state.set("Local.b", 5)
assert state._eval_simple("Local.a * Local.b") == 20.0
def test_multiplication_with_none(self):
"""Test * treats None as 0."""
state = WorkflowState()
state.set("Local.a", 5)
assert state._eval_simple("Local.a * Local.missing") == 0.0
def test_division(self):
"""Test / operator."""
state = WorkflowState()
state.set("Local.a", 20)
state.set("Local.b", 4)
assert state._eval_simple("Local.a / Local.b") == 5.0
def test_division_by_zero(self):
"""Test / by zero returns None."""
state = WorkflowState()
state.set("Local.a", 10)
state.set("Local.b", 0)
assert state._eval_simple("Local.a / Local.b") is None
def test_string_literal_double_quotes(self):
"""Test string literal with double quotes."""
state = WorkflowState()
assert state._eval_simple('"hello world"') == "hello world"
def test_string_literal_single_quotes(self):
"""Test string literal with single quotes."""
state = WorkflowState()
assert state._eval_simple("'hello world'") == "hello world"
def test_integer_literal(self):
"""Test integer literal."""
state = WorkflowState()
assert state._eval_simple("42") == 42
def test_float_literal(self):
"""Test float literal."""
state = WorkflowState()
assert state._eval_simple("3.14") == 3.14
def test_boolean_true_literal(self):
"""Test true literal (case insensitive)."""
state = WorkflowState()
assert state._eval_simple("true") is True
assert state._eval_simple("True") is True
assert state._eval_simple("TRUE") is True
def test_boolean_false_literal(self):
"""Test false literal (case insensitive)."""
state = WorkflowState()
assert state._eval_simple("false") is False
assert state._eval_simple("False") is False
assert state._eval_simple("FALSE") is False
def test_variable_reference(self):
"""Test simple variable reference."""
state = WorkflowState()
state.set("Local.myvar", "myvalue")
assert state._eval_simple("Local.myvar") == "myvalue"
def test_unknown_expression_returned_as_is(self):
"""Test that unknown expressions are returned as-is."""
state = WorkflowState()
result = state._eval_simple("unknown_identifier")
assert result == "unknown_identifier"
def test_agent_namespace_reference(self):
"""Test Agent namespace variable reference."""
state = WorkflowState()
state.set_agent_result(text="agent response")
assert state._eval_simple("Agent.text") == "agent response"
def test_conversation_namespace_reference(self):
"""Test Conversation namespace variable reference."""
state = WorkflowState()
state.add_conversation_message({"role": "user", "content": "hello"})
result = state._eval_simple("Conversation.messages")
assert len(result) == 1
def test_workflow_inputs_reference(self):
"""Test Workflow.Inputs reference."""
state = WorkflowState(inputs={"name": "test"})
assert state._eval_simple("Workflow.Inputs.name") == "test"
class TestWorkflowStateParseFunctionArgs:
"""Tests for _parse_function_args helper."""
def test_simple_args(self):
"""Test parsing simple comma-separated args."""
state = WorkflowState()
args = state._parse_function_args("1, 2, 3")
assert args == ["1", "2", "3"]
def test_string_args_with_commas(self):
"""Test parsing string args containing commas."""
state = WorkflowState()
args = state._parse_function_args('"hello, world", "another"')
assert args == ['"hello, world"', '"another"']
def test_nested_function_args(self):
"""Test parsing nested function calls."""
state = WorkflowState()
args = state._parse_function_args("Concat(a, b), c")
assert args == ["Concat(a, b)", "c"]
def test_empty_args(self):
"""Test parsing empty args string."""
state = WorkflowState()
args = state._parse_function_args("")
assert args == []
def test_single_arg(self):
"""Test parsing single argument."""
state = WorkflowState()
args = state._parse_function_args("single")
assert args == ["single"]
def test_deeply_nested_parens(self):
"""Test parsing deeply nested parentheses."""
state = WorkflowState()
args = state._parse_function_args("Func1(Func2(a, b)), c")
assert args == ["Func1(Func2(a, b))", "c"]
class TestWorkflowStateEvalIfExpression:
"""Tests for eval_if_expression method."""
def test_dict_values_evaluated(self):
"""Test that dict values are recursively evaluated."""
state = WorkflowState()
state.set("Local.name", "World")
result = state.eval_if_expression({"greeting": "=Local.name", "static": "value"})
assert result == {"greeting": "World", "static": "value"}
def test_list_values_evaluated(self):
"""Test that list values are recursively evaluated."""
state = WorkflowState()
state.set("Local.val", 42)
result = state.eval_if_expression(["=Local.val", "static"])
assert result == [42, "static"]
def test_nested_dict_in_list(self):
"""Test nested dict in list is evaluated."""
state = WorkflowState()
state.set("Local.x", 10)
result = state.eval_if_expression([{"key": "=Local.x"}])
assert result == [{"key": 10}]
class TestWorkflowStateSetErrors:
"""Tests for set() error handling."""
def test_set_workflow_directly_raises(self):
"""Test that setting Workflow directly raises error."""
state = WorkflowState()
with pytest.raises(ValueError, match="Cannot set 'Workflow' directly"):
state.set("Workflow", "value")
def test_set_unknown_workflow_namespace_raises(self):
"""Test that setting unknown Workflow sub-namespace raises."""
state = WorkflowState()
with pytest.raises(ValueError, match="Unknown Workflow namespace"):
state.set("Workflow.Unknown.path", "value")
def test_set_namespace_root_raises(self):
"""Test that setting namespace root raises error."""
state = WorkflowState()
with pytest.raises(ValueError, match="Cannot replace entire namespace"):
state.set("Local", "value")
class TestWorkflowStateGetEdgeCases:
"""Tests for get() edge cases."""
def test_get_empty_path(self):
"""Test get with empty path returns default."""
state = WorkflowState()
assert state.get("", "default") == "default"
def test_get_unknown_namespace(self):
"""Test get from unknown namespace returns default."""
state = WorkflowState()
assert state.get("Unknown.path") is None
assert state.get("Unknown.path", "fallback") == "fallback"
def test_get_with_object_attribute(self):
"""Test get navigates object attributes."""
state = WorkflowState()
class MockObj:
attr = "attribute_value"
state.set("Local.obj", MockObj())
assert state.get("Local.obj.attr") == "attribute_value"
def test_get_unknown_workflow_subspace(self):
"""Test get from unknown Workflow sub-namespace."""
state = WorkflowState()
assert state.get("Workflow.Unknown.path") is None
class TestWorkflowStateConversationIdInit:
"""Tests that WorkflowState generates a real UUID for System.ConversationId."""
@@ -135,6 +135,9 @@ async def main() -> None:
async for chunk in agent.run(user_input, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
for content in chunk.contents:
if content.annotations:
print(f"\n[Sources: {content.annotations}]", end="", flush=True)
print("\n")
@@ -4,7 +4,7 @@ import asyncio
import os
from agent_framework import Agent
from agent_framework.azure import AzureAIAgentClient, AzureAISearchContextProvider
from agent_framework.azure import AzureAIAgentClient, AzureAISearchContextProvider, AzureOpenAIEmbeddingClient
from azure.identity.aio import AzureCliCredential
from dotenv import load_dotenv
@@ -30,6 +30,8 @@ Prerequisites:
- AZURE_SEARCH_INDEX_NAME: Your search index name
- AZURE_AI_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint
- AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o")
- AZURE_OPENAI_EMBEDDING_MODEL_ID: (Optional) Your embedding model for hybrid search (e.g., "text-embedding-3-small")
- AZURE_OPENAI_ENDPOINT: (Optional) Your Azure OpenAI resource URL, required if using an OpenAI embedding model for hybrid search
"""
# Sample queries to demonstrate RAG
@@ -43,12 +45,24 @@ USER_INPUTS = [
async def main() -> None:
"""Main function demonstrating Azure AI Search semantic mode."""
credential = AzureCliCredential()
# Get configuration from environment
search_endpoint = os.environ["AZURE_SEARCH_ENDPOINT"]
search_key = os.environ.get("AZURE_SEARCH_API_KEY")
index_name = os.environ["AZURE_SEARCH_INDEX_NAME"]
project_endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o")
openai_endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
embedding_model = os.environ.get("AZURE_OPENAI_EMBEDDING_MODEL_ID", "text-embedding-3-small")
embedding_client = None
if openai_endpoint and embedding_model:
embedding_client = AzureOpenAIEmbeddingClient(
endpoint=openai_endpoint,
deployment_name=embedding_model,
credential=credential,
)
# Create Azure AI Search context provider with semantic mode (recommended, fast)
print("Using SEMANTIC mode (hybrid search + semantic ranking, fast)\n")
@@ -57,9 +71,13 @@ async def main() -> None:
endpoint=search_endpoint,
index_name=index_name,
api_key=search_key, # Use api_key for API key auth, or credential for managed identity
credential=AzureCliCredential() if not search_key else None,
credential=credential if not search_key else None,
mode="semantic", # Default mode
top_k=3, # Retrieve top 3 most relevant documents
embedding_function=embedding_client, # Provide embedding function for hybrid search
vector_field_name="DescriptionVector"
if embedding_client
else None, # Set vector field for hybrid search if using embeddings
)
# Create agent with search context provider
@@ -68,7 +86,7 @@ async def main() -> None:
AzureAIAgentClient(
project_endpoint=project_endpoint,
model_deployment_name=model_deployment,
credential=AzureCliCredential(),
credential=credential,
) as client,
Agent(
client=client,
@@ -0,0 +1,68 @@
# Agent Skills Sample
This sample demonstrates how to use **Agent Skills** with a `FileAgentSkillsProvider` in the Microsoft Agent Framework.
## What are Agent Skills?
Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern:
1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill)
2. **Load**: Full instructions are loaded on-demand via `load_skill` tool
3. **Resources**: References and other files loaded via `read_skill_resource` tool
## Skills Included
### expense-report
Policy-based expense filing with spending limits, receipt requirements, and approval workflows.
- `references/POLICY_FAQ.md` — Detailed expense policy Q&A
- `assets/expense-report-template.md` — Submission template
## Project Structure
```
basic_skills/
├── basic_file_skills.py
├── README.md
└── skills/
└── expense-report/
├── SKILL.md
├── references/
│ └── POLICY_FAQ.md
└── assets/
└── expense-report-template.md
```
## Running the Sample
### Prerequisites
- An [Azure AI Foundry](https://ai.azure.com/) project with a deployed model (e.g. `gpt-4o-mini`)
### Environment Variables
Set the required environment variables in a `.env` file (see `python/.env.example`):
- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`)
### Authentication
This sample uses `AzureCliCredential` for authentication. Run `az login` in your terminal before running the sample.
### Run
```bash
cd python
uv run samples/02-agents/skills/basic_skills/basic_file_skills.py
```
### Examples
The sample runs two examples:
1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource
2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset
## Learn More
- [Agent Skills Specification](https://agentskills.io/)
- [Microsoft Agent Framework Documentation](../../../../../docs/)
@@ -0,0 +1,88 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from pathlib import Path
from agent_framework import Agent, FileAgentSkillsProvider
from agent_framework.azure import AzureOpenAIResponsesClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Agent Skills Sample
This sample demonstrates how to use file-based Agent Skills with a FileAgentSkillsProvider.
Agent Skills are modular packages of instructions and resources that extend an agent's
capabilities. They follow the progressive disclosure pattern:
1. Advertise skill names and descriptions are injected into the system prompt
2. Load full instructions are loaded on-demand via the load_skill tool
3. Read resources supplementary files are read via the read_skill_resource tool
This sample includes the expense-report skill:
- Policy-based expense filing with references and assets
"""
async def main() -> None:
"""Run the Agent Skills demo."""
# --- Configuration ---
endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini")
# --- 1. Create the chat client ---
client = AzureOpenAIResponsesClient(
project_endpoint=endpoint,
deployment_name=deployment,
credential=AzureCliCredential(),
)
# --- 2. Create the skills provider ---
# Discovers skills from the 'skills' directory and makes them available to the agent
skills_dir = Path(__file__).parent / "skills"
skills_provider = FileAgentSkillsProvider(skill_paths=str(skills_dir))
# --- 3. Create the agent with skills ---
async with Agent(
client=client,
instructions="You are a helpful assistant.",
context_providers=[skills_provider],
) as agent:
# --- Example 1: Expense policy question (loads FAQ resource) ---
print("Example 1: Checking expense policy FAQ")
print("---------------------------------------")
response1 = await agent.run(
"Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered."
)
print(f"Agent: {response1}\n")
# --- Example 2: Filing an expense report (uses template asset) ---
print("Example 2: Filing an expense report")
print("---------------------------------------")
session = agent.create_session()
response2 = await agent.run(
"I had 3 client dinners and a $1,200 flight last week. "
"Return a draft expense report and ask about any missing details.",
session=session,
)
print(f"Agent: {response2}\n")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
Example 1: Checking expense policy FAQ
---------------------------------------
Agent: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping.
Since you left a 25% tip, the portion above 20% would require written justification...
Example 2: Filing an expense report
---------------------------------------
Agent: Here's a draft expense report based on what you've told me. I'll need a few more details...
"""
@@ -0,0 +1,40 @@
---
name: expense-report
description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories.
metadata:
author: contoso-finance
version: "2.1"
---
# Expense Report
## Categories and Limits
| Category | Limit | Receipt | Approval |
|---|---|---|---|
| Meals — solo | $50/day | >$25 | No |
| Meals — team/client | $75/person | Always | Manager if >$200 total |
| Lodging | $250/night | Always | Manager if >3 nights |
| Ground transport | $100/day | >$15 | No |
| Airfare | Economy | Always | Manager; VP if >$1,500 |
| Conference/training | $2,000/event | Always | Manager + L&D |
| Office supplies | $100 | Yes | No |
| Software/subscriptions | $50/month | Yes | Manager if >$200/year |
## Filing Process
1. Collect receipts — must show vendor, date, amount, payment method.
2. Categorize per table above.
3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md).
4. For client/team meals: list attendee names and business purpose.
5. Submit — auto-approved if <$500; manager if $500$2,000; VP if >$2,000.
6. Reimbursement: 10 business days via direct deposit.
## Policy Rules
- Submit within 30 days of transaction.
- Alcohol is never reimbursable.
- Foreign currency: convert to USD at transaction-date rate; note original currency and amount.
- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes.
- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter.
- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state.
@@ -0,0 +1,5 @@
# Expense Report Template
| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached |
|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------|
| | | | | | | | | | Yes or No |
@@ -0,0 +1,55 @@
# Expense Policy — Frequently Asked Questions
## Meals
**Q: Can I expense coffee or snacks during the workday?**
A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal.
**Q: What if a team dinner exceeds the per-person limit?**
A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP.
**Q: Do I need to list every attendee?**
A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list.
## Travel
**Q: Can I book a premium economy or business class flight?**
A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation.
**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?**
A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people.
**Q: Are tips reimbursable?**
A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification.
## Lodging
**Q: What if the $250/night limit isn't enough for the city I'm visiting?**
A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking.
**Q: Can I stay with friends/family instead and get a per-diem?**
A: No. Contoso reimburses actual lodging costs only, not per-diems.
## Subscriptions and Software
**Q: Can I expense a personal productivity tool?**
A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing.
**Q: What about annual subscriptions?**
A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report.
## Receipts and Documentation
**Q: My receipt is faded/damaged. What do I do?**
A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter.
**Q: Do I need a receipt for parking meters or tolls?**
A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required.
## Approval and Reimbursement
**Q: My manager is on leave. Who approves my report?**
A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system.
**Q: Can I submit expenses from a previous quarter?**
A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval.
+12 -10
View File
@@ -127,16 +127,18 @@ Orchestration-focused samples (Sequential, Concurrent, Handoff, GroupChat, Magen
YAML-based declarative workflows allow you to define multi-agent orchestration patterns without writing Python code. See the [declarative workflows README](./declarative/README.md) for more details on YAML workflow syntax and available actions.
| Sample | File | Concepts |
| -------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------- |
| Conditional Workflow | [declarative/conditional_workflow/](./declarative/conditional_workflow/) | Nested conditional branching based on user input |
| Customer Support | [declarative/customer_support/](./declarative/customer_support/) | Multi-agent customer support with routing |
| Deep Research | [declarative/deep_research/](./declarative/deep_research/) | Research workflow with planning, searching, and synthesis |
| Function Tools | [declarative/function_tools/](./declarative/function_tools/) | Invoking Python functions from declarative workflows |
| Human-in-Loop | [declarative/human_in_loop/](./declarative/human_in_loop/) | Interactive workflows that request user input |
| Marketing | [declarative/marketing/](./declarative/marketing/) | Marketing content generation workflow |
| Simple Workflow | [declarative/simple_workflow/](./declarative/simple_workflow/) | Basic workflow with variable setting, conditionals, and loops |
| Student Teacher | [declarative/student_teacher/](./declarative/student_teacher/) | Student-teacher interaction pattern |
| Sample | File | Concepts |
|---|---|---|
| Agent to Function Tool | [declarative/agent_to_function_tool/](./declarative/agent_to_function_tool/) | Chain agent output to InvokeFunctionTool actions |
| Conditional Workflow | [declarative/conditional_workflow/](./declarative/conditional_workflow/) | Nested conditional branching based on user input |
| Customer Support | [declarative/customer_support/](./declarative/customer_support/) | Multi-agent customer support with routing |
| Deep Research | [declarative/deep_research/](./declarative/deep_research/) | Research workflow with planning, searching, and synthesis |
| Function Tools | [declarative/function_tools/](./declarative/function_tools/) | Invoking Python functions from declarative workflows |
| Human-in-Loop | [declarative/human_in_loop/](./declarative/human_in_loop/) | Interactive workflows that request user input |
| Invoke Function Tool | [declarative/invoke_function_tool/](./declarative/invoke_function_tool/) | Call registered Python functions with InvokeFunctionTool |
| Marketing | [declarative/marketing/](./declarative/marketing/) | Marketing content generation workflow |
| Simple Workflow | [declarative/simple_workflow/](./declarative/simple_workflow/) | Basic workflow with variable setting, conditionals, and loops |
| Student Teacher | [declarative/student_teacher/](./declarative/student_teacher/) | Student-teacher interaction pattern |
### resources
@@ -69,6 +69,9 @@ actions:
- `InvokeAzureAgent` - Call an Azure AI agent
- `InvokePromptAgent` - Call a local prompt agent
### Tool Invocation
- `InvokeFunctionTool` - Call a registered Python function
### Human-in-Loop
- `Question` - Request user input
- `WaitForInput` - Pause for external input
@@ -0,0 +1,261 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent to Function Tool sample - demonstrates chaining agent output to function tools.
This sample shows how to:
1. Use InvokeAzureAgent to analyze user input with an AI model
2. Pass the agent's structured output to InvokeFunctionTool actions
3. Chain multiple function tools to process and transform data
The workflow:
1. Takes a user order request as input
2. Uses an Azure agent to extract structured order data (item, quantity, details)
3. Passes the extracted data to a function tool that calculates the order total
4. Uses another function tool to format the final confirmation message
Run with:
python -m samples.03-workflows.declarative.agent_to_function_tool.main
"""
import asyncio
from pathlib import Path
from typing import Any
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.declarative import WorkflowFactory
from azure.identity import AzureCliCredential
from pydantic import BaseModel, Field
# Pricing data for the order calculation
ITEM_PRICES = {
"pizza": {"small": 10.99, "medium": 14.99, "large": 18.99, "default": 14.99},
"burger": {"small": 6.99, "medium": 8.99, "large": 10.99, "default": 8.99},
"salad": {"small": 7.99, "medium": 9.99, "large": 11.99, "default": 9.99},
"sandwich": {"small": 6.99, "medium": 8.99, "large": 10.99, "default": 8.99},
"pasta": {"small": 11.99, "medium": 14.99, "large": 17.99, "default": 14.99},
}
EXTRAS_PRICES = {
"extra cheese": 2.00,
"bacon": 2.50,
"avocado": 1.50,
"mushrooms": 1.00,
"pepperoni": 2.00,
}
# Agent instructions for order analysis
ORDER_ANALYSIS_INSTRUCTIONS = """You are an order analysis assistant. Analyze the customer's order request and extract:
- item: what they want to order (e.g., "pizza", "burger", "salad")
- quantity: how many (as a number, default to 1 if not specified)
- details: any special requests, modifications, or size (e.g., "large", "extra cheese")
- delivery_address: where to deliver (if mentioned, otherwise empty string)
Always respond with valid JSON matching the required format."""
# Pydantic model for structured agent output
class OrderAnalysis(BaseModel):
"""Structured output from the order analysis agent."""
item: str = Field(description="The food item being ordered (e.g., pizza, burger)")
quantity: int = Field(description="Number of items ordered", default=1)
details: str = Field(description="Special requests, size, or modifications")
delivery_address: str = Field(description="Delivery address if provided, empty string otherwise", default="")
def calculate_order_total(order_data: dict[str, Any]) -> dict[str, Any]:
"""Calculate the total cost of an order based on the agent's structured analysis.
Args:
order_data: Structured dict from the agent containing order analysis.
Returns:
Dictionary with pricing breakdown.
"""
# Handle case where order_data might be None or invalid
if not order_data or not isinstance(order_data, dict):
return {
"error": f"Invalid order data: {order_data}",
"subtotal": 0.0,
"tax": 0.0,
"delivery_fee": 0.0,
"total": 0.0,
}
item = str(order_data.get("item", "")).lower()
quantity = int(order_data.get("quantity", 1))
details = str(order_data.get("details", "")).lower()
has_delivery = bool(order_data.get("delivery_address"))
# Determine size from details
size = "default"
for s in ["small", "medium", "large"]:
if s in details:
size = s
break
# Get base price for item
item_key = None
for key in ITEM_PRICES:
if key in item:
item_key = key
break
unit_price = ITEM_PRICES[item_key].get(size, ITEM_PRICES[item_key]["default"]) if item_key else 12.99
# Calculate extras
extras_total = 0.0
applied_extras: list[dict[str, Any]] = []
for extra, price in EXTRAS_PRICES.items():
if extra in details:
extras_total += price * quantity
applied_extras.append({"name": extra, "price": price})
# Calculate totals
subtotal = (unit_price * quantity) + extras_total
tax = round(subtotal * 0.08, 2) # 8% tax
delivery_fee = 5.00 if has_delivery else 0.0
total = round(subtotal + tax + delivery_fee, 2)
return {
"item": item,
"quantity": quantity,
"size": size if size != "default" else "regular",
"unit_price": unit_price,
"extras": applied_extras,
"extras_total": extras_total,
"subtotal": round(subtotal, 2),
"tax": tax,
"delivery_fee": delivery_fee,
"total": total,
"has_delivery": has_delivery,
}
def format_order_confirmation(order_data: dict[str, Any], order_calculation: dict[str, Any]) -> str:
"""Format a human-readable order confirmation message.
Args:
order_data: Structured dict from the agent with order details.
order_calculation: Pricing calculation from calculate_order_total.
Returns:
Formatted confirmation message.
"""
calc = order_calculation
# Handle error case
if "error" in calc:
return f"Sorry, we couldn't process your order: {calc['error']}"
# Build the confirmation message
qty = int(calc.get("quantity", 1))
size = calc.get("size", "regular").title()
item = calc.get("item", "item").title()
lines = [
"=" * 50,
"ORDER CONFIRMATION",
"=" * 50,
"",
f"Item: {qty}x {size} {item}",
f"Unit Price: ${calc.get('unit_price', 0):.2f}",
]
# Add extras if any
extras = calc.get("extras", [])
if extras:
lines.append("\nExtras:")
for extra in extras:
lines.append(f" + {extra['name'].title()}: ${extra['price']:.2f} each")
lines.append(f" Extras Total: ${calc.get('extras_total', 0):.2f}")
lines.extend([
"",
"-" * 30,
f"Subtotal: ${calc.get('subtotal', 0):.2f}",
f"Tax (8%): ${calc.get('tax', 0):.2f}",
])
if calc.get("has_delivery"):
delivery_address = order_data.get("delivery_address", "Address provided") if order_data else "Address provided"
lines.extend([
f"Delivery Fee: ${calc.get('delivery_fee', 0):.2f}",
f"Delivery To: {delivery_address}",
])
lines.extend([
"-" * 30,
f"TOTAL: ${calc.get('total', 0):.2f}",
"=" * 50,
"",
"Thank you for your order!",
])
return "\n".join(lines)
async def main():
"""Run the agent to function tool workflow."""
# Create Azure OpenAI client
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
# Create the order analysis agent with structured output
order_analysis_agent = chat_client.as_agent(
name="OrderAnalysisAgent",
instructions=ORDER_ANALYSIS_INSTRUCTIONS,
default_options={"response_format": OrderAnalysis},
)
# Agent registry
agents = {
"OrderAnalysisAgent": order_analysis_agent,
}
# Get the path to the workflow YAML file
workflow_path = Path(__file__).parent / "workflow.yaml"
# Create the workflow factory with agents and tools
factory = (
WorkflowFactory(agents=agents)
.register_tool("calculate_order_total", calculate_order_total)
.register_tool("format_order_confirmation", format_order_confirmation)
)
# Create the workflow from the YAML definition
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print("=" * 60)
print("Agent to Function Tool Workflow Demo")
print("=" * 60)
print()
print("This workflow demonstrates:")
print(" 1. Using InvokeAzureAgent to analyze user input")
print(" 2. Passing agent's structured output to InvokeFunctionTool")
print(" 3. Chaining multiple function tools together")
print()
# Test with different order inputs
test_queries = [
"I want to order 3 large pizzas with extra cheese for delivery to 123 Main St",
"2 medium burgers with bacon please",
"Can I get a small salad with avocado and mushrooms, pick up",
]
for query in test_queries:
print("-" * 60)
print(f"Input: {query}")
print("-" * 60)
# Run the workflow with streaming to capture output
try:
async for event in workflow.run(query, stream=True):
if event.type == "output" and isinstance(event.data, str):
print(event.data, end="", flush=True)
except Exception as e:
print(f"\nWorkflow error: {type(e).__name__}: {e}")
print("\n")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,56 @@
# Agent to Function Tool Workflow
#
# This workflow demonstrates chaining an agent invocation with a function tool.
# The agent analyzes user input, and the function tool processes the agent's output.
#
# Flow:
# 1. Receive user query
# 2. Invoke an Azure agent to analyze the query and extract structured data
# 3. Pass the agent's structured output to a function tool for processing
# 4. Return the final result
#
# Example input:
# I want to order 3 large pizzas with extra cheese for delivery to 123 Main St
kind: Workflow
trigger:
kind: OnConversationStart
id: agent_to_function_tool_demo
actions:
# Invoke the order analysis agent to extract structured order data
- kind: InvokeAzureAgent
id: analyze_order
agent:
name: OrderAnalysisAgent
input:
messages: =Workflow.Inputs.input
output:
response: Local.agentResponse
responseObject: Local.orderData
# Invoke a function tool to calculate order total using the agent's output
- kind: InvokeFunctionTool
id: calculate_order
functionName: calculate_order_total
arguments:
order_data: =Local.orderData
output:
result: Local.orderCalculation
# Invoke another function tool to format the final confirmation
- kind: InvokeFunctionTool
id: format_confirmation
functionName: format_order_confirmation
arguments:
order_data: =Local.orderData
order_calculation: =Local.orderCalculation
output:
result: Local.confirmation
# Send the final confirmation to the user
- kind: SendActivity
id: send_confirmation
activity:
text: =Local.confirmation
@@ -0,0 +1,116 @@
# Copyright (c) Microsoft. All rights reserved.
"""Invoke Function Tool sample - demonstrates InvokeFunctionTool workflow actions.
This sample shows how to:
1. Define Python functions that can be called from workflows
2. Register functions with WorkflowFactory.register_tool()
3. Use the InvokeFunctionTool action in YAML to invoke registered functions
4. Pass arguments using expression syntax (=Local.variable)
5. Capture function output in workflow variables
Run with:
python -m samples.03-workflows.declarative.invoke_function_tool.main
"""
import asyncio
from pathlib import Path
from typing import Any
from agent_framework.declarative import WorkflowFactory
# Define the function tools that will be registered with the workflow
def get_weather(location: str, unit: str = "F") -> dict[str, Any]:
"""Get weather information for a location.
This is a mock function that returns simulated weather data.
In a real application, this would call a weather API.
Args:
location: The city or location to get weather for.
unit: Temperature unit ("F" for Fahrenheit, "C" for Celsius).
Returns:
Dictionary with weather information.
"""
# Simulated weather data
weather_data = {
"Seattle": {"temp": 55, "condition": "rainy"},
"New York": {"temp": 70, "condition": "partly cloudy"},
"Los Angeles": {"temp": 85, "condition": "sunny"},
"Chicago": {"temp": 60, "condition": "windy"},
}
data = weather_data.get(location, {"temp": 72, "condition": "unknown"})
# Convert to Celsius if requested
temp = data["temp"]
if unit.upper() == "C":
temp = round((temp - 32) * 5 / 9) # type: ignore
return {
"location": location,
"temp": temp,
"unit": unit.upper(),
"condition": data["condition"],
}
def format_message(template: str, data: dict[str, Any]) -> str:
"""Format a message template with data.
Args:
template: A string template with {key} placeholders.
data: Dictionary of values to substitute.
Returns:
Formatted message string.
"""
try:
return template.format(**data)
except KeyError as e:
return f"Error formatting message: missing key {e}"
async def main():
"""Run the invoke function tool workflow."""
# Get the path to the workflow YAML file
workflow_path = Path(__file__).parent / "workflow.yaml"
# Create the workflow factory and register our tool functions
factory = (
WorkflowFactory().register_tool("get_weather", get_weather).register_tool("format_message", format_message)
)
# Create the workflow from the YAML definition
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print("=" * 60)
print("Invoke Function Tool Workflow Demo")
print("=" * 60)
# Test with different inputs - both location and unit must be provided
# as the workflow expects them in Workflow.Inputs
test_inputs = [
{"location": "Seattle", "unit": "F"},
{"location": "New York", "unit": "C"},
{"location": "Los Angeles", "unit": "F"},
{"location": "Chicago", "unit": "C"},
]
for inputs in test_inputs:
print(f"\nInput: {inputs}")
print("-" * 40)
# Run the workflow
events = await workflow.run(inputs)
# Get the outputs
outputs = events.get_outputs()
for output in outputs:
print(f"Output: {output}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,51 @@
# Invoke Function Tool Workflow
name: invoke_function_tool_demo
description: Demonstrates the InvokeFunctionTool action for invoking registered functions
actions:
# Set up input location
- kind: SetValue
id: set_location
path: Local.location
value: =If(IsBlank(inputs.location), "Seattle", inputs.location)
# Set up temperature unit
- kind: SetValue
id: set_unit
path: Local.unit
value: =If(IsBlank(inputs.unit), "F", inputs.unit)
# Invoke the get_weather function tool
- kind: InvokeFunctionTool
id: invoke_weather
functionName: get_weather
arguments:
location: =Local.location
unit: =Local.unit
output:
messages: Local.weatherToolCallItems
result: Local.weatherInfo
autoSend: true
# Format a human-readable message using another function
- kind: InvokeFunctionTool
id: format_output
functionName: format_message
arguments:
template: "The weather in {location} is {temp}°{unit}"
data: =Local.weatherInfo
output:
result: Local.formattedMessage
# Output the result
- kind: SendActivity
id: send_weather
activity:
text: =Local.formattedMessage
# Store the result in workflow outputs
- kind: SetValue
id: set_output
path: Workflow.Outputs.weather
value: =Local.weatherInfo
+463 -476
View File
File diff suppressed because it is too large Load Diff