diff --git a/docs/features/vector-stores-and-embeddings/README.md b/docs/features/vector-stores-and-embeddings/README.md index 02e71ab028..560fdd86d6 100644 --- a/docs/features/vector-stores-and-embeddings/README.md +++ b/docs/features/vector-stores-and-embeddings/README.md @@ -140,9 +140,10 @@ This feature ports the vector store abstractions, embedding generator abstractio ## Implementation Phases -### Phase 1: Core Embedding Abstractions & OpenAI Implementation +### Phase 1: Core Embedding Abstractions & OpenAI Implementation ✅ DONE **Goal:** Establish the embedding generator abstraction and ship one working implementation. **Mergeable:** Yes — adds new types/protocols, no breaking changes. +**Status:** Merged via PR #4153. Closes sub-issue #4163. #### 1.1 — Embedding types in `_types.py` - `EmbeddingInputT` TypeVar (default `str`) — generic input type for embedding generation diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 0de4409d53..396a316575 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -111,9 +111,9 @@ - - - + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 041da4a6f6..6e4bb0f02a 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -185,6 +185,7 @@ + @@ -227,6 +228,7 @@ + @@ -438,6 +440,7 @@ + @@ -483,6 +486,7 @@ + diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 3d7e04c3f8..dcfcac4077 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -2,11 +2,11 @@ 1.0.0 - 1 + 2 $(VersionPrefix)-rc$(RCNumber) - $(VersionPrefix)-$(VersionSuffix).260219.1 - $(VersionPrefix)-preview.260219.1 - 1.0.0-rc1 + $(VersionPrefix)-$(VersionSuffix).260225.1 + $(VersionPrefix)-preview.260225.1 + 1.0.0-rc2 Debug;Release;Publish true diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/FoundryAgents_Step23_MicrosoftFabric.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/FoundryAgents_Step23_MicrosoftFabric.csproj new file mode 100644 index 0000000000..4d17fe06bb --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/FoundryAgents_Step23_MicrosoftFabric.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + + enable + enable + $(NoWarn);CA1812;CS8321 + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/Program.cs new file mode 100644 index 0000000000..2f13c2c30c --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/Program.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use Microsoft Fabric 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 fabricConnectionId = Environment.GetEnvironmentVariable("FABRIC_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("FABRIC_PROJECT_CONNECTION_ID is not set."); + +const string AgentInstructions = "You are a helpful assistant with access to Microsoft Fabric data. Answer questions based on data available through your Fabric connection."; + +// 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()); + +// Configure Microsoft Fabric tool options with project connection +var fabricToolOptions = new FabricDataAgentToolOptions(); +fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection(fabricConnectionId)); + +AIAgent agent = await CreateAgentWithMEAIAsync(); +// AIAgent agent = await CreateAgentWithNativeSDKAsync(); + +Console.WriteLine($"Created agent: {agent.Name}"); + +// Run the agent with a sample query +AgentResponse response = await agent.RunAsync("What data is available in the connected Fabric workspace?"); + +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.CreateMicrosoftFabricTool (MEAI + AgentFramework) +async Task CreateAgentWithMEAIAsync() +{ + return await aiProjectClient.CreateAIAgentAsync( + model: deploymentName, + name: "FabricAgent-MEAI", + instructions: AgentInstructions, + tools: [((ResponseTool)AgentTool.CreateMicrosoftFabricTool(fabricToolOptions)).AsAITool()]); +} + +// Option 2 - Using PromptAgentDefinition with AgentTool.CreateMicrosoftFabricTool (Native SDK) +async Task CreateAgentWithNativeSDKAsync() +{ + return await aiProjectClient.CreateAIAgentAsync( + name: "FabricAgent-NATIVE", + creationOptions: new AgentVersionCreationOptions( + new PromptAgentDefinition(model: deploymentName) + { + Instructions = AgentInstructions, + Tools = + { + AgentTool.CreateMicrosoftFabricTool(fabricToolOptions), + } + }) + ); +} diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/README.md new file mode 100644 index 0000000000..cc7f91874e --- /dev/null +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/README.md @@ -0,0 +1,57 @@ +# Using Microsoft Fabric Tool with AI Agents + +This sample demonstrates how to use the Microsoft Fabric tool with AI Agents, allowing agents to query and interact with data in Microsoft Fabric workspaces. + +## What this sample demonstrates + +- Creating agents with Microsoft Fabric data access capabilities +- Using FabricDataAgentToolOptions to configure Fabric connections +- Two agent creation approaches: MEAI abstraction (Option 1) and Native SDK (Option 2) +- Managing agent lifecycle (creation and deletion) + +## Agent creation options + +This sample provides two approaches for creating agents with Microsoft Fabric: + +- **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 Microsoft Fabric workspace with a configured project connection in Azure Foundry + +**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:FABRIC_PROJECT_CONNECTION_ID="your-fabric-connection-id" # The Fabric project connection ID from Azure Foundry +``` + +## Run the sample + +Navigate to the FoundryAgents sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/FoundryAgents +dotnet run --project .\FoundryAgents_Step23_MicrosoftFabric +``` + +## Expected behavior + +The sample will: + +1. Create an agent with Microsoft Fabric tool capabilities +2. Configure the agent with a Fabric project connection +3. Run the agent with a query about available Fabric data +4. Display the agent's response +5. Clean up resources by deleting the agent diff --git a/dotnet/samples/GettingStarted/FoundryAgents/README.md b/dotnet/samples/GettingStarted/FoundryAgents/README.md index 8f83afbd1e..1315385164 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/README.md +++ b/dotnet/samples/GettingStarted/FoundryAgents/README.md @@ -60,6 +60,7 @@ Before you begin, ensure you have the following prerequisites: |[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| |[SharePoint grounding](./FoundryAgents_Step22_SharePoint/)|This sample demonstrates how to use the SharePoint grounding tool with a Foundry agent| +|[Microsoft Fabric](./FoundryAgents_Step23_MicrosoftFabric/)|This sample demonstrates how to use Microsoft Fabric 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| diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj new file mode 100644 index 0000000000..9c905deeb7 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj @@ -0,0 +1,39 @@ + + + + Exe + net10.0 + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml new file mode 100644 index 0000000000..7b942cb2bd --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml @@ -0,0 +1,63 @@ +# +# This workflow demonstrates invoking MCP tools directly from a declarative workflow. +# Uses the Foundry MCP server to search AI model details. +# +# The workflow: +# 1. Accepts a model search term as input +# 2. Invokes the Foundry MCP tool +# 3. Invokes the Microsoft Learn MCP tool +# 4. Uses an agent to summarize the results +# +# Example input: +# gpt-4.1 +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_invoke_mcp_tool + actions: + + # Set the search query from user input or use default + - kind: SetVariable + id: set_search_query + variable: Local.SearchQuery + value: =System.LastMessage.Text + + # Invoke MCP search tool on Foundry MCP server + - kind: InvokeMcpTool + id: invoke_foundry_search + serverUrl: https://mcp.ai.azure.com + serverLabel: azure_mcp_server + toolName: model_details_get + conversationId: =System.ConversationId + arguments: + modelName: =Local.SearchQuery + output: + autoSend: true + result: Local.FoundrySearchResult + + # Invoke MCP search tool on Microsoft Learn server + - kind: InvokeMcpTool + id: invoke_docs_search + serverUrl: https://learn.microsoft.com/api/mcp + serverLabel: microsoft_docs + toolName: microsoft_docs_search + conversationId: =System.ConversationId + arguments: + query: =Local.SearchQuery + output: + autoSend: true + result: Local.DocsSearchResult + + # Use the search agent to provide a helpful response based on results + - kind: InvokeAzureAgent + id: summarize_results + agent: + name: McpSearchAgent + conversationId: =System.ConversationId + input: + messages: =UserMessage("Based on the search results for '" & Local.SearchQuery & "', please provide a helpful summary.") + output: + autoSend: true + result: Local.Summary diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs new file mode 100644 index 0000000000..3afebc560b --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates using the InvokeMcpTool action to call MCP (Model Context Protocol) +// server tools directly from a declarative workflow. MCP servers expose tools that can be +// invoked to perform specific tasks, like searching documentation or executing operations. + +using Azure.AI.Projects; +using Azure.AI.Projects.OpenAI; +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.AI.Workflows.Declarative.Mcp; +using Microsoft.Extensions.Configuration; +using Shared.Foundry; +using Shared.Workflows; + +namespace Demo.Workflows.Declarative.InvokeMcpTool; + +/// +/// Demonstrates a workflow that uses InvokeMcpTool to call MCP server tools +/// directly from the workflow. +/// +/// +/// +/// The InvokeMcpTool action allows workflows to invoke tools on MCP (Model Context Protocol) +/// servers. This enables: +/// +/// +/// Searching external data sources like documentation +/// Executing operations on remote servers +/// Integrating with MCP-compatible services +/// +/// +/// This sample uses the Microsoft Learn MCP server to search Azure documentation and the Azure foundry MCP server to get AI model details. +/// When you run the sample, provide an AI model (e.g. gpt-4.1-mini) as input, +/// The workflow will use the MCP tools to find relevant information about the model from Microsoft Learn and foundry, then an agent will summarize the results. +/// +/// +/// See the README.md file in the parent folder (../README.md) for detailed +/// information about the configuration required to run this sample. +/// +/// +internal sealed class Program +{ + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + + // Ensure sample agent exists in Foundry + await CreateAgentAsync(foundryEndpoint, configuration); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // Create the MCP tool handler for invoking MCP server tools. + // The HttpClient callback allows configuring authentication per MCP server. + // Different MCP servers may require different authentication configurations. + // For Production scenarios, consider implementing a more robust HttpClient management strategy to reuse HttpClient instances and manage their lifetimes appropriately. + List createdHttpClients = []; + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + DefaultAzureCredential credential = new(); + DefaultMcpToolHandler mcpToolHandler = new( + httpClientProvider: async (serverUrl, cancellationToken) => + { + if (serverUrl.StartsWith("https://mcp.ai.azure.com", StringComparison.OrdinalIgnoreCase)) + { + // Acquire token for the Azure MCP server + AccessToken token = await credential.GetTokenAsync( + new TokenRequestContext(["https://mcp.ai.azure.com/.default"]), + cancellationToken); + + // Create HttpClient with Authorization header + HttpClient httpClient = new(); + httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.Token); + createdHttpClients.Add(httpClient); + return httpClient; + } + + if (serverUrl.StartsWith("https://learn.microsoft.com", StringComparison.OrdinalIgnoreCase)) + { + // Microsoft Learn MCP server does not require authentication + HttpClient httpClient = new(); + createdHttpClients.Add(httpClient); + return httpClient; + } + + // Return null for unknown servers to use the default HttpClient without auth. + return null; + }); + + try + { + // Create the workflow factory with MCP tool provider + WorkflowFactory workflowFactory = new("InvokeMcpTool.yaml", foundryEndpoint) + { + McpToolHandler = mcpToolHandler + }; + + // Execute the workflow + WorkflowRunner runner = new() { UseJsonCheckpoints = true }; + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + finally + { + // Clean up connections and dispose created HttpClients + await mcpToolHandler.DisposeAsync(); + + foreach (HttpClient httpClient in createdHttpClients) + { + httpClient.Dispose(); + } + } + } + + private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration) + { + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential()); + + await aiProjectClient.CreateAgentAsync( + agentName: "McpSearchAgent", + agentDefinition: DefineSearchAgent(configuration), + agentDescription: "Provides information based on search results"); + } + + private static PromptAgentDefinition DefineSearchAgent(IConfiguration configuration) + { + return new PromptAgentDefinition(configuration.GetValue(Application.Settings.FoundryModelMini)) + { + Instructions = + """ + You are a helpful assistant that answers questions based on search results. + Use the information provided in the conversation history to answer questions. + If the information is already available in the conversation, use it directly. + Be concise and helpful in your responses. + """ + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs index 9b6cdc2ffd..27882375d7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs @@ -98,7 +98,7 @@ internal sealed class PurviewWrapper : IDisposable try { - (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, options?.ConversationId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); + (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, options?.ConversationId, Activity.DownloadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); if (shouldBlockResponse) { if (this._logger.IsEnabled(LogLevel.Information)) @@ -186,7 +186,7 @@ internal sealed class PurviewWrapper : IDisposable sessionIdResponse = sessionId; } } - (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, sessionIdResponse, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); + (bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, sessionIdResponse, Activity.DownloadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false); if (shouldBlockResponse) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs new file mode 100644 index 0000000000..751f518277 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp; + +/// +/// Default implementation of using the MCP C# SDK. +/// +/// +/// This provider supports per-server authentication via the httpClientProvider callback. +/// The callback allows different MCP servers to use different authentication configurations by returning +/// a pre-configured for each server. +/// +public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable +{ + private readonly Func>? _httpClientProvider; + private readonly Dictionary _clients = []; + private readonly Dictionary _ownedHttpClients = []; + private readonly SemaphoreSlim _clientLock = new(1, 1); + + /// + /// Initializes a new instance of the class. + /// + /// + /// An optional callback that provides an for each MCP server. + /// The callback receives (serverUrl, cancellationToken) and should return an HttpClient + /// configured with any required authentication. Return to use a default HttpClient with no auth. + /// + public DefaultMcpToolHandler(Func>? httpClientProvider = null) + { + this._httpClientProvider = httpClientProvider; + } + + /// + public async Task InvokeToolAsync( + string serverUrl, + string? serverLabel, + string toolName, + IDictionary? arguments, + IDictionary? headers, + string? connectionName, + CancellationToken cancellationToken = default) + { + // TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore + McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString()); + McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false); + + // Convert IDictionary to IReadOnlyDictionary for CallToolAsync + IReadOnlyDictionary? readOnlyArguments = arguments is null + ? null + : arguments as IReadOnlyDictionary ?? new Dictionary(arguments); + + CallToolResult result = await client.CallToolAsync( + toolName, + readOnlyArguments, + cancellationToken: cancellationToken).ConfigureAwait(false); + + // Map MCP content blocks to MEAI AIContent types + PopulateResultContent(resultContent, result); + + return resultContent; + } + + /// + public async ValueTask DisposeAsync() + { + await this._clientLock.WaitAsync().ConfigureAwait(false); + try + { + foreach (McpClient client in this._clients.Values) + { + await client.DisposeAsync().ConfigureAwait(false); + } + + this._clients.Clear(); + + // Dispose only HttpClients that the handler created (not user-provided ones) + foreach (HttpClient httpClient in this._ownedHttpClients.Values) + { + httpClient.Dispose(); + } + + this._ownedHttpClients.Clear(); + } + finally + { + this._clientLock.Release(); + } + + this._clientLock.Dispose(); + } + + private async Task GetOrCreateClientAsync( + string serverUrl, + string? serverLabel, + IDictionary? headers, + CancellationToken cancellationToken) + { + string normalizedUrl = serverUrl.Trim().ToUpperInvariant(); + string clientCacheKey = $"{normalizedUrl}|{ComputeHeadersHash(headers)}"; + + await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (this._clients.TryGetValue(clientCacheKey, out McpClient? existingClient)) + { + return existingClient; + } + + McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, normalizedUrl, cancellationToken).ConfigureAwait(false); + this._clients[clientCacheKey] = newClient; + return newClient; + } + finally + { + this._clientLock.Release(); + } + } + + private async Task CreateClientAsync( + string serverUrl, + string? serverLabel, + IDictionary? headers, + string httpClientCacheKey, + CancellationToken cancellationToken) + { + // Get or create HttpClient (Can be shared across McpClients for the same server) + HttpClient? httpClient = null; + + if (this._httpClientProvider is not null) + { + httpClient = await this._httpClientProvider(serverUrl, cancellationToken).ConfigureAwait(false); + } + + if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient)) + { + httpClient = new HttpClient(); + this._ownedHttpClients[httpClientCacheKey] = httpClient; + } + + HttpClientTransportOptions transportOptions = new() + { + Endpoint = new Uri(serverUrl), + Name = serverLabel ?? "McpClient", + AdditionalHeaders = headers, + TransportMode = HttpTransportMode.AutoDetect + }; + + HttpClientTransport transport = new(transportOptions, httpClient); + + return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private static string ComputeHeadersHash(IDictionary? headers) + { + if (headers is null || headers.Count == 0) + { + return string.Empty; + } + + // Build a deterministic, sorted representation of the headers + // Within a single process lifetime, the hashcodes are consistent. + // This will ensure that the same set of headers always produces the same hash, regardless of order. + SortedDictionary sorted = new(headers.ToDictionary(h => h.Key.ToUpperInvariant(), h => h.Value.ToUpperInvariant())); + int hashCode = 17; + foreach (KeyValuePair kvp in sorted) + { + hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key); + hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value); + } + + return hashCode.ToString(CultureInfo.InvariantCulture); + } + + private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result) + { + // Ensure Output list is initialized + resultContent.Output ??= []; + + if (result.IsError == true) + { + // Collect error text from content blocks + string? errorText = null; + if (result.Content is not null) + { + foreach (ContentBlock block in result.Content) + { + if (block is TextContentBlock textBlock) + { + errorText = errorText is null ? textBlock.Text : $"{errorText}\n{textBlock.Text}"; + } + } + } + + resultContent.Output.Add(new TextContent($"Error: {errorText ?? "Unknown error from MCP Server call"}")); + return; + } + + if (result.Content is null || result.Content.Count == 0) + { + return; + } + + // Map each MCP content block to an MEAI AIContent type + foreach (ContentBlock block in result.Content) + { + AIContent content = ConvertContentBlock(block); + if (content is not null) + { + resultContent.Output.Add(content); + } + } + } + + private static AIContent ConvertContentBlock(ContentBlock block) + { + return block switch + { + TextContentBlock text => new TextContent(text.Text), + ImageContentBlock image => CreateDataContentFromBase64(image.Data, image.MimeType ?? "image/*"), + AudioContentBlock audio => CreateDataContentFromBase64(audio.Data, audio.MimeType ?? "audio/*"), + _ => new TextContent(block.ToString() ?? string.Empty), + }; + } + + private static DataContent CreateDataContentFromBase64(string? base64Data, string mediaType) + { + if (string.IsNullOrEmpty(base64Data)) + { + return new DataContent($"data:{mediaType};base64,", mediaType); + } + + // If it's already a data URI, use it directly + if (base64Data.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + return new DataContent(base64Data, mediaType); + } + + // Otherwise, construct a data URI from the base64 data + return new DataContent($"data:{mediaType};base64,{base64Data}", mediaType); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj new file mode 100644 index 0000000000..f9bf706669 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj @@ -0,0 +1,33 @@ + + + + true + $(NoWarn);MEAI001;OPENAI001 + + + + true + true + true + + + + + + + Microsoft Agent Framework Declarative Workflows MCP + Provides Microsoft Agent Framework support for MCP (Model Context Protocol) server integration in declarative workflows. + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs index c4808f9311..9e421832d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -20,6 +20,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid /// public ResponseAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider); + /// + /// Gets or sets the MCP tool handler for invoking MCP tools within workflows. + /// If not set, MCP tool invocations will fail with an appropriate error message. + /// + public IMcpToolHandler? McpToolHandler { get; init; } + /// /// Defines the configuration settings for the workflow. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs index e2b038bbd7..a74b9c6eb8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs @@ -42,6 +42,40 @@ internal static class JsonDocumentExtensions }; } + /// + /// Creates a VariableType.List with schema inferred from the first object element in the array. + /// + public static VariableType GetListTypeFromJson(this JsonElement arrayElement) + { + // Find the first object element to infer schema + foreach (JsonElement element in arrayElement.EnumerateArray()) + { + if (element.ValueKind == JsonValueKind.Object) + { + // Build schema from the object's properties + List<(string Key, VariableType Type)> fields = []; + foreach (JsonProperty property in element.EnumerateObject()) + { + VariableType fieldType = property.Value.ValueKind switch + { + JsonValueKind.String => typeof(string), + JsonValueKind.Number => typeof(decimal), + JsonValueKind.True or JsonValueKind.False => typeof(bool), + JsonValueKind.Object => VariableType.RecordType, + JsonValueKind.Array => VariableType.ListType, + _ => typeof(string), + }; + fields.Add((property.Name, fieldType)); + } + + return VariableType.List(fields); + } + } + + // Fallback for arrays of primitives or empty arrays + return VariableType.ListType; + } + private static Dictionary ParseRecord(this JsonElement currentElement, VariableType targetType) { IEnumerable> keyValuePairs = @@ -118,6 +152,7 @@ internal static class JsonDocumentExtensions JsonValueKind.True => typeof(bool), JsonValueKind.False => typeof(bool), JsonValueKind.Number => typeof(decimal), + JsonValueKind.Array => (VariableType)VariableType.ListType, // Add support for nested arrays _ => null, }; @@ -285,9 +320,16 @@ internal static class JsonDocumentExtensions private static bool TryParseList(JsonElement propertyElement, VariableType? targetType, out object? value) { + // Handle empty arrays without needing to determine element type + if (propertyElement.GetArrayLength() == 0) + { + value = new List(); + return true; + } + try { - value = ParseTable(propertyElement, targetType ?? VariableType.ListType); + value = ParseTable(propertyElement, targetType ?? GetListTypeFromJson(propertyElement)); return true; } catch diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs new file mode 100644 index 0000000000..56b1c3deb4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Declarative; + +/// +/// Defines the contract for invoking MCP tools within declarative workflows. +/// +/// +/// This interface allows the MCP tool invocation to be abstracted, enabling +/// different implementations for local development, hosted workflows, and testing scenarios. +/// +public interface IMcpToolHandler +{ + /// + /// Invokes an MCP tool on the specified server. + /// + /// The URL of the MCP server. + /// An optional label identifying the server connection. + /// The name of the tool to invoke. + /// Optional arguments to pass to the tool. + /// Optional headers to include in the request. + /// An optional connection name for managed connections. + /// A token to observe cancellation. + /// + /// A task representing the asynchronous operation. The result contains a + /// with the tool invocation output. + /// + Task InvokeToolAsync( + string serverUrl, + string? serverLabel, + string toolName, + IDictionary? arguments, + IDictionary? headers, + string? connectionName, + CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 7b84e24839..fd818672dd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -493,6 +493,42 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor this.ContinueWith(new SendActivityExecutor(item, this._workflowState)); } + protected override void Visit(InvokeMcpTool item) + { + this.Trace(item); + + // Verify MCP handler is configured + if (this._workflowOptions.McpToolHandler is null) + { + throw new DeclarativeModelException("MCP tool handler not configured. Set McpToolHandler in DeclarativeWorkflowOptions to use InvokeMcpTool actions."); + } + + // Entry point to invoke MCP tool - may yield for approval + InvokeMcpToolExecutor action = new(item, this._workflowOptions.McpToolHandler, this._workflowOptions.AgentProvider, this._workflowState); + this.ContinueWith(action); + + // Transition to post action if no external input is required (no approval needed) + string postId = Steps.Post(action.Id); + this._workflowModel.AddLink(action.Id, postId, InvokeMcpToolExecutor.RequiresNothing); + + // If approval is required, define request-port for approval flow + string externalInputPortId = InvokeMcpToolExecutor.Steps.ExternalInput(action.Id); + RequestPortAction externalInputPort = new(RequestPort.Create(externalInputPortId)); + this._workflowModel.AddNode(externalInputPort, action.ParentId); + this._workflowModel.AddLink(action.Id, externalInputPortId, InvokeMcpToolExecutor.RequiresInput); + + // Capture response when external input is received + string resumeId = InvokeMcpToolExecutor.Steps.Resume(action.Id); + this._workflowModel.AddNode(new DelegateActionExecutor(resumeId, this._workflowState, action.CaptureResponseAsync), action.ParentId); + this._workflowModel.AddLink(externalInputPortId, resumeId); + + // After resume, transition to post action + this._workflowModel.AddLink(resumeId, postId); + + // Define post action (completion) + this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId); + } + #region Not supported protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs index 568a38950c..f754c45c62 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs @@ -365,6 +365,8 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor #region Not supported + protected override void Visit(InvokeMcpTool item) => this.NotSupported(item); + protected override void Visit(InvokeFunctionTool item) => this.NotSupported(item); protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index a5215c283b..0e95bebe63 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -204,7 +204,7 @@ internal sealed class InvokeFunctionToolExecutor( object? parsedValue = jsonDocument.RootElement.ValueKind switch { JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType), - JsonValueKind.Array => jsonDocument.ParseList(CreateListTypeFromJson(jsonDocument.RootElement)), + JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()), JsonValueKind.String => jsonDocument.RootElement.GetString(), JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(), JsonValueKind.True => true, @@ -224,40 +224,6 @@ internal sealed class InvokeFunctionToolExecutor( await this.AssignAsync(this.Model.Output.Result?.Path, resultValue.ToFormula(), context).ConfigureAwait(false); } - /// - /// Creates a VariableType.List with schema inferred from the first object element in the array. - /// - private static VariableType CreateListTypeFromJson(JsonElement arrayElement) - { - // Find the first object element to infer schema - foreach (JsonElement element in arrayElement.EnumerateArray()) - { - if (element.ValueKind == JsonValueKind.Object) - { - // Build schema from the object's properties - List<(string Key, VariableType Type)> fields = []; - foreach (JsonProperty property in element.EnumerateObject()) - { - VariableType fieldType = property.Value.ValueKind switch - { - JsonValueKind.String => typeof(string), - JsonValueKind.Number => typeof(decimal), - JsonValueKind.True or JsonValueKind.False => typeof(bool), - JsonValueKind.Object => VariableType.RecordType, - JsonValueKind.Array => VariableType.ListType, - _ => typeof(string), - }; - fields.Add((property.Name, fieldType)); - } - - return VariableType.List(fields); - } - } - - // Fallback for arrays of primitives or empty arrays - return VariableType.ListType; - } - private string GetFunctionName() => this.Evaluator.GetValue( Throw.IfNull( diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs new file mode 100644 index 0000000000..45929f20f7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs @@ -0,0 +1,367 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.ObjectModel; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; + +/// +/// Executor for the action. +/// This executor invokes MCP tools on remote servers and handles approval flows. +/// +internal sealed class InvokeMcpToolExecutor( + InvokeMcpTool model, + IMcpToolHandler mcpToolHandler, + ResponseAgentProvider agentProvider, + WorkflowFormulaState state) : + DeclarativeActionExecutor(model, state) +{ + /// + /// Step identifiers for the MCP tool invocation workflow. + /// + public static class Steps + { + /// + /// Step for waiting for external input (approval or direct response). + /// + public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}"; + + /// + /// Step for resuming after receiving external input. + /// + public static string Resume(string id) => $"{id}_{nameof(Resume)}"; + } + + /// + /// Determines if the message indicates external input is required. + /// + public static bool RequiresInput(object? message) => message is ExternalInputRequest; + + /// + /// Determines if the message indicates no external input is required. + /// + public static bool RequiresNothing(object? message) => message is ActionExecutorResult; + + /// + protected override bool EmitResultEvent => false; + + /// + protected override bool IsDiscreteAction => false; + + /// + [SendsMessage(typeof(ExternalInputRequest))] + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + string serverUrl = this.GetServerUrl(); + string? serverLabel = this.GetServerLabel(); + string toolName = this.GetToolName(); + bool requireApproval = this.GetRequireApproval(); + Dictionary? arguments = this.GetArguments(); + Dictionary? headers = this.GetHeaders(); + string? connectionName = this.GetConnectionName(); + + if (requireApproval) + { + // Create tool call content for approval request + McpServerToolCallContent toolCall = new(this.Id, toolName, serverLabel ?? serverUrl) + { + Arguments = arguments + }; + + if (headers != null) + { + toolCall.AdditionalProperties ??= []; + toolCall.AdditionalProperties.Add(headers); + } + + McpServerToolApprovalRequestContent approvalRequest = new(this.Id, toolCall); + + ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]); + AgentResponse agentResponse = new([requestMessage]); + + // Yield to the caller for approval + ExternalInputRequest inputRequest = new(agentResponse); + await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false); + + return default; + } + + // No approval required - invoke the tool directly + McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync( + serverUrl, + serverLabel, + toolName, + arguments, + headers, + connectionName, + cancellationToken).ConfigureAwait(false); + + await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false); + + // Signal completion so the workflow routes via RequiresNothing + await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); + + return default; + } + + /// + /// Captures the external input response and processes the MCP tool result. + /// + /// The workflow context. + /// The external input response. + /// A cancellation token. + /// A representing the asynchronous operation. + public async ValueTask CaptureResponseAsync( + IWorkflowContext context, + ExternalInputResponse response, + CancellationToken cancellationToken) + { + // Check for approval response + McpServerToolApprovalResponseContent? approvalResponse = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .FirstOrDefault(r => r.Id == this.Id); + + if (approvalResponse?.Approved != true) + { + // Tool call was rejected + await this.AssignErrorAsync(context, "MCP tool invocation was not approved by user.").ConfigureAwait(false); + return; + } + + // Approved - now invoke the tool + string serverUrl = this.GetServerUrl(); + string? serverLabel = this.GetServerLabel(); + string toolName = this.GetToolName(); + Dictionary? arguments = this.GetArguments(); + Dictionary? headers = this.GetHeaders(); + string? connectionName = this.GetConnectionName(); + + McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync( + serverUrl, + serverLabel, + toolName, + arguments, + headers, + connectionName, + cancellationToken).ConfigureAwait(false); + + await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false); + } + + /// + /// Completes the MCP tool invocation by raising the completion event. + /// + public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken) + { + await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ProcessResultAsync(IWorkflowContext context, McpServerToolResultContent resultContent, CancellationToken cancellationToken) + { + bool autoSend = this.GetAutoSendValue(); + string? conversationId = this.GetConversationId(); + + await this.AssignResultAsync(context, resultContent).ConfigureAwait(false); + ChatMessage resultMessage = new(ChatRole.Tool, resultContent.Output); + + // Store messages if output path is configured + if (this.Model.Output?.Messages is not null) + { + await this.AssignAsync(this.Model.Output.Messages?.Path, resultMessage.ToFormula(), context).ConfigureAwait(false); + } + + // Auto-send the result if configured + if (autoSend) + { + AgentResponse resultResponse = new([resultMessage]); + await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false); + } + + // Add messages to conversation if conversationId is provided + if (conversationId is not null) + { + ChatMessage assistantMessage = new(ChatRole.Assistant, resultContent.Output); + await agentProvider.CreateMessageAsync(conversationId, assistantMessage, cancellationToken).ConfigureAwait(false); + } + } + + private async ValueTask AssignResultAsync(IWorkflowContext context, McpServerToolResultContent toolResult) + { + if (this.Model.Output?.Result is null || toolResult.Output is null || toolResult.Output.Count == 0) + { + return; + } + + List parsedResults = []; + foreach (AIContent resultContent in toolResult.Output) + { + object? resultValue = resultContent switch + { + TextContent text => text.Text, + DataContent data => data.Uri, + _ => resultContent.ToString(), + }; + + // Convert JsonElement to its raw JSON string for processing + if (resultValue is JsonElement jsonElement) + { + resultValue = jsonElement.GetRawText(); + } + + // Attempt to parse as JSON if it's a string (or was converted from JsonElement) + if (resultValue is string jsonString) + { + try + { + using JsonDocument jsonDocument = JsonDocument.Parse(jsonString); + + // Handle different JSON value kinds + object? parsedValue = jsonDocument.RootElement.ValueKind switch + { + JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType), + JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()), + JsonValueKind.String => jsonDocument.RootElement.GetString(), + JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => jsonString, + }; + + parsedResults.Add(parsedValue); + continue; + } + catch (JsonException) + { + // Not a valid JSON + } + } + + parsedResults.Add(resultValue); + } + + await this.AssignAsync(this.Model.Output.Result?.Path, parsedResults.ToFormula(), context).ConfigureAwait(false); + } + + private async ValueTask AssignErrorAsync(IWorkflowContext context, string errorMessage) + { + // Store error in result if configured (as a simple string) + if (this.Model.Output?.Result is not null) + { + await this.AssignAsync(this.Model.Output.Result?.Path, $"Error: {errorMessage}".ToFormula(), context).ConfigureAwait(false); + } + } + + private string GetServerUrl() => + this.Evaluator.GetValue( + Throw.IfNull( + this.Model.ServerUrl, + $"{nameof(this.Model)}.{nameof(this.Model.ServerUrl)}")).Value; + + private string? GetServerLabel() + { + if (this.Model.ServerLabel is null) + { + return null; + } + + string value = this.Evaluator.GetValue(this.Model.ServerLabel).Value; + return value.Length == 0 ? null : value; + } + + private string GetToolName() => + this.Evaluator.GetValue( + Throw.IfNull( + this.Model.ToolName, + $"{nameof(this.Model)}.{nameof(this.Model.ToolName)}")).Value; + + private string? GetConversationId() + { + if (this.Model.ConversationId is null) + { + return null; + } + + string value = this.Evaluator.GetValue(this.Model.ConversationId).Value; + return value.Length == 0 ? null : value; + } + + private bool GetRequireApproval() + { + if (this.Model.RequireApproval is null) + { + return false; + } + + return this.Evaluator.GetValue(this.Model.RequireApproval).Value; + } + + private bool GetAutoSendValue() + { + if (this.Model.Output?.AutoSend is null) + { + return true; + } + + return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value; + } + + private string? GetConnectionName() + { + if (this.Model.Connection?.Name is null) + { + return null; + } + + string value = this.Evaluator.GetValue(this.Model.Connection.Name).Value; + return value.Length == 0 ? null : value; + } + + private Dictionary? GetArguments() + { + if (this.Model.Arguments is null) + { + return null; + } + + Dictionary result = []; + foreach (KeyValuePair argument in this.Model.Arguments) + { + result[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject(); + } + + return result; + } + + private Dictionary? GetHeaders() + { + if (this.Model.Headers is null) + { + return null; + } + + Dictionary result = []; + foreach (KeyValuePair header in this.Model.Headers) + { + string value = this.Evaluator.GetValue(header.Value).Value; + if (!string.IsNullOrEmpty(value)) + { + result[header.Key] = value; + } + } + + return result; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs index f26f27ad96..506a0d1039 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs @@ -18,6 +18,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream private int _isDisposed; private readonly ISuperStepRunner _stepRunner; + private Activity? _sessionActivity; public ValueTask GetStatusAsync(CancellationToken cancellationToken = default) => new(this.RunStatus); @@ -30,7 +31,16 @@ internal sealed class LockstepRunEventStream : IRunEventStream public void Start() { - // No-op for lockstep execution + // Save and restore Activity.Current so the long-lived session activity + // doesn't leak into caller code via AsyncLocal. + Activity? previousActivity = Activity.Current; + + this._sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity(); + this._sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId) + .SetTag(Tags.SessionId, this._stepRunner.SessionId); + this._sessionActivity?.AddEvent(new ActivityEvent(EventNames.SessionStarted)); + + Activity.Current = previousActivity; } public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -44,19 +54,23 @@ internal sealed class LockstepRunEventStream : IRunEventStream } #endif - CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken); + using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken); ConcurrentQueue eventSink = []; this._stepRunner.OutgoingEvents.EventRaised += OnWorkflowEventAsync; - using Activity? activity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity(); - activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId); + // Re-establish session as parent so the run activity nests correctly. + Activity.Current = this._sessionActivity; + + // Not 'using' — must dispose explicitly in finally for deterministic export. + Activity? runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity(); + runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId); try { this.RunStatus = RunStatus.Running; - activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted)); + runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted)); do { @@ -65,7 +79,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream { // Because we may be yielding out of this function, we need to ensure that the Activity.Current // is set to our activity for the duration of this loop iteration. - Activity.Current = activity; + Activity.Current = runActivity; // Drain SuperSteps while there are steps to run try @@ -75,13 +89,13 @@ internal sealed class LockstepRunEventStream : IRunEventStream catch (OperationCanceledException) { } - catch (Exception ex) when (activity is not null) + catch (Exception ex) when (runActivity is not null) { - activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() { + runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() { { Tags.ErrorType, ex.GetType().FullName }, - { Tags.BuildErrorMessage, ex.Message }, + { Tags.ErrorMessage, ex.Message }, })); - activity.CaptureException(ex); + runActivity.CaptureException(ex); throw; } @@ -129,12 +143,16 @@ internal sealed class LockstepRunEventStream : IRunEventStream } } while (!ShouldBreak()); - activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted)); + runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted)); } finally { this.RunStatus = this._stepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle; this._stepRunner.OutgoingEvents.EventRaised -= OnWorkflowEventAsync; + + // Explicitly dispose the Activity so Activity.Stop fires deterministically, + // regardless of how the async iterator enumerator is disposed. + runActivity?.Dispose(); } ValueTask OnWorkflowEventAsync(object? sender, WorkflowEvent e) @@ -172,6 +190,14 @@ internal sealed class LockstepRunEventStream : IRunEventStream { this._stopCancellation.Cancel(); + // Stop the session activity + if (this._sessionActivity is not null) + { + this._sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionCompleted)); + this._sessionActivity.Dispose(); + this._sessionActivity = null; + } + this._stopCancellation.Dispose(); this._inputWaiter.Dispose(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs index a6c34f2b9f..a09dedd8ad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs @@ -55,13 +55,20 @@ internal sealed class StreamingRunEventStream : IRunEventStream private async Task RunLoopAsync(CancellationToken cancellationToken) { using CancellationTokenSource errorSource = new(); - CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(errorSource.Token, cancellationToken); + using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(errorSource.Token, cancellationToken); // Subscribe to events - they will flow directly to the channel as they're raised this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync; - using Activity? activity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity(); - activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId); + // Start the session-level activity that spans the entire run loop lifetime. + // Individual run-stage activities are nested within this session activity. + Activity? sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity(); + sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId) + .SetTag(Tags.SessionId, this._stepRunner.SessionId); + + Activity? runActivity = null; + + sessionActivity?.AddEvent(new ActivityEvent(EventNames.SessionStarted)); try { @@ -70,10 +77,15 @@ internal sealed class StreamingRunEventStream : IRunEventStream await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false); this._runStatus = RunStatus.Running; - activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted)); while (!linkedSource.Token.IsCancellationRequested) { + // Start a new run-stage activity for this input→processing→halt cycle + runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity(); + runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId) + .SetTag(Tags.SessionId, this._stepRunner.SessionId); + runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted)); + // Run all available supersteps continuously // Events are streamed out in real-time as they happen via the event handler while (this._stepRunner.HasUnprocessedMessages && !linkedSource.Token.IsCancellationRequested) @@ -93,6 +105,15 @@ internal sealed class StreamingRunEventStream : IRunEventStream RunStatus capturedStatus = this._runStatus; await this._eventChannel.Writer.WriteAsync(new InternalHaltSignal(currentEpoch, capturedStatus), linkedSource.Token).ConfigureAwait(false); + // Close the run-stage activity when processing halts. + // A new run activity will be created when the next input arrives. + if (runActivity is not null) + { + runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted)); + runActivity.Dispose(); + runActivity = null; + } + // Wait for next input from the consumer // Works for both Idle (no work) and PendingRequests (waiting for responses) await this._inputWaiter.WaitForInputAsync(TimeSpan.FromSeconds(1), linkedSource.Token).ConfigureAwait(false); @@ -107,14 +128,26 @@ internal sealed class StreamingRunEventStream : IRunEventStream } catch (Exception ex) { - if (activity != null) + // Record error on the run-stage activity if one is active + if (runActivity is not null) { - activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() { + runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() { { Tags.ErrorType, ex.GetType().FullName }, - { Tags.BuildErrorMessage, ex.Message }, + { Tags.ErrorMessage, ex.Message }, })); - activity.CaptureException(ex); + runActivity.CaptureException(ex); } + + // Record error on the session activity + if (sessionActivity is not null) + { + sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionError, tags: new() { + { Tags.ErrorType, ex.GetType().FullName }, + { Tags.ErrorMessage, ex.Message }, + })); + sessionActivity.CaptureException(ex); + } + await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(ex), linkedSource.Token).ConfigureAwait(false); } finally @@ -124,7 +157,20 @@ internal sealed class StreamingRunEventStream : IRunEventStream // Mark as ended when run loop exits this._runStatus = RunStatus.Ended; - activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted)); + + // Stop the run-stage activity if not already stopped (e.g. on cancellation or error) + if (runActivity is not null) + { + runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted)); + runActivity.Dispose(); + } + + // Stop the session activity — the session always ends when the run loop exits + if (sessionActivity is not null) + { + sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionCompleted)); + sessionActivity.Dispose(); + } } async ValueTask OnEventRaisedAsync(object? sender, WorkflowEvent e) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/ActivityNames.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/ActivityNames.cs index 9d2912ecd3..1639fc3c3c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/ActivityNames.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/ActivityNames.cs @@ -5,7 +5,8 @@ namespace Microsoft.Agents.AI.Workflows.Observability; internal static class ActivityNames { public const string WorkflowBuild = "workflow.build"; - public const string WorkflowRun = "workflow_invoke"; + public const string WorkflowSession = "workflow.session"; + public const string WorkflowInvoke = "workflow_invoke"; public const string MessageSend = "message.send"; public const string ExecutorProcess = "executor.process"; public const string EdgeGroupProcess = "edge_group.process"; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/EventNames.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/EventNames.cs index 8b9f5bbde8..84540efdc8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/EventNames.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/EventNames.cs @@ -8,6 +8,9 @@ internal static class EventNames public const string BuildValidationCompleted = "build.validation_completed"; public const string BuildCompleted = "build.completed"; public const string BuildError = "build.error"; + public const string SessionStarted = "session.started"; + public const string SessionCompleted = "session.completed"; + public const string SessionError = "session.error"; public const string WorkflowStarted = "workflow.started"; public const string WorkflowCompleted = "workflow.completed"; public const string WorkflowError = "workflow.error"; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs index 88c68eceb9..47ce701794 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/Tags.cs @@ -11,6 +11,7 @@ internal static class Tags public const string BuildErrorMessage = "build.error.message"; public const string BuildErrorType = "build.error.type"; public const string ErrorType = "error.type"; + public const string ErrorMessage = "error.message"; public const string SessionId = "session.id"; public const string ExecutorId = "executor.id"; public const string ExecutorType = "executor.type"; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs index e4b8d7a851..974ffce5c5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Observability/WorkflowTelemetryContext.cs @@ -88,7 +88,25 @@ internal sealed class WorkflowTelemetryContext } /// - /// Starts a workflow run activity if enabled. + /// Starts a workflow session activity if enabled. This is the outer/parent span + /// that represents the entire lifetime of a workflow execution (from start + /// until stop, cancellation, or error) within the current trace. + /// Individual run stages are typically nested within it. + /// + /// An activity if workflow run telemetry is enabled, otherwise null. + public Activity? StartWorkflowSessionActivity() + { + if (!this.IsEnabled || this.Options.DisableWorkflowRun) + { + return null; + } + + return this.ActivitySource.StartActivity(ActivityNames.WorkflowSession); + } + + /// + /// Starts a workflow run activity if enabled. This represents a single + /// input-to-halt cycle within a workflow session. /// /// An activity if workflow run telemetry is enabled, otherwise null. public Activity? StartWorkflowRunActivity() @@ -98,7 +116,7 @@ internal sealed class WorkflowTelemetryContext return null; } - return this.ActivitySource.StartActivity(ActivityNames.WorkflowRun); + return this.ActivitySource.StartActivity(ActivityNames.WorkflowInvoke); } /// diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs index 1f1570312a..a36c388e73 100644 --- a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs +++ b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs @@ -22,6 +22,9 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint) // Assign to enable logging public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance; + // Assign to provide MCP tool capabilities + public IMcpToolHandler? McpToolHandler { get; init; } + /// /// Create the workflow from the declarative YAML. Includes definition of the /// and the associated . @@ -42,6 +45,7 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint) Configuration = this.Configuration, ConversationId = this.ConversationId, LoggerFactory = this.LoggerFactory, + McpToolHandler = this.McpToolHandler, }; string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile); diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs index b4749c5624..08910060a2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewWrapperTests.cs @@ -51,7 +51,7 @@ public sealed class PurviewWrapperTests : IDisposable this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), - It.IsAny(), + Activity.UploadText, It.IsAny(), It.IsAny(), It.IsAny())) @@ -88,15 +88,24 @@ public sealed class PurviewWrapperTests : IDisposable It.IsAny())) .ReturnsAsync(innerResponse); - this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + // Prompt check uses UploadText, response check uses DownloadText + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), - It.IsAny(), + Activity.UploadText, It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((false, "user-123")) // Prompt allowed - .ReturnsAsync((true, "user-123")); // Response blocked + .ReturnsAsync((false, "user-123")); // Prompt allowed + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + Activity.DownloadText, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((true, "user-123")); // Response blocked // Act var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); @@ -237,14 +246,21 @@ public sealed class PurviewWrapperTests : IDisposable // Act await this._wrapper.ProcessChatContentAsync(messages, options, mockChatClient.Object, CancellationToken.None); - // Assert + // Assert - verify prompt uses UploadText and response uses DownloadText this._mockProcessor.Verify(x => x.ProcessMessagesAsync( It.IsAny>(), "conversation-123", - It.IsAny(), + Activity.UploadText, It.IsAny(), It.IsAny(), - It.IsAny()), Times.Exactly(2)); + It.IsAny()), Times.Once); + this._mockProcessor.Verify(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-123", + Activity.DownloadText, + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once); } #endregion @@ -264,7 +280,7 @@ public sealed class PurviewWrapperTests : IDisposable this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), - It.IsAny(), + Activity.UploadText, It.IsAny(), It.IsAny(), It.IsAny())) @@ -306,15 +322,24 @@ public sealed class PurviewWrapperTests : IDisposable ItExpr.IsAny()) .ReturnsAsync(innerResponse); - this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( + // Prompt check uses UploadText, response check uses DownloadText + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), - It.IsAny(), + Activity.UploadText, It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((false, "user-123")) // Prompt allowed - .ReturnsAsync((true, "user-123")); // Response blocked + .ReturnsAsync((false, "user-123")); // Prompt allowed + + this._mockProcessor.Setup(x => x.ProcessMessagesAsync( + It.IsAny>(), + It.IsAny(), + Activity.DownloadText, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((true, "user-123")); // Response blocked // Act var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); @@ -472,10 +497,17 @@ public sealed class PurviewWrapperTests : IDisposable this._mockProcessor.Verify(x => x.ProcessMessagesAsync( It.IsAny>(), "conversation-from-props", - It.IsAny(), + Activity.UploadText, It.IsAny(), It.IsAny(), - It.IsAny()), Times.Exactly(2)); + It.IsAny()), Times.Once); + this._mockProcessor.Verify(x => x.ProcessMessagesAsync( + It.IsAny>(), + "conversation-from-props", + Activity.DownloadText, + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs index 662575cdf4..c7583acf0a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs @@ -61,6 +61,11 @@ public abstract class IntegrationTest : IDisposable internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? WorkflowFormulaState.DefaultScopeName}.{variableName}"; protected async ValueTask CreateOptionsAsync(bool externalConversation = false, params IEnumerable functionTools) + { + return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, functionTools).ConfigureAwait(false); + } + + protected async ValueTask CreateOptionsAsync(bool externalConversation, IMcpToolHandler? mcpToolProvider, params IEnumerable functionTools) { AzureAgentProvider agentProvider = new(this.TestEndpoint, new AzureCliCredential()) @@ -78,7 +83,8 @@ public abstract class IntegrationTest : IDisposable new DeclarativeWorkflowOptions(agentProvider) { ConversationId = conversationId, - LoggerFactory = this.Output + LoggerFactory = this.Output, + McpToolHandler = mcpToolProvider }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs similarity index 51% rename from dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs rename to dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs index 289fbe2faa..359d9389a6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeFunctionToolWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs @@ -10,31 +10,48 @@ using Microsoft.Agents.AI.Workflows.Declarative.Events; using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.Mcp; using Microsoft.Extensions.AI; using Xunit.Abstractions; namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; /// -/// Integration tests for InvokeFunctionTool action. -/// This test pattern can be extended for other InvokeTool types. +/// Integration tests for InvokeFunctionTool and InvokeMcpTool actions. /// -public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : IntegrationTest(output) +public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : IntegrationTest(output) { + #region InvokeFunctionTool Tests + [Theory] [InlineData("InvokeFunctionTool.yaml", new string[] { "GetSpecials", "GetItemPrice" }, "2.95")] [InlineData("InvokeFunctionToolWithApproval.yaml", new string[] { "GetItemPrice" }, "4.9")] public Task ValidateInvokeFunctionToolAsync(string workflowFileName, string[] expectedFunctionCalls, string? expectedResultContains) => - this.RunInvokeToolTestAsync(workflowFileName, expectedFunctionCalls, expectedResultContains); + this.RunInvokeFunctionToolTestAsync(workflowFileName, expectedFunctionCalls, expectedResultContains); + + #endregion + + #region InvokeMcpTool Tests + + [Theory] + [InlineData("InvokeMcpTool.yaml", "Azure OpenAI")] + public Task ValidateInvokeMcpToolAsync(string workflowFileName, string? expectedResultContains) => + this.RunInvokeMcpToolTestAsync(workflowFileName, expectedResultContains, requireApproval: false); + + [Theory] + [InlineData("InvokeMcpToolWithApproval.yaml", "Azure OpenAI", true)] + [InlineData("InvokeMcpToolWithApproval.yaml", "MCP tool invocation was not approved by user", false)] + public Task ValidateInvokeMcpToolWithApprovalAsync(string workflowFileName, string? expectedResultContains, bool approveRequest) => + this.RunInvokeMcpToolTestAsync(workflowFileName, expectedResultContains, requireApproval: true, approveRequest: approveRequest); + + #endregion + + #region InvokeFunctionTool Test Helpers /// - /// Runs an InvokeTool workflow test with the specified configuration. - /// This method is designed to be generic and reusable for different InvokeTool types. + /// Runs an InvokeFunctionTool workflow test with the specified configuration. /// - /// The workflow YAML file name. - /// Expected function names to be called in order. - /// Expected text to be present in the final result. - private async Task RunInvokeToolTestAsync( + private async Task RunInvokeFunctionToolTestAsync( string workflowFileName, string[] expectedFunctionCalls, string? expectedResultContains = null) @@ -72,7 +89,6 @@ public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : I // Continue processing until there are no more pending input events from the resumed workflow if (resumeEvents.InputEvents.Count == 0) { - // No more input events from the last resume - workflow completed break; } } @@ -85,19 +101,12 @@ public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : I } // Assert - Verify executor and action events - Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents); - Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents); - Assert.NotEmpty(workflowEvents.ActionInvokeEvents); + AssertWorkflowEventsEmitted(workflowEvents); // Assert - Verify expected result if specified if (expectedResultContains is not null) { - MessageActivityEvent? messageEvent = workflowEvents.Events - .OfType() - .LastOrDefault(); - - Assert.NotNull(messageEvent); - Assert.Contains(expectedResultContains, messageEvent.Message, StringComparison.OrdinalIgnoreCase); + AssertResultContains(workflowEvents, expectedResultContains); } } @@ -150,6 +159,119 @@ public sealed class InvokeFunctionToolWorkflowTest(ITestOutputHelper output) : I return results; } + #endregion + + #region InvokeMcpTool Test Helpers + + /// + /// Runs an InvokeMcpTool workflow test with the specified configuration. + /// + private async Task RunInvokeMcpToolTestAsync( + string workflowFileName, + string? expectedResultContains = null, + bool requireApproval = false, + bool approveRequest = true) + { + // Arrange + string workflowPath = GetWorkflowPath(workflowFileName); + DefaultMcpToolHandler mcpToolProvider = new(); + DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync( + externalConversation: false, + mcpToolProvider: mcpToolProvider); + + Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); + WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); + + // Act - Run workflow and handle MCP tool invocations + WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("start").ConfigureAwait(false); + + while (workflowEvents.InputEvents.Count > 0) + { + RequestInfoEvent inputEvent = workflowEvents.InputEvents[^1]; + ExternalInputRequest? toolRequest = inputEvent.Request.Data.As(); + Assert.NotNull(toolRequest); + + IList mcpResults = this.ProcessMcpToolRequests( + toolRequest, + approveRequest); + + ChatMessage resultMessage = new(ChatRole.Tool, mcpResults); + WorkflowEvents resumeEvents = await harness.ResumeAsync( + inputEvent.Request.CreateResponse(new ExternalInputResponse(resultMessage))).ConfigureAwait(false); + + workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. resumeEvents.Events]); + + // Continue processing until there are no more pending input events from the resumed workflow + if (resumeEvents.InputEvents.Count == 0) + { + break; + } + } + + // Assert - Verify executor and action events + AssertWorkflowEventsEmitted(workflowEvents); + + // Assert - Verify expected result if specified + if (expectedResultContains is not null) + { + AssertResultContains(workflowEvents, expectedResultContains); + } + + // Cleanup + await mcpToolProvider.DisposeAsync().ConfigureAwait(false); + } + + /// + /// Processes MCP tool requests from an external input request. + /// Handles approval requests for MCP tools. + /// + private List ProcessMcpToolRequests( + ExternalInputRequest toolRequest, + bool approveRequest) + { + List results = []; + + foreach (ChatMessage message in toolRequest.AgentResponse.Messages) + { + // Handle MCP approval requests if present + foreach (McpServerToolApprovalRequestContent approvalRequest in message.Contents.OfType()) + { + this.Output.WriteLine($"MCP APPROVAL REQUEST: {approvalRequest.Id}"); + + // Respond based on test configuration + McpServerToolApprovalResponseContent response = approvalRequest.CreateResponse(approved: approveRequest); + results.Add(response); + + this.Output.WriteLine($"MCP APPROVAL RESPONSE: {(approveRequest ? "Approved" : "Rejected")}"); + } + } + + return results; + } + + #endregion + + #region Shared Helpers + + private static void AssertWorkflowEventsEmitted(WorkflowEvents workflowEvents) + { + Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents); + Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents); + Assert.NotEmpty(workflowEvents.ActionInvokeEvents); + } + + private static void AssertResultContains(WorkflowEvents workflowEvents, string expectedResultContains) + { + MessageActivityEvent? messageEvent = workflowEvents.Events + .OfType() + .LastOrDefault(); + + Assert.NotNull(messageEvent); + Assert.Contains(expectedResultContains, messageEvent.Message, StringComparison.OrdinalIgnoreCase); + } + private static string GetWorkflowPath(string workflowFileName) => Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName); + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj index 309a590b83..9bc867b139 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj @@ -10,6 +10,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpTool.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpTool.yaml new file mode 100644 index 0000000000..ff30f7902e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpTool.yaml @@ -0,0 +1,35 @@ +# +# This workflow tests invoking MCP tools directly from a workflow. +# Uses the Microsoft Learn MCP server: search tool +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_invoke_mcp_tool_test + actions: + + # Set the search query we want to use + - kind: SetVariable + id: set_search_query + variable: Local.SearchQuery + value: Azure OpenAI + + # Invoke MCP search tool on Microsoft Learn server + - kind: InvokeMcpTool + id: invoke_mcp_search + serverUrl: https://learn.microsoft.com/api/mcp + serverLabel: microsoft_docs + toolName: microsoft_docs_search + conversationId: =System.ConversationId + arguments: + query: =Local.SearchQuery + output: + autoSend: true + result: Local.SearchResult + + # Send the result as an activity + - kind: SendMessage + id: show_search_result + message: "Search results: {Local.SearchResult}" + # message: "Search results for {Local.SearchQuery}: {Local.SearchResult}" diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpToolWithApproval.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpToolWithApproval.yaml new file mode 100644 index 0000000000..77d62c79ce --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpToolWithApproval.yaml @@ -0,0 +1,35 @@ +# +# This workflow tests invoking MCP tools with approval requirement. +# Uses the Microsoft Learn MCP server: search tool with requireApproval: true +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_invoke_mcp_tool_approval_test + actions: + + # Set the search query we want to use + - kind: SetVariable + id: set_search_query + variable: Local.ContentUrl + value: https://learn.microsoft.com/azure/ai-foundry/openai/concepts/use-your-data + + # Invoke MCP search tool with approval requirement + - kind: InvokeMcpTool + id: invoke_mcp_search + serverUrl: https://learn.microsoft.com/api/mcp + serverLabel: MicrosoftLearn + toolName: microsoft_docs_fetch + requireApproval: true + arguments: + url: =Local.ContentUrl + output: + autoSend: false + result: Local.FetchResult + messages: Local.FetchMessages + + # Send the result as an activity + - kind: SendMessage + id: show_search_result + message: "Content for {Local.ContentUrl}: {Local.FetchResult}" diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs new file mode 100644 index 0000000000..858ea9db14 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests; + +/// +/// Unit tests for . +/// +public sealed class DefaultMcpToolHandlerTests +{ + #region Constructor Tests + + [Fact] + public async Task Constructor_WithNoParameters_ShouldCreateInstanceAsync() + { + // Act + DefaultMcpToolHandler handler = new(); + + // Assert + handler.Should().NotBeNull(); + await handler.DisposeAsync(); + } + + [Fact] + public async Task Constructor_WithNullHttpClientProvider_ShouldCreateInstanceAsync() + { + // Act + DefaultMcpToolHandler handler = new(httpClientProvider: null); + + // Assert + handler.Should().NotBeNull(); + await handler.DisposeAsync(); + } + + [Fact] + public async Task Constructor_WithHttpClientProvider_ShouldCreateInstanceAsync() + { + // Arrange + static Task ProviderAsync(string url, CancellationToken ct) => Task.FromResult(new HttpClient()); + + // Act + DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync); + + // Assert + handler.Should().NotBeNull(); + await handler.DisposeAsync(); + } + + #endregion + + #region DisposeAsync Tests + + [Fact] + public async Task DisposeAsync_WhenCalled_ShouldCompleteWithoutErrorAsync() + { + // Arrange + DefaultMcpToolHandler handler = new(); + + // Act + Func act = async () => await handler.DisposeAsync(); + + // Assert + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task DisposeAsync_WhenCalledMultipleTimes_ShouldHandleGracefullyAsync() + { + // Arrange + DefaultMcpToolHandler handler = new(); + + // Act + await handler.DisposeAsync(); + Func act = async () => await handler.DisposeAsync(); + + // Assert - Second dispose should throw ObjectDisposedException from the semaphore + await act.Should().ThrowAsync(); + } + + #endregion + + #region HttpClientProvider Tests + + [Fact] + public async Task InvokeToolAsync_WithHttpClientProvider_ShouldCallProviderAsync() + { + // Arrange + bool providerCalled = false; + string? capturedServerUrl = null; + + Task ProviderAsync(string url, CancellationToken ct) + { + providerCalled = true; + capturedServerUrl = url; + return Task.FromResult(null); + } + + DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync); + + // Act & Assert - The call will fail because there's no real MCP server, but the provider should be called + try + { + await handler.InvokeToolAsync( + serverUrl: "http://localhost:12345/mcp", + serverLabel: "test", + toolName: "testTool", + arguments: null, + headers: null, + connectionName: null); + } + catch + { + // Expected to fail - no real server + } + finally + { + await handler.DisposeAsync(); + } + + // Assert + providerCalled.Should().BeTrue(); + capturedServerUrl.Should().Be("http://localhost:12345/mcp"); + } + + [Fact] + public async Task InvokeToolAsync_WithHttpClientProviderReturningClient_ShouldUseProvidedClientAsync() + { + // Arrange + bool providerCalled = false; + HttpClient? providedClient = null; + + Task ProviderAsync(string url, CancellationToken ct) + { + providerCalled = true; + providedClient = new HttpClient(); + return Task.FromResult(providedClient); + } + + DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync); + + // Act & Assert - The call will fail because there's no real MCP server, but the provider should be called + try + { + await handler.InvokeToolAsync( + serverUrl: "http://localhost:12345/mcp", + serverLabel: "test", + toolName: "testTool", + arguments: null, + headers: null, + connectionName: null); + } + catch + { + // Expected to fail - no real server + } + finally + { + await handler.DisposeAsync(); + providedClient?.Dispose(); + } + + // Assert + providerCalled.Should().BeTrue(); + } + + #endregion + + #region Caching Tests + + [Fact] + public async Task InvokeToolAsync_SameServerUrl_ShouldCallProviderOncePerAttemptWhenConnectionFailsAsync() + { + // Arrange + int providerCallCount = 0; + + Task ProviderAsync(string url, CancellationToken ct) + { + providerCallCount++; + return Task.FromResult(null); + } + + DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync); + const string ServerUrl = "http://localhost:12345/mcp"; + + try + { + // Act - Call twice with the same server URL + // Since there's no real server, the McpClient.CreateAsync will fail, + // so the client won't be cached and the provider will be called each time + for (int i = 0; i < 2; i++) + { + try + { + await handler.InvokeToolAsync( + serverUrl: ServerUrl, + serverLabel: "test", + toolName: "testTool", + arguments: null, + headers: null, + connectionName: null); + } + catch + { + // Expected to fail - no real server + } + } + + // Assert - Provider is called each time because McpClient creation fails before caching + providerCallCount.Should().Be(2); + } + finally + { + await handler.DisposeAsync(); + } + } + + [Fact] + public async Task InvokeToolAsync_DifferentServerUrls_ShouldCreateSeparateClientsAsync() + { + // Arrange + int providerCallCount = 0; + + Task ProviderAsync(string url, CancellationToken ct) + { + providerCallCount++; + return Task.FromResult(null); + } + + DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync); + + try + { + // Act - Call with different server URLs + foreach (string serverUrl in new[] { "http://localhost:12345/mcp1", "http://localhost:12345/mcp2" }) + { + try + { + await handler.InvokeToolAsync( + serverUrl: serverUrl, + serverLabel: "test", + toolName: "testTool", + arguments: null, + headers: null, + connectionName: null); + } + catch + { + // Expected to fail - no real server + } + } + + // Assert - Provider should be called once per unique server URL + providerCallCount.Should().Be(2); + } + finally + { + await handler.DisposeAsync(); + } + } + + [Fact] + public async Task InvokeToolAsync_SameUrlDifferentHeaders_ShouldCreateSeparateClientsAsync() + { + // Arrange + int providerCallCount = 0; + + Task ProviderAsync(string url, CancellationToken ct) + { + providerCallCount++; + return Task.FromResult(null); + } + + DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync); + const string ServerUrl = "http://localhost:12345/mcp"; + + try + { + // Act - Call with same URL but different headers + Dictionary[] headerSets = + [ + new() { ["Authorization"] = "Bearer token1" }, + new() { ["Authorization"] = "Bearer token2" } + ]; + + foreach (Dictionary headers in headerSets) + { + try + { + await handler.InvokeToolAsync( + serverUrl: ServerUrl, + serverLabel: "test", + toolName: "testTool", + arguments: null, + headers: headers, + connectionName: null); + } + catch + { + // Expected to fail - no real server + } + } + + // Assert - Different headers should create different cache keys + providerCallCount.Should().Be(2); + } + finally + { + await handler.DisposeAsync(); + } + } + + #endregion + + #region Interface Implementation Tests + + [Fact] + public async Task DefaultMcpToolHandler_ShouldImplementIMcpToolHandlerAsync() + { + // Arrange & Act + DefaultMcpToolHandler handler = new(); + + // Assert + handler.Should().BeAssignableTo(); + await handler.DisposeAsync(); + } + + [Fact] + public async Task DefaultMcpToolHandler_ShouldImplementIAsyncDisposableAsync() + { + // Arrange & Act + DefaultMcpToolHandler handler = new(); + + // Assert + handler.Should().BeAssignableTo(); + await handler.DisposeAsync(); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj new file mode 100644 index 0000000000..057e2cd950 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj @@ -0,0 +1,15 @@ + + + + true + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs index fc03d17142..6ace20bebb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs @@ -459,4 +459,208 @@ public sealed class JsonDocumentExtensionsTests Assert.Equal("Bob", second["name"]); Assert.Equal("Designer", second["role"]); } + + [Fact] + public void GetListTypeFromJson_EmptyArray_ReturnsFallbackListType() + { + // Arrange + JsonDocument document = JsonDocument.Parse("[]"); + + // Act + VariableType result = document.RootElement.GetListTypeFromJson(); + + // Assert + Assert.Equal(VariableType.ListType, result.Type); + Assert.False(result.HasSchema); + } + + [Fact] + public void GetListTypeFromJson_ArrayOfPrimitives_ReturnsFallbackListType() + { + // Arrange + JsonDocument document = JsonDocument.Parse("[1, 2, 3]"); + + // Act + VariableType result = document.RootElement.GetListTypeFromJson(); + + // Assert + Assert.Equal(VariableType.ListType, result.Type); + Assert.False(result.HasSchema); + } + + [Fact] + public void GetListTypeFromJson_ObjectWithStringField_InfersStringType() + { + // Arrange + JsonDocument document = JsonDocument.Parse( + """ + [{ "name": "hello" }] + """); + + // Act + VariableType result = document.RootElement.GetListTypeFromJson(); + + // Assert + Assert.True(result.HasSchema); + Assert.True(result.Schema!.ContainsKey("name")); + Assert.Equal(typeof(string), result.Schema["name"].Type); + } + + [Fact] + public void GetListTypeFromJson_ObjectWithNumberField_InfersDecimalType() + { + // Arrange + JsonDocument document = JsonDocument.Parse( + """ + [{ "value": 42 }] + """); + + // Act + VariableType result = document.RootElement.GetListTypeFromJson(); + + // Assert + Assert.True(result.HasSchema); + Assert.True(result.Schema!.ContainsKey("value")); + Assert.Equal(typeof(decimal), result.Schema["value"].Type); + } + + [Fact] + public void GetListTypeFromJson_ObjectWithBooleanTrueField_InfersBoolType() + { + // Arrange + JsonDocument document = JsonDocument.Parse( + """ + [{ "flag": true }] + """); + + // Act + VariableType result = document.RootElement.GetListTypeFromJson(); + + // Assert + Assert.True(result.HasSchema); + Assert.True(result.Schema!.ContainsKey("flag")); + Assert.Equal(typeof(bool), result.Schema["flag"].Type); + } + + [Fact] + public void GetListTypeFromJson_ObjectWithBooleanFalseField_InfersBoolType() + { + // Arrange + JsonDocument document = JsonDocument.Parse( + """ + [{ "flag": false }] + """); + + // Act + VariableType result = document.RootElement.GetListTypeFromJson(); + + // Assert + Assert.True(result.HasSchema); + Assert.True(result.Schema!.ContainsKey("flag")); + Assert.Equal(typeof(bool), result.Schema["flag"].Type); + } + + [Fact] + public void GetListTypeFromJson_ObjectWithNestedObjectField_InfersRecordType() + { + // Arrange + JsonDocument document = JsonDocument.Parse( + """ + [{ "child": { "inner": 1 } }] + """); + + // Act + VariableType result = document.RootElement.GetListTypeFromJson(); + + // Assert + Assert.True(result.HasSchema); + Assert.True(result.Schema!.ContainsKey("child")); + Assert.Equal(VariableType.RecordType, result.Schema["child"].Type); + } + + [Fact] + public void GetListTypeFromJson_ObjectWithNestedArrayField_InfersListType() + { + // Arrange + JsonDocument document = JsonDocument.Parse( + """ + [{ "items": [1, 2, 3] }] + """); + + // Act + VariableType result = document.RootElement.GetListTypeFromJson(); + + // Assert + Assert.True(result.HasSchema); + Assert.True(result.Schema!.ContainsKey("items")); + Assert.Equal(VariableType.ListType, result.Schema["items"].Type); + } + + [Fact] + public void GetListTypeFromJson_ObjectWithNullField_InfersStringTypeDefault() + { + // Arrange + JsonDocument document = JsonDocument.Parse( + """ + [{ "missing": null }] + """); + + // Act + VariableType result = document.RootElement.GetListTypeFromJson(); + + // Assert + Assert.True(result.HasSchema); + Assert.True(result.Schema!.ContainsKey("missing")); + Assert.Equal(typeof(string), result.Schema["missing"].Type); + } + + [Fact] + public void GetListTypeFromJson_SkipsNonObjectElements_InfersFromFirstObject() + { + // Arrange + JsonDocument document = JsonDocument.Parse( + """ + [1, "text", { "id": 99 }] + """); + + // Act + VariableType result = document.RootElement.GetListTypeFromJson(); + + // Assert + Assert.True(result.HasSchema); + Assert.True(result.Schema!.ContainsKey("id")); + Assert.Equal(typeof(decimal), result.Schema["id"].Type); + } + + [Fact] + public void GetListTypeFromJson_ObjectWithAllFieldTypes_InfersCorrectTypes() + { + // Arrange + JsonDocument document = JsonDocument.Parse( + """ + [{ + "text": "hello", + "count": 5, + "enabled": true, + "disabled": false, + "nested": { "x": 1 }, + "list": [1, 2], + "empty": null + }] + """); + + // Act + VariableType result = document.RootElement.GetListTypeFromJson(); + + // Assert + Assert.True(result.HasSchema); + Assert.Equal(7, result.Schema!.Count); + Assert.Equal(typeof(string), result.Schema["text"].Type); + Assert.Equal(typeof(decimal), result.Schema["count"].Type); + Assert.Equal(typeof(bool), result.Schema["enabled"].Type); + Assert.Equal(typeof(bool), result.Schema["disabled"].Type); + Assert.Equal(VariableType.RecordType, result.Schema["nested"].Type); + Assert.Equal(VariableType.ListType, result.Schema["list"].Type); + Assert.Equal(typeof(string), result.Schema["empty"].Type); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs new file mode 100644 index 0000000000..2cad0029ff --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs @@ -0,0 +1,845 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.ObjectModel; +using Microsoft.Extensions.AI; +using Moq; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + private const string TestServerUrl = "https://mcp.example.com"; + private const string TestServerLabel = "TestMcpServer"; + private const string TestToolName = "test_tool"; + + #region Step Naming Convention Tests + + [Fact] + public void InvokeMcpToolThrowsWhenModelInvalid() + { + // Arrange + Mock mockProvider = new(); + MockAgentProvider mockAgentProvider = new(); + + // Act & Assert + Assert.Throws(() => new InvokeMcpToolExecutor( + new InvokeMcpTool(), + mockProvider.Object, + mockAgentProvider.Object, + this.State)); + } + + [Fact] + public void InvokeMcpToolNamingConvention() + { + // Arrange + string testId = this.CreateActionId().Value; + + // Act + string externalInputStep = InvokeMcpToolExecutor.Steps.ExternalInput(testId); + string resumeStep = InvokeMcpToolExecutor.Steps.Resume(testId); + + // Assert + Assert.Equal($"{testId}_{nameof(InvokeMcpToolExecutor.Steps.ExternalInput)}", externalInputStep); + Assert.Equal($"{testId}_{nameof(InvokeMcpToolExecutor.Steps.Resume)}", resumeStep); + } + + #endregion + + #region RequiresInput and RequiresNothing Tests + + [Fact] + public void RequiresInputReturnsTrueForExternalInputRequest() + { + // Arrange + ExternalInputRequest request = new(new AgentResponse([])); + + // Act + bool result = InvokeMcpToolExecutor.RequiresInput(request); + + // Assert + Assert.True(result); + } + + [Fact] + public void RequiresInputReturnsFalseForOtherTypes() + { + // Act & Assert + Assert.False(InvokeMcpToolExecutor.RequiresInput(null)); + Assert.False(InvokeMcpToolExecutor.RequiresInput("string")); + Assert.False(InvokeMcpToolExecutor.RequiresInput(new ActionExecutorResult("test"))); + } + + [Fact] + public void RequiresNothingReturnsTrueForActionExecutorResult() + { + // Arrange + ActionExecutorResult result = new("test"); + + // Act + bool requiresNothing = InvokeMcpToolExecutor.RequiresNothing(result); + + // Assert + Assert.True(requiresNothing); + } + + [Fact] + public void RequiresNothingReturnsFalseForOtherTypes() + { + // Act & Assert + Assert.False(InvokeMcpToolExecutor.RequiresNothing(null)); + Assert.False(InvokeMcpToolExecutor.RequiresNothing("string")); + Assert.False(InvokeMcpToolExecutor.RequiresNothing(new ExternalInputRequest(new AgentResponse([])))); + } + + #endregion + + #region ExecuteAsync Tests + + [Fact] + public async Task InvokeMcpToolExecuteWithoutApprovalAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithoutApprovalAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + requireApproval: false); + + // Act and Assert + await this.ExecuteTestAsync(model); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithServerLabelAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithServerLabelAsync), + serverUrl: TestServerUrl, + serverLabel: TestServerLabel, + toolName: TestToolName); + + // Act and Assert + await this.ExecuteTestAsync(model); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithArgumentsAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithArgumentsAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + argumentKey: "query", + argumentValue: "test query"); + + // Act and Assert + await this.ExecuteTestAsync(model); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithHeadersAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithHeadersAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + headerKey: "Authorization", + headerValue: "Bearer token123"); + + // Act and Assert + await this.ExecuteTestAsync(model); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithRequireApprovalAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithRequireApprovalAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + requireApproval: true); + + // Act and Assert + await this.ExecuteTestAsync(model); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithEmptyConversationIdAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithEmptyConversationIdAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + conversationId: ""); + + // Act and Assert + await this.ExecuteTestAsync(model); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithNullArgumentsAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithNullArgumentsAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + argumentKey: null); + + // Act and Assert + await this.ExecuteTestAsync(model); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithNullRequireApprovalAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithNullRequireApprovalAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + requireApproval: null); + + // Act and Assert + await this.ExecuteTestAsync(model); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithNullConversationIdAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithNullConversationIdAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + conversationId: null); + + // Act and Assert + await this.ExecuteTestAsync(model); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithEmptyServerLabelAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithEmptyServerLabelAsync), + serverUrl: TestServerUrl, + serverLabel: "", + toolName: TestToolName); + + // Act and Assert + await this.ExecuteTestAsync(model); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithConversationIdAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithConversationIdAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + conversationId: "test-conversation-id"); + + // Act and Assert + await this.ExecuteTestAsync(model); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithRequireApprovalAndHeadersAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithRequireApprovalAndHeadersAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + requireApproval: true, + headerKey: "X-Custom-Header", + headerValue: "custom-value"); + + // Act and Assert + await this.ExecuteTestAsync(model); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithEmptyHeaderValueAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithEmptyHeaderValueAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + headerKey: "X-Empty-Header", + headerValue: ""); + + // Act and Assert + await this.ExecuteTestAsync(model); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithJsonObjectResultAsync() + { + // Arrange - Tests JSON object parsing in AssignResultAsync + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithJsonObjectResultAsync), + serverUrl: TestServerUrl, + toolName: TestToolName); + MockMcpToolProvider mockProvider = new(returnJsonObject: true); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert + VerifyModel(model, action); + VerifyInvocationEvent(events); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithJsonArrayResultAsync() + { + // Arrange - Tests JSON array parsing in AssignResultAsync + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithJsonArrayResultAsync), + serverUrl: TestServerUrl, + toolName: TestToolName); + MockMcpToolProvider mockProvider = new(returnJsonArray: true); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert + VerifyModel(model, action); + VerifyInvocationEvent(events); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithInvalidJsonResultAsync() + { + // Arrange - Tests graceful handling of invalid JSON + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithInvalidJsonResultAsync), + serverUrl: TestServerUrl, + toolName: TestToolName); + MockMcpToolProvider mockProvider = new(returnInvalidJson: true); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert - Should handle gracefully + VerifyModel(model, action); + VerifyInvocationEvent(events); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithDataContentResultAsync() + { + // Arrange - Tests DataContent handling (returns URI) + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithDataContentResultAsync), + serverUrl: TestServerUrl, + toolName: TestToolName); + MockMcpToolProvider mockProvider = new(returnDataContent: true); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert + VerifyModel(model, action); + VerifyInvocationEvent(events); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithEmptyOutputAsync() + { + // Arrange - Tests empty output list handling + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithEmptyOutputAsync), + serverUrl: TestServerUrl, + toolName: TestToolName); + MockMcpToolProvider mockProvider = new(returnEmptyOutput: true); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert + VerifyModel(model, action); + VerifyInvocationEvent(events); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithNullOutputAsync() + { + // Arrange - Tests null output handling + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithNullOutputAsync), + serverUrl: TestServerUrl, + toolName: TestToolName); + MockMcpToolProvider mockProvider = new(returnNullOutput: true); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert + VerifyModel(model, action); + VerifyInvocationEvent(events); + } + + [Fact] + public async Task InvokeMcpToolExecuteWithMultipleContentTypesAsync() + { + // Arrange - Tests handling of multiple content types in output + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithMultipleContentTypesAsync), + serverUrl: TestServerUrl, + toolName: TestToolName); + MockMcpToolProvider mockProvider = new(returnMultipleContent: true); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert + VerifyModel(model, action); + VerifyInvocationEvent(events); + } + + #endregion + + #region CaptureResponseAsync Tests + + [Fact] + public async Task InvokeMcpToolCaptureResponseWithApprovalApprovedAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolCaptureResponseWithApprovalApprovedAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + requireApproval: true); + MockMcpToolProvider mockProvider = new(); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Create approval request then response + McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl); + McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); + McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse])); + + // Act + WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response); + + // Assert + VerifyModel(model, action); + Assert.NotEmpty(events); + } + + [Fact] + public async Task InvokeMcpToolCaptureResponseWithApprovalRejectedAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolCaptureResponseWithApprovalRejectedAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + requireApproval: true); + MockMcpToolProvider mockProvider = new(); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Create approval request then response (rejected) + McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl); + McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); + McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: false); + ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse])); + + // Act + WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response); + + // Assert + VerifyModel(model, action); + Assert.NotEmpty(events); + } + + [Fact] + public async Task InvokeMcpToolCaptureResponseWithEmptyMessagesAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolCaptureResponseWithEmptyMessagesAsync), + serverUrl: TestServerUrl, + toolName: TestToolName); + MockMcpToolProvider mockProvider = new(); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Empty response - no approval found, should treat as rejected + ExternalInputResponse response = new([]); + + // Act + WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response); + + // Assert + VerifyModel(model, action); + Assert.NotEmpty(events); + } + + [Fact] + public async Task InvokeMcpToolCaptureResponseWithNonMatchingApprovalIdAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolCaptureResponseWithNonMatchingApprovalIdAsync), + serverUrl: TestServerUrl, + toolName: TestToolName); + MockMcpToolProvider mockProvider = new(); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Create approval with different ID + McpServerToolCallContent toolCall = new("different_id", TestToolName, TestServerUrl); + McpServerToolApprovalRequestContent approvalRequest = new("different_id", toolCall); + McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse])); + + // Act + WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response); + + // Assert - Should be treated as rejected since no matching approval + VerifyModel(model, action); + Assert.NotEmpty(events); + } + + [Fact] + public async Task InvokeMcpToolCaptureResponseWithApprovedAndArgumentsAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolCaptureResponseWithApprovedAndArgumentsAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + requireApproval: true, + argumentKey: "query", + argumentValue: "test query"); + MockMcpToolProvider mockProvider = new(); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Create approval request then response + McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl); + McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); + McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse])); + + // Act + WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response); + + // Assert + VerifyModel(model, action); + Assert.NotEmpty(events); + } + + [Fact] + public async Task InvokeMcpToolCaptureResponseWithApprovedAndHeadersAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolCaptureResponseWithApprovedAndHeadersAsync), + serverUrl: TestServerUrl, + serverLabel: TestServerLabel, + toolName: TestToolName, + requireApproval: true, + headerKey: "X-Custom-Header", + headerValue: "custom-value"); + MockMcpToolProvider mockProvider = new(); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Create approval request then response + McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerLabel); + McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); + McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse])); + + // Act + WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response); + + // Assert + VerifyModel(model, action); + Assert.NotEmpty(events); + } + + [Fact] + public async Task InvokeMcpToolCaptureResponseWithApprovedAndConversationIdAsync() + { + // Arrange + this.State.InitializeSystem(); + const string ConversationId = "TestConversationId"; + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolCaptureResponseWithApprovedAndConversationIdAsync), + serverUrl: TestServerUrl, + toolName: TestToolName, + requireApproval: true, + conversationId: ConversationId); + MockMcpToolProvider mockProvider = new(); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Create approval request then response + McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl); + McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); + McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse])); + + // Act + WorkflowEvent[] events = await this.ExecuteCaptureResponseTestAsync(action, response); + + // Assert + VerifyModel(model, action); + Assert.NotEmpty(events); + } + + #endregion + + #region CompleteAsync Tests + + [Fact] + public async Task InvokeMcpToolCompleteAsyncRaisesCompletionEventAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool model = this.CreateModel( + displayName: nameof(InvokeMcpToolCompleteAsyncRaisesCompletionEventAsync), + serverUrl: TestServerUrl, + toolName: TestToolName); + MockMcpToolProvider mockProvider = new(); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + ActionExecutorResult result = new(action.Id); + + // Act + WorkflowEvent[] events = await this.ExecuteCompleteTestAsync(action, result); + + // Assert + VerifyModel(model, action); + Assert.NotEmpty(events); + } + + #endregion + + #region Helper Methods + + private async Task ExecuteTestAsync(InvokeMcpTool model) + { + MockMcpToolProvider mockProvider = new(); + MockAgentProvider mockAgentProvider = new(); + InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert + VerifyModel(model, action); + VerifyInvocationEvent(events); + + // IsDiscreteAction should be false for InvokeMcpTool + VerifyIsDiscrete(action, isDiscrete: false); + } + + private async Task ExecuteCaptureResponseTestAsync( + InvokeMcpToolExecutor action, + ExternalInputResponse response) + { + return await this.ExecuteAsync( + action, + InvokeMcpToolExecutor.Steps.ExternalInput(action.Id), + (context, _, cancellationToken) => action.CaptureResponseAsync(context, response, cancellationToken)); + } + + private async Task ExecuteCompleteTestAsync( + InvokeMcpToolExecutor action, + ActionExecutorResult result) + { + return await this.ExecuteAsync( + action, + InvokeMcpToolExecutor.Steps.Resume(action.Id), + (context, _, cancellationToken) => action.CompleteAsync(context, result, cancellationToken)); + } + + private InvokeMcpTool CreateModel( + string displayName, + string serverUrl, + string toolName, + string? serverLabel = null, + bool? requireApproval = false, + string? conversationId = null, + string? argumentKey = null, + string? argumentValue = null, + string? headerKey = null, + string? headerValue = null) + { + InvokeMcpTool.Builder builder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + ServerUrl = new StringExpression.Builder(StringExpression.Literal(serverUrl)), + ToolName = new StringExpression.Builder(StringExpression.Literal(toolName)), + RequireApproval = requireApproval != null ? new BoolExpression.Builder(BoolExpression.Literal(requireApproval.Value)) : null + }; + + if (serverLabel is not null) + { + builder.ServerLabel = new StringExpression.Builder(StringExpression.Literal(serverLabel)); + } + + if (conversationId is not null) + { + builder.ConversationId = new StringExpression.Builder(StringExpression.Literal(conversationId)); + } + + if (argumentKey is not null && argumentValue is not null) + { + builder.Arguments.Add(argumentKey, ValueExpression.Literal(new StringDataValue(argumentValue))); + } + + if (headerKey is not null && headerValue is not null) + { + builder.Headers.Add(headerKey, new StringExpression.Builder(StringExpression.Literal(headerValue))); + } + + return AssignParent(builder); + } + + #endregion + + #region Mock MCP Tool Provider + + /// + /// Mock implementation of for unit testing purposes. + /// + private sealed class MockMcpToolProvider : Mock + { + public MockMcpToolProvider( + bool returnJsonObject = false, + bool returnJsonArray = false, + bool returnInvalidJson = false, + bool returnDataContent = false, + bool returnEmptyOutput = false, + bool returnNullOutput = false, + bool returnMultipleContent = false) + { + this.Setup(provider => provider.InvokeToolAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Returns?, IDictionary?, string?, CancellationToken>( + (_, _, _, _, _, _, _) => + { + McpServerToolResultContent result = new("mock-call-id"); + + if (returnNullOutput) + { + result.Output = null; + } + else if (returnEmptyOutput) + { + result.Output = []; + } + else if (returnJsonObject) + { + result.Output = [new TextContent("{\"key\": \"value\", \"number\": 42}")]; + } + else if (returnJsonArray) + { + result.Output = [new TextContent("[1, 2, 3, \"four\"]")]; + } + else if (returnInvalidJson) + { + result.Output = [new TextContent("this is not valid json {")]; + } + else if (returnDataContent) + { + result.Output = [new DataContent("data:image/png;base64,iVBORw0KGgo=", "image/png")]; + } + else if (returnMultipleContent) + { + result.Output = + [ + new TextContent("First text"), + new TextContent("{\"nested\": true}"), + new DataContent("data:audio/mp3;base64,SUQz", "audio/mp3") + ]; + } + else + { + result.Output = [new TextContent("Mock MCP tool result")]; + } + + return Task.FromResult(result); + }); + } + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs index e7a99d5ca2..af8a9d8e0d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ObservabilityTests.cs @@ -43,15 +43,16 @@ public sealed class ObservabilityTests : IDisposable /// Create a sample workflow for testing. /// /// - /// This workflow is expected to create 8 activities that will be captured by the tests + /// This workflow is expected to create 9 activities that will be captured by the tests /// - ActivityNames.WorkflowBuild - /// - ActivityNames.WorkflowRun - /// -- ActivityNames.EdgeGroupProcess - /// -- ActivityNames.ExecutorProcess (UppercaseExecutor) - /// --- ActivityNames.MessageSend - /// ---- ActivityNames.EdgeGroupProcess - /// -- ActivityNames.ExecutorProcess (ReverseTextExecutor) - /// --- ActivityNames.MessageSend + /// - ActivityNames.WorkflowSession + /// -- ActivityNames.WorkflowInvoke + /// --- ActivityNames.EdgeGroupProcess + /// --- ActivityNames.ExecutorProcess (UppercaseExecutor) + /// ---- ActivityNames.MessageSend + /// ----- ActivityNames.EdgeGroupProcess + /// --- ActivityNames.ExecutorProcess (ReverseTextExecutor) + /// ---- ActivityNames.MessageSend /// /// The created workflow. private static Workflow CreateWorkflow() @@ -74,7 +75,8 @@ public sealed class ObservabilityTests : IDisposable new() { { ActivityNames.WorkflowBuild, 1 }, - { ActivityNames.WorkflowRun, 1 }, + { ActivityNames.WorkflowSession, 1 }, + { ActivityNames.WorkflowInvoke, 1 }, { ActivityNames.EdgeGroupProcess, 2 }, { ActivityNames.ExecutorProcess, 2 }, { ActivityNames.MessageSend, 2 } @@ -113,7 +115,7 @@ public sealed class ObservabilityTests : IDisposable // Assert var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); - capturedActivities.Should().HaveCount(8, "Exactly 8 activities should be created."); + capturedActivities.Should().HaveCount(9, "Exactly 9 activities should be created."); // Make sure all expected activities exist and have the correct count foreach (var kvp in GetExpectedActivityNameCounts()) @@ -125,7 +127,7 @@ public sealed class ObservabilityTests : IDisposable } // Verify WorkflowRun activity events include workflow lifecycle events - var workflowRunActivity = capturedActivities.First(a => a.OperationName.StartsWith(ActivityNames.WorkflowRun, StringComparison.Ordinal)); + var workflowRunActivity = capturedActivities.First(a => a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)); var activityEvents = workflowRunActivity.Events.ToList(); activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowStarted, "activity should have workflow started event"); activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowCompleted, "activity should have workflow completed event"); @@ -273,8 +275,11 @@ public sealed class ObservabilityTests : IDisposable // Assert var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList(); capturedActivities.Should().NotContain( - a => a.OperationName.StartsWith(ActivityNames.WorkflowRun, StringComparison.Ordinal), + a => a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal), "WorkflowRun activity should be disabled."); + capturedActivities.Should().NotContain( + a => a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal), + "WorkflowSession activity should also be disabled when DisableWorkflowRun is true."); capturedActivities.Should().Contain( a => a.OperationName.StartsWith(ActivityNames.WorkflowBuild, StringComparison.Ordinal), "Other activities should still be created."); @@ -303,7 +308,7 @@ public sealed class ObservabilityTests : IDisposable a => a.OperationName.StartsWith(ActivityNames.ExecutorProcess, StringComparison.Ordinal), "ExecutorProcess activity should be disabled."); capturedActivities.Should().Contain( - a => a.OperationName.StartsWith(ActivityNames.WorkflowRun, StringComparison.Ordinal), + a => a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal), "Other activities should still be created."); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs new file mode 100644 index 0000000000..f35910f26b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Observability; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// Regression test for https://github.com/microsoft/agent-framework/issues/4155 +/// Verifies that the workflow_invoke Activity is properly stopped/disposed so it gets exported +/// to telemetry backends. The ActivityStopped callback must fire for the workflow_invoke span. +/// +[Collection("ObservabilityTests")] +public sealed class WorkflowRunActivityStopTests : IDisposable +{ + private readonly ActivityListener _activityListener; + private readonly ConcurrentBag _startedActivities = []; + private readonly ConcurrentBag _stoppedActivities = []; + private bool _isDisposed; + + public WorkflowRunActivityStopTests() + { + this._activityListener = new ActivityListener + { + ShouldListenTo = source => source.Name.Contains(typeof(Workflow).Namespace!), + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData, + ActivityStarted = activity => this._startedActivities.Add(activity), + ActivityStopped = activity => this._stoppedActivities.Add(activity), + }; + ActivitySource.AddActivityListener(this._activityListener); + } + + public void Dispose() + { + if (!this._isDisposed) + { + this._activityListener?.Dispose(); + this._isDisposed = true; + } + } + + /// + /// Creates a simple sequential workflow with OpenTelemetry enabled. + /// + private static Workflow CreateWorkflow() + { + Func uppercaseFunc = s => s.ToUpperInvariant(); + var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); + + Func reverseFunc = s => new string(s.Reverse().ToArray()); + var reverse = reverseFunc.BindAsExecutor("ReverseTextExecutor"); + + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); + + return builder.WithOpenTelemetry().Build(); + } + + /// + /// Verifies that the workflow_invoke Activity is stopped (and thus exportable) when + /// using the Lockstep execution environment. + /// Bug: The Activity created by LockstepRunEventStream.TakeEventStreamAsync is never + /// disposed because yield break in async iterators does not trigger using disposal. + /// + [Fact] + public async Task WorkflowRunActivity_IsStopped_LockstepAsync() + { + // Arrange + using var testActivity = new Activity("WorkflowRunStopTest_Lockstep").Start(); + + // Act + var workflow = CreateWorkflow(); + Run run = await InProcessExecution.Lockstep.RunAsync(workflow, "Hello, World!"); + await run.DisposeAsync(); + + // Assert - workflow.session should have been started and stopped + var startedSessions = this._startedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal)) + .ToList(); + startedSessions.Should().HaveCount(1, "workflow.session Activity should be started"); + + var stoppedSessions = this._stoppedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal)) + .ToList(); + stoppedSessions.Should().HaveCount(1, + "workflow.session Activity should be stopped/disposed so it is exported to telemetry backends"); + + // Assert - workflow_invoke should have been started and stopped + var startedWorkflowRuns = this._startedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) + .ToList(); + startedWorkflowRuns.Should().HaveCount(1, "workflow_invoke Activity should be started"); + + var stoppedWorkflowRuns = this._stoppedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) + .ToList(); + stoppedWorkflowRuns.Should().HaveCount(1, + "workflow_invoke Activity should be stopped/disposed so it is exported to telemetry backends (issue #4155)"); + } + + /// + /// Verifies that the workflow_invoke Activity is stopped when using the OffThread (Default) + /// execution environment (StreamingRunEventStream). + /// + [Fact] + public async Task WorkflowRunActivity_IsStopped_OffThreadAsync() + { + // Arrange + using var testActivity = new Activity("WorkflowRunStopTest_OffThread").Start(); + + // Act + var workflow = CreateWorkflow(); + Run run = await InProcessExecution.OffThread.RunAsync(workflow, "Hello, World!"); + await run.DisposeAsync(); + + // Assert - workflow.session should have been started and stopped + var startedSessions = this._startedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal)) + .ToList(); + startedSessions.Should().HaveCount(1, "workflow.session Activity should be started"); + + var stoppedSessions = this._stoppedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal)) + .ToList(); + stoppedSessions.Should().HaveCount(1, + "workflow.session Activity should be stopped/disposed so it is exported to telemetry backends"); + + // Assert - workflow_invoke should have been started and stopped + var startedWorkflowRuns = this._startedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) + .ToList(); + startedWorkflowRuns.Should().HaveCount(1, "workflow_invoke Activity should be started"); + + var stoppedWorkflowRuns = this._stoppedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) + .ToList(); + stoppedWorkflowRuns.Should().HaveCount(1, + "workflow_invoke Activity should be stopped/disposed so it is exported to telemetry backends (issue #4155)"); + } + + /// + /// Verifies that the workflow_invoke Activity is stopped when using the streaming API + /// (StreamingRun.WatchStreamAsync) with the OffThread execution environment. + /// This matches the exact usage pattern described in the issue. + /// + [Fact] + public async Task WorkflowRunActivity_IsStopped_Streaming_OffThreadAsync() + { + // Arrange + using var testActivity = new Activity("WorkflowRunStopTest_Streaming_OffThread").Start(); + + // Act - use streaming path (WatchStreamAsync), which is the pattern from the issue + var workflow = CreateWorkflow(); + StreamingRun run = await InProcessExecution.OffThread.RunStreamingAsync(workflow, "Hello, World!"); + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + // Consume all events + } + + // Dispose the run before asserting — the run Activity is disposed when the + // run loop exits, which happens during DisposeAsync. Without this, assertions + // can race against the background run loop's finally block. + await run.DisposeAsync(); + + // Assert - workflow.session should have been started + var startedSessions = this._startedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal)) + .ToList(); + startedSessions.Should().HaveCount(1, "workflow.session Activity should be started"); + + // Assert - workflow_invoke should have been started + var startedWorkflowRuns = this._startedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) + .ToList(); + startedWorkflowRuns.Should().HaveCount(1, "workflow_invoke Activity should be started"); + + // Assert - workflow_invoke should have been stopped + var stoppedWorkflowRuns = this._stoppedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) + .ToList(); + stoppedWorkflowRuns.Should().HaveCount(1, + "workflow_invoke Activity should be stopped/disposed so it is exported to telemetry backends (issue #4155)"); + } + + /// + /// Verifies that a new workflow_invoke activity is started and stopped for each + /// streaming invocation, even when using the same workflow in a multi-turn pattern, + /// and that each session gets its own session activity. + /// + [Fact] + public async Task WorkflowRunActivity_IsStopped_Streaming_OffThread_MultiTurnAsync() + { + // Arrange + using var testActivity = new Activity("WorkflowRunStopTest_Streaming_OffThread_MultiTurn").Start(); + + var workflow = CreateWorkflow(); + + // Act - first streaming run + await using (StreamingRun run1 = await InProcessExecution.OffThread.RunStreamingAsync(workflow, "Hello, World!")) + { + await foreach (WorkflowEvent evt in run1.WatchStreamAsync()) + { + // Consume all events from first turn + } + } + + // Act - second streaming run (multi-turn scenario with same workflow) + await using (StreamingRun run2 = await InProcessExecution.OffThread.RunStreamingAsync(workflow, "Second turn!")) + { + await foreach (WorkflowEvent evt in run2.WatchStreamAsync()) + { + // Consume all events from second turn + } + } + + // Assert - two workflow.session activities should have been started and stopped + var startedSessions = this._startedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal)) + .ToList(); + startedSessions.Should().HaveCount(2, + "each streaming invocation should start its own workflow.session Activity"); + + var stoppedSessions = this._stoppedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal)) + .ToList(); + stoppedSessions.Should().HaveCount(2, + "each workflow.session Activity should be stopped/disposed so it is exported to telemetry backends"); + + // Assert - two workflow_invoke activities should have been started and stopped + var startedWorkflowRuns = this._startedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) + .ToList(); + startedWorkflowRuns.Should().HaveCount(2, + "each streaming invocation should start its own workflow_invoke Activity"); + + var stoppedWorkflowRuns = this._stoppedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) + .ToList(); + stoppedWorkflowRuns.Should().HaveCount(2, + "each workflow_invoke Activity should be stopped/disposed so it is exported to telemetry backends in multi-turn scenarios"); + } + + /// + /// Verifies that all started activities (not just workflow_invoke) are properly stopped. + /// This ensures no spans are "leaked" without being exported. + /// + [Fact] + public async Task AllActivities_AreStopped_AfterWorkflowCompletionAsync() + { + // Arrange + using var testActivity = new Activity("AllActivitiesStopTest").Start(); + + // Act + var workflow = CreateWorkflow(); + Run run = await InProcessExecution.Lockstep.RunAsync(workflow, "Hello, World!"); + await run.DisposeAsync(); + + // Assert - every started activity should also be stopped + var started = this._startedActivities + .Where(a => a.RootId == testActivity.RootId) + .Select(a => a.Id) + .ToHashSet(); + + var stopped = this._stoppedActivities + .Where(a => a.RootId == testActivity.RootId) + .Select(a => a.Id) + .ToHashSet(); + + var neverStopped = started.Except(stopped).ToList(); + if (neverStopped.Count > 0) + { + var neverStoppedNames = this._startedActivities + .Where(a => neverStopped.Contains(a.Id)) + .Select(a => a.OperationName) + .ToList(); + neverStoppedNames.Should().BeEmpty( + "all started activities should be stopped so they are exported. " + + $"Activities started but never stopped: [{string.Join(", ", neverStoppedNames)}]"); + } + } + + /// + /// Verifies that Activity.Current is not leaked after lockstep RunAsync. + /// Application code creating activities after RunAsync returns should not + /// be parented under the workflow session span. The run activity should + /// still nest correctly under the session. + /// + [Fact] + public async Task Lockstep_SessionActivity_DoesNotLeak_IntoCaller_ActivityCurrentAsync() + { + // Arrange + using var testActivity = new Activity("SessionLeakTest").Start(); + var workflow = CreateWorkflow(); + + // Act — run the workflow via lockstep (Start + drain happen inside RunAsync) + Run run = await InProcessExecution.Lockstep.RunAsync(workflow, "Hello, World!"); + + // Create an application activity after RunAsync returns. + // If the session leaked into Activity.Current, this would be parented under it. + using var appActivity = new Activity("AppWork").Start(); + appActivity.Stop(); + + await run.DisposeAsync(); + + // Assert — the app activity should be parented under the test root, not the session + var sessionActivities = this._startedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowSession, StringComparison.Ordinal)) + .ToList(); + sessionActivities.Should().HaveCount(1, "one session activity should exist"); + + appActivity.ParentId.Should().Be(testActivity.Id, + "application activity should be parented under the test root, not the workflow session"); + + // Assert — the run activity should still be parented under the session + var invokeActivities = this._startedActivities + .Where(a => a.RootId == testActivity.RootId && + a.OperationName.StartsWith(ActivityNames.WorkflowInvoke, StringComparison.Ordinal)) + .ToList(); + invokeActivities.Should().HaveCount(1, "one workflow_invoke activity should exist"); + invokeActivities[0].ParentId.Should().Be(sessionActivities[0].Id, + "workflow_invoke activity should be nested under the session activity"); + } +} diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index b21d8dc0c1..6ae989c0c1 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0rc2] - 2026-02-25 + +### Added + +- **agent-framework-core**: Support Agent Skills ([#4210](https://github.com/microsoft/agent-framework/pull/4210)) +- **agent-framework-core**: Add embedding abstractions and OpenAI implementation (Phase 1) ([#4153](https://github.com/microsoft/agent-framework/pull/4153)) +- **agent-framework-core**: Add Foundry Memory Context Provider ([#3943](https://github.com/microsoft/agent-framework/pull/3943)) +- **agent-framework-core**: Add `max_function_calls` to `FunctionInvocationConfiguration` ([#4175](https://github.com/microsoft/agent-framework/pull/4175)) +- **agent-framework-core**: Add `CreateConversationExecutor`, fix input routing, remove unused handler layer ([#4159](https://github.com/microsoft/agent-framework/pull/4159)) +- **agent-framework-azure-ai-search**: Azure AI Search provider improvements - EmbeddingGenerator, async context manager, KB message handling ([#4212](https://github.com/microsoft/agent-framework/pull/4212)) +- **agent-framework-azure-ai-search**: Enhance Azure AI Search Citations with Document URLs in Foundry V2 ([#4028](https://github.com/microsoft/agent-framework/pull/4028)) +- **agent-framework-ag-ui**: Add Workflow Support, Harden Streaming Semantics, and add Dynamic Handoff Demo ([#3911](https://github.com/microsoft/agent-framework/pull/3911)) + +### Changed + +- **agent-framework-declarative**: [BREAKING] Add `InvokeFunctionTool` action for declarative workflows ([#3716](https://github.com/microsoft/agent-framework/pull/3716)) + +### Fixed + +- **agent-framework-core**: Fix thread corruption when `max_iterations` is reached ([#4234](https://github.com/microsoft/agent-framework/pull/4234)) +- **agent-framework-core**: Fix workflow runner concurrent processing ([#4143](https://github.com/microsoft/agent-framework/pull/4143)) +- **agent-framework-core**: Fix doubled `tool_call` arguments in `MESSAGES_SNAPSHOT` when streaming ([#4200](https://github.com/microsoft/agent-framework/pull/4200)) +- **agent-framework-core**: Fix OpenAI chat client compatibility with third-party endpoints and OTel 0.4.14 ([#4161](https://github.com/microsoft/agent-framework/pull/4161)) +- **agent-framework-claude**: Fix `structured_output` propagation in `ClaudeAgent` ([#4137](https://github.com/microsoft/agent-framework/pull/4137)) + ## [1.0.0rc1] - 2026-02-19 Release candidate for **agent-framework-core** and **agent-framework-azure-ai** packages. @@ -675,7 +700,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai** For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc1...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc2...HEAD +[1.0.0rc2]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc1...python-1.0.0rc2 [1.0.0rc1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260212...python-1.0.0rc1 [1.0.0b260212]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260210...python-1.0.0b260212 [1.0.0b260210]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260130...python-1.0.0b260210 diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 418cfcc827..6a96201ed3 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "a2a-sdk>=0.3.5", ] @@ -37,6 +37,7 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' addopts = "-ra -q -r fEX" @@ -46,6 +47,9 @@ filterwarnings = [ "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" ] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -79,6 +83,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a" test = "pytest --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 461467d616..460c0a6d1a 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b260219" +version = "1.0.0b260225" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "ag-ui-protocol>=0.1.9", "fastapi>=0.115.0", "uvicorn>=0.30.0" @@ -45,6 +45,9 @@ packages = ["agent_framework_ag_ui", "agent_framework_ag_ui_examples"] asyncio_mode = "auto" testpaths = ["tests/ag_ui"] pythonpath = ["."] +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] line-length = 120 diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index 48064681c9..3d0b1ab955 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "anthropic>=0.70.0,<1", ] @@ -37,6 +37,7 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' addopts = "-ra -q -r fEX" @@ -46,6 +47,9 @@ filterwarnings = [ "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" ] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -80,6 +84,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic" test = "pytest --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered -n auto --dist worksteal tests" diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index 0408bdc423..ce43ddae3a 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "azure-search-documents==11.7.0b2", ] @@ -47,6 +47,9 @@ filterwarnings = [ "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" ] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -82,6 +85,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai_search" test = "pytest --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py index 7cc51e4930..3c4fb68fe8 100644 --- a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py +++ b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py @@ -1297,7 +1297,6 @@ class TestPrepareMessagesForKbSearch: 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])] @@ -1309,7 +1308,6 @@ class TestPrepareMessagesForKbSearch: 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") @@ -1319,7 +1317,6 @@ class TestPrepareMessagesForKbSearch: 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])] @@ -1327,7 +1324,6 @@ class TestPrepareMessagesForKbSearch: 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])] @@ -1341,7 +1337,6 @@ class TestPrepareMessagesForKbSearch: 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])] @@ -1352,7 +1347,6 @@ class TestPrepareMessagesForKbSearch: 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])] @@ -1568,9 +1562,7 @@ class TestParseMessagesFromKbResponse: KnowledgeBaseMessage(role="assistant", content=[KnowledgeBaseMessageTextContent(text="answer")]), ], references=[ - KnowledgeBaseWebReference( - id="ref-1", activity_source=0, url="https://example.com", title="Example" - ), + KnowledgeBaseWebReference(id="ref-1", activity_source=0, url="https://example.com", title="Example"), ], ) result = AzureAISearchContextProvider._parse_messages_from_kb_response(response) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/__init__.py b/python/packages/azure-ai/agent_framework_azure_ai/__init__.py index 1b4c5716d0..46b1ed5b3b 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/__init__.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/__init__.py @@ -5,6 +5,12 @@ import importlib.metadata from ._agent_provider import AzureAIAgentsProvider from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions from ._client import AzureAIClient, AzureAIProjectAgentOptions, RawAzureAIClient +from ._embedding_client import ( + AzureAIInferenceEmbeddingClient, + AzureAIInferenceEmbeddingOptions, + AzureAIInferenceEmbeddingSettings, + RawAzureAIInferenceEmbeddingClient, +) from ._foundry_memory_provider import FoundryMemoryProvider from ._project_provider import AzureAIProjectAgentProvider from ._shared import AzureAISettings @@ -19,10 +25,14 @@ __all__ = [ "AzureAIAgentOptions", "AzureAIAgentsProvider", "AzureAIClient", + "AzureAIInferenceEmbeddingClient", + "AzureAIInferenceEmbeddingOptions", + "AzureAIInferenceEmbeddingSettings", "AzureAIProjectAgentOptions", "AzureAIProjectAgentProvider", "AzureAISettings", "FoundryMemoryProvider", "RawAzureAIClient", + "RawAzureAIInferenceEmbeddingClient", "__version__", ] diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py new file mode 100644 index 0000000000..7e6cdfc8b7 --- /dev/null +++ b/python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py @@ -0,0 +1,396 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +import sys +from collections.abc import Sequence +from contextlib import suppress +from typing import Any, ClassVar, Generic, TypedDict + +from agent_framework import ( + BaseEmbeddingClient, + Content, + Embedding, + EmbeddingGenerationOptions, + GeneratedEmbeddings, + UsageDetails, + load_settings, +) +from agent_framework.observability import EmbeddingTelemetryLayer +from azure.ai.inference.aio import EmbeddingsClient, ImageEmbeddingsClient +from azure.ai.inference.models import ImageEmbeddingInput +from azure.core.credentials import AzureKeyCredential + +if sys.version_info >= (3, 13): + from typing import TypeVar # type: ignore # pragma: no cover +else: + from typing_extensions import TypeVar # type: ignore # pragma: no cover + + +logger = logging.getLogger("agent_framework.azure_ai") + +_IMAGE_MEDIA_PREFIXES = ("image/",) + + +class AzureAIInferenceEmbeddingOptions(EmbeddingGenerationOptions, total=False): + """Azure AI Inference-specific embedding options. + + Extends EmbeddingGenerationOptions with Azure AI Inference-specific fields. + + Examples: + .. code-block:: python + + from agent_framework_azure_ai import AzureAIInferenceEmbeddingOptions + + options: AzureAIInferenceEmbeddingOptions = { + "model_id": "text-embedding-3-small", + "dimensions": 1536, + "input_type": "document", + "encoding_format": "float", + } + """ + + input_type: str + """Input type hint for the model. Common values: ``"text"``, ``"query"``, ``"document"``.""" + + image_model_id: str + """Override model for image embeddings. Falls back to the client's ``image_model_id``.""" + + encoding_format: str + """Output encoding format. + + Common values: ``"float"``, ``"base64"``, ``"int8"``, ``"uint8"``, + ``"binary"``, ``"ubinary"``. + """ + + extra_parameters: dict[str, Any] + """Additional model-specific parameters passed directly to the API.""" + + +AzureAIInferenceEmbeddingOptionsT = TypeVar( + "AzureAIInferenceEmbeddingOptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="AzureAIInferenceEmbeddingOptions", + covariant=True, +) + + +class AzureAIInferenceEmbeddingSettings(TypedDict, total=False): + """Azure AI Inference embedding settings.""" + + endpoint: str | None + api_key: str | None + embedding_model_id: str | None + image_embedding_model_id: str | None + + +class RawAzureAIInferenceEmbeddingClient( + BaseEmbeddingClient[Content | str, list[float], AzureAIInferenceEmbeddingOptionsT], + Generic[AzureAIInferenceEmbeddingOptionsT], +): + """Raw Azure AI Inference embedding client without telemetry. + + Accepts both text (``str``) and image (``Content``) inputs. Text and image + inputs within a single batch are separated and dispatched to + ``EmbeddingsClient`` and ``ImageEmbeddingsClient`` respectively. Results + are reassembled in the original input order. + + Keyword Args: + model_id: The text embedding model deployment name (e.g. "text-embedding-3-small"). + Can also be set via environment variable AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID. + image_model_id: The image embedding model deployment name (e.g. "Cohere-embed-v3-english"). + Can also be set via environment variable AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID. + Falls back to ``model_id`` if not provided. + endpoint: The Azure AI Inference endpoint URL. + Can also be set via environment variable AZURE_AI_INFERENCE_ENDPOINT. + api_key: API key for authentication. + Can also be set via environment variable AZURE_AI_INFERENCE_API_KEY. + text_client: Optional pre-configured ``EmbeddingsClient``. + image_client: Optional pre-configured ``ImageEmbeddingsClient``. + credential: Optional ``AzureKeyCredential`` or token credential. If not provided, + one is created from ``api_key``. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + """ + + def __init__( + self, + *, + model_id: str | None = None, + image_model_id: str | None = None, + endpoint: str | None = None, + api_key: str | None = None, + text_client: EmbeddingsClient | None = None, + image_client: ImageEmbeddingsClient | None = None, + credential: AzureKeyCredential | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a raw Azure AI Inference embedding client.""" + settings = load_settings( + AzureAIInferenceEmbeddingSettings, + env_prefix="AZURE_AI_INFERENCE_", + required_fields=["endpoint", "embedding_model_id"], + endpoint=endpoint, + api_key=api_key, + embedding_model_id=model_id, + image_embedding_model_id=image_model_id, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + self.model_id = settings["embedding_model_id"] # type: ignore[reportTypedDictNotRequiredAccess] + self.image_model_id: str = settings.get("image_embedding_model_id") or self.model_id # type: ignore[assignment] + resolved_endpoint = settings["endpoint"] # type: ignore[reportTypedDictNotRequiredAccess] + + if credential is None and settings.get("api_key"): + credential = AzureKeyCredential(settings["api_key"]) # type: ignore[arg-type] + + if credential is None and text_client is None and image_client is None: + raise ValueError("Either 'api_key', 'credential', or pre-configured client(s) must be provided.") + + self._text_client = text_client or EmbeddingsClient( + endpoint=resolved_endpoint, # type: ignore[arg-type] + credential=credential, # type: ignore[arg-type] + ) + self._image_client = image_client or ImageEmbeddingsClient( + endpoint=resolved_endpoint, # type: ignore[arg-type] + credential=credential, # type: ignore[arg-type] + ) + self._endpoint = resolved_endpoint + super().__init__(**kwargs) + + async def close(self) -> None: + """Close the underlying SDK clients and release resources.""" + with suppress(Exception): + await self._text_client.close() + with suppress(Exception): + await self._image_client.close() + + async def __aenter__(self) -> RawAzureAIInferenceEmbeddingClient[AzureAIInferenceEmbeddingOptionsT]: + """Enter the async context manager.""" + return self + + async def __aexit__(self, *args: Any) -> None: + """Exit the async context manager and close clients.""" + await self.close() + + def service_url(self) -> str: + """Get the URL of the service.""" + return self._endpoint or "" + + async def get_embeddings( + self, + values: Sequence[Content | str], + *, + options: AzureAIInferenceEmbeddingOptionsT | None = None, + ) -> GeneratedEmbeddings[list[float]]: + """Generate embeddings for text and/or image inputs. + + Text inputs (``str`` or ``Content`` with ``type="text"``) are sent to the + text embeddings endpoint. Image inputs (``Content`` with an image + ``media_type``) are sent to the image embeddings endpoint. Results are + returned in the same order as the input. + + Args: + values: A sequence of text strings or ``Content`` instances. + options: Optional embedding generation options. + + Returns: + Generated embeddings with usage metadata. + + Raises: + ValueError: If model_id is not provided or an unsupported content type is encountered. + """ + if not values: + return GeneratedEmbeddings([], options=options) # type: ignore[reportReturnType] + + opts: dict[str, Any] = dict(options) if options else {} + + # Separate text and image inputs, tracking original indices. + text_items: list[tuple[int, str]] = [] + image_items: list[tuple[int, ImageEmbeddingInput]] = [] + + for idx, value in enumerate(values): + if isinstance(value, str): + text_items.append((idx, value)) + elif isinstance(value, Content): + if value.type == "text" and value.text is not None: + text_items.append((idx, value.text)) + elif ( + value.type in ("data", "uri") + and value.media_type + and value.media_type.startswith(_IMAGE_MEDIA_PREFIXES[0]) + ): + if not value.uri: + raise ValueError(f"Image Content at index {idx} has no URI.") + image_input = ImageEmbeddingInput(image=value.uri, text=value.text) + image_items.append((idx, image_input)) + else: + raise ValueError( + f"Unsupported Content type '{value.type}' with media_type " + f"'{value.media_type}' at index {idx}. Expected text content or " + f"image content (media_type starting with 'image/')." + ) + else: + raise ValueError(f"Unsupported input type {type(value).__name__} at index {idx}.") + + # Build shared API kwargs (without model, which differs per client). + common_kwargs: dict[str, Any] = {} + if dimensions := opts.get("dimensions"): + common_kwargs["dimensions"] = dimensions + if encoding_format := opts.get("encoding_format"): + common_kwargs["encoding_format"] = encoding_format + if input_type := opts.get("input_type"): + common_kwargs["input_type"] = input_type + if extra_parameters := opts.get("extra_parameters"): + common_kwargs["model_extras"] = extra_parameters + + # Allocate results array. + embeddings: list[Embedding[list[float]] | None] = [None] * len(values) + usage_details: UsageDetails = {} + + # Embed text inputs. + if text_items: + if not (text_model := opts.get("model_id") or self.model_id): + raise ValueError("An model_id is required, either in the client or options, for text inputs.") + text_inputs = [t for _, t in text_items] + response = await self._text_client.embed( + input=text_inputs, + model=text_model, + **common_kwargs, + ) + for i, item in enumerate(response.data): + original_idx = text_items[i][0] + vector: list[float] = [float(v) for v in item.embedding] + embeddings[original_idx] = Embedding( + vector=vector, + dimensions=len(vector), + model_id=response.model or text_model, + ) + if response.usage: + usage_details["input_token_count"] = (usage_details.get("input_token_count") or 0) + ( + response.usage.prompt_tokens or 0 + ) + usage_details["output_token_count"] = (usage_details.get("output_token_count") or 0) + ( + getattr(response.usage, "completion_tokens", 0) or 0 + ) + + # Embed image inputs. + if image_items: + if not (image_model := opts.get("image_model_id") or self.image_model_id): + raise ValueError("An image_model_id is required, either in the client or options, for image inputs.") + image_inputs = [img for _, img in image_items] + response = await self._image_client.embed( + input=image_inputs, + model=image_model, + **common_kwargs, + ) + for i, item in enumerate(response.data): + original_idx = image_items[i][0] + image_vector: list[float] = [float(v) for v in item.embedding] + embeddings[original_idx] = Embedding( + vector=image_vector, + dimensions=len(image_vector), + model_id=response.model or image_model, + ) + if response.usage: + usage_details["input_token_count"] = (usage_details.get("input_token_count") or 0) + ( + response.usage.prompt_tokens or 0 + ) + usage_details["output_token_count"] = (usage_details.get("output_token_count") or 0) + ( + getattr(response.usage, "completion_tokens", 0) or 0 + ) + return GeneratedEmbeddings( + [embedding for embedding in embeddings if embedding is not None], + options=options, + usage=usage_details, + ) # type: ignore[reportReturnType] + + +class AzureAIInferenceEmbeddingClient( + EmbeddingTelemetryLayer[Content | str, list[float], AzureAIInferenceEmbeddingOptionsT], + RawAzureAIInferenceEmbeddingClient[AzureAIInferenceEmbeddingOptionsT], + Generic[AzureAIInferenceEmbeddingOptionsT], +): + """Azure AI Inference embedding client with telemetry support. + + Supports both text and image inputs in a single client. Pass plain strings + or ``Content`` instances created with ``Content.from_text()`` or + ``Content.from_data()``. + + Keyword Args: + model_id: The text embedding model deployment name (e.g. "text-embedding-3-small"). + Can also be set via environment variable AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID. + image_model_id: The image embedding model deployment name + (e.g. "Cohere-embed-v3-english"). Can also be set via environment variable + AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID. Falls back to ``model_id``. + endpoint: The Azure AI Inference endpoint URL. + Can also be set via environment variable AZURE_AI_INFERENCE_ENDPOINT. + api_key: API key for authentication. + Can also be set via environment variable AZURE_AI_INFERENCE_API_KEY. + text_client: Optional pre-configured ``EmbeddingsClient``. + image_client: Optional pre-configured ``ImageEmbeddingsClient``. + credential: Optional ``AzureKeyCredential`` or token credential. + otel_provider_name: Override for the OpenTelemetry provider name. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + + Examples: + .. code-block:: python + + from agent_framework_azure_ai import AzureAIInferenceEmbeddingClient + + # Using environment variables + # Set AZURE_AI_INFERENCE_ENDPOINT=https://your-endpoint.inference.ai.azure.com + # Set AZURE_AI_INFERENCE_API_KEY=your-key + # Set AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID=text-embedding-3-small + # Set AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID=Cohere-embed-v3-english + client = AzureAIInferenceEmbeddingClient() + + # Text embeddings + result = await client.get_embeddings(["Hello, world!"]) + + # Image embeddings + from agent_framework import Content + + image = Content.from_data(data=image_bytes, media_type="image/png") + result = await client.get_embeddings([image]) + + # Mixed text and image + result = await client.get_embeddings(["hello", image]) + """ + + OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.inference" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + model_id: str | None = None, + image_model_id: str | None = None, + endpoint: str | None = None, + api_key: str | None = None, + text_client: EmbeddingsClient | None = None, + image_client: ImageEmbeddingsClient | None = None, + credential: AzureKeyCredential | None = None, + otel_provider_name: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize an Azure AI Inference embedding client.""" + super().__init__( + model_id=model_id, + image_model_id=image_model_id, + endpoint=endpoint, + api_key=api_key, + text_client=text_client, + image_client=image_client, + credential=credential, + otel_provider_name=otel_provider_name, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + **kwargs, + ) diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index 8fb0adfbd1..af8baf1fb9 100644 --- a/python/packages/azure-ai/pyproject.toml +++ b/python/packages/azure-ai/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0rc1" +version = "1.0.0rc2" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,8 +23,9 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "azure-ai-agents == 1.2.0b5", + "azure-ai-inference>=1.0.0b9", "aiohttp", ] @@ -38,6 +39,7 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' addopts = "-ra -q -r fEX" @@ -45,6 +47,9 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -78,6 +83,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai" test = "pytest --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/azure-ai/tests/azure_ai/test_azure_ai_inference_embedding_client.py b/python/packages/azure-ai/tests/azure_ai/test_azure_ai_inference_embedding_client.py new file mode 100644 index 0000000000..0ec6a8b811 --- /dev/null +++ b/python/packages/azure-ai/tests/azure_ai/test_azure_ai_inference_embedding_client.py @@ -0,0 +1,316 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import os +from collections.abc import Sequence +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import Content + +from agent_framework_azure_ai import ( + AzureAIInferenceEmbeddingClient, + AzureAIInferenceEmbeddingOptions, + RawAzureAIInferenceEmbeddingClient, +) + + +def _make_embed_response( + embeddings: Sequence[list[float]], + model: str = "test-model", + prompt_tokens: int = 10, +) -> MagicMock: + """Create a mock EmbeddingsResult.""" + data = [] + for emb in embeddings: + item = MagicMock() + item.embedding = emb + data.append(item) + + usage = MagicMock() + usage.prompt_tokens = prompt_tokens + usage.completion_tokens = 0 + + result = MagicMock() + result.data = data + result.model = model + result.usage = usage + return result + + +@pytest.fixture +def mock_text_client() -> AsyncMock: + """Create a mock text EmbeddingsClient.""" + client = AsyncMock() + client.embed = AsyncMock(return_value=_make_embed_response([[0.1, 0.2, 0.3]])) + return client + + +@pytest.fixture +def mock_image_client() -> AsyncMock: + """Create a mock image ImageEmbeddingsClient.""" + client = AsyncMock() + client.embed = AsyncMock(return_value=_make_embed_response([[0.4, 0.5, 0.6]])) + return client + + +@pytest.fixture +def raw_client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> RawAzureAIInferenceEmbeddingClient[Any]: + """Create a RawAzureAIInferenceEmbeddingClient with mocked SDK clients.""" + return RawAzureAIInferenceEmbeddingClient( + model_id="test-model", + endpoint="https://test.inference.ai.azure.com", + api_key="test-key", + text_client=mock_text_client, + image_client=mock_image_client, + ) + + +@pytest.fixture +def client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> AzureAIInferenceEmbeddingClient[Any]: + """Create an AzureAIInferenceEmbeddingClient with mocked SDK clients.""" + return AzureAIInferenceEmbeddingClient( + model_id="test-model", + endpoint="https://test.inference.ai.azure.com", + api_key="test-key", + text_client=mock_text_client, + image_client=mock_image_client, + ) + + +class TestRawAzureAIInferenceEmbeddingClient: + """Tests for the raw Azure AI Inference embedding client.""" + + async def test_text_embeddings( + self, raw_client: RawAzureAIInferenceEmbeddingClient[Any], mock_text_client: AsyncMock + ) -> None: + """Text inputs are dispatched to the text client.""" + result = await raw_client.get_embeddings(["hello", "world"]) + assert result is not None + call_kwargs = mock_text_client.embed.call_args + assert call_kwargs.kwargs["input"] == ["hello", "world"] + assert call_kwargs.kwargs["model"] == "test-model" + + async def test_text_content_embeddings( + self, raw_client: RawAzureAIInferenceEmbeddingClient[Any], mock_text_client: AsyncMock + ) -> None: + """Content.from_text() inputs are dispatched to the text client.""" + text_content = Content.from_text("hello") + await raw_client.get_embeddings([text_content]) + + mock_text_client.embed.assert_called_once() + call_kwargs = mock_text_client.embed.call_args + assert call_kwargs.kwargs["input"] == ["hello"] + + async def test_image_content_embeddings( + self, raw_client: RawAzureAIInferenceEmbeddingClient[Any], mock_image_client: AsyncMock + ) -> None: + """Image Content inputs are dispatched to the image client.""" + image_content = Content.from_data(data=b"\x89PNG", media_type="image/png") + await raw_client.get_embeddings([image_content]) + + mock_image_client.embed.assert_called_once() + call_kwargs = mock_image_client.embed.call_args + image_inputs = call_kwargs.kwargs["input"] + assert len(image_inputs) == 1 + assert image_inputs[0].image == image_content.uri + + async def test_mixed_text_and_image( + self, + raw_client: RawAzureAIInferenceEmbeddingClient[Any], + mock_text_client: AsyncMock, + mock_image_client: AsyncMock, + ) -> None: + """Mixed text and image inputs are dispatched to the correct clients.""" + mock_text_client.embed.return_value = _make_embed_response([[0.1, 0.2]]) + mock_image_client.embed.return_value = _make_embed_response([[0.3, 0.4]]) + + image = Content.from_data(data=b"\x89PNG", media_type="image/png") + await raw_client.get_embeddings(["hello", image, "world"]) + + # Text client gets "hello" and "world" + text_call = mock_text_client.embed.call_args + assert text_call.kwargs["input"] == ["hello", "world"] + + # Image client gets the image + image_call = mock_image_client.embed.call_args + assert len(image_call.kwargs["input"]) == 1 + + async def test_empty_input(self, raw_client: RawAzureAIInferenceEmbeddingClient[Any]) -> None: + """Empty input returns empty result.""" + result = await raw_client.get_embeddings([]) + assert len(result) == 0 + + async def test_options_passed_through( + self, raw_client: RawAzureAIInferenceEmbeddingClient[Any], mock_text_client: AsyncMock + ) -> None: + """Options are passed through to the SDK.""" + options: AzureAIInferenceEmbeddingOptions = { + "dimensions": 512, + "input_type": "document", + "encoding_format": "float", + } + await raw_client.get_embeddings(["hello"], options=options) + + call_kwargs = mock_text_client.embed.call_args + assert call_kwargs.kwargs["dimensions"] == 512 + assert call_kwargs.kwargs["input_type"] == "document" + assert call_kwargs.kwargs["encoding_format"] == "float" + + async def test_model_override_in_options( + self, raw_client: RawAzureAIInferenceEmbeddingClient[Any], mock_text_client: AsyncMock + ) -> None: + """model_id in options overrides the default.""" + options: AzureAIInferenceEmbeddingOptions = {"model_id": "custom-model"} + await raw_client.get_embeddings(["hello"], options=options) + + call_kwargs = mock_text_client.embed.call_args + assert call_kwargs.kwargs["model"] == "custom-model" + + async def test_unsupported_content_type_raises(self, raw_client: RawAzureAIInferenceEmbeddingClient[Any]) -> None: + """Non-text, non-image Content raises ValueError.""" + error_content = Content("error", message="fail") + with pytest.raises(ValueError, match="Unsupported Content type"): + await raw_client.get_embeddings([error_content]) + + async def test_usage_metadata( + self, raw_client: RawAzureAIInferenceEmbeddingClient[Any], mock_text_client: AsyncMock + ) -> None: + """Usage metadata is populated from the response.""" + mock_text_client.embed.return_value = _make_embed_response([[0.1, 0.2]], prompt_tokens=42) + result = await raw_client.get_embeddings(["hello"]) + assert result.usage is not None + assert result.usage["input_token_count"] == 42 + + def test_service_url(self, raw_client: RawAzureAIInferenceEmbeddingClient[Any]) -> None: + """service_url returns the configured endpoint.""" + assert raw_client.service_url() == "https://test.inference.ai.azure.com" + + def test_settings_from_env(self) -> None: + """Settings are loaded from environment variables.""" + with ( + patch.dict( + os.environ, + { + "AZURE_AI_INFERENCE_ENDPOINT": "https://env.inference.ai.azure.com", + "AZURE_AI_INFERENCE_API_KEY": "env-key", + "AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID": "env-model", + }, + ), + patch("agent_framework_azure_ai._embedding_client.EmbeddingsClient"), + patch("agent_framework_azure_ai._embedding_client.ImageEmbeddingsClient"), + ): + client = RawAzureAIInferenceEmbeddingClient() + assert client.model_id == "env-model" + assert client.image_model_id == "env-model" # falls back to model_id + + def test_image_model_id_from_env(self) -> None: + """image_model_id is loaded from its own environment variable.""" + with ( + patch.dict( + os.environ, + { + "AZURE_AI_INFERENCE_ENDPOINT": "https://env.inference.ai.azure.com", + "AZURE_AI_INFERENCE_API_KEY": "env-key", + "AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID": "text-model", + "AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID": "image-model", + }, + ), + patch("agent_framework_azure_ai._embedding_client.EmbeddingsClient"), + patch("agent_framework_azure_ai._embedding_client.ImageEmbeddingsClient"), + ): + client = RawAzureAIInferenceEmbeddingClient() + assert client.model_id == "text-model" + assert client.image_model_id == "image-model" + + def test_image_model_id_explicit(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None: + """image_model_id can be set explicitly.""" + client = RawAzureAIInferenceEmbeddingClient( + model_id="text-model", + image_model_id="image-model", + endpoint="https://test.inference.ai.azure.com", + api_key="test-key", + text_client=mock_text_client, + image_client=mock_image_client, + ) + assert client.model_id == "text-model" + assert client.image_model_id == "image-model" + + async def test_image_model_id_sent_to_image_client( + self, mock_text_client: AsyncMock, mock_image_client: AsyncMock + ) -> None: + """image_model_id is passed to the image client embed call.""" + client = RawAzureAIInferenceEmbeddingClient( + model_id="text-model", + image_model_id="image-model", + endpoint="https://test.inference.ai.azure.com", + api_key="test-key", + text_client=mock_text_client, + image_client=mock_image_client, + ) + image_content = Content.from_data(data=b"\x89PNG", media_type="image/png") + await client.get_embeddings([image_content]) + call_kwargs = mock_image_client.embed.call_args + assert call_kwargs.kwargs["model"] == "image-model" + + +class TestAzureAIInferenceEmbeddingClient: + """Tests for the telemetry-enabled Azure AI Inference embedding client.""" + + async def test_text_embeddings( + self, client: AzureAIInferenceEmbeddingClient[Any], mock_text_client: AsyncMock + ) -> None: + """Text embeddings work through the telemetry layer.""" + result = await client.get_embeddings(["hello"]) + assert len(result) == 1 + assert result[0].vector == [0.1, 0.2, 0.3] + + async def test_otel_provider_name_default(self) -> None: + """Default OTEL provider name is azure.ai.inference.""" + assert AzureAIInferenceEmbeddingClient.OTEL_PROVIDER_NAME == "azure.ai.inference" + + async def test_otel_provider_name_override(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None: + """OTEL provider name can be overridden.""" + client = AzureAIInferenceEmbeddingClient( + model_id="test-model", + endpoint="https://test.inference.ai.azure.com", + api_key="test-key", + text_client=mock_text_client, + image_client=mock_image_client, + otel_provider_name="custom-provider", + ) + assert client.otel_provider_name == "custom-provider" + + +_SKIP_REASON = "Azure AI Inference integration tests disabled" + + +def _integration_tests_enabled() -> bool: + return bool( + os.environ.get("AZURE_AI_INFERENCE_ENDPOINT") + and os.environ.get("AZURE_AI_INFERENCE_API_KEY") + and os.environ.get("AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID") + ) + + +skip_if_azure_ai_inference_integration_tests_disabled = pytest.mark.skipif( + not _integration_tests_enabled(), + reason=_SKIP_REASON, +) + + +class TestAzureAIInferenceEmbeddingIntegration: + """Integration tests requiring a live Azure AI Inference endpoint.""" + + @pytest.mark.flaky + @pytest.mark.integration + @skip_if_azure_ai_inference_integration_tests_disabled + async def test_text_embedding_live(self) -> None: + """Generate text embeddings against a live endpoint.""" + client = AzureAIInferenceEmbeddingClient() + result = await client.get_embeddings(["Hello, world!"]) + assert len(result) == 1 + assert len(result[0].vector) > 0 + assert result[0].model_id is not None diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index c0bda645ec..35f992e400 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "agent-framework-durabletask", "azure-functions", "azure-functions-durable", @@ -41,6 +41,7 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' pythonpath = ["tests/integration_tests"] @@ -88,6 +89,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions" test = "pytest --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/bedrock/agent_framework_bedrock/__init__.py b/python/packages/bedrock/agent_framework_bedrock/__init__.py index c33badcb35..3fbf5c15cf 100644 --- a/python/packages/bedrock/agent_framework_bedrock/__init__.py +++ b/python/packages/bedrock/agent_framework_bedrock/__init__.py @@ -3,6 +3,7 @@ import importlib.metadata from ._chat_client import BedrockChatClient, BedrockChatOptions, BedrockGuardrailConfig, BedrockSettings +from ._embedding_client import BedrockEmbeddingClient, BedrockEmbeddingOptions, BedrockEmbeddingSettings try: __version__ = importlib.metadata.version(__name__) @@ -12,6 +13,9 @@ except importlib.metadata.PackageNotFoundError: __all__ = [ "BedrockChatClient", "BedrockChatOptions", + "BedrockEmbeddingClient", + "BedrockEmbeddingOptions", + "BedrockEmbeddingSettings", "BedrockGuardrailConfig", "BedrockSettings", "__version__", diff --git a/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py new file mode 100644 index 0000000000..30be74eed9 --- /dev/null +++ b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py @@ -0,0 +1,292 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +import json +import logging +import sys +from collections.abc import Sequence +from typing import Any, ClassVar, Generic, TypedDict + +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + BaseEmbeddingClient, + Embedding, + EmbeddingGenerationOptions, + GeneratedEmbeddings, + SecretString, + UsageDetails, + load_settings, +) +from agent_framework.observability import EmbeddingTelemetryLayer +from boto3.session import Session as Boto3Session +from botocore.client import BaseClient +from botocore.config import Config as BotoConfig + +if sys.version_info >= (3, 13): + from typing import TypeVar # type: ignore # pragma: no cover +else: + from typing_extensions import TypeVar # type: ignore # pragma: no cover + + +logger = logging.getLogger("agent_framework.bedrock") +DEFAULT_REGION = "us-east-1" + + +class BedrockEmbeddingSettings(TypedDict, total=False): + """Bedrock embedding settings.""" + + region: str | None + embedding_model_id: str | None + access_key: SecretString | None + secret_key: SecretString | None + session_token: SecretString | None + + +class BedrockEmbeddingOptions(EmbeddingGenerationOptions, total=False): + """Bedrock-specific embedding options. + + Extends EmbeddingGenerationOptions with Bedrock-specific fields. + + Examples: + .. code-block:: python + + from agent_framework_bedrock import BedrockEmbeddingOptions + + options: BedrockEmbeddingOptions = { + "model_id": "amazon.titan-embed-text-v2:0", + "dimensions": 1024, + "normalize": True, + } + """ + + normalize: bool + + +BedrockEmbeddingOptionsT = TypeVar( + "BedrockEmbeddingOptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="BedrockEmbeddingOptions", + covariant=True, +) + + +class RawBedrockEmbeddingClient( + BaseEmbeddingClient[str, list[float], BedrockEmbeddingOptionsT], + Generic[BedrockEmbeddingOptionsT], +): + """Raw Bedrock embedding client without telemetry. + + Keyword Args: + model_id: The Bedrock embedding model ID (e.g. "amazon.titan-embed-text-v2:0"). + Can also be set via environment variable BEDROCK_EMBEDDING_MODEL_ID. + region: AWS region. Will try to load from BEDROCK_REGION env var, + if not set, the regular Boto3 configuration/loading applies + (which may include other env vars, config files, or instance metadata). + access_key: AWS access key for manual credential injection. + secret_key: AWS secret key paired with access_key. + session_token: AWS session token for temporary credentials. + client: Preconfigured Bedrock runtime client. + boto3_session: Custom boto3 session used to build the runtime client. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + """ + + def __init__( + self, + *, + region: str | None = None, + model_id: str | None = None, + access_key: str | None = None, + secret_key: str | None = None, + session_token: str | None = None, + client: BaseClient | None = None, + boto3_session: Boto3Session | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a raw Bedrock embedding client.""" + settings = load_settings( + BedrockEmbeddingSettings, + env_prefix="BEDROCK_", + required_fields=["embedding_model_id"], + region=region, + embedding_model_id=model_id, + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + resolved_region = settings.get("region") or DEFAULT_REGION + + if client is None: + if not boto3_session: + session_kwargs: dict[str, Any] = {} + if region := settings.get("region"): + session_kwargs["region_name"] = region + if (access_key := settings.get("access_key")) and (secret_key := settings.get("secret_key")): + session_kwargs["aws_access_key_id"] = access_key.get_secret_value() # type: ignore[union-attr] + session_kwargs["aws_secret_access_key"] = secret_key.get_secret_value() # type: ignore[union-attr] + if session_token := settings.get("session_token"): + session_kwargs["aws_session_token"] = session_token.get_secret_value() # type: ignore[union-attr] + boto3_session = Boto3Session(**session_kwargs) + client = boto3_session.client( + "bedrock-runtime", + region_name=boto3_session.region_name or resolved_region, + config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT), + ) + + self._bedrock_client = client + self.model_id = settings["embedding_model_id"] # type: ignore[assignment] + self.region = resolved_region + super().__init__(**kwargs) + + def service_url(self) -> str: + """Get the URL of the service.""" + return str(self._bedrock_client.meta.endpoint_url) + + async def get_embeddings( + self, + values: Sequence[str], + *, + options: BedrockEmbeddingOptionsT | None = None, + ) -> GeneratedEmbeddings[list[float]]: + """Call the Bedrock invoke_model API for embeddings. + + Uses the Amazon Titan Embeddings model format. Each value is embedded + individually since Titan's invoke_model API accepts one input at a time. + + Args: + values: The text values to generate embeddings for. + options: Optional embedding generation options. + + Returns: + Generated embeddings with usage metadata. + + Raises: + ValueError: If model_id is not provided or values is empty. + """ + if not values: + return GeneratedEmbeddings([], options=options) + + opts: dict[str, Any] = dict(options) if options else {} + model = opts.get("model_id") or self.model_id + if not model: + raise ValueError("model_id is required") + + embedding_results = await asyncio.gather( + *(self._generate_embedding_for_text(opts, model, text) for text in values) + ) + embeddings: list[Embedding[list[float]]] = [] + total_input_tokens = 0 + for embedding, input_tokens in embedding_results: + embeddings.append(embedding) + total_input_tokens += input_tokens + + usage_dict: UsageDetails | None = None + if total_input_tokens > 0: + usage_dict = {"input_token_count": total_input_tokens} + + return GeneratedEmbeddings(embeddings, options=options, usage=usage_dict) + + async def _generate_embedding_for_text( + self, + opts: dict[str, Any], + model: str, + text: str, + ) -> tuple[Embedding[list[float]], int]: + body: dict[str, Any] = {"inputText": text} + if dimensions := opts.get("dimensions"): + body["dimensions"] = dimensions + if (normalize := opts.get("normalize")) is not None: + body["normalize"] = normalize + + response = await asyncio.to_thread( + self._bedrock_client.invoke_model, + modelId=model, + contentType="application/json", + accept="application/json", + body=json.dumps(body), + ) + + response_body = json.loads(response["body"].read()) + embedding = Embedding( + vector=response_body["embedding"], + dimensions=len(response_body["embedding"]), + model_id=model, + ) + input_tokens = int(response_body.get("inputTextTokenCount", 0)) + return embedding, input_tokens + + +class BedrockEmbeddingClient( + EmbeddingTelemetryLayer[str, list[float], BedrockEmbeddingOptionsT], + RawBedrockEmbeddingClient[BedrockEmbeddingOptionsT], + Generic[BedrockEmbeddingOptionsT], +): + """Bedrock embedding client with telemetry support. + + Uses the Amazon Titan Embeddings model via Bedrock's invoke_model API. + + Keyword Args: + model_id: The Bedrock embedding model ID (e.g. "amazon.titan-embed-text-v2:0"). + Can also be set via environment variable BEDROCK_EMBEDDING_MODEL_ID. + region: AWS region. Defaults to "us-east-1". + Can also be set via environment variable BEDROCK_REGION. + access_key: AWS access key for manual credential injection. + secret_key: AWS secret key paired with access_key. + session_token: AWS session token for temporary credentials. + client: Preconfigured Bedrock runtime client. + boto3_session: Custom boto3 session used to build the runtime client. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + + Examples: + .. code-block:: python + + from agent_framework_bedrock import BedrockEmbeddingClient + + # Using default AWS credentials + client = BedrockEmbeddingClient( + model_id="amazon.titan-embed-text-v2:0", + ) + + # Generate embeddings + result = await client.get_embeddings(["Hello, world!"]) + print(result[0].vector) + """ + + OTEL_PROVIDER_NAME: ClassVar[str] = "aws.bedrock" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + region: str | None = None, + model_id: str | None = None, + access_key: str | None = None, + secret_key: str | None = None, + session_token: str | None = None, + client: BaseClient | None = None, + boto3_session: Boto3Session | None = None, + otel_provider_name: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a Bedrock embedding client.""" + super().__init__( + region=region, + model_id=model_id, + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + client=client, + boto3_session=boto3_session, + otel_provider_name=otel_provider_name, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + **kwargs, + ) diff --git a/python/packages/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml index a7bc169104..a5bd9577a8 100644 --- a/python/packages/bedrock/pyproject.toml +++ b/python/packages/bedrock/pyproject.toml @@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,12 +23,11 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "boto3>=1.35.0,<2.0.0", "botocore>=1.35.0,<2.0.0", ] - [tool.uv] prerelease = "if-necessary-or-explicit" environments = [ @@ -46,6 +45,9 @@ addopts = "-ra -q -r fEX" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [] +markers = [ + "integration: marks tests as integration tests that require external services", +] timeout = 120 [tool.ruff] diff --git a/python/packages/bedrock/tests/bedrock/test_bedrock_embedding_client.py b/python/packages/bedrock/tests/bedrock/test_bedrock_embedding_client.py new file mode 100644 index 0000000000..729583e0bc --- /dev/null +++ b/python/packages/bedrock/tests/bedrock/test_bedrock_embedding_client.py @@ -0,0 +1,168 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import json +import os +from typing import Any +from unittest.mock import MagicMock + +import pytest +from agent_framework import Embedding, GeneratedEmbeddings + +from agent_framework_bedrock import BedrockEmbeddingClient, BedrockEmbeddingOptions + + +class _StubBedrockEmbeddingRuntime: + """Stub for the Bedrock runtime client that handles invoke_model for embeddings.""" + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def invoke_model(self, **kwargs: Any) -> dict[str, Any]: + self.calls.append(kwargs) + body = json.loads(kwargs.get("body", "{}")) + # Simulate Titan embedding response + dimensions = body.get("dimensions", 3) + return { + "body": MagicMock( + read=lambda: json.dumps({ + "embedding": [0.1 * (i + 1) for i in range(dimensions)], + "inputTextTokenCount": 5, + }).encode() + ), + } + + +async def test_bedrock_embedding_construction() -> None: + """Test construction with explicit parameters.""" + stub = _StubBedrockEmbeddingRuntime() + client = BedrockEmbeddingClient( + model_id="amazon.titan-embed-text-v2:0", + region="us-west-2", + client=stub, + ) + assert client.model_id == "amazon.titan-embed-text-v2:0" + assert client.region == "us-west-2" + + +async def test_bedrock_embedding_construction_missing_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: + """Test that missing model_id raises an error.""" + monkeypatch.delenv("BEDROCK_EMBEDDING_MODEL_ID", raising=False) + from agent_framework.exceptions import SettingNotFoundError + + with pytest.raises(SettingNotFoundError): + BedrockEmbeddingClient(region="us-west-2") + + +async def test_bedrock_embedding_get_embeddings() -> None: + """Test generating embeddings via the Bedrock invoke_model API.""" + stub = _StubBedrockEmbeddingRuntime() + client = BedrockEmbeddingClient( + model_id="amazon.titan-embed-text-v2:0", + region="us-west-2", + client=stub, + ) + + result = await client.get_embeddings(["hello", "world"]) + + assert isinstance(result, GeneratedEmbeddings) + assert len(result) == 2 + assert len(result[0].vector) == 3 + assert len(result[1].vector) == 3 + assert result[0].model_id == "amazon.titan-embed-text-v2:0" + assert result.usage == {"input_token_count": 10} + + # Two calls since Titan processes one input at a time + assert len(stub.calls) == 2 + call_texts = {json.loads(call["body"])["inputText"] for call in stub.calls} + assert call_texts == {"hello", "world"} + + +async def test_bedrock_embedding_get_embeddings_empty_input() -> None: + """Test generating embeddings with empty input.""" + stub = _StubBedrockEmbeddingRuntime() + client = BedrockEmbeddingClient( + model_id="amazon.titan-embed-text-v2:0", + region="us-west-2", + client=stub, + ) + + result = await client.get_embeddings([]) + + assert isinstance(result, GeneratedEmbeddings) + assert len(result) == 0 + assert len(stub.calls) == 0 + + +async def test_bedrock_embedding_get_embeddings_with_options() -> None: + """Test generating embeddings with custom options.""" + stub = _StubBedrockEmbeddingRuntime() + client = BedrockEmbeddingClient( + model_id="amazon.titan-embed-text-v2:0", + region="us-west-2", + client=stub, + ) + + options: BedrockEmbeddingOptions = { + "dimensions": 5, + "normalize": True, + } + result = await client.get_embeddings(["hello"], options=options) + + assert len(result) == 1 + assert len(result[0].vector) == 5 + + body = json.loads(stub.calls[0]["body"]) + assert body["dimensions"] == 5 + assert body["normalize"] is True + + +async def test_bedrock_embedding_get_embeddings_no_model_raises() -> None: + """Test that missing model_id at call time raises ValueError.""" + stub = _StubBedrockEmbeddingRuntime() + client = BedrockEmbeddingClient( + model_id="amazon.titan-embed-text-v2:0", + region="us-west-2", + client=stub, + ) + client.model_id = None # type: ignore[assignment] + + with pytest.raises(ValueError, match="model_id is required"): + await client.get_embeddings(["hello"]) + + +async def test_bedrock_embedding_default_region() -> None: + """Test that default region is us-east-1.""" + stub = _StubBedrockEmbeddingRuntime() + client = BedrockEmbeddingClient( + model_id="amazon.titan-embed-text-v2:0", + client=stub, + ) + assert client.region == "us-east-1" + + +# region: Integration Tests + +skip_if_bedrock_embedding_integration_tests_disabled = pytest.mark.skipif( + os.getenv("BEDROCK_EMBEDDING_MODEL_ID", "") in ("", "test-model") + or not (os.getenv("AWS_ACCESS_KEY_ID") or os.getenv("BEDROCK_ACCESS_KEY")), + reason="No real Bedrock embedding model or AWS credentials provided; skipping integration tests.", +) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_bedrock_embedding_integration_tests_disabled +async def test_bedrock_embedding_integration() -> None: + """Integration test for Bedrock embedding client.""" + client = BedrockEmbeddingClient() + result = await client.get_embeddings(["Hello, world!", "How are you?"]) + + assert isinstance(result, GeneratedEmbeddings) + assert len(result) == 2 + for embedding in result: + assert isinstance(embedding, Embedding) + assert isinstance(embedding.vector, list) + assert len(embedding.vector) > 0 + assert all(isinstance(v, float) for v in embedding.vector) diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index e2a58f129a..c39b89f792 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "openai-chatkit>=1.4.0,<2.0.0", ] @@ -36,6 +36,7 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' addopts = "-ra -q -r fEX" @@ -43,6 +44,9 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -80,6 +84,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_chatkit" test = "pytest --cov=agent_framework_chatkit --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index 9d242c1fd5..3c2e37e14e 100644 --- a/python/packages/claude/pyproject.toml +++ b/python/packages/claude/pyproject.toml @@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "claude-agent-sdk>=0.1.25", ] @@ -37,6 +37,7 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' addopts = "-ra -q -r fEX" @@ -46,6 +47,9 @@ filterwarnings = [ "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" ] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -80,6 +84,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_claude" test = "pytest --cov=agent_framework_claude --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index fbcda0185e..9851dcab30 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "microsoft-agents-copilotstudio-client>=0.3.1", ] @@ -37,6 +37,7 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' addopts = "-ra -q -r fEX" @@ -46,6 +47,9 @@ filterwarnings = [ "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" ] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -58,7 +62,6 @@ omit = [ [tool.pyright] extends = "../../pyproject.toml" - [tool.mypy] plugins = ['pydantic.mypy'] strict = true @@ -80,6 +83,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_copilotstudio" test = "pytest --cov=agent_framework_copilotstudio --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 96e8dc0b6a..278657a154 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -667,16 +667,12 @@ class SupportsFileSearchTool(Protocol): # region SupportsGetEmbeddings Protocol -# Contravariant/covariant TypeVars for the Protocol +# Contravariant TypeVars for the Protocol EmbeddingInputContraT = TypeVar( "EmbeddingInputContraT", default="str", contravariant=True, ) -EmbeddingCoT = TypeVar( - "EmbeddingCoT", - default="list[float]", -) EmbeddingOptionsContraT = TypeVar( "EmbeddingOptionsContraT", bound=TypedDict, # type: ignore[valid-type] @@ -686,7 +682,7 @@ EmbeddingOptionsContraT = TypeVar( @runtime_checkable -class SupportsGetEmbeddings(Protocol[EmbeddingInputContraT, EmbeddingCoT, EmbeddingOptionsContraT]): +class SupportsGetEmbeddings(Protocol[EmbeddingInputContraT, EmbeddingT, EmbeddingOptionsContraT]): """Protocol for an embedding client that can generate embeddings. This protocol enables duck-typing for embedding generation. Any class that @@ -714,7 +710,7 @@ class SupportsGetEmbeddings(Protocol[EmbeddingInputContraT, EmbeddingCoT, Embedd values: Sequence[EmbeddingInputContraT], *, options: EmbeddingOptionsContraT | None = None, - ) -> Awaitable[GeneratedEmbeddings[EmbeddingCoT]]: + ) -> Awaitable[GeneratedEmbeddings[EmbeddingT]]: """Generate embeddings for the given values. Args: @@ -733,15 +729,15 @@ class SupportsGetEmbeddings(Protocol[EmbeddingInputContraT, EmbeddingCoT, Embedd # region BaseEmbeddingClient # Covariant for the BaseEmbeddingClient -EmbeddingOptionsCoT = TypeVar( - "EmbeddingOptionsCoT", +EmbeddingOptionsT = TypeVar( + "EmbeddingOptionsT", bound=TypedDict, # type: ignore[valid-type] default="EmbeddingGenerationOptions", covariant=True, ) -class BaseEmbeddingClient(SerializationMixin, ABC, Generic[EmbeddingInputT, EmbeddingT, EmbeddingOptionsCoT]): +class BaseEmbeddingClient(SerializationMixin, ABC, Generic[EmbeddingInputT, EmbeddingT, EmbeddingOptionsT]): """Abstract base class for embedding clients. Subclasses implement ``get_embeddings`` to provide the actual @@ -785,7 +781,7 @@ class BaseEmbeddingClient(SerializationMixin, ABC, Generic[EmbeddingInputT, Embe self, values: Sequence[EmbeddingInputT], *, - options: EmbeddingOptionsCoT | None = None, + options: EmbeddingOptionsT | None = None, ) -> GeneratedEmbeddings[EmbeddingT]: """Generate embeddings for the given values. diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index 0e132a9336..33d001b6f2 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -115,6 +115,7 @@ class _FileAgentSkill: source_path: str resource_names: list[str] = field(default_factory=list) + # endregion # region Private module-level functions (skill discovery, parsing, security) @@ -165,9 +166,7 @@ def _has_symlink_in_path(full_path: str, directory_path: str) -> bool: 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 + 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: @@ -436,6 +435,7 @@ def _build_skills_instruction_prompt( return template.format("\n".join(lines)) + # endregion # region Public API @@ -494,7 +494,9 @@ class FileAgentSkillsProvider(BaseContextProvider): """ 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] + 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) @@ -594,4 +596,5 @@ class FileAgentSkillsProvider(BaseContextProvider): 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 diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index b3d7128d7e..37ee9f1138 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -377,6 +377,12 @@ class UsageDetails(TypedDict, total=False): This is a non-closed dictionary, so any specific provider fields can be added as needed. Whenever they can be mapped to standard fields, they will be. + + Keys: + input_token_count: The number of input tokens used. + output_token_count: The number of output tokens generated. + total_token_count: The total number of tokens (input + output). + """ input_token_count: int | None @@ -3289,7 +3295,7 @@ class GeneratedEmbeddings(list[Embedding[EmbeddingT]], Generic[EmbeddingT, Embed embeddings: Iterable[Embedding[EmbeddingT]] | None = None, *, options: EmbeddingOptionsT | None = None, - usage: dict[str, Any] | None = None, + usage: UsageDetails | None = None, additional_properties: dict[str, Any] | None = None, ) -> None: super().__init__(embeddings or []) diff --git a/python/packages/core/agent_framework/amazon/__init__.py b/python/packages/core/agent_framework/amazon/__init__.py index 42324acf96..e9282b8873 100644 --- a/python/packages/core/agent_framework/amazon/__init__.py +++ b/python/packages/core/agent_framework/amazon/__init__.py @@ -8,6 +8,9 @@ This module lazily re-exports objects from: Supported classes: - BedrockChatClient - BedrockChatOptions +- BedrockEmbeddingClient +- BedrockEmbeddingOptions +- BedrockEmbeddingSettings - BedrockGuardrailConfig - BedrockSettings """ @@ -17,7 +20,15 @@ from typing import Any IMPORT_PATH = "agent_framework_bedrock" PACKAGE_NAME = "agent-framework-bedrock" -_IMPORTS = ["BedrockChatClient", "BedrockChatOptions", "BedrockGuardrailConfig", "BedrockSettings"] +_IMPORTS = [ + "BedrockChatClient", + "BedrockChatOptions", + "BedrockEmbeddingClient", + "BedrockEmbeddingOptions", + "BedrockEmbeddingSettings", + "BedrockGuardrailConfig", + "BedrockSettings", +] def __getattr__(name: str) -> Any: diff --git a/python/packages/core/agent_framework/amazon/__init__.pyi b/python/packages/core/agent_framework/amazon/__init__.pyi index c691334da5..a9dd7a9117 100644 --- a/python/packages/core/agent_framework/amazon/__init__.pyi +++ b/python/packages/core/agent_framework/amazon/__init__.pyi @@ -3,6 +3,9 @@ from agent_framework_bedrock import ( BedrockChatClient, BedrockChatOptions, + BedrockEmbeddingClient, + BedrockEmbeddingOptions, + BedrockEmbeddingSettings, BedrockGuardrailConfig, BedrockSettings, ) @@ -10,6 +13,9 @@ from agent_framework_bedrock import ( __all__ = [ "BedrockChatClient", "BedrockChatOptions", + "BedrockEmbeddingClient", + "BedrockEmbeddingOptions", + "BedrockEmbeddingSettings", "BedrockGuardrailConfig", "BedrockSettings", ] diff --git a/python/packages/core/agent_framework/azure/_embedding_client.py b/python/packages/core/agent_framework/azure/_embedding_client.py index 05d6b5d603..13455e78a4 100644 --- a/python/packages/core/agent_framework/azure/_embedding_client.py +++ b/python/packages/core/agent_framework/azure/_embedding_client.py @@ -99,6 +99,7 @@ class AzureOpenAIEmbeddingClient( credential: AzureCredentialTypes | AzureTokenProvider | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | None = None, + otel_provider_name: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -133,4 +134,5 @@ class AzureOpenAIEmbeddingClient( credential=credential, default_headers=default_headers, client=async_client, + otel_provider_name=otel_provider_name, ) diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 11023aef67..9a60053068 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -1279,15 +1279,15 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): return _get_response() -EmbeddingOptionsCoT = TypeVar( - "EmbeddingOptionsCoT", +EmbeddingOptionsT = TypeVar( + "EmbeddingOptionsT", bound=TypedDict, # type: ignore[valid-type] default="EmbeddingGenerationOptions", covariant=True, ) -class EmbeddingTelemetryLayer(Generic[EmbeddingInputT, EmbeddingT, EmbeddingOptionsCoT]): +class EmbeddingTelemetryLayer(Generic[EmbeddingInputT, EmbeddingT, EmbeddingOptionsT]): """Layer that wraps embedding client get_embeddings with OpenTelemetry tracing.""" def __init__(self, *args: Any, otel_provider_name: str | None = None, **kwargs: Any) -> None: @@ -1301,7 +1301,7 @@ class EmbeddingTelemetryLayer(Generic[EmbeddingInputT, EmbeddingT, EmbeddingOpti self, values: Sequence[EmbeddingInputT], *, - options: EmbeddingOptionsCoT | None = None, + options: EmbeddingOptionsT | None = None, ) -> GeneratedEmbeddings[EmbeddingT]: """Trace embedding generation with OpenTelemetry spans and metrics.""" global OBSERVABILITY_SETTINGS diff --git a/python/packages/core/agent_framework/ollama/__init__.py b/python/packages/core/agent_framework/ollama/__init__.py index 1c6ba6820c..38a04f8044 100644 --- a/python/packages/core/agent_framework/ollama/__init__.py +++ b/python/packages/core/agent_framework/ollama/__init__.py @@ -7,6 +7,10 @@ This module lazily re-exports objects from: Supported classes: - OllamaChatClient +- OllamaChatOptions +- OllamaEmbeddingClient +- OllamaEmbeddingOptions +- OllamaEmbeddingSettings - OllamaSettings """ @@ -15,7 +19,14 @@ from typing import Any IMPORT_PATH = "agent_framework_ollama" PACKAGE_NAME = "agent-framework-ollama" -_IMPORTS = ["OllamaChatClient", "OllamaSettings"] +_IMPORTS = [ + "OllamaChatClient", + "OllamaChatOptions", + "OllamaEmbeddingClient", + "OllamaEmbeddingOptions", + "OllamaEmbeddingSettings", + "OllamaSettings", +] def __getattr__(name: str) -> Any: diff --git a/python/packages/core/agent_framework/ollama/__init__.pyi b/python/packages/core/agent_framework/ollama/__init__.pyi index ed439f3b36..36415c714c 100644 --- a/python/packages/core/agent_framework/ollama/__init__.pyi +++ b/python/packages/core/agent_framework/ollama/__init__.pyi @@ -2,10 +2,18 @@ from agent_framework_ollama import ( OllamaChatClient, + OllamaChatOptions, + OllamaEmbeddingClient, + OllamaEmbeddingOptions, + OllamaEmbeddingSettings, OllamaSettings, ) __all__ = [ "OllamaChatClient", + "OllamaChatOptions", + "OllamaEmbeddingClient", + "OllamaEmbeddingOptions", + "OllamaEmbeddingSettings", "OllamaSettings", ] diff --git a/python/packages/core/agent_framework/openai/_embedding_client.py b/python/packages/core/agent_framework/openai/_embedding_client.py index 0b59f9bf45..fb479c181c 100644 --- a/python/packages/core/agent_framework/openai/_embedding_client.py +++ b/python/packages/core/agent_framework/openai/_embedding_client.py @@ -12,7 +12,7 @@ from openai import AsyncOpenAI from .._clients import BaseEmbeddingClient from .._settings import load_settings -from .._types import Embedding, EmbeddingGenerationOptions, GeneratedEmbeddings +from .._types import Embedding, EmbeddingGenerationOptions, GeneratedEmbeddings, UsageDetails from ..observability import EmbeddingTelemetryLayer from ._shared import OpenAIBase, OpenAIConfigMixin, OpenAISettings @@ -116,11 +116,11 @@ class RawOpenAIEmbeddingClient( ) ) - usage_dict: dict[str, Any] | None = None + usage_dict: UsageDetails | None = None if response.usage: usage_dict = { - "prompt_tokens": response.usage.prompt_tokens, - "total_tokens": response.usage.total_tokens, + "input_token_count": response.usage.prompt_tokens, + "total_token_count": response.usage.total_tokens, } return GeneratedEmbeddings(embeddings, options=options, usage=usage_dict) @@ -143,6 +143,7 @@ class OpenAIEmbeddingClient( default_headers: Additional HTTP headers. async_client: Pre-configured AsyncOpenAI client. base_url: Custom API base URL. + otel_provider_name: Override the OpenTelemetry provider name for telemetry. env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. @@ -176,6 +177,7 @@ class OpenAIEmbeddingClient( default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, base_url: str | None = None, + otel_provider_name: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -208,4 +210,5 @@ class OpenAIEmbeddingClient( org_id=openai_settings["org_id"], default_headers=default_headers, client=async_client, + otel_provider_name=otel_provider_name, ) diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 09ccda3c18..a3c3f53ed6 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0rc1" +version = "1.0.0rc2" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -91,6 +91,9 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.coverage.run] omit = [ @@ -124,6 +127,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework" test = "pytest --cov=agent_framework --cov-report=term-missing:skip-covered -n auto --dist worksteal tests" diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index f278afaeac..319d35f152 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -937,8 +937,7 @@ async def test_max_iterations_no_orphaned_function_calls(chat_client_base: Suppo orphaned_calls = all_call_ids - all_result_ids assert not orphaned_calls, ( - f"Response contains orphaned FunctionCallContent without matching " - f"FunctionResultContent: {orphaned_calls}." + f"Response contains orphaned FunctionCallContent without matching FunctionResultContent: {orphaned_calls}." ) @@ -1123,8 +1122,7 @@ async def test_max_iterations_thread_integrity_with_agent(chat_client_base: Supp 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." + f"Response contains orphaned function calls {orphaned_calls}. This would cause API errors on the next call." ) diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index 571abeaa21..a77f214718 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -38,6 +38,7 @@ def _symlinks_supported(tmp: Path) -> bool: test_link.unlink(missing_ok=True) test_target.unlink(missing_ok=True) + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/python/packages/core/tests/openai/test_openai_embedding_client.py b/python/packages/core/tests/openai/test_openai_embedding_client.py index f6fbbad6aa..c606b67e31 100644 --- a/python/packages/core/tests/openai/test_openai_embedding_client.py +++ b/python/packages/core/tests/openai/test_openai_embedding_client.py @@ -100,8 +100,8 @@ async def test_openai_get_embeddings_usage(openai_unit_test_env: None) -> None: result = await client.get_embeddings(["test"]) assert result.usage is not None - assert result.usage["prompt_tokens"] == 10 - assert result.usage["total_tokens"] == 10 + assert result.usage["input_token_count"] == 10 + assert result.usage["total_token_count"] == 10 async def test_openai_options_passthrough_dimensions(openai_unit_test_env: None) -> None: @@ -284,7 +284,7 @@ async def test_integration_openai_get_embeddings() -> None: assert all(isinstance(v, float) for v in result[0].vector) assert result[0].model_id is not None assert result.usage is not None - assert result.usage["prompt_tokens"] > 0 + assert result.usage["input_token_count"] > 0 @skip_if_openai_integration_tests_disabled @@ -327,7 +327,7 @@ async def test_integration_azure_openai_get_embeddings() -> None: assert all(isinstance(v, float) for v in result[0].vector) assert result[0].model_id is not None assert result.usage is not None - assert result.usage["prompt_tokens"] > 0 + assert result.usage["input_token_count"] > 0 @skip_if_azure_openai_integration_tests_disabled diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 192111cc85..f8cb556d26 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "powerfx>=0.0.31; python_version < '3.14'", "pyyaml>=6.0,<7.0", ] @@ -39,9 +39,9 @@ environments = [ "sys_platform == 'win32'" ] - [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' addopts = "-ra -q -r fEX" @@ -51,6 +51,9 @@ filterwarnings = [ "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" ] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -88,6 +91,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_declarative" test = "pytest --cov=agent_framework_declarative --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 9eaa5ece83..5987fb0ea1 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "fastapi>=0.104.0", "uvicorn[standard]>=0.24.0", "python-dotenv>=1.0.0", @@ -54,6 +54,9 @@ addopts = "-ra -q -r fEX" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [] +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -66,7 +69,6 @@ omit = [ [tool.pyright] extends = "../../pyproject.toml" - [tool.mypy] plugins = ['pydantic.mypy'] strict = true @@ -89,6 +91,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_devui" test = "pytest --cov=agent_framework_devui --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml index aa119e1ee1..431b5f32f0 100644 --- a/python/packages/durabletask/pyproject.toml +++ b/python/packages/durabletask/pyproject.toml @@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "durabletask>=1.3.0", "durabletask-azuremanaged>=1.3.0", "python-dateutil>=2.8.0", @@ -43,6 +43,7 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' pythonpath = ["tests/integration_tests"] @@ -94,6 +95,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_durabletask" test = "pytest --cov=agent_framework_durabletask --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index 7d6ea0625f..6235bd5866 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "foundry-local-sdk>=0.5.1,<1", ] @@ -37,6 +37,7 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' addopts = "-ra -q -r fEX" @@ -44,6 +45,9 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -78,6 +82,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry_local" test = "pytest --cov=agent_framework_foundry_local --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index e34132f2bc..eba4f0519f 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "github-copilot-sdk>=0.1.0", ] @@ -37,6 +37,7 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' addopts = "-ra -q -r fEX" @@ -46,6 +47,9 @@ filterwarnings = [ "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" ] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -58,7 +62,6 @@ omit = [ [tool.pyright] extends = "../../pyproject.toml" - [tool.mypy] plugins = ['pydantic.mypy'] strict = true @@ -80,6 +83,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_github_copilot" test = "pytest --cov=agent_framework_github_copilot --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index d6cea73d01..137c47b0ff 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework" authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", ] [project.optional-dependencies] diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 438c879e78..406da3ab88 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "mem0ai>=1.0.0", ] @@ -37,6 +37,7 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' addopts = "-ra -q -r fEX" @@ -46,6 +47,9 @@ filterwarnings = [ "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" ] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -58,7 +62,6 @@ omit = [ [tool.pyright] extends = "../../pyproject.toml" - [tool.mypy] plugins = ['pydantic.mypy'] strict = true @@ -80,6 +83,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_mem0" test = "pytest --cov=agent_framework_mem0 --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/ollama/agent_framework_ollama/__init__.py b/python/packages/ollama/agent_framework_ollama/__init__.py index d1bd699e1a..4b6e4c6ba0 100644 --- a/python/packages/ollama/agent_framework_ollama/__init__.py +++ b/python/packages/ollama/agent_framework_ollama/__init__.py @@ -3,6 +3,7 @@ import importlib.metadata from ._chat_client import OllamaChatClient, OllamaChatOptions, OllamaSettings +from ._embedding_client import OllamaEmbeddingClient, OllamaEmbeddingOptions, OllamaEmbeddingSettings try: __version__ = importlib.metadata.version(__name__) @@ -12,6 +13,9 @@ except importlib.metadata.PackageNotFoundError: __all__ = [ "OllamaChatClient", "OllamaChatOptions", + "OllamaEmbeddingClient", + "OllamaEmbeddingOptions", + "OllamaEmbeddingSettings", "OllamaSettings", "__version__", ] diff --git a/python/packages/ollama/agent_framework_ollama/_embedding_client.py b/python/packages/ollama/agent_framework_ollama/_embedding_client.py new file mode 100644 index 0000000000..4fcf75b465 --- /dev/null +++ b/python/packages/ollama/agent_framework_ollama/_embedding_client.py @@ -0,0 +1,230 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +import sys +from collections.abc import Sequence +from typing import Any, ClassVar, Generic, TypedDict + +from agent_framework import ( + BaseEmbeddingClient, + Embedding, + EmbeddingGenerationOptions, + GeneratedEmbeddings, + UsageDetails, + load_settings, +) +from agent_framework.observability import EmbeddingTelemetryLayer +from ollama import AsyncClient + +if sys.version_info >= (3, 13): + from typing import TypeVar # type: ignore # pragma: no cover +else: + from typing_extensions import TypeVar # type: ignore # pragma: no cover + + +logger = logging.getLogger("agent_framework.ollama") + + +class OllamaEmbeddingOptions(EmbeddingGenerationOptions, total=False): + """Ollama-specific embedding options. + + Extends EmbeddingGenerationOptions with Ollama-specific fields. + + Examples: + .. code-block:: python + + from agent_framework_ollama import OllamaEmbeddingOptions + + options: OllamaEmbeddingOptions = { + "model_id": "nomic-embed-text", + "dimensions": 768, + "truncate": True, + } + """ + + truncate: bool + """Whether to truncate input text that exceeds the model's context length. + + When True, input that is too long will be silently truncated. + When False (default), the request will fail if input exceeds the context length. + """ + + keep_alive: float | str + """How long to keep the model loaded in memory (e.g. ``"5m"``, ``300``).""" + + +OllamaEmbeddingOptionsT = TypeVar( + "OllamaEmbeddingOptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="OllamaEmbeddingOptions", + covariant=True, +) + + +class OllamaEmbeddingSettings(TypedDict, total=False): + """Ollama embedding settings.""" + + host: str | None + embedding_model_id: str | None + + +class RawOllamaEmbeddingClient( + BaseEmbeddingClient[str, list[float], OllamaEmbeddingOptionsT], + Generic[OllamaEmbeddingOptionsT], +): + """Raw Ollama embedding client without telemetry. + + Keyword Args: + model_id: The Ollama embedding model ID (e.g. "nomic-embed-text"). + Can also be set via environment variable OLLAMA_EMBEDDING_MODEL_ID. + host: Ollama server URL. Defaults to http://localhost:11434. + Can also be set via environment variable OLLAMA_HOST. + client: Optional pre-configured Ollama AsyncClient. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + """ + + def __init__( + self, + *, + model_id: str | None = None, + host: str | None = None, + client: AsyncClient | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a raw Ollama embedding client.""" + ollama_settings = load_settings( + OllamaEmbeddingSettings, + env_prefix="OLLAMA_", + required_fields=["embedding_model_id"], + host=host, + embedding_model_id=model_id, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + self.model_id = ollama_settings["embedding_model_id"] + self.client = client or AsyncClient(host=ollama_settings.get("host")) + self.host = str(self.client._client.base_url) # pyright: ignore[reportUnknownMemberType,reportPrivateUsage,reportUnknownArgumentType] + super().__init__(**kwargs) + + def service_url(self) -> str: + """Get the URL of the service.""" + return self.host + + async def get_embeddings( + self, + values: Sequence[str], + *, + options: OllamaEmbeddingOptionsT | None = None, + ) -> GeneratedEmbeddings[list[float]]: + """Call the Ollama embed API. + + Args: + values: The text values to generate embeddings for. + options: Optional embedding generation options. + + Returns: + Generated embeddings with usage metadata. + + Raises: + ValueError: If model_id is not provided or values is empty. + """ + if not values: + return GeneratedEmbeddings([], options=options) + + opts: dict[str, Any] = dict(options) if options else {} + model = opts.get("model_id") or self.model_id + if not model: + raise ValueError("model_id is required") + + kwargs: dict[str, Any] = {"model": model, "input": list(values)} + if (truncate := opts.get("truncate")) is not None: + kwargs["truncate"] = truncate + if keep_alive := opts.get("keep_alive"): + kwargs["keep_alive"] = keep_alive + if dimensions := opts.get("dimensions"): + kwargs["dimensions"] = dimensions + + response = await self.client.embed(**kwargs) + + embeddings = [ + Embedding( + vector=list(emb), + dimensions=len(emb), + model_id=response.get("model") or model, + ) + for emb in response.get("embeddings", []) + ] + + usage_dict: UsageDetails | None = None + prompt_eval_count = response.get("prompt_eval_count") + if prompt_eval_count is not None: + usage_dict = {"input_token_count": prompt_eval_count} + + return GeneratedEmbeddings(embeddings, options=options, usage=usage_dict) + + +class OllamaEmbeddingClient( + EmbeddingTelemetryLayer[str, list[float], OllamaEmbeddingOptionsT], + RawOllamaEmbeddingClient[OllamaEmbeddingOptionsT], + Generic[OllamaEmbeddingOptionsT], +): + """Ollama embedding client with telemetry support. + + Keyword Args: + model_id: The Ollama embedding model ID (e.g. "nomic-embed-text"). + Can also be set via environment variable OLLAMA_EMBEDDING_MODEL_ID. + host: Ollama server URL. Defaults to http://localhost:11434. + Can also be set via environment variable OLLAMA_HOST. + client: Optional pre-configured Ollama AsyncClient. + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + + Examples: + .. code-block:: python + + from agent_framework_ollama import OllamaEmbeddingClient + + # Using environment variables + # Set OLLAMA_EMBEDDING_MODEL_ID=nomic-embed-text + client = OllamaEmbeddingClient() + + # Or passing parameters directly + client = OllamaEmbeddingClient( + model_id="nomic-embed-text", + host="http://localhost:11434", + ) + + # Generate embeddings + result = await client.get_embeddings(["Hello, world!"]) + print(result[0].vector) + """ + + OTEL_PROVIDER_NAME: ClassVar[str] = "ollama" + + def __init__( + self, + *, + model_id: str | None = None, + host: str | None = None, + client: AsyncClient | None = None, + otel_provider_name: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize an Ollama embedding client.""" + super().__init__( + model_id=model_id, + host=host, + client=client, + otel_provider_name=otel_provider_name, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + **kwargs, + ) diff --git a/python/packages/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml index 47568ecfc6..686dbe2c8f 100644 --- a/python/packages/ollama/pyproject.toml +++ b/python/packages/ollama/pyproject.toml @@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "ollama >= 0.5.3", ] @@ -37,12 +37,16 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' addopts = "-ra -q -r fEX" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [] +markers = [ + "integration: marks tests as integration tests that require external services", +] timeout = 120 [tool.ruff] @@ -82,6 +86,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ollama" test = "pytest --cov=agent_framework_ollama --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/ollama/tests/ollama/test_ollama_embedding_client.py b/python/packages/ollama/tests/ollama/test_ollama_embedding_client.py new file mode 100644 index 0000000000..c585f9c481 --- /dev/null +++ b/python/packages/ollama/tests/ollama/test_ollama_embedding_client.py @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft. All rights reserved. + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import Embedding, GeneratedEmbeddings + +from agent_framework_ollama import OllamaEmbeddingClient, OllamaEmbeddingOptions + +# region: Unit Tests + + +def test_ollama_embedding_construction(monkeypatch: pytest.MonkeyPatch) -> None: + """Test construction with explicit parameters.""" + monkeypatch.setenv("OLLAMA_EMBEDDING_MODEL_ID", "nomic-embed-text") + with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls: + mock_client_cls.return_value = MagicMock() + client = OllamaEmbeddingClient() + assert client.model_id == "nomic-embed-text" + + +def test_ollama_embedding_construction_with_params() -> None: + """Test construction with explicit parameters.""" + with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls: + mock_client_cls.return_value = MagicMock() + client = OllamaEmbeddingClient( + model_id="nomic-embed-text", + host="http://localhost:11434", + ) + assert client.model_id == "nomic-embed-text" + + +def test_ollama_embedding_construction_missing_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: + """Test that missing model_id raises an error.""" + monkeypatch.delenv("OLLAMA_EMBEDDING_MODEL_ID", raising=False) + monkeypatch.delenv("OLLAMA_MODEL_ID", raising=False) + from agent_framework.exceptions import SettingNotFoundError + + with pytest.raises(SettingNotFoundError): + OllamaEmbeddingClient() + + +async def test_ollama_embedding_get_embeddings() -> None: + """Test generating embeddings via the Ollama API.""" + mock_response = { + "model": "nomic-embed-text", + "embeddings": [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], + "prompt_eval_count": 10, + } + + with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls: + mock_client = MagicMock() + mock_client.embed = AsyncMock(return_value=mock_response) + mock_client_cls.return_value = mock_client + + client = OllamaEmbeddingClient(model_id="nomic-embed-text") + result = await client.get_embeddings(["hello", "world"]) + + assert isinstance(result, GeneratedEmbeddings) + assert len(result) == 2 + assert result[0].vector == [0.1, 0.2, 0.3] + assert result[1].vector == [0.4, 0.5, 0.6] + assert result[0].model_id == "nomic-embed-text" + assert result.usage == {"input_token_count": 10} + + mock_client.embed.assert_called_once_with( + model="nomic-embed-text", + input=["hello", "world"], + ) + + +async def test_ollama_embedding_get_embeddings_empty_input() -> None: + """Test generating embeddings with empty input.""" + with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + + client = OllamaEmbeddingClient(model_id="nomic-embed-text") + result = await client.get_embeddings([]) + + assert isinstance(result, GeneratedEmbeddings) + assert len(result) == 0 + mock_client.embed.assert_not_called() + + +async def test_ollama_embedding_get_embeddings_with_options() -> None: + """Test generating embeddings with custom options.""" + mock_response = { + "model": "nomic-embed-text", + "embeddings": [[0.1, 0.2, 0.3]], + } + + with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls: + mock_client = MagicMock() + mock_client.embed = AsyncMock(return_value=mock_response) + mock_client_cls.return_value = mock_client + + client = OllamaEmbeddingClient(model_id="nomic-embed-text") + options: OllamaEmbeddingOptions = { + "truncate": True, + "dimensions": 512, + } + result = await client.get_embeddings(["hello"], options=options) + + assert len(result) == 1 + mock_client.embed.assert_called_once_with( + model="nomic-embed-text", + input=["hello"], + truncate=True, + dimensions=512, + ) + + +async def test_ollama_embedding_get_embeddings_no_model_raises() -> None: + """Test that missing model_id at call time raises ValueError.""" + with patch("agent_framework_ollama._embedding_client.AsyncClient") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + + client = OllamaEmbeddingClient(model_id="nomic-embed-text") + client.model_id = None # type: ignore[assignment] + + with pytest.raises(ValueError, match="model_id is required"): + await client.get_embeddings(["hello"]) + + +# region: Integration Tests + +skip_if_ollama_embedding_integration_tests_disabled = pytest.mark.skipif( + os.getenv("OLLAMA_EMBEDDING_MODEL_ID", "") in ("", "test-model"), + reason="No real Ollama embedding model provided; skipping integration tests.", +) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_ollama_embedding_integration_tests_disabled +async def test_ollama_embedding_integration() -> None: + """Integration test for Ollama embedding client.""" + client = OllamaEmbeddingClient() + result = await client.get_embeddings(["Hello, world!", "How are you?"]) + + assert isinstance(result, GeneratedEmbeddings) + assert len(result) == 2 + for embedding in result: + assert isinstance(embedding, Embedding) + assert isinstance(embedding.vector, list) + assert len(embedding.vector) > 0 + assert all(isinstance(v, float) for v in embedding.vector) diff --git a/python/packages/orchestrations/pyproject.toml b/python/packages/orchestrations/pyproject.toml index d4bd40c411..f1cc4bfb45 100644 --- a/python/packages/orchestrations/pyproject.toml +++ b/python/packages/orchestrations/pyproject.toml @@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", ] [tool.uv] @@ -44,6 +44,9 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -78,6 +81,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_orchestrations" test = "pytest --cov=agent_framework_orchestrations --cov-report=term-missing:skip-covered -n auto --dist worksteal tests" diff --git a/python/packages/purview/agent_framework_purview/_middleware.py b/python/packages/purview/agent_framework_purview/_middleware.py index 2296122135..55619d0a39 100644 --- a/python/packages/purview/agent_framework_purview/_middleware.py +++ b/python/packages/purview/agent_framework_purview/_middleware.py @@ -106,7 +106,7 @@ class PurviewPolicyMiddleware(AgentMiddleware): if context.result and not context.stream: should_block_response, _ = await self._processor.process_messages( context.result.messages, # type: ignore[union-attr] - Activity.UPLOAD_TEXT, + Activity.DOWNLOAD_TEXT, session_id=session_id, user_id=resolved_user_id, ) @@ -210,7 +210,7 @@ class PurviewChatPolicyMiddleware(ChatMiddleware): messages = getattr(result_obj, "messages", None) if messages: should_block_response, _ = await self._processor.process_messages( - messages, Activity.UPLOAD_TEXT, session_id=session_id_response, user_id=resolved_user_id + messages, Activity.DOWNLOAD_TEXT, session_id=session_id_response, user_id=resolved_user_id ) if should_block_response: from agent_framework import ChatResponse, Message diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index a7fc030cbf..e911fae7a5 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -158,7 +158,8 @@ class ScopedContentProcessor: name=f"Agent Framework Message {message_id}", is_truncated=False, correlation_id=correlation_id, - sequence_number=time.time_ns(), + # This would be c# ticks equivalent and needs to fit inside c# long + sequence_number=time.time_ns() // 100 + 621355968000000000, ) activity_meta = ActivityMetadata(activity=activity) diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index c9a65870cb..3481b27618 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "azure-core>=1.30.0", "httpx>=0.27.0", ] @@ -39,12 +39,16 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' addopts = "-ra -q -r fEX" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [] +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -57,7 +61,6 @@ omit = [ [tool.pyright] extends = "../../pyproject.toml" - [tool.mypy] plugins = ['pydantic.mypy'] strict = true @@ -79,6 +82,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_purview" test = "pytest --cov=agent_framework_purview --cov-report=term-missing:skip-covered tests" diff --git a/python/packages/purview/tests/purview/test_middleware.py b/python/packages/purview/tests/purview/test_middleware.py index 3a34c48344..b49b5d46a0 100644 --- a/python/packages/purview/tests/purview/test_middleware.py +++ b/python/packages/purview/tests/purview/test_middleware.py @@ -148,8 +148,10 @@ class TestPurviewPolicyMiddleware: await middleware.process(context, mock_next) assert mock_process.call_count == 2 - for call in mock_process.call_args_list: - assert call[0][1] == Activity.UPLOAD_TEXT + # First call (pre-check) should be UPLOAD_TEXT for user prompt + assert mock_process.call_args_list[0][0][1] == Activity.UPLOAD_TEXT + # Second call (post-check) should be DOWNLOAD_TEXT for agent response + assert mock_process.call_args_list[1][0][1] == Activity.DOWNLOAD_TEXT async def test_middleware_streaming_skips_post_check( self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 58a6a6a9e4..ab05066471 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260219" +version = "1.0.0b260225" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc1", + "agent-framework-core>=1.0.0rc2", "redis>=6.4.0", "redisvl>=0.8.2", "numpy>=2.2.6" @@ -39,6 +39,7 @@ environments = [ [tool.uv-dynamic-versioning] fallback-version = "0.0.0" + [tool.pytest.ini_options] testpaths = 'tests' addopts = "-ra -q -r fEX" @@ -48,6 +49,9 @@ filterwarnings = [ "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" ] timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] [tool.ruff] extend = "../../pyproject.toml" @@ -81,6 +85,7 @@ exclude_dirs = ["tests"] [tool.poe] executor.type = "uv" include = "../../shared_tasks.toml" + [tool.poe.tasks] mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_redis" test = "pytest --cov=agent_framework_redis --cov-report=term-missing:skip-covered tests" diff --git a/python/pyproject.toml b/python/pyproject.toml index 259caffaf9..a03123e9a2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0rc1" +version = "1.0.0rc2" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core[all]==1.0.0rc1", + "agent-framework-core[all]==1.0.0rc2", ] [dependency-groups] diff --git a/python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py b/python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py new file mode 100644 index 0000000000..70d60983e9 --- /dev/null +++ b/python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py @@ -0,0 +1,87 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-azure-ai", +# ] +# /// +# Run with: uv run samples/02-agents/embeddings/azure_ai_inference_embeddings.py + +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import pathlib + +from agent_framework import Content +from agent_framework_azure_ai import AzureAIInferenceEmbeddingClient +from dotenv import load_dotenv + +load_dotenv() + +"""Azure AI Inference Image Embedding Example + +This sample demonstrates how to generate image embeddings using the +Azure AI Inference embedding client with the Cohere-embed-v3-english model. +Images are passed as ``Content`` objects created with ``Content.from_data()``. + +Prerequisites: + Set the following environment variables or add them to a .env file: + - AZURE_AI_INFERENCE_ENDPOINT: Your Azure AI model inference endpoint URL + - AZURE_AI_INFERENCE_API_KEY: Your API key + - AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID: The text embedding model name + (e.g. "text-embedding-3-small") + - AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID: The image embedding model name + (e.g. "Cohere-embed-v3-english") +""" + +SAMPLE_IMAGE_PATH = pathlib.Path(__file__).parent.parent.parent / "shared" / "sample_assets" / "sample_image.jpg" + + +async def main() -> None: + """Generate image embeddings with Azure AI Inference.""" + async with AzureAIInferenceEmbeddingClient() as client: + # 1. Generate an image embedding. + image_bytes = SAMPLE_IMAGE_PATH.read_bytes() + image_content = Content.from_data(data=image_bytes, media_type="image/jpeg") + result = await client.get_embeddings([image_content]) + print(f"Image embedding dimensions: {result[0].dimensions}") + print(f"First 5 values: {result[0].vector[:5]}") + print(f"Model: {result[0].model_id}") + print(f"Usage: {result.usage}") + print() + + # 2. Generate image and text embeddings separately in one call. + # The client dispatches text to the text endpoint and images to the image + # endpoint, then reassembles results in the original input order. + result = await client.get_embeddings(["A half-timbered house in a forested valley", image_content]) + print(f"Text embedding dimensions: {result[0].dimensions}") + print(f"First 5 values: {result[0].vector[:5]}") + print(f"Image embedding dimensions: {result[1].dimensions}") + print(f"First 5 values: {result[1].vector[:5]}") + print() + + # 3. Generate image embeddings with input_type option. + result = await client.get_embeddings( + [image_content], + options={"input_type": "document"}, + ) + print(f"Document embedding dimensions: {result[0].dimensions}") + print(f"First 5 values: {result[0].vector[:5]}") + + +if __name__ == "__main__": + asyncio.run(main()) + + +""" +Sample output (using Cohere-embed-v3-english): +Image embedding dimensions: 1024 +First 5 values: [0.023, -0.045, 0.067, -0.089, 0.011] +Model: Cohere-embed-v3-english +Usage: {'prompt_tokens': 1, 'total_tokens': 1} + +Image+text (separate) results: +Text embedding dimensions: 1536 +Image embedding dimensions: 1024 + +Document embedding dimensions: 1024 +""" diff --git a/python/samples/shared/sample_assets/sample_image.jpg b/python/samples/shared/sample_assets/sample_image.jpg new file mode 100644 index 0000000000..ea6486656f Binary files /dev/null and b/python/samples/shared/sample_assets/sample_image.jpg differ diff --git a/python/uv.lock b/python/uv.lock index f5c2efd880..9f2e97a91e 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -96,7 +96,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.0.0rc1" +version = "1.0.0rc2" source = { virtual = "." } dependencies = [ { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -145,7 +145,7 @@ dev = [ [[package]] name = "agent-framework-a2a" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/a2a" } dependencies = [ { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -160,7 +160,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -188,7 +188,7 @@ provides-extras = ["dev"] [[package]] name = "agent-framework-anthropic" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/anthropic" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -203,12 +203,13 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai" -version = "1.0.0rc1" +version = "1.0.0rc2" source = { editable = "packages/azure-ai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "azure-ai-agents", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-inference", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] @@ -216,11 +217,12 @@ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "aiohttp" }, { name = "azure-ai-agents", specifier = "==1.2.0b5" }, + { name = "azure-ai-inference", specifier = ">=1.0.0b9" }, ] [[package]] name = "agent-framework-azure-ai-search" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/azure-ai-search" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -235,7 +237,7 @@ requires-dist = [ [[package]] name = "agent-framework-azurefunctions" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/azurefunctions" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -257,7 +259,7 @@ dev = [] [[package]] name = "agent-framework-bedrock" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/bedrock" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -274,7 +276,7 @@ requires-dist = [ [[package]] name = "agent-framework-chatkit" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/chatkit" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -289,7 +291,7 @@ requires-dist = [ [[package]] name = "agent-framework-claude" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/claude" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -304,7 +306,7 @@ requires-dist = [ [[package]] name = "agent-framework-copilotstudio" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/copilotstudio" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -319,7 +321,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.0.0rc1" +version = "1.0.0rc2" source = { editable = "packages/core" } dependencies = [ { name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -399,7 +401,7 @@ provides-extras = ["all"] [[package]] name = "agent-framework-declarative" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -424,7 +426,7 @@ dev = [{ name = "types-pyyaml" }] [[package]] name = "agent-framework-devui" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/devui" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -460,7 +462,7 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-durabletask" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/durabletask" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -487,7 +489,7 @@ dev = [{ name = "types-python-dateutil", specifier = ">=2.9.0" }] [[package]] name = "agent-framework-foundry-local" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/foundry_local" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -502,7 +504,7 @@ requires-dist = [ [[package]] name = "agent-framework-github-copilot" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/github_copilot" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -517,7 +519,7 @@ requires-dist = [ [[package]] name = "agent-framework-lab" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/lab" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -596,7 +598,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -611,7 +613,7 @@ requires-dist = [ [[package]] name = "agent-framework-ollama" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/ollama" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -626,7 +628,7 @@ requires-dist = [ [[package]] name = "agent-framework-orchestrations" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/orchestrations" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -637,7 +639,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }] [[package]] name = "agent-framework-purview" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/purview" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -654,7 +656,7 @@ requires-dist = [ [[package]] name = "agent-framework-redis" -version = "1.0.0b260219" +version = "1.0.0b260225" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -979,6 +981,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/6d/15070d23d7a94833a210da09d5d7ed3c24838bb84f0463895e5d159f1695/azure_ai_agents-1.2.0b5-py3-none-any.whl", hash = "sha256:257d0d24a6bf13eed4819cfa5c12fb222e5908deafb3cbfd5711d3a511cc4e88", size = 217948, upload-time = "2025-09-30T01:55:04.155Z" }, ] +[[package]] +name = "azure-ai-inference" +version = "1.0.0b9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/6a/ed85592e5c64e08c291992f58b1a94dab6869f28fb0f40fd753dced73ba6/azure_ai_inference-1.0.0b9.tar.gz", hash = "sha256:1feb496bd84b01ee2691befc04358fa25d7c344d8288e99364438859ad7cd5a4", size = 182408, upload-time = "2025-02-15T00:37:28.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/0f/27520da74769db6e58327d96c98e7b9a07ce686dff582c9a5ec60b03f9dd/azure_ai_inference-1.0.0b9-py3-none-any.whl", hash = "sha256:49823732e674092dad83bb8b0d1b65aa73111fab924d61349eb2a8cdc0493990", size = 124885, upload-time = "2025-02-15T00:37:29.964Z" }, +] + [[package]] name = "azure-ai-projects" version = "2.0.0b3" @@ -1362,7 +1378,7 @@ name = "clr-loader" version = "0.2.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" } wheels = [ @@ -1841,7 +1857,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -2307,6 +2323,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -2314,6 +2331,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -2322,6 +2340,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -2330,6 +2349,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -2338,6 +2358,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -2346,6 +2367,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -4567,8 +4589,8 @@ name = "powerfx" version = "0.0.34" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pythonnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "pythonnet", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/fb/6c4bf87e0c74ca1c563921ce89ca1c5785b7576bca932f7255cdf81082a7/powerfx-0.0.34.tar.gz", hash = "sha256:956992e7afd272657ed16d80f4cad24ec95d9e4a79fb9dfa4a068a09e136af32", size = 3237555, upload-time = "2025-12-22T15:50:59.682Z" } wheels = [ @@ -5231,7 +5253,7 @@ name = "pythonnet" version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "clr-loader", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } wheels = [