From 84849a24ca5f3477242e0506909312dbcd76f925 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Wed, 25 Feb 2026 08:53:19 -0800 Subject: [PATCH 1/7] Update .NET package version to rc2 (#4257) - Bump RCNumber from 1 to 2 - Update GitTag to 1.0.0-rc2 - Update preview date stamps from 260219 to 260225 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/nuget/nuget-package.props | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 From e3a5b915a61a3d085eac3ab6afe4c9b4003be9cf Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:34:13 +0000 Subject: [PATCH 2/7] .NET: Add Microsoft Fabric sample #3674 (#4230) Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> --- dotnet/agent-framework-dotnet.slnx | 1 + ...oundryAgents_Step23_MicrosoftFabric.csproj | 22 ++++++ .../Program.cs | 72 +++++++++++++++++++ .../README.md | 57 +++++++++++++++ .../GettingStarted/FoundryAgents/README.md | 1 + 5 files changed, 153 insertions(+) create mode 100644 dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/FoundryAgents_Step23_MicrosoftFabric.csproj create mode 100644 dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/Program.cs create mode 100644 dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 041da4a6f6..059919f58d 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -185,6 +185,7 @@ + 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| From 61384878880d23e4b90223c505faf97e9f063c8d Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 25 Feb 2026 18:45:08 +0100 Subject: [PATCH 3/7] Python: Phase 2: Embedding clients for Ollama, Bedrock, and Azure AI Inference (#4207) * Phase 2: Embedding clients for Ollama, Bedrock, and Azure AI Inference Add embedding client implementations to existing provider packages: - OllamaEmbeddingClient: Text embeddings via Ollama's embed API - BedrockEmbeddingClient: Text embeddings via Amazon Titan on Bedrock - AzureAIInferenceEmbeddingClient: Text and image embeddings via Azure AI Inference, supporting Content | str input with separate model IDs for text (AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID) and image (AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID) endpoints Additional changes: - Rename EmbeddingCoT -> EmbeddingT, EmbeddingOptionsCoT -> EmbeddingOptionsT - Add otel_provider_name passthrough to all embedding clients - Register integration pytest marker in all packages - Add lazy-loading namespace exports for Ollama and Bedrock embeddings - Add image embedding sample using Cohere-embed-v3-english - Add azure-ai-inference dependency to azure-ai package Part of #1188 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix mypy duplicate name and ruff lint issues - Rename second 'vector' variable to 'img_vector' in image embedding loop - Combine nested with statements in tests - Remove unused result assignments in tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * updates from feedback * Fix CI failures in embedding usage handling - Fix Azure AI embedding mypy issues by normalizing vectors to list[float], safely accumulating optional usage token fields, and filtering None entries before constructing GeneratedEmbeddings - Avoid Bandit false positive by initializing usage details as an empty dict - Update OpenAI embedding tests to assert canonical usage keys (input_token_count/total_token_count) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../vector-stores-and-embeddings/README.md | 3 +- python/packages/a2a/pyproject.toml | 5 + python/packages/ag-ui/pyproject.toml | 3 + python/packages/anthropic/pyproject.toml | 5 + .../packages/azure-ai-search/pyproject.toml | 4 + .../agent_framework_azure_ai/__init__.py | 10 + .../_embedding_client.py | 396 ++++++++++++++++++ python/packages/azure-ai/pyproject.toml | 6 + ...est_azure_ai_inference_embedding_client.py | 316 ++++++++++++++ python/packages/azurefunctions/pyproject.toml | 2 + .../agent_framework_bedrock/__init__.py | 4 + .../_embedding_client.py | 292 +++++++++++++ python/packages/bedrock/pyproject.toml | 4 +- .../bedrock/test_bedrock_embedding_client.py | 168 ++++++++ python/packages/chatkit/pyproject.toml | 5 + python/packages/claude/pyproject.toml | 5 + python/packages/copilotstudio/pyproject.toml | 6 +- .../packages/core/agent_framework/_clients.py | 18 +- .../packages/core/agent_framework/_types.py | 8 +- .../core/agent_framework/amazon/__init__.py | 13 +- .../core/agent_framework/amazon/__init__.pyi | 6 + .../azure/_embedding_client.py | 2 + .../core/agent_framework/observability.py | 8 +- .../core/agent_framework/ollama/__init__.py | 13 +- .../core/agent_framework/ollama/__init__.pyi | 8 + .../openai/_embedding_client.py | 11 +- python/packages/core/pyproject.toml | 4 + .../openai/test_openai_embedding_client.py | 8 +- python/packages/declarative/pyproject.toml | 6 +- python/packages/devui/pyproject.toml | 5 +- python/packages/durabletask/pyproject.toml | 2 + python/packages/foundry_local/pyproject.toml | 5 + python/packages/github_copilot/pyproject.toml | 6 +- python/packages/mem0/pyproject.toml | 6 +- .../ollama/agent_framework_ollama/__init__.py | 4 + .../_embedding_client.py | 230 ++++++++++ python/packages/ollama/pyproject.toml | 5 + .../ollama/test_ollama_embedding_client.py | 150 +++++++ python/packages/orchestrations/pyproject.toml | 4 + python/packages/purview/pyproject.toml | 6 +- python/packages/redis/pyproject.toml | 5 + .../azure_ai_inference_embeddings.py | 87 ++++ .../shared/sample_assets/sample_image.jpg | Bin 0 -> 182161 bytes python/uv.lock | 16 + 44 files changed, 1836 insertions(+), 34 deletions(-) create mode 100644 python/packages/azure-ai/agent_framework_azure_ai/_embedding_client.py create mode 100644 python/packages/azure-ai/tests/azure_ai/test_azure_ai_inference_embedding_client.py create mode 100644 python/packages/bedrock/agent_framework_bedrock/_embedding_client.py create mode 100644 python/packages/bedrock/tests/bedrock/test_bedrock_embedding_client.py create mode 100644 python/packages/ollama/agent_framework_ollama/_embedding_client.py create mode 100644 python/packages/ollama/tests/ollama/test_ollama_embedding_client.py create mode 100644 python/samples/02-agents/embeddings/azure_ai_inference_embeddings.py create mode 100644 python/samples/shared/sample_assets/sample_image.jpg 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/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 418cfcc827..3cabcb8223 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -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..dba360cb00 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -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..6fef24c1d8 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -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..0d492b9d07 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -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/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..bbd438eb3a 100644 --- a/python/packages/azure-ai/pyproject.toml +++ b/python/packages/azure-ai/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ dependencies = [ "agent-framework-core>=1.0.0rc1", "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..15fa714654 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -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..5883c29f32 100644 --- a/python/packages/bedrock/pyproject.toml +++ b/python/packages/bedrock/pyproject.toml @@ -28,7 +28,6 @@ dependencies = [ "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..7256de7fd6 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -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..f3140cae6d 100644 --- a/python/packages/claude/pyproject.toml +++ b/python/packages/claude/pyproject.toml @@ -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..8268c222fd 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -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/_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..a93f12903f 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -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/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..d6de326fac 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -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..36a583d478 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -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..766aa8ede1 100644 --- a/python/packages/durabletask/pyproject.toml +++ b/python/packages/durabletask/pyproject.toml @@ -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..37b313bebb 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -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..f13bffeb04 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -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/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 438c879e78..ce0e0d0dcb 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -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..1daa45084c 100644 --- a/python/packages/ollama/pyproject.toml +++ b/python/packages/ollama/pyproject.toml @@ -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..bfcf7068d7 100644 --- a/python/packages/orchestrations/pyproject.toml +++ b/python/packages/orchestrations/pyproject.toml @@ -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/pyproject.toml b/python/packages/purview/pyproject.toml index c9a65870cb..a98fa0f4a0 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -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/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 58a6a6a9e4..9da016f2e1 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -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/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 0000000000000000000000000000000000000000..ea6486656fd5b603af043e29b941c99845baea7a GIT binary patch literal 182161 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%0Dk-Pm;7s}kHuGa7vE${d#SHv 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 UcKRcD&9zq&ZLV4!^WzTS~D|(tTOER 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 B_m}_x literal 0 HcmV?d00001 diff --git a/python/uv.lock b/python/uv.lock index f5c2efd880..1cd73c34ae 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -209,6 +209,7 @@ 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,6 +217,7 @@ 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]] @@ -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" From 2e26bb9387d9df9593243e12b61903832d5cd533 Mon Sep 17 00:00:00 2001 From: Rishabh Chawla Date: Wed, 25 Feb 2026 10:40:36 -0800 Subject: [PATCH 4/7] [Purview] Mark responses as responses and fix epoch bug for python long overflow (#4225) --- .../PurviewWrapper.cs | 4 +- .../PurviewWrapperTests.cs | 62 ++++++++++++++----- .../agent_framework_purview/_middleware.py | 4 +- .../agent_framework_purview/_processor.py | 3 +- .../purview/tests/purview/test_middleware.py | 6 +- 5 files changed, 57 insertions(+), 22 deletions(-) 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/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/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/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 From de9d886aba1244c438a15121a4cfa7dd2b621c88 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:21:36 -0800 Subject: [PATCH 5/7] .NET: Support InvokeMcpTool for declarative workflows (#4204) * Initial implementation of InvokeMcpTool in declarative workflow * Cleaned up sample implementation * Updated sample comments. * Added missing executor routing attribute * Fix PR comments. * Updated based on PR comments. * Updated based on PR comments. * Removed unnecessary using statement. --- dotnet/Directory.Packages.props | 6 +- dotnet/agent-framework-dotnet.slnx | 3 + .../InvokeMcpTool/InvokeMcpTool.csproj | 39 + .../InvokeMcpTool/InvokeMcpTool.yaml | 63 ++ .../Declarative/InvokeMcpTool/Program.cs | 141 +++ .../DefaultMcpToolHandler.cs | 252 ++++++ ...Agents.AI.Workflows.Declarative.Mcp.csproj | 33 + .../DeclarativeWorkflowOptions.cs | 6 + .../Extensions/JsonDocumentExtensions.cs | 44 +- .../IMcpToolHandler.cs | 41 + .../Interpreter/WorkflowActionVisitor.cs | 36 + .../Interpreter/WorkflowTemplateVisitor.cs | 2 + .../ObjectModel/InvokeFunctionToolExecutor.cs | 36 +- .../ObjectModel/InvokeMcpToolExecutor.cs | 367 ++++++++ .../Workflows/Execution/WorkflowFactory.cs | 4 + .../Framework/IntegrationTest.cs | 8 +- ...kflowTest.cs => InvokeToolWorkflowTest.cs} | 162 +++- ...kflows.Declarative.IntegrationTests.csproj | 1 + .../Workflows/InvokeMcpTool.yaml | 35 + .../Workflows/InvokeMcpToolWithApproval.yaml | 35 + .../DefaultMcpToolHandlerTests.cs | 345 +++++++ ...Workflows.Declarative.Mcp.UnitTests.csproj | 15 + .../Extensions/JsonDocumentExtensionsTests.cs | 204 +++++ .../ObjectModel/InvokeMcpToolExecutorTest.cs | 845 ++++++++++++++++++ 24 files changed, 2663 insertions(+), 60 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj create mode 100644 dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/Program.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IMcpToolHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs rename dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/{InvokeFunctionToolWorkflowTest.cs => InvokeToolWorkflowTest.cs} (51%) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpTool.yaml create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/InvokeMcpToolWithApproval.yaml create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs 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 059919f58d..6e4bb0f02a 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -228,6 +228,7 @@ + @@ -439,6 +440,7 @@ + @@ -484,6 +486,7 @@ + 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.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/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.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 +} From 7d56a5a4d6869da9d21c4324dbcf4142c29897c6 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:37:44 -0800 Subject: [PATCH 6/7] Update Python package versions to rc2 (#4258) - Bump core and azure-ai to 1.0.0rc2 - Bump preview packages to 1.0.0b260225 - Update dependencies to >=1.0.0rc2 - Add CHANGELOG entries for changes since rc1 - Update uv.lock Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/CHANGELOG.md | 28 ++++++++- python/packages/a2a/pyproject.toml | 4 +- python/packages/ag-ui/pyproject.toml | 4 +- python/packages/anthropic/pyproject.toml | 4 +- .../packages/azure-ai-search/pyproject.toml | 4 +- .../tests/test_aisearch_context_provider.py | 10 +-- python/packages/azure-ai/pyproject.toml | 4 +- python/packages/azurefunctions/pyproject.toml | 4 +- python/packages/bedrock/pyproject.toml | 4 +- python/packages/chatkit/pyproject.toml | 4 +- python/packages/claude/pyproject.toml | 4 +- python/packages/copilotstudio/pyproject.toml | 4 +- .../packages/core/agent_framework/_skills.py | 11 ++-- python/packages/core/pyproject.toml | 2 +- .../core/test_function_invocation_logic.py | 6 +- .../packages/core/tests/core/test_skills.py | 1 + python/packages/declarative/pyproject.toml | 4 +- python/packages/devui/pyproject.toml | 4 +- python/packages/durabletask/pyproject.toml | 4 +- python/packages/foundry_local/pyproject.toml | 4 +- python/packages/github_copilot/pyproject.toml | 4 +- python/packages/lab/pyproject.toml | 4 +- python/packages/mem0/pyproject.toml | 4 +- python/packages/ollama/pyproject.toml | 4 +- python/packages/orchestrations/pyproject.toml | 4 +- python/packages/purview/pyproject.toml | 4 +- python/packages/redis/pyproject.toml | 4 +- python/pyproject.toml | 4 +- python/uv.lock | 62 ++++++++++--------- 29 files changed, 117 insertions(+), 91 deletions(-) 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 3cabcb8223..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", ] diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index dba360cb00..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" diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index 6fef24c1d8..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", ] diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index 0d492b9d07..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", ] 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/pyproject.toml b/python/packages/azure-ai/pyproject.toml index bbd438eb3a..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,7 +23,7 @@ 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", diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index 15fa714654..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", diff --git a/python/packages/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml index 5883c29f32..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,7 +23,7 @@ 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", ] diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index 7256de7fd6..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", ] diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index f3140cae6d..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", ] diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 8268c222fd..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", ] 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/pyproject.toml b/python/packages/core/pyproject.toml index a93f12903f..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" 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/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index d6de326fac..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", ] diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 36a583d478..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", diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml index 766aa8ede1..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", diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index 37b313bebb..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", ] diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index f13bffeb04..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", ] 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 ce0e0d0dcb..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", ] diff --git a/python/packages/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml index 1daa45084c..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", ] diff --git a/python/packages/orchestrations/pyproject.toml b/python/packages/orchestrations/pyproject.toml index bfcf7068d7..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] diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index a98fa0f4a0..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", ] diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 9da016f2e1..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" 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/uv.lock b/python/uv.lock index 1cd73c34ae..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,7 +203,7 @@ 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'" }, @@ -222,7 +222,7 @@ requires-dist = [ [[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'" }, @@ -237,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'" }, @@ -259,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'" }, @@ -276,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'" }, @@ -291,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'" }, @@ -306,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'" }, @@ -321,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'" }, @@ -401,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'" }, @@ -426,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'" }, @@ -462,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'" }, @@ -489,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'" }, @@ -504,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'" }, @@ -519,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'" }, @@ -598,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'" }, @@ -613,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'" }, @@ -628,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'" }, @@ -639,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'" }, @@ -656,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'" }, @@ -1378,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 = [ @@ -1857,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 = [ @@ -2323,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" }, @@ -2330,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" }, @@ -2338,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" }, @@ -2346,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" }, @@ -2354,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" }, @@ -2362,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" }, @@ -4583,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 = [ @@ -5247,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 = [ From 425f27f98934de197013fa1ce7fab591a6667606 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 25 Feb 2026 11:42:45 -0800 Subject: [PATCH 7/7] .NET: Fixing issue where OpenTelemetry span is never exported in .NET in-process workflow execution (#4196) * 1. Add reproduction test for issue #4155: workflow.run Activity never stopped in streaming OffThread path The WorkflowRunActivity_IsStopped_Streaming_OffThread test demonstrates that the workflow.run OpenTelemetry Activity created in StreamingRunEventStream.RunLoopAsync is started but never stopped when using the OffThread/Default streaming execution. The background run loop keeps running after event consumption completes, so the using Activity? declaration never disposes until explicit StopAsync() is called. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> 2. Fix workflow.run Activity never stopped in streaming OffThread execution (#4155) The workflow.run OpenTelemetry Activity in StreamingRunEventStream.RunLoopAsync was scoped to the method lifetime via 'using'. Since the run loop only exits on cancellation, the Activity was never stopped/exported until explicit disposal. Fix: Remove 'using' and explicitly dispose the Activity when the workflow reaches Idle status (all supersteps complete). A safety-net disposal in the finally block handles cancellation and error paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add root-level workflow.session activity spanning run loop lifetime\n\nImplements two-level telemetry hierarchy per PR feedback from lokitoth:\n- workflow.session: spans the entire run loop / stream lifetime\n- workflow_invoke: per input-to-halt cycle, nested within the session\n\nThis ensures the session activity stays open across multiple turns,\nwhile individual run activities are created and disposed per cycle.\n\nAlso fixes linkedSource CancellationTokenSource disposal leak in\nStreamingRunEventStream (added using declaration)." * Address Copilot review: fix Activity/CTS disposal, rename activity, add error tag\n\n1. LockstepRunEventStream: Remove 'using' from Activity in async iterator\n and manually dispose in finally block (fixes #4155 pattern). Also dispose\n linkedSource CTS in finally to prevent leak.\n2. Tags.cs: Add ErrorMessage (\"error.message\") tag for runtime errors,\n distinct from BuildErrorMessage (\"build.error.message\").\n3. ActivityNames: Rename WorkflowRun from \"workflow_invoke\" to \"workflow.run\"\n for cross-language consistency.\n4. WorkflowTelemetryContext: Fix XML doc to say \"outer/parent span\" instead\n of \"root-level span\".\n5. ObservabilityTests: Assert WorkflowSession absence when DisableWorkflowRun\n is true.\n6. WorkflowRunActivityStopTests: Fix streaming test race by disposing\n StreamingRun before asserting activities are stopped.\n7. StreamingRunEventStream/LockstepRunEventStream: Use Tags.ErrorMessage\n instead of Tags.BuildErrorMessage for runtime error events." * Review fixes: revert workflow_invoke rename, use 'using' for linkedSource, move SessionStarted earlier\n\n- Revert ActivityNames.WorkflowRun back to \"workflow_invoke\" (OTEL semantic convention contract)\n- Use 'using' declaration for linkedSource CTS in LockstepRunEventStream (no timing sensitivity)\n- Move SessionStarted event before WaitForInputAsync in StreamingRunEventStream to match Lockstep behavior" * Improve naming and comments in WorkflowRunActivityStopTests" * Prevent session Activity.Current leak in lockstep mode, add nesting test Save and restore Activity.Current in LockstepRunEventStream.Start() so the session activity doesn't leak into caller code via AsyncLocal. Re-establish Activity.Current = sessionActivity before creating the run activity in TakeEventStreamAsync to preserve parent-child nesting. Add test verifying app activities after RunAsync are not parented under the session, and that the workflow_invoke activity nests under the session." * Fix stale XML doc: WorkflowRun -> WorkflowInvoke in ObservabilityTests --------- Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Execution/LockstepRunEventStream.cs | 48 ++- .../Execution/StreamingRunEventStream.cs | 64 +++- .../Observability/ActivityNames.cs | 3 +- .../Observability/EventNames.cs | 3 + .../Observability/Tags.cs | 1 + .../Observability/WorkflowTelemetryContext.cs | 22 +- .../ObservabilityTests.cs | 31 +- .../WorkflowRunActivityStopTests.cs | 344 ++++++++++++++++++ 8 files changed, 480 insertions(+), 36 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowRunActivityStopTests.cs 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/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"); + } +}