mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-xunit3-mtp-upgrade
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
@@ -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" />
|
||||
|
||||
+22
@@ -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>
|
||||
+76
@@ -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),
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
+63
@@ -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|
|
||||
|
||||
+142
-3
@@ -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);
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
+1
-2
@@ -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" />
|
||||
|
||||
+3
-1
@@ -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;
|
||||
}
|
||||
|
||||
+75
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
+171
-47
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "<tags>" in prompt
|
||||
assert "&" 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 "<tags>" in prompt
|
||||
assert "&" 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",
|
||||
|
||||
+20
-3
@@ -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
|
||||
|
||||
-68
@@ -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,
|
||||
}
|
||||
|
||||
+711
@@ -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."""
|
||||
|
||||
|
||||
+3
@@ -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")
|
||||
|
||||
|
||||
+21
-3
@@ -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.
|
||||
+5
@@ -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 |
|
||||
+55
@@ -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.
|
||||
@@ -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
|
||||
Generated
+463
-476
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user