diff --git a/.github/workflows/dotnet-integration-tests.yml b/.github/workflows/dotnet-integration-tests.yml index 5b08752abb3..4a10694bda5 100644 --- a/.github/workflows/dotnet-integration-tests.yml +++ b/.github/workflows/dotnet-integration-tests.yml @@ -88,6 +88,7 @@ jobs: env: COSMOSDB_ENDPOINT: https://localhost:8081 COSMOSDB_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw== + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} OpenAI__ApiKey: ${{ secrets.OPENAI__APIKEY }} OpenAI__ChatModelId: ${{ vars.OPENAI__CHATMODELID }} OpenAI__ChatReasoningModelId: ${{ vars.OPENAI__CHATREASONINGMODELID }} diff --git a/.github/workflows/merge-gatekeeper.yml b/.github/workflows/merge-gatekeeper.yml index 52adbcb8e47..c33d3e016a2 100644 --- a/.github/workflows/merge-gatekeeper.yml +++ b/.github/workflows/merge-gatekeeper.yml @@ -27,7 +27,7 @@ jobs: # "Cleanup artifacts", "Agent", "Prepare", and "Upload results" are check runs # created by an org-level GitHub App (MSDO), not by any workflow in this repo. # They are outside our control and their transient failures should not block merges. - IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results" + IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results,review" with: script: | const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS); diff --git a/.gitignore b/.gitignore index 9cb714813a2..258a8c07042 100644 --- a/.gitignore +++ b/.gitignore @@ -206,6 +206,7 @@ temp*/ .temp/ # AI +**/.checkpoints/ .claude/ .omc/ .omx/ @@ -213,6 +214,7 @@ WARP.md **/memory-bank/ **/projectBrief.md **/tmpclaude* +.kiro/ # Dependency-bound validation reports python/scripts/dependency-*-results.json python/scripts/dependencies/dependency-*-results.json diff --git a/docs/assets/PNG/Microsoft Foundry Agent Framework - Color.png b/docs/assets/PNG/Microsoft Foundry Agent Framework - Color.png new file mode 100644 index 00000000000..75559538435 Binary files /dev/null and b/docs/assets/PNG/Microsoft Foundry Agent Framework - Color.png differ diff --git a/docs/assets/PNG/Microsoft Foundry Agent Framework - Fill (Black).png b/docs/assets/PNG/Microsoft Foundry Agent Framework - Fill (Black).png new file mode 100644 index 00000000000..a490d0c6df5 Binary files /dev/null and b/docs/assets/PNG/Microsoft Foundry Agent Framework - Fill (Black).png differ diff --git a/docs/assets/PNG/Microsoft Foundry Agent Framework - Fill (White).png b/docs/assets/PNG/Microsoft Foundry Agent Framework - Fill (White).png new file mode 100644 index 00000000000..fa8bdd0d7d3 Binary files /dev/null and b/docs/assets/PNG/Microsoft Foundry Agent Framework - Fill (White).png differ diff --git a/docs/assets/PNG/Microsoft Foundry Agent Framework - Stroke (Black).png b/docs/assets/PNG/Microsoft Foundry Agent Framework - Stroke (Black).png new file mode 100644 index 00000000000..8d78004f0d3 Binary files /dev/null and b/docs/assets/PNG/Microsoft Foundry Agent Framework - Stroke (Black).png differ diff --git a/docs/assets/PNG/Microsoft Foundry Agent Framework - Stroke (White).png b/docs/assets/PNG/Microsoft Foundry Agent Framework - Stroke (White).png new file mode 100644 index 00000000000..ac9663e405a Binary files /dev/null and b/docs/assets/PNG/Microsoft Foundry Agent Framework - Stroke (White).png differ diff --git a/docs/assets/SVG/Microsoft Foundry Agent Framework - Color.svg b/docs/assets/SVG/Microsoft Foundry Agent Framework - Color.svg new file mode 100644 index 00000000000..d6b88d6f5bf --- /dev/null +++ b/docs/assets/SVG/Microsoft Foundry Agent Framework - Color.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/SVG/Microsoft Foundry Agent Framework - Fill (Black).svg b/docs/assets/SVG/Microsoft Foundry Agent Framework - Fill (Black).svg new file mode 100644 index 00000000000..29c9583ddbc --- /dev/null +++ b/docs/assets/SVG/Microsoft Foundry Agent Framework - Fill (Black).svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/assets/SVG/Microsoft Foundry Agent Framework - Fill (White).svg b/docs/assets/SVG/Microsoft Foundry Agent Framework - Fill (White).svg new file mode 100644 index 00000000000..87d0878fc25 --- /dev/null +++ b/docs/assets/SVG/Microsoft Foundry Agent Framework - Fill (White).svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/assets/SVG/Microsoft Foundry Agent Framework - Stroke (Black).svg b/docs/assets/SVG/Microsoft Foundry Agent Framework - Stroke (Black).svg new file mode 100644 index 00000000000..a9aabefdd88 --- /dev/null +++ b/docs/assets/SVG/Microsoft Foundry Agent Framework - Stroke (Black).svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/assets/SVG/Microsoft Foundry Agent Framework - Stroke (White).svg b/docs/assets/SVG/Microsoft Foundry Agent Framework - Stroke (White).svg new file mode 100644 index 00000000000..63a56a70886 --- /dev/null +++ b/docs/assets/SVG/Microsoft Foundry Agent Framework - Stroke (White).svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/assets/readme-banner.png b/docs/assets/readme-banner.png index defc110e49b..0b79ce6cc93 100644 Binary files a/docs/assets/readme-banner.png and b/docs/assets/readme-banner.png differ diff --git a/docs/decisions/0007-agent-filtering-middleware.md b/docs/decisions/0007-agent-filtering-middleware.md index dbdd6d37d10..4fd3b57727a 100644 --- a/docs/decisions/0007-agent-filtering-middleware.md +++ b/docs/decisions/0007-agent-filtering-middleware.md @@ -1125,7 +1125,7 @@ Naming (Python): N/A (Composable Components) Supports: N Observation: No explicit middleware/filters; modularity allows composable units but no dedicated interception hooks or callbacks for custom reading/modification mid-execution. -For more details, see the official documentation: [Atomic Agents Docs](https://brainblend-ai.github.io/atomic-agents/). No specific code examples available for interception. +No specific code examples available for interception. #### Smolagents (Hugging Face) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index e4e4b83e713..76a4444bc1c 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -41,19 +41,19 @@ - + - + - - + + @@ -72,12 +72,12 @@ - - - - - - + + + + + + @@ -86,12 +86,12 @@ - + - + @@ -138,10 +138,15 @@ + + + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index e395627bc98..6afa318012c 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -194,6 +194,8 @@ + + @@ -344,6 +346,9 @@ + + + @@ -622,6 +627,7 @@ + @@ -675,5 +681,6 @@ + diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 28bc03d112a..c5c14856dad 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -1,14 +1,14 @@ - 1.9.0 + 1.10.0 1 - 260603 + 260610 $(VersionPrefix)-rc$(RCNumber) $(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1 $(VersionPrefix)-preview.$(DateSuffix).1 $(VersionPrefix) - 1.9.0 + 1.10.0 Debug;Release;Publish true diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/AgentWithMemory_Step03_MemoryUsingValkey.csproj b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/AgentWithMemory_Step03_MemoryUsingValkey.csproj new file mode 100644 index 00000000000..1217591bc1b --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/AgentWithMemory_Step03_MemoryUsingValkey.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/Program.cs b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/Program.cs new file mode 100644 index 00000000000..6faa02a0f36 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/Program.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates using Valkey for persistent chat history with the Agent Framework. +// ValkeyChatHistoryProvider persists conversation history across sessions using Valkey lists. +// +// Prerequisites: +// - A running Valkey server (any version): +// docker run -d --name valkey -p 6379:6379 valkey/valkey:latest +// - Azure OpenAI endpoint and deployment configured via environment variables + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Valkey; +using Microsoft.Extensions.AI; +using OpenAI.Chat; +using Valkey.Glide; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +var valkeyConnection = Environment.GetEnvironmentVariable("VALKEY_CONNECTION") ?? "localhost:6379"; + +var connection = await ConnectionMultiplexer.ConnectAsync(valkeyConnection); + +Console.WriteLine("=== ValkeyChatHistoryProvider — Persistent Chat History ===\n"); + +var historyProvider = new ValkeyChatHistoryProvider( + connection, + _ => new ValkeyChatHistoryProvider.State($"sample-{Guid.NewGuid():N}"), + new ValkeyChatHistoryProviderOptions + { + KeyPrefix = "sample_chat", + MaxMessages = 20 + }); + +AIAgent historyAgent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName) + .AsAIAgent(new ChatClientAgentOptions() + { + ChatOptions = new() { Instructions = "You are a helpful assistant that remembers our conversation." }, + ChatHistoryProvider = historyProvider + }); + +AgentSession session1 = await historyAgent.CreateSessionAsync(); +Console.WriteLine(await historyAgent.RunAsync("Hello! My name is Alex and I'm a software engineer.", session1)); +Console.WriteLine(await historyAgent.RunAsync("I'm working on a project using Valkey for caching.", session1)); +Console.WriteLine(await historyAgent.RunAsync("What do you remember about me?", session1)); + +var messageCount = await historyProvider.GetMessageCountAsync(session1); +Console.WriteLine($"\n Stored {messageCount} messages in Valkey.\n"); + +// Clean up +connection.Dispose(); + +Console.WriteLine("Done!"); diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/README.md b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/README.md new file mode 100644 index 00000000000..08f65ecffaa --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/README.md @@ -0,0 +1,30 @@ +# Agent with Memory Using Valkey + +This sample demonstrates using Valkey for persistent chat history with the Agent Framework. + +## Components + +- **ValkeyChatHistoryProvider** — Persists conversation history across sessions using Valkey lists. Works with any Valkey or Redis OSS server (no search module required). + +## Prerequisites + +- Azure OpenAI endpoint and deployment +- A running Valkey server (any version): + +```bash +docker run -d --name valkey -p 6379:6379 valkey/valkey:latest +``` + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL | (required) | +| `AZURE_OPENAI_DEPLOYMENT_NAME` | Model deployment name | `gpt-5.4-mini` | +| `VALKEY_CONNECTION` | Valkey connection string | `localhost:6379` | + +## Running + +```bash +dotnet run +``` diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj new file mode 100644 index 00000000000..274ac15c97c --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/Program.cs b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/Program.cs new file mode 100644 index 00000000000..6f3027681fd --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/Program.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates using Valkey for persistent chat history with the Agent Framework, +// powered by Amazon Bedrock. +// +// Prerequisites: +// - A running Valkey server (any version): +// docker run -d --name valkey -p 6379:6379 valkey/valkey:latest +// - AWS credentials configured (environment variables, AWS profile, or IAM role) +// - Access to an Amazon Bedrock model (e.g., Anthropic Claude) + +using Amazon; +using Amazon.BedrockRuntime; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Valkey; +using Microsoft.Extensions.AI; +using Valkey.Glide; + +var awsRegion = Environment.GetEnvironmentVariable("AWS_REGION") ?? "us-east-1"; +var modelId = Environment.GetEnvironmentVariable("BEDROCK_MODEL_ID") ?? "anthropic.claude-3-5-sonnet-20241022-v2:0"; +var valkeyConnection = Environment.GetEnvironmentVariable("VALKEY_CONNECTION") ?? "localhost:6379"; + +// Create the Bedrock runtime client. +var bedrockRuntime = new AmazonBedrockRuntimeClient(RegionEndpoint.GetBySystemName(awsRegion)); +IChatClient chatClient = bedrockRuntime.AsIChatClient(modelId); + +var connection = await ConnectionMultiplexer.ConnectAsync(valkeyConnection); + +Console.WriteLine("=== ValkeyChatHistoryProvider — Persistent Chat History (Bedrock) ===\n"); + +var historyProvider = new ValkeyChatHistoryProvider( + connection, + _ => new ValkeyChatHistoryProvider.State($"bedrock-sample-{Guid.NewGuid():N}"), + new ValkeyChatHistoryProviderOptions + { + KeyPrefix = "bedrock_chat", + MaxMessages = 20 + }); + +AIAgent historyAgent = chatClient.AsAIAgent(new ChatClientAgentOptions() +{ + ChatOptions = new() { Instructions = "You are a helpful assistant that remembers our conversation." }, + ChatHistoryProvider = historyProvider +}); + +AgentSession session1 = await historyAgent.CreateSessionAsync(); +Console.WriteLine(await historyAgent.RunAsync("Hello! My name is Alex and I'm a software engineer.", session1)); +Console.WriteLine(await historyAgent.RunAsync("I'm working on a project using Valkey for caching.", session1)); +Console.WriteLine(await historyAgent.RunAsync("What do you remember about me?", session1)); + +var messageCount = await historyProvider.GetMessageCountAsync(session1); +Console.WriteLine($"\n Stored {messageCount} messages in Valkey.\n"); + +// Clean up +connection.Dispose(); + +Console.WriteLine("Done!"); diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/README.md b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/README.md new file mode 100644 index 00000000000..06d4012bd92 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/README.md @@ -0,0 +1,41 @@ +# Agent with Memory Using Valkey + Amazon Bedrock + +This sample demonstrates using Valkey for persistent chat history with the Agent Framework, powered by Amazon Bedrock via the `AWSSDK.Extensions.Bedrock.MEAI` adapter. + +## Components + +- **ValkeyChatHistoryProvider** — Persists conversation history across sessions using Valkey lists. Works with any Valkey or Redis OSS server (no search module required). +- **Amazon Bedrock** — Provides the LLM via `AWSSDK.Extensions.Bedrock.MEAI`, which implements `IChatClient` from `Microsoft.Extensions.AI`. + +## Prerequisites + +- AWS credentials configured (environment variables, AWS CLI profile, or IAM role) +- Access to an Amazon Bedrock model (e.g., Anthropic Claude 3.5 Sonnet) +- A running Valkey server (any version): + +```bash +docker run -d --name valkey -p 6379:6379 valkey/valkey:latest +``` + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `AWS_REGION` | AWS region for Bedrock | `us-east-1` | +| `BEDROCK_MODEL_ID` | Bedrock model identifier | `anthropic.claude-3-5-sonnet-20241022-v2:0` | +| `VALKEY_CONNECTION` | Valkey connection string | `localhost:6379` | +| `AWS_ACCESS_KEY_ID` | AWS access key (if not using profile/role) | — | +| `AWS_SECRET_ACCESS_KEY` | AWS secret key (if not using profile/role) | — | + +## Running + +```bash +# Using default AWS credential chain (profile, env vars, or IAM role) +dotnet run + +# Or with explicit credentials +export AWS_ACCESS_KEY_ID="your-access-key" +export AWS_SECRET_ACCESS_KEY="your-secret-key" +export AWS_REGION="us-east-1" +dotnet run +``` diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/AgentWithRAG_Step05_Neo4jGraphRAG.csproj b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/AgentWithRAG_Step05_Neo4jGraphRAG.csproj index a25c6263230..5476ec3221d 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/AgentWithRAG_Step05_Neo4jGraphRAG.csproj +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step05_Neo4jGraphRAG/AgentWithRAG_Step05_Neo4jGraphRAG.csproj @@ -23,7 +23,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/README.md index 5fcfddab227..404f88d0743 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/README.md @@ -71,6 +71,34 @@ curl -X POST http://localhost:8088/invocations \ -d "Hello from Docker!" ``` +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir hosted-invocations-echo-agent && cd hosted-invocations-echo-agent +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME hosted-invocations-echo-agent +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + +--- + ## NuGet package users If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `Hosted-Invocations-EchoAgent.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md index 28917a19e01..01239efef5f 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md @@ -107,3 +107,29 @@ azd env set SKILL_NAMES "support-style,escalation-policy" The deployed agent's Managed Identity needs **Azure AI User** on the Foundry project to download skills at startup. > The `skills/` source folder is **not** deployed to Foundry — only the downloaded skills are used at runtime. The provisioning step must have been run against the same Foundry project before the agent can download the skills. + +### Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir hosted-agent-skills && cd hosted-agent-skills +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME hosted-agent-skills +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md index ede4db10103..f82ee30e5a3 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md @@ -174,6 +174,34 @@ The model receives the top three search results as additional context and cites Replace the seed documents (or point the sample at an existing index with your own content) to ground the agent in your own knowledge base. +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir hosted-azure-search-rag && cd hosted-azure-search-rag +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME hosted-azure-search-rag +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + +--- + ## NuGet package users If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedAzureSearchRag.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md index ace88925728..4e11ed80231 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md @@ -104,6 +104,32 @@ curl -X POST http://localhost:8088/responses \ -d '{"input": "Hello!", "model": "hosted-chat-client-agent"}' ``` +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir hosted-chat-client-agent && cd hosted-chat-client-agent +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME hosted-chat-client-agent +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + ## NuGet package users If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedChatClientAgent.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md index 729aca5c5f0..80b68abe2ef 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md @@ -112,6 +112,34 @@ docker run --rm -p 8088:8088 \ The bundled `resources/` folder is part of the published output and ships inside the image. +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir hosted-files && cd hosted-files +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME hosted-files +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + +--- + ## NuGet package users If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor` and switch the `ProjectReference` entries in `HostedFiles.csproj` to `PackageReference` (commented section in the csproj). diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md index 8265a806322..3aa80756eee 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md @@ -107,6 +107,32 @@ curl -X POST http://localhost:8088/responses \ -d '{"input": "Hello!", "model": ""}' ``` +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir hosted-foundry-agent && cd hosted-foundry-agent +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME hosted-foundry-agent +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + ## NuGet package users If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedFoundryAgent.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md index 8016ff7ae98..8ad876e21e7 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md @@ -108,6 +108,34 @@ The agent has a single tool `GetAvailableHotels` defined as a C# method with `[D The tool searches a mock database of 6 Seattle hotels and returns formatted results with name, location, rating, and pricing. +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir hosted-local-tools && cd hosted-local-tools +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME hosted-local-tools +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + +--- + ## NuGet package users If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedLocalTools.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md index 3773d9760dd..db0a2324127 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md @@ -78,6 +78,40 @@ docker run --rm -p 8088:8088 \ hosted-mcp-tools ``` +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir mcp-tools && cd mcp-tools +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME mcp-tools +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + +--- + ## NuGet package users Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedMcpTools.csproj` for the `PackageReference` alternative. + +## Related samples + +- [`Hosted-Toolbox/`](../Hosted-Toolbox/) — connects to a single Foundry Toolbox via the AF Foundry hosting bridge (`AddFoundryToolboxes` + `FoundryAITool.CreateHostedMcpToolbox`). +- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — same hosting bones as `Hosted-Toolbox/`, but the toolbox bundles three MCP tools each authenticated differently (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL. + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/README.md index d9b3a118259..d8820096a07 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/README.md @@ -139,6 +139,34 @@ The script publishes the project, builds the image, runs the container with two `HOSTED_USER_ISOLATION_KEY` values, drives a multi-turn conversation per user, asserts that each user only sees their own memories, and exits non-zero on failure. +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir hosted-memory-agent && cd hosted-memory-agent +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME hosted-memory-agent +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + +--- + ## NuGet package users If you are consuming the Agent Framework as a NuGet package (not building from source), use the diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/README.md index 889eacca82b..e51959c6f55 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/README.md @@ -104,6 +104,34 @@ docker run --rm -p 8088:8088 \ Once deployed, telemetry flows to the Application Insights instance attached to your Foundry project. In the Foundry UI, the **Traces** tab next to **Playground** lists conversations and lets you drill into the span tree for any request. +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir hosted-observability && cd hosted-observability +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME hosted-observability +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + +--- + ## NuGet package users If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedObservability.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md index 5e4e5140c0f..c45b3f91012 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md @@ -111,6 +111,34 @@ The `TextSearchProvider` runs a mock search **before each model invocation**: The model receives the search results as additional context and cites the source in its response. In production, replace `MockSearchAsync` with a call to Azure AI Search or your preferred search provider. +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir hosted-text-rag && cd hosted-text-rag +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME hosted-text-rag +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + +--- + ## NuGet package users If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedTextRag.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/.env.example new file mode 100644 index 00000000000..e4302a5a9a8 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/.env.example @@ -0,0 +1,16 @@ +# Azure AI Foundry project endpoint (auto-injected in hosted containers). +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ + +# Model deployment name. Must exist in the Foundry project above. +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o + +# Name of the Foundry Toolbox you provisioned in the portal (see README.md). +TOOLBOX_NAME=auth-paths-toolbox + +# Agent name advertised over the wire. Must be unique if running side-by-side with +# other Hosted-* samples (e.g. Hosted-Toolbox), otherwise the REPL client cannot +# disambiguate which agent to chat with. +AGENT_NAME=hosted-toolbox-auth-paths-agent + +# Application Insights connection string (auto-injected in hosted containers; optional locally). +# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=... diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Dockerfile new file mode 100644 index 00000000000..b803098b6ae --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedToolboxAuthPaths.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Dockerfile.contributor new file mode 100644 index 00000000000..bbeb4a098ba --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Dockerfile.contributor @@ -0,0 +1,21 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local source, which means a standard +# multi-stage Docker build cannot resolve dependencies outside this folder. +# Pre-publish the app targeting the container runtime and copy the output: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-toolbox-auth-paths . +# docker run --rm -p 8088:8088 \ +# -e AGENT_NAME=hosted-toolbox-auth-paths-agent \ +# -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ +# --env-file .env hosted-toolbox-auth-paths +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedToolboxAuthPaths.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Hosted-Toolbox-AuthPaths.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Hosted-Toolbox-AuthPaths.csproj new file mode 100644 index 00000000000..da484bb9e1c --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Hosted-Toolbox-AuthPaths.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + HostedToolboxAuthPaths + HostedToolboxAuthPaths + $(NoWarn);OPENAI001 + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Program.cs new file mode 100644 index 00000000000..7bf9baeaf53 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Program.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Foundry Toolbox Auth Paths Agent — A hosted agent backed by a single Foundry Toolbox +// that bundles MCP tools using THREE different authentication paths. +// +// This sample demonstrates the same hosting bones as Hosted-Toolbox/, but the toolbox +// (provisioned by the user out-of-band) contains three MCP tool entries each authenticated +// differently. The agent code itself is agnostic to authentication — the educational +// surface lives in the toolbox configuration in the Foundry portal and in this sample's +// README.md. +// +// Required environment variables: +// AZURE_AI_PROJECT_ENDPOINT (local-dev) OR FOUNDRY_PROJECT_ENDPOINT (hosted runtime) +// - Azure AI Foundry project endpoint. The Foundry hosted +// runtime auto-injects FOUNDRY_PROJECT_ENDPOINT; locally +// set AZURE_AI_PROJECT_ENDPOINT (the AF-repo convention). +// TOOLBOX_NAME - Name of the Foundry Toolbox to load +// (default: auth-paths-toolbox) +// +// Optional: +// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o) +// AGENT_NAME - Defaults to "hosted-toolbox-auth-paths-agent". +// +// The Foundry.Hosting package builds the toolbox proxy URL from FOUNDRY_PROJECT_ENDPOINT +// per tools-integration-spec.md §2–§3, so the sample does not need to plumb any +// toolbox-specific URL env var. +// +// NOTE: All FOUNDRY_* and AGENT_* env-var prefixes (other than the platform-injected ones +// listed above) are reserved by the Foundry container platform and rejected by the +// agent-create API. Use TOOLBOX_NAME, not FOUNDRY_TOOLBOX_NAME, for sample-owned config. + +#pragma warning disable OPENAI001 // FoundryAITool.CreateHostedMcpToolbox is experimental + +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Hosted_Shared_Contributor_Setup; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +// Project endpoint resolution order: +// 1. FOUNDRY_PROJECT_ENDPOINT — auto-injected by the Foundry hosted runtime. +// 2. AZURE_AI_PROJECT_ENDPOINT — the convention developers set locally for `dotnet run`. +// When deployed, only (1) is available; the AF-repo sample convention to set (2) at +// deploy time fails silently because the platform reserves all FOUNDRY_* env-var names +// and rejects them at agent-create time. Read both, prefer the platform-injected one. +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException( + "Neither FOUNDRY_PROJECT_ENDPOINT (platform-injected in hosted runtime) " + + "nor AZURE_AI_PROJECT_ENDPOINT (local-dev convention) is set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +string toolboxName = Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "auth-paths-toolbox"; +string agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-auth-paths-agent"; + +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +// Notes on toolbox wiring — there are two ways to attach a Foundry Toolbox to an agent: +// - Server-side "baked-in" (what this sample uses): calling AddFoundryToolboxes(name) +// below registers the toolbox with the Foundry.Hosting layer, which resolves that +// toolbox's MCP tools once at startup and automatically makes them available to the +// agent on every request. The agent code does nothing per request. +// - Per-request / caller-driven (NOT used here): a client can attach a toolbox for a +// single call by placing a FoundryAITool.CreateHostedMcpToolbox(name) marker in the +// request body's tool list. +// Because this sample bakes the toolbox in on the server, it uses AddFoundryToolboxes and +// does NOT put the CreateHostedMcpToolbox marker in the agent's `tools:` array. +AIAgent agent = new AIProjectClient(new Uri(endpoint), credential) + .AsAIAgent( + model: deploymentName, + instructions: """ + You are a helpful assistant with access to several tools, each provided by a different + upstream service authenticated through a distinct mechanism (API key, agent managed + identity, and a literal token + shipped with the tool definition). Pick the tool that best fits the user's question + and explain which upstream service answered when you respond. + """, + name: agentName, + description: "Hosted agent demonstrating three MCP-tool authentication paths via a Foundry Toolbox."); + +// Tier 3 spine (WebApplication.CreateBuilder + AddFoundryResponses + MapFoundryResponses): +// the Foundry.Hosting package auto-maps the spec-required GET /readiness probe inside +// MapFoundryResponses (idempotent — skipped when AgentHost or the developer already +// mapped it), so the sample stays free of platform plumbing. +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddFoundryResponses(agent); +// Pre-register the toolbox name so FoundryToolboxService resolves the foundry-toolbox:// +// marker at request time. With FOUNDRY_PROJECT_ENDPOINT injected by the platform, startup +// MCP tools/list against the toolbox proxy is typically <100ms in-region. +builder.Services.AddFoundryToolboxes(toolboxName); + +var app = builder.Build(); +app.MapFoundryResponses(); + +// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry +// uses so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint). +// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path. +app.MapDevTemporaryLocalAgentEndpoint(); + +app.Run(); + +// ── DevTemporaryTokenCredential ─────────────────────────────────────────────── + +/// +/// A for local Docker debugging only. +/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable +/// once at startup. This should NOT be used in production. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + this._token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => this.GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(this.GetAccessToken()); + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential") + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(this._token, DateTimeOffset.MaxValue); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md new file mode 100644 index 00000000000..e1cf78fc244 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md @@ -0,0 +1,197 @@ +# Hosted Toolbox — Authentication Paths + +A hosted Foundry agent backed by a single Foundry Toolbox that bundles MCP tools using **three different authentication paths**. The educational surface lives in the toolbox configuration (which you provision in the Foundry portal) and in this README — the agent code itself is identical to the existing [`Hosted-Toolbox/`](../Hosted-Toolbox/) sample. + +Drive the agent interactively across the auth paths with the shared [`Using-Samples/SimpleAgent/`](../Using-Samples/SimpleAgent/) REPL client, pointed at this agent. + +## What this sample teaches + +| Aspect | This sample | Existing siblings | +|---|---|---| +| Toolbox marker pattern | `FoundryAITool.CreateHostedMcpToolbox(name)` + `AddFoundryToolboxes(name)` | Same as [`Hosted-Toolbox/`](../Hosted-Toolbox/) | +| Tools per toolbox | **Three MCP tools, each with a different auth method** | `Hosted-Toolbox/`: typically one demo tool | +| Consumption | Server-side (Foundry resolves the marker) | Same | +| Client | Shared [`Using-Samples/SimpleAgent/`](../Using-Samples/SimpleAgent/) REPL, pointed at this agent | `Hosted-Toolbox/`: any client | + +Related samples: +- [`Hosted-Toolbox/`](../Hosted-Toolbox/) — simpler single-tool toolbox. +- [`Hosted-McpTools/`](../Hosted-McpTools/) — contrasts client-side `McpClient` vs server-side `HostedMcpServerTool` for non-toolbox MCP servers. + +## Authentication-path matrix + +The sample's purpose is to enumerate every authentication path a Foundry toolbox can drive, so each path appears alongside the others. Pick the ones your scenario needs — each connection in a toolbox is independent. + +| # | Auth method | MCP target | Connection `authType` | What flows where | When to pick this | +|---|---|---|---|---|---| +| 1 | **Key-based via project connection** | GitHub MCP at `https://api.githubcopilot.com/mcp` | `CustomKeys` | A PAT stored as `Authorization: Bearer ` lives in the Foundry connection. The toolbox proxy reads it server-side and injects on every MCP call. | The upstream service only accepts API keys or PATs. | +| 2 | **Microsoft Entra — agent identity** | Any Azure Cognitive Services MCP endpoint your project can reach (e.g., Language service MCP) | `AgenticIdentityToken` | Foundry mints an Entra token for the agent's own identity (`instance_identity` in the new agent object model), scoped to the connection's `audience`, and forwards it to the MCP server. The agent identity must hold the required role (typically `Cognitive Services User`) on the target resource. | Per-agent least-privilege access to Entra-protected services. Recommended default for new agents. | +| 3 | **Inline `Authorization` (anti-pattern)** | `https://gitmcp.io/Azure/azure-rest-api-specs` | none | A literal bearer string lives on the toolbox tool entry's `authorization` field. **Do not do this in production** — there's no rotation, no secret store, no per-user identity. Shown for completeness. | Local-dev or public MCP servers that accept any (or no) bearer. | + +## Prerequisites + +### 0. (Path #2 only) Identify an Entra-authenticated MCP target + +Path #2 requires an MCP server that accepts Microsoft Entra tokens. Any **Azure Cognitive Services** resource that exposes an MCP endpoint works — they all accept Entra ID tokens and gate access via standard RBAC. + +The reference walkthrough below uses an **Azure Language service** MCP endpoint: + +``` +https://.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview +``` + +Substitute any other Cognitive Services MCP endpoint you have. If your project has none, omit tool #2 from your toolbox — the remaining two paths still work. + +#### RBAC for path #2 + +Grant the **`Cognitive Services User`** role on the target resource to the agent's instance identity. Find it on the agent ARM resource (Azure portal → your agent → JSON view) at `instance_identity.principal_id`. This is the principal the Foundry proxy uses when minting tokens for `AgenticIdentityToken` connections. + +```powershell +$lang = "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/" + +az role assignment create ` + --assignee-object-id ` + --assignee-principal-type ServicePrincipal ` + --role "Cognitive Services User" ` + --scope $lang +``` + +Repeat for any additional Cognitive Services resources the agent identity needs to call. + +> The RBAC grant requires `Microsoft.Authorization/roleAssignments/write` on the target scope. In many enterprise subscriptions this needs a PIM JIT activation. + +### 1. Foundry project + Azure AI User role + +- An active Microsoft Foundry project ([create one](https://learn.microsoft.com/en-us/azure/foundry/how-to/create-projects)). +- The **Azure AI User** role on the project assigned to: + - The developer (you) creating the toolbox. + - The agent identity for tool invocation. + +### 2. Create the project connections + +The Entra-based connection (path #2) is not available in the Foundry portal connection wizard today. Create it via ARM REST: + +```powershell +$armToken = az account get-access-token --query accessToken -o tsv +$h = @{ Authorization = "Bearer $armToken"; "Content-Type" = "application/json" } +$proj = "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/" +$lang = "https://.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview" + +# Path 2 — agent identity +$body2 = @{ properties = @{ + category = "RemoteTool"; target = $lang + authType = "AgenticIdentityToken"; audience = "https://cognitiveservices.azure.com" + isSharedToAll = $false +}} | ConvertTo-Json -Depth 5 +az rest --method PUT --headers "Content-Type=application/json" ` + --url "https://management.azure.com$proj/connections/lang-mcp-agent-id?api-version=2025-04-01-preview" ` + --body $body2 +``` + +Connection summary: + +| Connection name (used by the toolbox) | `category` | `authType` | `audience` | +|---|---|---|---| +| `github-mcp-key` | `CustomKeys` | `CustomKeys` | n/a (key value carries `Authorization: Bearer `) | +| `lang-mcp-agent-id` | `RemoteTool` | `AgenticIdentityToken` | `https://cognitiveservices.azure.com` | + +Path #3 (`gitmcp.io`) needs no connection — the auth lives on the toolbox tool entry itself. + +The `audience` value is the token resource identifier of the target service — for any Cognitive Services resource it is `https://cognitiveservices.azure.com`. For other Azure services consult [Agent identity — runtime token exchange](https://learn.microsoft.com/azure/foundry/agents/concepts/agent-identity#runtime-token-exchange). + +### 3. Create the toolbox + +In the Foundry portal → Tools → Add Toolbox. Name it `auth-paths-toolbox` (or whatever you prefer; export the name as `TOOLBOX_NAME`). Add three MCP tool entries: + +| Tool `server_label` | `server_url` | Auth | +|---|---|---| +| `github_pat` | `https://api.githubcopilot.com/mcp` | `project_connection_id: github-mcp-key` | +| `lang_agent` | Your Language service MCP URL | `project_connection_id: lang-mcp-agent-id` | +| `gitmcp_inline` | `https://gitmcp.io/Azure/azure-rest-api-specs` | `authorization: "Bearer demo-only-not-real"` (no `project_connection_id`) | + +Each entry should also carry: + +- `require_approval: never` (this sample is focused on auth, not approval flows; see [`ToolCallingApprovalHostedAgentFixture.cs`](../../../../../tests/Foundry.Hosting.IntegrationTests/Fixtures/ToolCallingApprovalHostedAgentFixture.cs) for that concern). +- A tight `allowed_tools` list. GitHub MCP exposes ~50 tools; restrict to what you actually want the model to invoke. For example: `github_pat` → `["search_issues", "list_pull_requests"]`. **Every name in `allowed_tools` must match a real tool on the upstream server** — an unknown name (e.g., `get_issue`, which GitHub MCP does not expose) makes the whole source fail enumeration. See the partial-failure note below. + +### Sidebar — what the toolbox-creation code looks like + +This sample assumes the toolbox already exists; it does not provision one programmatically. For an end-to-end code example of toolbox creation from a publisher script (suitable for a CI/CD pipeline), see [`02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs`](../../../../02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs) — its `CreateSampleToolboxAsync` helper uses `AgentAdministrationClient.GetAgentToolboxes().CreateToolboxVersionAsync(...)` and is the canonical pattern. + +## Run the agent + +Set environment variables (or copy `.env.example` to `.env` and fill it in): + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME = "gpt-4o" +$env:TOOLBOX_NAME = "auth-paths-toolbox" +``` + +Locally, the `Foundry.Hosting` package reads `AZURE_AI_PROJECT_ENDPOINT` as a fallback when `FOUNDRY_PROJECT_ENDPOINT` is absent. In the hosted Foundry runtime, the platform auto-injects `FOUNDRY_PROJECT_ENDPOINT` and the package builds the toolbox proxy URL as `{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1` per [`tools-integration-spec.md`](https://github.com/microsoft/AgentSchema/blob/main/specs/agents/hosted_agents/container-spec/docs/tools-integration-spec.md) §2–§3. + +Then sign in (`az login`) and start the server: + +```powershell +dotnet run --tl:off +``` + +The server logs at `http://localhost:8088/`. In Development it also maps the per-agent OpenAI route shape (`MapDevTemporaryLocalAgentEndpoint()`), so the shared `SimpleAgent` REPL client can reach it through `AsAIAgent(agentEndpoint)` — the only supported way to consume a hosted Foundry agent. In a separate terminal: + +**Against the local dev server** (point the client at localhost; the `{project}` segment is a wildcard the server ignores): + +```powershell +cd ../Using-Samples/SimpleAgent +$env:AZURE_AI_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local" +$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-auth-paths-agent" +dotnet run --tl:off +``` + +**Against a deployed agent** (point the client at the real project endpoint and the deployed agent name): + +```powershell +cd ../Using-Samples/SimpleAgent +$env:AZURE_AI_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" +$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-auth-paths-agent" +dotnet run --tl:off +``` + +Either way the client derives the per-agent endpoint URL (`{AZURE_AI_PROJECT_ENDPOINT}/agents/{AZURE_AI_AGENT_NAME}/endpoint/protocols/openai`) and consumes the agent via `AsAIAgent(agentEndpoint)`. Run `az login` first so the client can mint a bearer token. + +> **Parallel-run warning**: `Hosted-Toolbox/` and other `Hosted-*` samples default to the same port (8088) and the same agent name slot. Always set a unique `AGENT_NAME` (this sample defaults to `hosted-toolbox-auth-paths-agent`) and stop other hosted samples before starting this one. + +## Sample prompts + +One per auth path so each tool gets exercised at least once: + +``` +List the latest 3 issues in microsoft/agent-framework. # path #1 — GitHub MCP (key) +Detect the language of "Bonjour le monde". # path #2 — Language MCP (agent identity) +What's the latest API version for Microsoft.CognitiveServices? # path #3 — gitmcp.io (inline Authorization) +``` + +## Troubleshooting / partial-failure semantics + +`AddFoundryToolboxes` resolves the toolbox at startup by listing its tools via MCP `tools/list`. This enumeration is **all-or-nothing**: if *any* single tool source fails to enumerate, the Foundry toolbox proxy returns a top-level JSON-RPC error (`-32007`) instead of a partial list, the hosting package marks the toolbox startup as failed, `/readiness` returns 503, and *every* invoke against the agent returns **HTTP 424** — even for the auth paths that are configured correctly. So one misconfigured connection or one bad `allowed_tools` entry bricks the whole agent at startup, not just at tool-call time. Get each source enumerating cleanly before deploying. Symptoms per auth path: + +| Symptom | Likely cause | +|---|---| +| **All invokes return HTTP 424 ("Failed Dependency")** | One or more tool sources failed `tools/list` at startup (see all-or-nothing note above). Common causes: an `allowed_tools` name that does not exist on the upstream server, or an Entra connection whose token is rejected. Reproduce by calling the toolbox `tools/list` directly with your own token — a `-32007` top-level error names the failing source. | +| **HTTP 401 "audience is incorrect"** | The connection's `audience` field is missing or does not match the OAuth resource identifier the target service accepts. For Cognitive Services targets, set `audience: "https://cognitiveservices.azure.com"`. | +| **HTTP 401 / 403 "principal does not have access"** | Path #1: PAT expired or scope insufficient. Path #2: the agent's instance identity is missing the required role on the target resource. | +| **Container reports zero tools but startup succeeded** | `FoundryToolboxService.StartAsync` caches the `tools/list` result at startup. If a connection or RBAC grant changed after the container started, force a fresh container (re-deploy the agent version) — the cache won't pick up the change until then. | +| **HTTP 404 from a tool call** | Toolbox name mismatch (`TOOLBOX_NAME` vs the name in the portal), or the toolbox was deleted. | +| **Server logs a warning "Neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support is disabled"** | Local dev without the env var set. The agent will load with zero tools and respond as if it has none. Set `AZURE_AI_PROJECT_ENDPOINT` (local-dev fallback) or `FOUNDRY_PROJECT_ENDPOINT` to your project endpoint. | +| **Tools appear but model never invokes them** | `instructions:` in `Program.cs` may not surface what each tool is for. Tighten the `allowed_tools` lists and rephrase prompts to mention the upstream service by name. | + +## Region and model compatibility + +Foundry Toolboxes have region constraints; some tool types are limited to specific models. This sample defaults to `gpt-4o`, which works in all supported regions. For the full matrix, see the [Foundry tools compatibility matrix](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox#region-and-model-compatibility). + +## Anti-pattern note for path #3 + +Inline `authorization` on a toolbox tool entry stores credentials **inside the toolbox definition**. There is no rotation, no per-user scoping, no secret-store integration. Use it only for: + +- Public MCP servers that ignore the bearer (the `gitmcp.io` case demonstrated here). +- Local development against a test MCP server with a throwaway token. + +For everything else use `project_connection_id` and let the platform inject credentials. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.manifest.yaml new file mode 100644 index 00000000000..77336ea78f1 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.manifest.yaml @@ -0,0 +1,48 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-toolbox-auth-paths +displayName: "Hosted Toolbox - Authentication Paths" + +description: > + A hosted agent demonstrating three MCP-tool authentication paths in a single + Foundry Toolbox: API key via project connection, Microsoft Entra agent + identity, and inline Authorization + (anti-pattern). The toolbox itself is + provisioned out of band; see this sample's README for the portal walkthrough. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Agent Framework + - Foundry Toolbox + - Authentication + - MCP + +template: + name: hosted-toolbox-auth-paths + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi + environment_variables: + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: TOOLBOX_NAME + value: "{{TOOLBOX_NAME}}" +parameters: + properties: + - name: TOOLBOX_NAME + type: string + default: "auth-paths-toolbox" + description: "Name of the Foundry Toolbox to load at runtime." +resources: + - kind: model + id: gpt-4o + name: AZURE_AI_MODEL_DEPLOYMENT_NAME + - kind: toolbox + name: "{{TOOLBOX_NAME}}" + tools: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.yaml new file mode 100644 index 00000000000..87cca57bb79 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-toolbox-auth-paths +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs index 3f6c0a70a7f..b06b8f56888 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs @@ -1,21 +1,27 @@ // Copyright (c) Microsoft. All rights reserved. -// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolset MCP tools. +// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolbox MCP tools. // -// Demonstrates how to register one or more Foundry toolsets so the agent can +// Demonstrates how to register one or more Foundry toolboxes so the agent can // call tools provided by the Foundry platform's managed MCP proxy. // // Required environment variables: -// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint +// AZURE_AI_PROJECT_ENDPOINT (local-dev) OR FOUNDRY_PROJECT_ENDPOINT (hosted runtime) +// - Azure AI Foundry project endpoint. The Foundry hosted +// runtime auto-injects FOUNDRY_PROJECT_ENDPOINT; locally +// set AZURE_AI_PROJECT_ENDPOINT. // AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o) -// FOUNDRY_AGENT_TOOLSET_ENDPOINT - Foundry Toolsets proxy base URL -// (injected automatically by Foundry platform at runtime) // // Optional: -// FOUNDRY_TOOLBOX_NAME - Name of the toolset to load (default: my-toolset) -// FOUNDRY_AGENT_NAME - Client name reported to MCP server -// FOUNDRY_AGENT_VERSION - Client version reported to MCP server -// FOUNDRY_AGENT_TOOLSET_FEATURES - Feature flags sent to Foundry proxy via header +// TOOLBOX_NAME - Name of the toolbox to load (default: my-toolbox) +// FOUNDRY_AGENT_NAME - Client name reported to MCP server (auto-injected in hosted runtime) +// FOUNDRY_AGENT_VERSION - Client version reported to MCP server (auto-injected in hosted runtime) +// FOUNDRY_AGENT_TOOLSET_FEATURES - Additional Foundry-Features header flags (the mandatory +// Toolboxes=V1Preview flag is always sent; this env var +// appends additional flags if present). +// +// The Foundry.Hosting package builds the toolbox proxy URL from FOUNDRY_PROJECT_ENDPOINT +// per tools-integration-spec.md §2–§3. using Azure.AI.Projects; using Azure.Core; @@ -28,10 +34,13 @@ // Load .env file if present (for local development) Env.TraversePath().Load(); -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException( + "Neither FOUNDRY_PROJECT_ENDPOINT (platform-injected in hosted runtime) " + + "nor AZURE_AI_PROJECT_ENDPOINT (local-dev convention) is set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; -string toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME") ?? "my-toolset"; +string toolboxName = Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "my-toolbox"; // Use a chained credential: try a temporary dev token first (for local Docker debugging), // then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). @@ -45,12 +54,12 @@ .AsAIAgent( model: deploymentName, instructions: """ - You are a helpful assistant with access to tools provided by the Foundry Toolset. + You are a helpful assistant with access to tools provided by the Foundry Toolbox. Use the available tools to answer user questions. If a tool is not available for a request, let the user know clearly. """, name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-agent", - description: "Hosted agent backed by Foundry Toolset MCP tools"); + description: "Hosted agent backed by Foundry Toolbox MCP tools"); // ── Build the host ──────────────────────────────────────────────────────────── @@ -61,8 +70,8 @@ Use the available tools to answer user questions. builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production. // Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available. -// The toolset name must match a toolset registered in your Foundry project. -// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent (e.g., in local development without Foundry +// The toolbox name must match a toolbox registered in your Foundry project. +// When FOUNDRY_PROJECT_ENDPOINT is absent (e.g., in local development without Foundry // infrastructure), startup succeeds without error and no toolbox tools are loaded. builder.Services.AddFoundryToolboxes(toolboxName); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md new file mode 100644 index 00000000000..37d4dd7d281 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md @@ -0,0 +1,27 @@ +# Hosted-Toolbox + +A hosted Foundry agent that loads tools from a Foundry Toolbox via the AF Foundry hosting bridge. + +The agent declares one `FoundryAITool.CreateHostedMcpToolbox(name)` marker; `AddFoundryToolboxes(name)` registers a `FoundryToolboxService` that resolves the marker into the individual MCP tools the toolbox bundles, connecting to the Foundry Toolboxes MCP proxy at startup and discovering tools via `tools/list`. + +## Prerequisites + +- A Microsoft Foundry project with a Toolbox configured. +- Azure CLI logged in (`az login`). +- Set environment variables: + - `AZURE_AI_PROJECT_ENDPOINT` (local-dev) or `FOUNDRY_PROJECT_ENDPOINT` (auto-injected in hosted containers) + - `AZURE_AI_MODEL_DEPLOYMENT_NAME` (default `gpt-4o`) + - `TOOLBOX_NAME` (default `my-toolbox`) + +The `Foundry.Hosting` package builds the toolbox proxy URL from `FOUNDRY_PROJECT_ENDPOINT` as `{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1` per [`tools-integration-spec.md`](https://github.com/microsoft/AgentSchema/blob/main/specs/agents/hosted_agents/container-spec/docs/tools-integration-spec.md) §2–§3. + +## Run + +```powershell +dotnet run --tl:off +``` + +## Related samples + +- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — extends this pattern with a three-tool toolbox demonstrating different MCP-tool authentication paths (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL. +- [`Hosted-McpTools/`](../Hosted-McpTools/) — contrasts client-side `McpClient` vs server-side `HostedMcpServerTool` for non-toolbox MCP servers. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md index 0c6b5ba60ed..707f37fb582 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md @@ -98,6 +98,34 @@ Using the Azure Developer CLI: azd ai agent invoke --local "What skills do you have available?" ``` +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir hosted-toolbox-mcp-skills && cd hosted-toolbox-mcp-skills +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME hosted-toolbox-mcp-skills +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-5 +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + +--- + ## NuGet package users If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedToolboxMcpSkills.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md index 643af745512..341564ad738 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md @@ -121,6 +121,34 @@ User message The triage agent receives every message and hands off to the appropriate specialist. Specialists route back to the triage agent after responding, allowing for multi-turn conversations. +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir triage-workflow && cd triage-workflow +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME triage-workflow +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + +--- + ## NuGet package users Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowHandoff.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/README.md index d91d27445d6..12aa97a8d78 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/README.md @@ -5,7 +5,7 @@ A hosted agent that demonstrates **multi-agent workflow orchestration**. Three t ## Prerequisites - [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) -- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- An Azure AI Foundry project with a deployed model (e.g., `hosted-workflow-simple`) - Azure CLI logged in (`az login`) ## Configuration @@ -22,7 +22,7 @@ Edit `.env` and set your Azure AI Foundry project endpoint: AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +AZURE_AI_MODEL_DEPLOYMENT_NAME=hosted-workflow-simple ``` > **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. @@ -104,6 +104,34 @@ Input text Each agent in the chain receives the output of the previous agent. The final result demonstrates how meaning is preserved (or subtly shifted) through multiple translation hops. +## Deploying to Foundry (azd spec) + +This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry. + +Initialize an `azd` project from this sample's manifest: + +```bash +mkdir hosted-workflows && cd hosted-workflows +azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.manifest.yaml +``` + +Then deploy: + +```bash +azd deploy +``` + +If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying: + +```bash +azd env set AGENT_NAME hosted-workflow-simple +azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME hosted-workflow-simple +``` + +For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent). + +--- + ## NuGet package users Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowSimple.csproj` for the `PackageReference` alternative. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs index d3452972762..23e28583229 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -13,24 +14,32 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// An that: /// -/// Acquires a fresh Azure bearer token (scope: https://cognitiveservices.azure.com/.default) per request. -/// Injects the Foundry-Features header from FOUNDRY_AGENT_TOOLSET_FEATURES when non-empty. +/// Acquires a fresh Azure bearer token (scope: https://ai.azure.com/.default) per request, per tools-integration-spec.md §4. +/// Always injects the mandatory Foundry-Features: Toolboxes=V1Preview header per spec §2, merging any additional flags from FOUNDRY_AGENT_TOOLSET_FEATURES. +/// Propagates W3C trace context (traceparent, tracestate, baggage) from per spec §6.3. /// Retries on HTTP 429, 500, 502, and 503 with exponential back-off (max 3 attempts, per spec §7). /// /// internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler { private const int MaxRetries = 3; + + // Per tools-integration-spec.md §4, the container authenticates to the Foundry Toolbox + // proxy with a bearer token whose audience is https://ai.azure.com. private static readonly TokenRequestContext s_tokenContext = - new(["https://cognitiveservices.azure.com/.default"]); + new(["https://ai.azure.com/.default"]); + + // Per tools-integration-spec.md §2, every proxy request MUST include the + // Foundry-Features: Toolboxes=V1Preview opt-in header while the service is in preview. + private const string MandatoryFeatureFlag = "Toolboxes=V1Preview"; private readonly TokenCredential _credential; - private readonly string? _featuresHeaderValue; + private readonly string? _additionalFeaturesHeaderValue; - internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? featuresHeaderValue) + internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? additionalFeaturesHeaderValue) { this._credential = credential; - this._featuresHeaderValue = featuresHeaderValue; + this._additionalFeaturesHeaderValue = additionalFeaturesHeaderValue; } protected override async Task SendAsync( @@ -43,10 +52,9 @@ protected override async Task SendAsync( request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); - if (!string.IsNullOrEmpty(this._featuresHeaderValue)) - { - request.Headers.TryAddWithoutValidation("Foundry-Features", this._featuresHeaderValue); - } + request.Headers.TryAddWithoutValidation("Foundry-Features", BuildFeaturesHeaderValue(this._additionalFeaturesHeaderValue)); + + PropagateTraceContext(request); // MaxRetries is the total number of attempts (not additional retries after the first). for (int attempt = 0; attempt < MaxRetries; attempt++) @@ -82,6 +90,75 @@ await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken) throw new InvalidOperationException("Retry loop completed without returning a response."); } + // Returns "Toolboxes=V1Preview" when no override is set, or + // "Toolboxes=V1Preview," when an override is set and doesn't already include it. + internal static string BuildFeaturesHeaderValue(string? additional) + { + if (string.IsNullOrWhiteSpace(additional)) + { + return MandatoryFeatureFlag; + } + + // Avoid duplicating the mandatory flag if the override happens to already include it + // (case-insensitive, ignore surrounding whitespace). + foreach (var part in additional!.Split(',')) + { + if (string.Equals(part.Trim(), MandatoryFeatureFlag, StringComparison.OrdinalIgnoreCase)) + { + return additional; + } + } + + return $"{MandatoryFeatureFlag},{additional}"; + } + + // Per tools-integration-spec.md §6.3, propagate W3C trace context onto outbound requests. + // Skip headers already set on the message (callers / inner handlers may override). + private static void PropagateTraceContext(HttpRequestMessage request) + { + var activity = Activity.Current; + if (activity is null) + { + return; + } + + if (!request.Headers.Contains("traceparent")) + { + var traceparent = activity.Id; + if (!string.IsNullOrEmpty(traceparent)) + { + request.Headers.TryAddWithoutValidation("traceparent", traceparent); + } + } + + var traceState = activity.TraceStateString; + if (!string.IsNullOrEmpty(traceState) && !request.Headers.Contains("tracestate")) + { + request.Headers.TryAddWithoutValidation("tracestate", traceState); + } + + // Baggage is a comma-separated list of key=value pairs per the W3C Baggage spec. + if (!request.Headers.Contains("baggage")) + { + string? baggageHeader = null; + foreach (var pair in activity.Baggage) + { + if (pair.Value is null) + { + continue; + } + + var entry = $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"; + baggageHeader = baggageHeader is null ? entry : $"{baggageHeader},{entry}"; + } + + if (baggageHeader is not null) + { + request.Headers.TryAddWithoutValidation("baggage", baggageHeader); + } + } + } + private static async Task CloneRequestAsync( HttpRequestMessage original, CancellationToken cancellationToken) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxHealthCheck.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxHealthCheck.cs new file mode 100644 index 00000000000..e4e297d63b2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxHealthCheck.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Adapts to the AspNetCore +/// HealthChecks pipeline so the GET /readiness probe (mapped by +/// ) reflects whether +/// pre-registered toolbox connections are usable. Registered automatically by +/// +/// and its overloads. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class FoundryToolboxHealthCheck : IHealthCheck +{ + private readonly FoundryToolboxService _toolboxService; + + public FoundryToolboxHealthCheck(FoundryToolboxService toolboxService) + { + ArgumentNullException.ThrowIfNull(toolboxService); + this._toolboxService = toolboxService; + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + switch (this._toolboxService.StartupStatus) + { + case FoundryToolboxStartupStatus.Healthy: + return Task.FromResult(HealthCheckResult.Healthy( + description: $"Foundry toolbox: {this._toolboxService.Tools.Count} tool(s) available.")); + + case FoundryToolboxStartupStatus.NoEndpoint: + return Task.FromResult(HealthCheckResult.Healthy( + description: "Foundry toolbox: neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support disabled (local dev).")); + + case FoundryToolboxStartupStatus.Pending: + return Task.FromResult(new HealthCheckResult( + status: context.Registration.FailureStatus, + description: "Foundry toolbox: startup has not completed yet.")); + + case FoundryToolboxStartupStatus.Unhealthy: + var data = new Dictionary(StringComparer.Ordinal) + { + ["failedToolboxes"] = this._toolboxService.FailedToolboxNames, + }; + return Task.FromResult(new HealthCheckResult( + status: context.Registration.FailureStatus, + description: $"Foundry toolbox: {this._toolboxService.FailedToolboxNames.Count} pre-registered toolbox(es) failed to open at startup.", + data: data)); + + default: + return Task.FromResult(new HealthCheckResult( + status: context.Registration.FailureStatus, + description: $"Foundry toolbox: unknown startup status '{this._toolboxService.StartupStatus}'.")); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs index 78430f40bf0..de3ccbf2ade 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs @@ -16,14 +16,15 @@ public sealed class FoundryToolboxOptions /// Gets the list of toolbox names to connect to at startup. /// Each name corresponds to a toolbox registered in the Foundry project. /// The platform proxy URL is constructed as: - /// {FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version={ApiVersion} + /// {FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion} + /// per tools-integration-spec.md §2–§3. /// public IList ToolboxNames { get; } = []; /// - /// Gets or sets the Toolsets API version to use when constructing proxy URLs. + /// Gets or sets the Toolboxes API version to use when constructing proxy URLs. /// - public string ApiVersion { get; set; } = "2025-05-01-preview"; + public string ApiVersion { get; set; } = "v1"; /// /// Gets or sets a value indicating whether per-request toolbox markers (referenced via @@ -36,7 +37,9 @@ public sealed class FoundryToolboxOptions public bool StrictMode { get; set; } = true; /// - /// For testing only: overrides FOUNDRY_AGENT_TOOLSET_ENDPOINT. + /// For testing only: overrides the toolbox proxy base URL (skipping the + /// FOUNDRY_PROJECT_ENDPOINT-derived default). When set, the proxy URL + /// becomes {EndpointOverride}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}. /// Not part of the public API. /// internal string? EndpointOverride { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs index 7a8bc71e022..44b3d123b8e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs @@ -24,7 +24,13 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// /// -/// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent the service starts without error and +/// The toolbox proxy base URL is derived from the platform-injected +/// FOUNDRY_PROJECT_ENDPOINT environment variable per tools-integration-spec.md +/// §2–§3. The per-toolbox proxy URL is constructed as +/// {FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}. +/// +/// +/// When FOUNDRY_PROJECT_ENDPOINT is absent the service starts without error and /// no tools are registered, keeping the container healthy per spec §2. /// /// @@ -56,6 +62,24 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable /// public IReadOnlyList Tools { get; private set; } = []; + /// + /// Gets the startup status of the service. Reflects the outcome of pre-registered + /// toolbox connections opened in ; lazy-opens triggered by + /// per-request markers do not change this value. + /// + /// + /// Consumed by to gate the + /// GET /readiness probe so the Foundry hosted runtime does not start routing + /// traffic to a container whose pre-registered toolbox failed to open at startup. + /// + public FoundryToolboxStartupStatus StartupStatus { get; private set; } = FoundryToolboxStartupStatus.Pending; + + /// + /// Gets the names of pre-registered toolboxes that failed to open during + /// . Empty when startup was successful or has not run yet. + /// + public IReadOnlyList FailedToolboxNames { get; private set; } = []; + /// /// Initializes a new instance of . /// @@ -75,16 +99,24 @@ public FoundryToolboxService( /// public async Task StartAsync(CancellationToken cancellationToken) { - this._resolvedEndpoint = this._options.EndpointOverride - ?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT"); - - if (string.IsNullOrEmpty(this._resolvedEndpoint)) + // Per tools-integration-spec.md §2-§3, the container derives the toolbox proxy base + // URL from the platform-injected FOUNDRY_PROJECT_ENDPOINT. The EndpointOverride + // option exists for tests; AZURE_AI_PROJECT_ENDPOINT is honored as a local-dev + // fallback to mirror the convention used by AF-repo samples. + var projectEndpoint = this._options.EndpointOverride + ?? Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT"); + + if (string.IsNullOrEmpty(projectEndpoint)) { - this._logger.LogInformation("FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set; toolbox support is disabled."); + this._logger.LogWarning( + "Neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support is disabled."); this.Tools = []; + this.StartupStatus = FoundryToolboxStartupStatus.NoEndpoint; return; } + this._resolvedEndpoint = projectEndpoint.TrimEnd('/'); this._featuresHeader = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_FEATURES"); this._agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME") ?? "hosted-agent"; this._agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION") ?? "1.0.0"; @@ -93,10 +125,12 @@ public async Task StartAsync(CancellationToken cancellationToken) { this._logger.LogInformation("No pre-registered toolbox names configured."); this.Tools = []; + this.StartupStatus = FoundryToolboxStartupStatus.Healthy; return; } var allTools = new List(); + var failed = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var toolboxName in this._options.ToolboxNames) @@ -121,10 +155,16 @@ public async Task StartAsync(CancellationToken cancellationToken) "Failed to connect to toolbox '{ToolboxName}'. Tools from this toolbox will not be available.", toolboxName); } + + failed.Add(toolboxName); } } this.Tools = allTools; + this.FailedToolboxNames = failed; + this.StartupStatus = failed.Count == 0 + ? FoundryToolboxStartupStatus.Healthy + : FoundryToolboxStartupStatus.Unhealthy; } /// @@ -165,7 +205,7 @@ public async ValueTask> GetToolboxToolsAsync( if (string.IsNullOrEmpty(this._resolvedEndpoint)) { throw new InvalidOperationException( - $"Cannot resolve toolbox '{toolboxName}': FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set."); + $"Cannot resolve toolbox '{toolboxName}': FOUNDRY_PROJECT_ENDPOINT is not set."); } await this._lazyOpenLock.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -192,7 +232,7 @@ private async Task OpenToolboxAsync( string? version, CancellationToken cancellationToken) { - var proxyUrl = $"{this._resolvedEndpoint!.TrimEnd('/')}/{toolboxName}/mcp?api-version={this._options.ApiVersion}"; + var proxyUrl = $"{this._resolvedEndpoint!}/toolboxes/{toolboxName}/mcp?api-version={this._options.ApiVersion}"; if (this._logger.IsEnabled(LogLevel.Information)) { diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxStartupStatus.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxStartupStatus.cs new file mode 100644 index 00000000000..821287192d8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxStartupStatus.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Outcome of startup. Drives the +/// foundry-toolbox health-check that gates the GET /readiness probe so the +/// Foundry hosted runtime does not start routing traffic before pre-registered toolbox +/// connections are confirmed open (per container-image-spec.md §3.1). +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public enum FoundryToolboxStartupStatus +{ + /// + /// has not run yet. The health-check + /// reports Unhealthy in this state so the platform waits for startup to + /// complete before the first invocation. + /// + Pending = 0, + + /// + /// Startup completed and either every pre-registered toolbox opened successfully or + /// no pre-registered toolboxes were configured. The health-check reports + /// Healthy. + /// + Healthy = 1, + + /// + /// One or more pre-registered toolboxes failed to open during startup (including the + /// partial case where some opened and some did not). The health-check reports + /// Unhealthy and exposes the failed names in the HealthCheckResult.Data + /// dictionary so operators can diagnose the failure without parsing log output. + /// + Unhealthy = 2, + + /// + /// Neither the FOUNDRY_PROJECT_ENDPOINT nor the AZURE_AI_PROJECT_ENDPOINT + /// environment variable is set. This is normal for local dotnet run flows and the + /// health-check reports Healthy so the container is still routable; toolbox tools + /// will simply not be available. + /// + NoEndpoint = 3, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj index 71d9af8f71c..ec348b2167c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj @@ -13,7 +13,7 @@ true true true - $(NoWarn);OPENAI001;MEAI001;NU1903 + $(NoWarn);OPENAI001;MEAI001;MAAI001;NU1903 false diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index a0f53b342e3..bd61b9f4eb8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -7,10 +7,12 @@ using Azure.AI.AgentServer.Responses; using Azure.Core; using Azure.Identity; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Foundry.Hosting; @@ -49,6 +51,7 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser { ArgumentNullException.ThrowIfNull(services); services.AddResponsesServer(); + services.AddHealthChecks(); services.TryAddSingleton(_ => FileSystemAgentSessionStore.CreateDefault()); services.TryAddSingleton(); return services; @@ -84,6 +87,7 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser ArgumentNullException.ThrowIfNull(agent); services.AddResponsesServer(); + services.AddHealthChecks(); agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault(); if (!string.IsNullOrWhiteSpace(agent.Name)) @@ -109,10 +113,10 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser /// /// Each string in is a toolbox name registered in the Foundry /// project. The proxy URL per toolbox is constructed as: - /// {FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version=2025-05-01-preview + /// {FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version=v1 /// /// - /// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent, startup succeeds without error and + /// When FOUNDRY_PROJECT_ENDPOINT is absent, startup succeeds without error and /// no tools are loaded (the container remains healthy per spec §2). /// /// @@ -167,12 +171,61 @@ public static IServiceCollection AddFoundryToolboxes( // multiple times will not invoke StartAsync twice on the same singleton. services.AddHostedService(sp => sp.GetRequiredService()); + // Register the toolbox health check on the same /readiness pipeline that + // MapFoundryResponses maps. This gates the Foundry hosted runtime's readiness + // probe (per container-image-spec.md §3.1) on the outcome of the pre-registered + // toolbox connections opened in FoundryToolboxService.StartAsync. + // AddCheck(name, ...) does NOT dedupe by name, so guard against duplicate + // registration when AddFoundryToolboxes is called multiple times. + const string HealthCheckName = "foundry-toolbox"; + services.AddHealthChecks(); + services.Configure(opts => + { + foreach (var existing in opts.Registrations) + { + if (string.Equals(existing.Name, HealthCheckName, StringComparison.Ordinal)) + { + return; + } + } + + opts.Registrations.Add(new HealthCheckRegistration( + name: HealthCheckName, + factory: sp => ActivatorUtilities.CreateInstance(sp), + failureStatus: HealthStatus.Unhealthy, + tags: ["foundry", "toolbox", "readiness"])); + }); + return services; } /// /// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline. /// + /// + /// + /// Also maps the Foundry-required GET /readiness health probe to + /// + /// when no /readiness route is already registered. This makes the package + /// spec-compliant in the Foundry hosted runtime (which probes /readiness + /// before accepting any invocation per container-image-spec.md §2; without + /// it every request fails with HTTP 424 session_not_ready) regardless of the + /// host spine the developer chose: + /// + /// + /// Tier 1/2 (AgentHost.CreateBuilder) — the Core SDK + /// already maps /readiness. The duplicate-route guard below skips + /// re-mapping it. + /// Tier 3 (WebApplication.CreateBuilder + + /// AddFoundryResponses + MapFoundryResponses) — the Core SDK + /// does NOT map it. This call covers the gap automatically. + /// + /// + /// Developers can still opt out by registering their own /readiness route + /// before calling MapFoundryResponses; the existing route is detected and + /// preserved. + /// + /// /// The endpoint route builder. /// Optional route prefix (e.g., "/openai/v1"). Default: empty (routes at /responses). /// The endpoint route builder for chaining. @@ -180,9 +233,37 @@ public static IEndpointRouteBuilder MapFoundryResponses(this IEndpointRouteBuild { ArgumentNullException.ThrowIfNull(endpoints); endpoints.MapResponsesServer(prefix); + MapReadinessIfMissing(endpoints); return endpoints; } + /// + /// Maps GET /readiness to the AspNetCore HealthChecks pipeline only when no + /// route already serves that path. The duplicate guard scans + /// entries by route pattern, which catches both the + /// SDK-mapped MapHealthChecks("/readiness") path used by + /// AgentHostBuilder and any user-registered app.MapGet("/readiness", ...) + /// route. Idempotent across multiple MapFoundryResponses invocations. + /// + private static void MapReadinessIfMissing(IEndpointRouteBuilder endpoints) + { + const string ReadinessPath = "/readiness"; + + foreach (var dataSource in endpoints.DataSources) + { + foreach (var endpoint in dataSource.Endpoints) + { + if (endpoint is RouteEndpoint route && + string.Equals(route.RoutePattern.RawText, ReadinessPath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + } + } + + endpoints.MapHealthChecks(ReadinessPath); + } + /// /// The ActivitySource name for the Responses hosting pipeline. /// diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj index 8b868b5f774..dad0b31f59d 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj @@ -1,7 +1,7 @@ - preview + true $(TargetFrameworksCore) $(NoWarn);GHCP001 @@ -32,4 +32,52 @@ Provides Microsoft Agent Framework support for GitHub Copilot SDK. + + + + + + + + + <_CopilotSdkPackageVersion Include="@(PackageVersion)" Condition="'%(Identity)' == 'GitHub.Copilot.SDK'" /> + + + <_CopilotSdkResolvedVersion>@(_CopilotSdkPackageVersion->'%(Version)') + + + + <_BuildTransitivePropsContent> + + + <_MicrosoftAgentsAICopilotSdkPackagedVersion>$(_CopilotSdkResolvedVersion) + +]]> + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/buildTransitive/.gitignore b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/buildTransitive/.gitignore new file mode 100644 index 00000000000..87bc8ad53ed --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/buildTransitive/.gitignore @@ -0,0 +1,3 @@ +# Auto-generated at pack time by _GenerateBuildTransitiveProps in the csproj. +Microsoft.Agents.AI.GitHub.Copilot.props + diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/buildTransitive/Microsoft.Agents.AI.GitHub.Copilot.targets b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/buildTransitive/Microsoft.Agents.AI.GitHub.Copilot.targets new file mode 100644 index 00000000000..04a777550f8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/buildTransitive/Microsoft.Agents.AI.GitHub.Copilot.targets @@ -0,0 +1,34 @@ + + + + <_MicrosoftAgentsAICopilotSdkVersion Condition="'$(_MicrosoftAgentsAICopilotSdkVersion)' == ''">$(_MicrosoftAgentsAICopilotSdkPackagedVersion) + <_MicrosoftAgentsAICopilotSdkTargetsPath Condition="'$(_MicrosoftAgentsAICopilotSdkVersion)' != ''">$([System.IO.Path]::Combine('$(NuGetPackageRoot)', 'github.copilot.sdk', '$(_MicrosoftAgentsAICopilotSdkVersion)', 'build', 'GitHub.Copilot.SDK.targets')) + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs b/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs index 85a4fa54c3c..03f73d8007a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/BackgroundJobRunner.cs @@ -1,10 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; +using Microsoft.Agents.AI.Purview.Models.Common; using Microsoft.Agents.AI.Purview.Models.Jobs; +using Microsoft.Agents.AI.Purview.Models.Requests; +using Microsoft.Agents.AI.Purview.Models.Responses; using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.Purview; @@ -16,6 +20,7 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner { private readonly IChannelHandler _channelHandler; private readonly IPurviewClient _purviewClient; + private readonly ICacheProvider _cacheProvider; private readonly ILogger _logger; /// @@ -23,12 +28,14 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner /// /// The channel handler used to manage job channels. /// The Purview client used to send requests to Purview. + /// The cache provider used to store protection scopes results. /// The logger used to log information about background jobs. /// The settings used to configure Purview client behavior. - public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ILogger logger, PurviewSettings purviewSettings) + public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ICacheProvider cacheProvider, ILogger logger, PurviewSettings purviewSettings) { this._channelHandler = channelHandler; this._purviewClient = purviewClient; + this._cacheProvider = cacheProvider; this._logger = logger; for (int i = 0; i < purviewSettings.MaxConcurrentJobConsumers; i++) @@ -67,6 +74,28 @@ private async Task RunJobAsync(BackgroundJobBase job) break; case ContentActivityJob contentActivityJob: _ = await this._purviewClient.SendContentActivitiesAsync(contentActivityJob.Request, CancellationToken.None).ConfigureAwait(false); + break; + case ScopeRetrievalJob scopeRetrievalJob: + try + { + ProtectionScopesResponse response = await this._purviewClient.GetProtectionScopesAsync(scopeRetrievalJob.Request, CancellationToken.None).ConfigureAwait(false); + await this._cacheProvider.SetAsync(scopeRetrievalJob.CacheKey, response, CancellationToken.None).ConfigureAwait(false); + (bool shouldProcess, List _, ExecutionMode _) = ScopedContentProcessor.CheckApplicableScopes(scopeRetrievalJob.ProcessContentRequest, response); + if (!shouldProcess) + { + ProcessContentRequest pcRequest = scopeRetrievalJob.ProcessContentRequest; + ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId); + this._channelHandler.QueueJob(new ContentActivityJob(caRequest)); + } + } + catch (PurviewPaymentRequiredException ex) + { + await this._cacheProvider.SetAsync( + new PaymentRequiredCacheKey(scopeRetrievalJob.Request.TenantId), + new PaymentRequiredCacheEntry(ex.Message), + CancellationToken.None).ConfigureAwait(false); + } + break; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PaymentRequiredCacheEntry.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PaymentRequiredCacheEntry.cs new file mode 100644 index 00000000000..6bd9d40853a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PaymentRequiredCacheEntry.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// Cached tenant-level payment required state. +/// +internal sealed class PaymentRequiredCacheEntry +{ + /// + /// Creates a new instance of . + /// + /// The payment required error message. + public PaymentRequiredCacheEntry(string? message) + { + this.Message = message; + } + + /// + /// The payment required error message. + /// + public string? Message { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PaymentRequiredCacheKey.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PaymentRequiredCacheKey.cs new file mode 100644 index 00000000000..3c9ad4f813b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Common/PaymentRequiredCacheKey.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Purview.Models.Common; + +/// +/// A cache key for tenant-level payment required state. +/// +internal sealed class PaymentRequiredCacheKey +{ + /// + /// Creates a new instance of . + /// + /// The id of the tenant. + public PaymentRequiredCacheKey(string tenantId) + { + this.TenantId = tenantId; + } + + /// + /// The id of the tenant. + /// + public string TenantId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ScopeRetrievalJob.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ScopeRetrievalJob.cs new file mode 100644 index 00000000000..c23553f1855 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ScopeRetrievalJob.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Purview.Models.Common; +using Microsoft.Agents.AI.Purview.Models.Requests; + +namespace Microsoft.Agents.AI.Purview.Models.Jobs; + +/// +/// Class representing a job that refreshes the protection scopes cache in the background. +/// +/// +/// Used by the parallel protection scopes retrieval path to warm the cache without blocking the +/// foreground ProcessContent call. +/// +internal sealed class ScopeRetrievalJob : BackgroundJobBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The protection scopes request to send to Purview. + /// The cache key used to store the response. + /// The original process content request that triggered scope retrieval. + public ScopeRetrievalJob(ProtectionScopesRequest request, ProtectionScopesCacheKey cacheKey, ProcessContentRequest processContentRequest) + { + this.Request = request; + this.CacheKey = cacheKey; + this.ProcessContentRequest = processContentRequest; + } + + /// + /// Gets the protection scopes request. + /// + public ProtectionScopesRequest Request { get; } + + /// + /// Gets the cache key used to store the response. + /// + public ProtectionScopesCacheKey CacheKey { get; } + + /// + /// Gets the original process content request that triggered scope retrieval. + /// + public ProcessContentRequest ProcessContentRequest { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs index f8e9602cefb..d41a3a20905 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Requests/ProcessContentRequest.cs @@ -53,4 +53,10 @@ public ProcessContentRequest(ContentToProcess contentToProcess, string userId, s /// [JsonIgnore] internal string? ScopeIdentifier { get; set; } + + /// + /// Indicates whether the ProcessContent request should ask the service for inline evaluation. + /// + [JsonIgnore] + internal bool ProcessInline { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs index 28013f524eb..43b564b58fc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs @@ -130,6 +130,11 @@ public async Task ProcessContentAsync(ProcessContentRequ message.Headers.Add("If-None-Match", request.ScopeIdentifier); } + if (request.ProcessInline) + { + message.Headers.Add("Prefer", "evaluateInline"); + } + string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentRequest))); message.Content = new StringContent(content, Encoding.UTF8, "application/json"); diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/README.md b/dotnet/src/Microsoft.Agents.AI.Purview/README.md index 1a9fc70725f..bcd1a261922 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Purview/README.md @@ -218,8 +218,8 @@ The policy logic is identical; the only difference is the hook point in the pipe The user id from the prompt message(s) is reused for the response evaluation so both evaluations map consistently to the same user. -There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls. -If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit. +There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls. When a lookup is not cached, the middleware will refresh it in a background worker so the foreground ProcessContent request does not have to wait. +If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit. Payment Required responses from background scope lookups are cached at the tenant level so subsequent requests for the tenant short-circuit. ## Exceptions | Exception | Scenario | diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index 3fb7aa6c4df..3e280014a08 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Purview.Models.Common; @@ -193,43 +194,60 @@ private async Task ProcessContentWithProtectionScopesAsy { ProtectionScopesRequest psRequest = CreateProtectionScopesRequest(pcRequest, pcRequest.UserId, pcRequest.TenantId, pcRequest.CorrelationId); + PaymentRequiredCacheEntry? cachedPaymentRequired = await this._cacheProvider.GetAsync( + new PaymentRequiredCacheKey(pcRequest.TenantId), + cancellationToken).ConfigureAwait(false); + + if (cachedPaymentRequired != null) + { + throw new PurviewPaymentRequiredException(cachedPaymentRequired.Message ?? "Payment required"); + } + ProtectionScopesCacheKey cacheKey = new(psRequest); ProtectionScopesResponse? cacheResponse = await this._cacheProvider.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false); - ProtectionScopesResponse psResponse; - if (cacheResponse != null) { - psResponse = cacheResponse; + return await this.ProcessWithCachedScopesAsync(pcRequest, cacheResponse, cacheKey, cancellationToken).ConfigureAwait(false); } - else + + try { - psResponse = await this._purviewClient.GetProtectionScopesAsync(psRequest, cancellationToken).ConfigureAwait(false); - await this._cacheProvider.SetAsync(cacheKey, psResponse, cancellationToken).ConfigureAwait(false); + this._channelHandler.QueueJob(new ScopeRetrievalJob(psRequest, cacheKey, pcRequest)); + } + catch (PurviewJobException) + { + // QueueJob already logs failures. Scope warmup is best effort; don't block ProcessContent. } + return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions: null, cancellationToken).ConfigureAwait(false); + } + + /// + /// Apply locally-cached protection scopes to the request and dispatch ProcessContent appropriately. + /// + private async Task ProcessWithCachedScopesAsync( + ProcessContentRequest pcRequest, + ProtectionScopesResponse psResponse, + ProtectionScopesCacheKey cacheKey, + CancellationToken cancellationToken) + { pcRequest.ScopeIdentifier = psResponse.ScopeIdentifier; (bool shouldProcess, List dlpActions, ExecutionMode executionMode) = CheckApplicableScopes(pcRequest, psResponse); if (shouldProcess) { + pcRequest.ProcessInline = executionMode == ExecutionMode.EvaluateInline; + if (executionMode == ExecutionMode.EvaluateOffline) { this._channelHandler.QueueJob(new ProcessContentJob(pcRequest)); return new ProcessContentResponse(); } - ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false); - - if (pcResponse.ProtectionScopeState == ProtectionScopeState.Modified) - { - await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false); - } - - pcResponse = CombinePolicyActions(pcResponse, dlpActions); - return pcResponse; + return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions, cancellationToken).ConfigureAwait(false); } ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId); @@ -238,6 +256,30 @@ private async Task ProcessContentWithProtectionScopesAsy return new ProcessContentResponse(); } + /// + /// Call ProcessContent and invalidate the protection scopes cache when the response indicates the cached scopes are stale. + /// + private async Task CallProcessContentAsync( + ProcessContentRequest pcRequest, + ProtectionScopesCacheKey cacheKey, + List? dlpActions, + CancellationToken cancellationToken) + { + ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false); + + if (pcRequest.ScopeIdentifier != null && pcResponse.ProtectionScopeState == ProtectionScopeState.Modified) + { + await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false); + } + + if (dlpActions?.Count > 0) + { + pcResponse = CombinePolicyActions(pcResponse, dlpActions); + } + + return pcResponse; + } + /// /// Dedupe policy actions received from the service. /// @@ -248,9 +290,21 @@ private static ProcessContentResponse CombinePolicyActions(ProcessContentRespons { if (actionInfos?.Count > 0) { - pcResponse.PolicyActions = pcResponse.PolicyActions is null ? - actionInfos : - [.. pcResponse.PolicyActions, .. actionInfos]; + List combinedActions = []; + HashSet<(DlpAction Action, RestrictionAction? RestrictionAction)> seenActions = []; + IEnumerable allActions = pcResponse.PolicyActions is null + ? actionInfos + : pcResponse.PolicyActions.Concat(actionInfos); + + foreach (DlpActionInfo actionInfo in allActions) + { + if (seenActions.Add((actionInfo.Action, actionInfo.RestrictionAction))) + { + combinedActions.Add(actionInfo); + } + } + + pcResponse.PolicyActions = combinedActions; } return pcResponse; @@ -262,7 +316,7 @@ private static ProcessContentResponse CombinePolicyActions(ProcessContentRespons /// The process content request. /// The protection scopes response that was returned for the process content request. /// A bool indicating if the content needs to be processed. A list of applicable actions from the scopes response, and the execution mode for the process content request. - private static (bool shouldProcess, List dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse) + internal static (bool shouldProcess, List dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse) { ProtectionScopeActivities requestActivity = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity); @@ -284,7 +338,11 @@ private static (bool shouldProcess, List dlpActions, ExecutionMod foreach (var location in scope.Locations ?? Array.Empty()) { - locationMatch = location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase); + if (location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase)) + { + locationMatch = true; + break; + } } if (activityMatch && locationMatch) diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs index 320fbcd3b6f..0be4c592671 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Serialization/PurviewSerializationUtils.cs @@ -18,6 +18,8 @@ namespace Microsoft.Agents.AI.Purview.Serialization; [JsonSerializable(typeof(ContentActivitiesRequest))] [JsonSerializable(typeof(ContentActivitiesResponse))] [JsonSerializable(typeof(ProtectionScopesCacheKey))] +[JsonSerializable(typeof(PaymentRequiredCacheKey))] +[JsonSerializable(typeof(PaymentRequiredCacheEntry))] internal sealed partial class SourceGenerationContext : JsonSerializerContext; /// diff --git a/dotnet/src/Microsoft.Agents.AI.Valkey/Microsoft.Agents.AI.Valkey.csproj b/dotnet/src/Microsoft.Agents.AI.Valkey/Microsoft.Agents.AI.Valkey.csproj new file mode 100644 index 00000000000..e819c3f51c5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Valkey/Microsoft.Agents.AI.Valkey.csproj @@ -0,0 +1,40 @@ + + + + $(TargetFrameworksCore) + Microsoft.Agents.AI.Valkey + alpha + $(NoWarn);CA1873 + + + + true + true + + + + + + false + + + + + Microsoft Agent Framework - Valkey integration + Provides Valkey integration for Microsoft Agent Framework, including chat history persistence and context provider with full-text search. + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProvider.cs new file mode 100644 index 00000000000..088d66ed471 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProvider.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; +using Valkey.Glide; + +namespace Microsoft.Agents.AI.Valkey; + +/// +/// Provides a Valkey-backed implementation of for persistent chat history storage. +/// +/// +/// +/// Uses basic Valkey list operations via Valkey.Glide. +/// No search module is required — this provider works with any Valkey server. +/// +/// +/// Data retention: Stored messages have no TTL and persist indefinitely. +/// Use to limit per-conversation storage, and +/// for explicit cleanup. Callers are responsible for implementing data retention policies. +/// +/// +/// Security considerations: +/// +/// PII and sensitive data: Chat history stored in Valkey may contain PII and sensitive +/// conversation content. Ensure the Valkey server is configured with appropriate access controls and encryption in transit +/// (TLS). The property can limit stored messages per conversation. +/// Compromised store risks: Agent Framework does not validate or filter messages loaded +/// from the store — they are accepted as-is. If the Valkey store is compromised, adversarial content could be injected +/// into the conversation context. +/// +/// +/// +public sealed class ValkeyChatHistoryProvider : ChatHistoryProvider +{ + private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; + private readonly IConnectionMultiplexer _connection; + private readonly string _keyPrefix; + private readonly int? _maxMessages; + private readonly int? _maxMessagesToRetrieve; + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// An existing instance. + /// A delegate that initializes the provider state on the first invocation. + /// Optional configuration options. + /// Optional logger factory. + public ValkeyChatHistoryProvider( + IConnectionMultiplexer connection, + Func stateInitializer, + ValkeyChatHistoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : base(options?.ProvideOutputMessageFilter, options?.StoreInputRequestMessageFilter, options?.StoreInputResponseMessageFilter) + { + this._sessionState = new ProviderSessionState( + Throw.IfNull(stateInitializer), + options?.StateKey ?? this.GetType().Name, + options?.JsonSerializerOptions); + this._connection = Throw.IfNull(connection); + this._keyPrefix = options?.KeyPrefix ?? "chat_history"; + this._maxMessages = options?.MaxMessages; + this._maxMessagesToRetrieve = options?.MaxMessagesToRetrieve; + this._jsonSerializerOptions = options?.JsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions; + this._logger = loggerFactory?.CreateLogger(); + } + + /// + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; + + /// + protected override async ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + Throw.IfNull(context); + cancellationToken.ThrowIfCancellationRequested(); + + var state = this._sessionState.GetOrInitializeState(context.Session); + var db = this._connection.GetDatabase(); + var key = this.BuildKey(state); + + // Fetch only the tail when MaxMessagesToRetrieve is set [Low: avoid fetching all then trimming] + ValkeyValue[] values; + if (this._maxMessagesToRetrieve.HasValue) + { + values = await db.ListRangeAsync(key, -this._maxMessagesToRetrieve.Value, -1).ConfigureAwait(false); + } + else + { + values = await db.ListRangeAsync(key).ConfigureAwait(false); + } + + var messages = new List(values.Length); + + foreach (var value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (value.IsNullOrEmpty) + { + continue; + } + + try + { + var message = JsonSerializer.Deserialize(value.ToString(), this._jsonSerializerOptions.GetTypeInfo(typeof(ChatMessage))) as ChatMessage; + if (message is not null) + { + messages.Add(message); + } + } + catch (JsonException ex) + { + // Skip malformed entries rather than crashing the session [VERIFY-002] + this._logger?.LogWarning(ex, "ValkeyChatHistoryProvider: Skipping malformed message in conversation '{ConversationId}'.", state.ConversationId); + } + } + + this._logger?.LogInformation( + "ValkeyChatHistoryProvider: Retrieved {Count} messages for conversation.", + messages.Count); + + return messages; + } + + /// + protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) + { + Throw.IfNull(context); + cancellationToken.ThrowIfCancellationRequested(); + + var state = this._sessionState.GetOrInitializeState(context.Session); + var messageList = context.RequestMessages.Concat(context.ResponseMessages ?? []).ToList(); + if (messageList.Count == 0) + { + return; + } + + var db = this._connection.GetDatabase(); + var key = this.BuildKey(state); + + // Batch push — single round-trip [Medium-8] + var serialized = new ValkeyValue[messageList.Count]; + for (int i = 0; i < messageList.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + serialized[i] = JsonSerializer.Serialize(messageList[i], this._jsonSerializerOptions.GetTypeInfo(typeof(ChatMessage))); + } + + await db.ListRightPushAsync(key, serialized).ConfigureAwait(false); + + // Trim to max messages if configured + if (this._maxMessages.HasValue) + { + await db.ListTrimAsync(key, -this._maxMessages.Value, -1).ConfigureAwait(false); + } + + this._logger?.LogInformation( + "ValkeyChatHistoryProvider: Stored {Count} messages for conversation.", + messageList.Count); + } + + /// + /// Clears all messages for the specified session's conversation. + /// + /// The session containing the conversation state. + /// Cancellation token. + /// A task representing the asynchronous operation. + public async Task ClearMessagesAsync(AgentSession? session, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var state = this._sessionState.GetOrInitializeState(session); + var db = this._connection.GetDatabase(); + var key = this.BuildKey(state); + await db.KeyDeleteAsync(key).ConfigureAwait(false); + } + + /// + /// Gets the count of stored messages for the specified session's conversation. + /// + /// The session containing the conversation state. + /// Cancellation token. + /// The number of stored messages. + public async Task GetMessageCountAsync(AgentSession? session, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var state = this._sessionState.GetOrInitializeState(session); + var db = this._connection.GetDatabase(); + var key = this.BuildKey(state); + return await db.ListLengthAsync(key).ConfigureAwait(false); + } + + private string BuildKey(State state) => $"{this._keyPrefix}:{state.ConversationId}"; + + /// + /// Represents the per-session state of a . + /// + public sealed class State + { + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier for this conversation thread. + [JsonConstructor] + public State(string conversationId) + { + this.ConversationId = Throw.IfNullOrWhitespace(conversationId); + } + + /// + /// Gets the conversation ID associated with this state. + /// + public string ConversationId { get; } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProviderOptions.cs new file mode 100644 index 00000000000..eabe4680cfc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProviderOptions.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Valkey; + +/// +/// Options for configuring . +/// +public sealed class ValkeyChatHistoryProviderOptions +{ + /// + /// Gets or sets the prefix for Valkey keys. Defaults to "chat_history". + /// + public string KeyPrefix { get; set; } = "chat_history"; + + /// + /// Gets or sets the maximum number of messages to retain per conversation. + /// When exceeded, oldest messages are automatically trimmed. Null means unlimited. + /// + public int? MaxMessages { get; set; } + + /// + /// Gets or sets the maximum number of messages to retrieve from the provider. + /// Null means no limit. + /// + public int? MaxMessagesToRetrieve { get; set; } + + /// + /// Gets or sets an optional key for storing state in the session's StateBag. + /// + public string? StateKey { get; set; } + + /// + /// Gets or sets optional JSON serializer options for serializing the state of this provider. + /// + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + + /// + /// Gets or sets an optional filter for messages when retrieving from history. + /// + public Func, IEnumerable>? ProvideOutputMessageFilter { get; set; } + + /// + /// Gets or sets an optional filter for request messages before storing. + /// + public Func, IEnumerable>? StoreInputRequestMessageFilter { get; set; } + + /// + /// Gets or sets an optional filter for response messages before storing. + /// + public Func, IEnumerable>? StoreInputResponseMessageFilter { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs index c133b38bbdd..8b0a410d8bd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs @@ -2,10 +2,10 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; @@ -39,7 +39,7 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true }; private readonly Func>? _httpClientProvider; - private readonly Dictionary _clients = []; + private readonly Dictionary<(string Url, string Label, string Connection, string HeadersHash), McpClient> _clients = []; private readonly Dictionary _ownedHttpClients = []; private readonly SemaphoreSlim _clientLock = new(1, 1); @@ -66,16 +66,15 @@ public async Task InvokeToolAsync( string? connectionName, CancellationToken cancellationToken = default) { - // TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore if (IsListToolsToolName(toolName)) { ThrowIfListToolsArgumentsSpecified(arguments); - McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false); + McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false); IList tools = await listToolsClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool)); } - McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false); + McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false); McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString()); @@ -145,10 +144,11 @@ private async Task GetOrCreateClientAsync( string serverUrl, string? serverLabel, IDictionary? headers, + string? connectionName, CancellationToken cancellationToken) { - string normalizedUrl = serverUrl.Trim().ToUpperInvariant(); - string clientCacheKey = $"{normalizedUrl}|{ComputeHeadersHash(headers)}"; + string trimmedUrl = serverUrl.Trim(); + var clientCacheKey = BuildCacheKey(trimmedUrl, serverLabel, connectionName, headers); await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -158,7 +158,7 @@ private async Task GetOrCreateClientAsync( return existingClient; } - McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, normalizedUrl, cancellationToken).ConfigureAwait(false); + McpClient newClient = await this.CreateClientAsync(trimmedUrl, serverLabel, headers, trimmedUrl, cancellationToken).ConfigureAwait(false); this._clients[clientCacheKey] = newClient; return newClient; } @@ -168,6 +168,19 @@ private async Task GetOrCreateClientAsync( } } + /// + /// Builds the per-client cache key as a 4-tuple of + /// (trimmed serverUrl, serverLabel, connectionName, headers hash). All four components + /// participate so that callers using different labels/connections/headers receive + /// distinct instances even when targeting the same URL. + /// + internal static (string Url, string Label, string Connection, string HeadersHash) BuildCacheKey( + string trimmedUrl, + string? serverLabel, + string? connectionName, + IDictionary? headers) => + (trimmedUrl, serverLabel ?? string.Empty, connectionName ?? string.Empty, ComputeHeadersHash(headers)); + private async Task CreateClientAsync( string serverUrl, string? serverLabel, @@ -185,7 +198,12 @@ private async Task CreateClientAsync( if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient)) { - httpClient = new HttpClient(); + // Disable cookies so handler-level state (cookie jar) cannot cross the cache-key + // isolation boundary established by GetOrCreateClientAsync. The actual MCP auth + // travels via AdditionalHeaders (set per-transport below), not session cookies. + // CheckCertificateRevocationList satisfies CA5399 since we're explicitly constructing the handler. + HttpClientHandler handler = new() { UseCookies = false, CheckCertificateRevocationList = true }; + httpClient = new HttpClient(handler); this._ownedHttpClients[httpClientCacheKey] = httpClient; } @@ -202,26 +220,50 @@ private async Task CreateClientAsync( return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false); } - private static string ComputeHeadersHash(IDictionary? headers) + /// + /// Computes a deterministic, order-independent hash of the header set. + /// Header names are lower-cased for case-insensitive matching (RFC 7230 §3.2). + /// Header values remain case-sensitive (RFC 7235 — credentials are case-sensitive). + /// +#pragma warning disable CA1308 // RFC 7230 §3.2 requires lower-cased header names for case-insensitive comparison; CA1308's uppercase preference does not apply here + internal 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; + // Sort by lower-cased key for deterministic ordering, preserving value case. + SortedDictionary sorted = new(StringComparer.Ordinal); + foreach (KeyValuePair header in headers) + { + sorted[header.Key.ToLowerInvariant()] = header.Value; + } + + StringBuilder payload = new(); foreach (KeyValuePair kvp in sorted) { - hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key); - hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value); + payload.Append(kvp.Key).Append(':').Append(kvp.Value).Append('\n'); + } + + byte[] inputBytes = Encoding.UTF8.GetBytes(payload.ToString()); +#if NET5_0_OR_GREATER + byte[] hashBytes = SHA256.HashData(inputBytes); +#else + using SHA256 sha256 = SHA256.Create(); + byte[] hashBytes = sha256.ComputeHash(inputBytes); +#endif + + // Convert to hex string (compatible with net472/netstandard2.0) + StringBuilder hex = new(hashBytes.Length * 2); + foreach (byte b in hashBytes) + { + hex.Append(b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture)); } - return hashCode.ToString(CultureInfo.InvariantCulture); + return hex.ToString(); } +#pragma warning restore CA1308 private static void ThrowIfListToolsArgumentsSpecified(IDictionary? arguments) { 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 6ca429c648a..08d57a6b6e6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -13,6 +13,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; @@ -27,6 +28,13 @@ internal sealed class InvokeFunctionToolExecutor( WorkflowFormulaState state) : DeclarativeActionExecutor(model, state) { + private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshot); + + /// + /// Snapshot of evaluated parameters at approval-request time. + /// + private ApprovalSnapshot? _approvalSnapshot; + /// /// Step identifiers for the function tool invocation workflow. /// @@ -69,6 +77,10 @@ public static class Steps // If approval is required, add user input request content if (requireApproval) { + // Snapshot the evaluated parameters. + // If state mutates during the approval window, the approved values are used on resume. + this._approvalSnapshot = new ApprovalSnapshot(functionName, arguments); + requestMessage.Contents.Add(new ToolApprovalRequestContent(this.Id, functionCall)); } @@ -155,6 +167,31 @@ public async ValueTask CaptureResponseAsync( // Completes the action after processing the function result. await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); + + // Clear the approval snapshot after the action completes so a subsequent + // execution of the same executor instance doesn't reuse stale data. + this._approvalSnapshot = null; + await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false); + } + + /// + /// + /// Persists the approval snapshot to workflow state so it survives checkpoint/restore cycles. + /// + protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, this._approvalSnapshot, null, cancellationToken).ConfigureAwait(false); + await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); + } + + /// + /// + /// Restores the approval snapshot from workflow state after a checkpoint restore. + /// + protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + this._approvalSnapshot = await context.ReadStateAsync(ApprovalSnapshotStateKey, null, cancellationToken).ConfigureAwait(false); } /// @@ -262,7 +299,24 @@ private string GetFunctionName() => private async ValueTask InvokeRegisteredFunctionAsync(CancellationToken cancellationToken) { - string functionName = this.GetFunctionName(); + string functionName; + Dictionary? arguments; + + if (this._approvalSnapshot is { } snapshot) + { + // Use the snapshot captured at approval-request time so we invoke exactly what + // the user approved, even if Power Fx state has mutated during the approval window. + functionName = snapshot.FunctionName; + arguments = snapshot.Arguments; + } + else + { + // Fallback for checkpoints created before approval snapshots were introduced. + this.Logger.LogWarning("Approval snapshot missing for '{ActionId}'; falling back to expression re-evaluation.", this.Id); + functionName = this.GetFunctionName(); + arguments = this.GetArguments(); + } + AIFunction? function = agentProvider.Functions?.FirstOrDefault( f => string.Equals(f.Name, functionName, StringComparison.Ordinal)); @@ -275,8 +329,7 @@ private string GetFunctionName() => }; } - Dictionary? arguments = this.GetArguments(); - AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments); + AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments.NormalizePortableValues()); object? result; try @@ -341,4 +394,13 @@ private bool GetAutoSendValue() return result; } + + /// + /// Stores the evaluated parameters at approval-request time so that + /// uses the values the user reviewed, + /// even if mutates during the approval window. + /// + internal sealed record ApprovalSnapshot( + string FunctionName, + Dictionary? Arguments); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/MagenticConstants.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/MagenticConstants.cs index 2ff41cc43d7..b71a50390c5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/MagenticConstants.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/MagenticConstants.cs @@ -5,4 +5,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic; internal static class MagenticConstants { public const string MagenticTaskContextKey = nameof(MagenticTaskContextKey); + public const string CurrentSpeakerStateKey = nameof(CurrentSpeakerStateKey); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/MagenticOrchestrator.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/MagenticOrchestrator.cs index 31e80d87250..5b685dc8356 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/MagenticOrchestrator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/MagenticOrchestrator.cs @@ -90,6 +90,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List team, Ta private MagenticTaskContext? _taskContext; private PortBinding? _planReviewPort; + private string? _currentSpeakerExecutorId; protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) { @@ -196,15 +197,46 @@ protected override async ValueTask TakeTurnAsync(List messages, IWo else { // Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan). - // Capture the participant's reply into the manager-visible chat history so the progress ledger can see it. if (messages is { Count: > 0 }) { + // Capture the participant's reply into the manager-visible chat history so the progress ledger can see it. this._taskContext.ChatHistory.AddRange(messages); + + // Share the reply with the other participants except the replier + await this.BroadcastReplyToOtherParticipantsAsync(messages, context, cancellationToken).ConfigureAwait(false); } await this.RunCoordinationRoundAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false); } } + /// + /// Forwards a participant's reply to every other participant so they share the running conversation. + /// The messages are buffered (no is sent) - they only become context for the participant's next turn. + /// + private ValueTask BroadcastReplyToOtherParticipantsAsync( + List messages, IWorkflowContext context, CancellationToken cancellationToken) + { + // Without a known current speaker we cannot exclude the reply's author, so skip the broadcast + // rather than risk echoing the reply back to its own author. This covers the window after a + // checkpoint restore but before any delegation has set the current speaker. + if (string.IsNullOrEmpty(this._currentSpeakerExecutorId)) + { + return default; + } + + List? sendTasks = null; + foreach (AIAgent agent in team) + { + string executorId = AIAgentHostExecutor.IdFor(agent); + if (string.Equals(executorId, this._currentSpeakerExecutorId, StringComparison.Ordinal)) + { + continue; + } + (sendTasks ??= []).Add(context.SendMessageAsync(messages, executorId, cancellationToken).AsTask()); + } + return sendTasks is null ? default : new ValueTask(Task.WhenAll(sendTasks)); + } + private ChatMessage? _fullTaskLedgerMessage; private ValueTask DelegateToTeamAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken) { @@ -287,15 +319,18 @@ await context.AddEventAsync(new WorkflowWarningEvent($"Invalid next speaker: {ne return; } + string nextExecutorId = AIAgentHostExecutor.IdFor(nextAgent); + if (!string.IsNullOrWhiteSpace(taskContext.ProgressLedger.InstructionOrQuestion)) { ChatMessage instruction = new(ChatRole.Assistant, taskContext.ProgressLedger.InstructionOrQuestion); taskContext.ChatHistory.Add(instruction); - await context.SendMessageAsync(instruction, cancellationToken).ConfigureAwait(false); + // Target the instruction at the chosen speaker only. + await context.SendMessageAsync(instruction, nextExecutorId, cancellationToken).ConfigureAwait(false); } - string nextExecutorId = AIAgentHostExecutor.IdFor(nextAgent); + this._currentSpeakerExecutorId = nextExecutorId; await context.SendMessageAsync(new TurnToken(taskContext.EmitUpdateEvents), nextExecutorId, cancellationToken).ConfigureAwait(false); } @@ -303,6 +338,7 @@ private async ValueTask ResetAndReplanAsync(MagenticTaskContext taskContext, IWo { bool wasStalled = taskContext.IsStalled; taskContext.Reset(); + this._currentSpeakerExecutorId = null; await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false); await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken, replanAfterStall: wasStalled).ConfigureAwait(false); @@ -313,9 +349,9 @@ private async ValueTask PrepareFinalAnswerAsync(MagenticTaskContext taskContext, List messages = [await this._manager.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false)]; await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false); taskContext.IsTerminated = true; + this._currentSpeakerExecutorId = null; } - private const string CurrentTurnEmitUpdateEventsKey = nameof(CurrentTurnEmitUpdateEventsKey); protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { Task contextStateTask = this._taskContext == null @@ -325,14 +361,21 @@ protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContex cancellationToken: cancellationToken) .AsTask(); + Task currentSpeakerTask = context.QueueStateUpdateAsync(MagenticConstants.CurrentSpeakerStateKey, + this._currentSpeakerExecutorId, + cancellationToken: cancellationToken) + .AsTask(); + await Task.WhenAll(base.OnCheckpointingAsync(context, cancellationToken).AsTask(), - contextStateTask).ConfigureAwait(false); + contextStateTask, + currentSpeakerTask).ConfigureAwait(false); } protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { - await Task.WhenAll(base.OnCheckpointRestoredAsync(context, cancellationToken).AsTask(), LoadContextStateAsync()) - .ConfigureAwait(false); + await Task.WhenAll(base.OnCheckpointRestoredAsync(context, cancellationToken).AsTask(), + LoadContextStateAsync(), + LoadCurrentSpeakerAsync()).ConfigureAwait(false); async Task LoadContextStateAsync() { @@ -344,5 +387,11 @@ async Task LoadContextStateAsync() this._taskContext = new MagenticTaskContext(state, team, limits, []); } } + + async Task LoadCurrentSpeakerAsync() + { + this._currentSpeakerExecutorId = await context.ReadStateAsync(MagenticConstants.CurrentSpeakerStateKey, cancellationToken: cancellationToken) + .ConfigureAwait(false); + } } } diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs index babfdfcddf7..29d57d369a1 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs @@ -54,7 +54,6 @@ var app = builder.Build(); app.MapFoundryResponses(); -app.MapGet("/readiness", () => Results.Ok()); app.Run(); static AIAgent CreateHappyPathAgent(AIProjectClient client, string deployment) => diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryProjectEndpointEnvFixture.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryProjectEndpointEnvFixture.cs new file mode 100644 index 00000000000..667bc46b593 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryProjectEndpointEnvFixture.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +/// +/// xUnit collection that serializes tests mutating the FOUNDRY_PROJECT_ENDPOINT +/// process environment variable. Without this, parallel test execution causes flaky +/// races between tests that set / unset the variable. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class FoundryProjectEndpointEnvFixture +{ + public const string Name = "FoundryProjectEndpointEnv"; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxBearerTokenHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxBearerTokenHandlerTests.cs index e619445481a..eafa3c497a2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxBearerTokenHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxBearerTokenHandlerTests.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; using System.Net; using System.Net.Http; using System.Threading; @@ -51,28 +54,144 @@ public async Task SendAsync_InjectsBearerTokenAsync() } [Fact] - public async Task SendAsync_InjectsFoundryFeaturesHeaderAsync() + public async Task SendAsync_UsesAiAzureComScopeAsync() { - var (handler, _) = CreateHandlerPair(featuresHeader: "feature1,feature2"); + // Arrange + var capturedContexts = new List(); + var credential = new Mock(); + credential + .Setup(c => c.GetTokenAsync(It.IsAny(), It.IsAny())) + .Callback((ctx, _) => capturedContexts.Add(ctx)) + .ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.MaxValue)); + var (handler, _) = CreateHandlerPair(credential); using var invoker = new HttpMessageInvoker(handler); + // Act using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); - using var response = await invoker.SendAsync(request, CancellationToken.None); + await invoker.SendAsync(request, CancellationToken.None); - Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values)); - Assert.Contains("feature1,feature2", values); + // Assert: spec §4 mandates the https://ai.azure.com audience. + Assert.Single(capturedContexts); + Assert.Contains("https://ai.azure.com/.default", capturedContexts[0].Scopes); } [Fact] - public async Task SendAsync_OmitsFeaturesHeaderWhenNullAsync() + public async Task SendAsync_AlwaysInjectsMandatoryFoundryFeaturesHeaderAsync() { + // Arrange var (handler, _) = CreateHandlerPair(featuresHeader: null); using var invoker = new HttpMessageInvoker(handler); + // Act using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); using var response = await invoker.SendAsync(request, CancellationToken.None); - Assert.False(request.Headers.Contains("Foundry-Features")); + // Assert: spec §2 requires Foundry-Features: Toolboxes=V1Preview on every request. + Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values)); + Assert.Equal("Toolboxes=V1Preview", values.Single()); + } + + [Fact] + public async Task SendAsync_MergesMandatoryAndOverrideFeaturesAsync() + { + var (handler, _) = CreateHandlerPair(featuresHeader: "feature1,feature2"); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + await invoker.SendAsync(request, CancellationToken.None); + + Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values)); + var header = values.Single(); + Assert.Contains("Toolboxes=V1Preview", header, StringComparison.Ordinal); + Assert.Contains("feature1", header, StringComparison.Ordinal); + Assert.Contains("feature2", header, StringComparison.Ordinal); + } + + [Fact] + public async Task SendAsync_DoesNotDuplicateMandatoryFlagAsync() + { + // Override already contains the mandatory flag — must not be duplicated in the merged value. + var (handler, _) = CreateHandlerPair(featuresHeader: "Toolboxes=V1Preview"); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + await invoker.SendAsync(request, CancellationToken.None); + + Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values)); + var header = values.Single(); + var count = 0; + var idx = 0; + while ((idx = header.IndexOf("Toolboxes=V1Preview", idx, StringComparison.OrdinalIgnoreCase)) >= 0) + { + count++; + idx += "Toolboxes=V1Preview".Length; + } + Assert.Equal(1, count); + } + + [Fact] + public async Task SendAsync_PropagatesTraceContextFromActivityAsync() + { + // Arrange: activate an Activity so Activity.Current is populated. + using var listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(listener); + using var source = new ActivitySource("test-source"); + using var activity = source.StartActivity("test-op")!; + Assert.NotNull(activity); + activity.TraceStateString = "vendor=value"; + activity.AddBaggage("user", "alice"); + + var (handler, _) = CreateHandlerPair(); + using var invoker = new HttpMessageInvoker(handler); + + // Act + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + await invoker.SendAsync(request, CancellationToken.None); + + // Assert: spec §6.3 requires traceparent/tracestate/baggage propagation. + Assert.True(request.Headers.TryGetValues("traceparent", out var tpValues)); + Assert.Contains(activity.TraceId.ToString(), tpValues.Single(), StringComparison.Ordinal); + + Assert.True(request.Headers.TryGetValues("tracestate", out var tsValues)); + Assert.Equal("vendor=value", tsValues.Single()); + + Assert.True(request.Headers.TryGetValues("baggage", out var bgValues)); + Assert.Contains("user=alice", bgValues.Single(), StringComparison.Ordinal); + } + + [Fact] + public async Task SendAsync_DoesNotOverrideExistingTraceparentAsync() + { + // Caller pre-set traceparent on the message; must not be duplicated or replaced. + using var listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(listener); + using var source = new ActivitySource("test-source"); + using var activity = source.StartActivity("test-op")!; + Assert.NotNull(activity); + + var (handler, _) = CreateHandlerPair(); + using var invoker = new HttpMessageInvoker(handler); + + const string PresetTraceparent = "00-00000000000000000000000000000001-0000000000000001-01"; + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + request.Headers.TryAddWithoutValidation("traceparent", PresetTraceparent); + + // Act + await invoker.SendAsync(request, CancellationToken.None); + + // Assert + Assert.True(request.Headers.TryGetValues("traceparent", out var values)); + var list = values.ToList(); + Assert.Single(list); + Assert.Equal(PresetTraceparent, list[0]); } [Theory] diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxHealthCheckTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxHealthCheckTests.cs new file mode 100644 index 00000000000..7ea5d14e1c7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxHealthCheckTests.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +[Collection(FoundryProjectEndpointEnvFixture.Name)] +public class FoundryToolboxHealthCheckTests +{ + [Fact] + public async Task CheckHealthAsync_PendingStatus_ReturnsConfiguredFailureAsync() + { + // Arrange: a fresh FoundryToolboxService whose StartAsync has never run reports + // Pending. The health check must surface that as the registration's failure + // status so the platform waits before sending traffic. + var service = CreateServiceWithoutStarting(); + var check = new FoundryToolboxHealthCheck(service); + var context = NewContext(failureStatus: HealthStatus.Unhealthy); + + // Act + var result = await check.CheckHealthAsync(context); + + // Assert + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("startup has not completed", result.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CheckHealthAsync_NoEndpointStatus_ReturnsHealthyAsync() + { + // Arrange: no FOUNDRY_PROJECT_ENDPOINT / AZURE_AI_PROJECT_ENDPOINT is normal local-dev. + // The container must still pass readiness because the rest of the agent is functional. + var savedFoundry = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT"); + var savedAzure = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT"); + Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", null); + Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", null); + try + { + var service = CreateServiceWithoutStarting(toolbox: "any"); + await service.StartAsync(CancellationToken.None); + + var check = new FoundryToolboxHealthCheck(service); + var context = NewContext(failureStatus: HealthStatus.Unhealthy); + + // Act + var result = await check.CheckHealthAsync(context); + + // Assert + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal(FoundryToolboxStartupStatus.NoEndpoint, service.StartupStatus); + } + finally + { + Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", savedFoundry); + Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", savedAzure); + } + } + + [Fact] + public async Task CheckHealthAsync_UnhealthyStatus_ReturnsConfiguredFailureWithFailedNamesAsync() + { + // Arrange: pre-registered toolbox at an unreachable endpoint forces StartAsync to + // record the failure. The health-check must reflect Unhealthy and expose the + // failed toolbox names in the result data so operators can diagnose without log + // diving. + var options = new FoundryToolboxOptions + { + EndpointOverride = "http://127.0.0.1:1/unreachable", + }; + options.ToolboxNames.Add("broken-toolbox"); + var service = new FoundryToolboxService(Options.Create(options), Mock.Of()); + await service.StartAsync(CancellationToken.None); + + var check = new FoundryToolboxHealthCheck(service); + var context = NewContext(failureStatus: HealthStatus.Unhealthy); + + // Act + var result = await check.CheckHealthAsync(context); + + // Assert + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.True(result.Data.ContainsKey("failedToolboxes")); + var failed = Assert.IsAssignableFrom>(result.Data["failedToolboxes"]); + Assert.Equal("broken-toolbox", Assert.Single(failed)); + } + + [Fact] + public async Task CheckHealthAsync_HealthyStatus_ReturnsHealthyAsync() + { + // Arrange: an endpoint set but no pre-registered toolboxes is the legitimate + // lazy-only setup. StartAsync reports Healthy and the check must agree. + var options = new FoundryToolboxOptions + { + EndpointOverride = "http://127.0.0.1:1/unused", + }; + var service = new FoundryToolboxService(Options.Create(options), Mock.Of()); + await service.StartAsync(CancellationToken.None); + + var check = new FoundryToolboxHealthCheck(service); + var context = NewContext(failureStatus: HealthStatus.Unhealthy); + + // Act + var result = await check.CheckHealthAsync(context); + + // Assert + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + private static FoundryToolboxService CreateServiceWithoutStarting(string? toolbox = null) + { + var options = new FoundryToolboxOptions(); + if (toolbox is not null) + { + options.ToolboxNames.Add(toolbox); + } + return new FoundryToolboxService(Options.Create(options), Mock.Of()); + } + + private static HealthCheckContext NewContext(HealthStatus failureStatus) => + new() + { + Registration = new HealthCheckRegistration( + name: "foundry-toolbox", + instance: Mock.Of(), + failureStatus: failureStatus, + tags: null), + }; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs index cdcdf5ee8e3..d8a56adcd6f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs @@ -9,6 +9,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; +[Collection(FoundryProjectEndpointEnvFixture.Name)] public class FoundryToolboxServiceTests { [Fact] @@ -39,15 +40,17 @@ public async Task GetToolboxToolsAsync_NonStrictMode_RequiresEndpointAsync() var ex = await Assert.ThrowsAsync( async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None)); - Assert.Contains("FOUNDRY_AGENT_TOOLSET_ENDPOINT", ex.Message, StringComparison.Ordinal); + Assert.Contains("FOUNDRY_PROJECT_ENDPOINT", ex.Message, StringComparison.Ordinal); } [Fact] public async Task StartAsync_WithoutEndpoint_LeavesToolsEmptyAsync() { - // Ensure env var is not set (tests may run in any CI environment) - var saved = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT"); - Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", null); + // Ensure neither env var is set (tests may run in any CI environment) + var savedFoundry = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT"); + var savedAzure = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT"); + Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", null); + Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", null); try { var options = new FoundryToolboxOptions(); @@ -59,10 +62,157 @@ public async Task StartAsync_WithoutEndpoint_LeavesToolsEmptyAsync() await service.StartAsync(CancellationToken.None); Assert.Empty(service.Tools); + Assert.Equal(FoundryToolboxStartupStatus.NoEndpoint, service.StartupStatus); + Assert.Empty(service.FailedToolboxNames); } finally { - Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", saved); + Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", savedFoundry); + Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", savedAzure); } } + + [Fact] + public async Task StartAsync_AttemptsOpenForPreRegisteredToolboxFromProjectEndpointAsync() + { + // Arrange: point the service at an unreachable host and confirm StartAsync + // attempts to open the pre-registered toolbox (verified via FailedToolboxNames + // recording the attempted name and StartupStatus reflecting the failure). + var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT"); + Environment.SetEnvironmentVariable( + "FOUNDRY_PROJECT_ENDPOINT", + "https://example.invalid/api/projects/proj"); + try + { + var options = new FoundryToolboxOptions { ApiVersion = "v1" }; + options.ToolboxNames.Add("my-toolbox"); + var service = new FoundryToolboxService( + Options.Create(options), + Mock.Of()); + + // Act: StartAsync attempts to connect to the invalid endpoint and fails. + // The failure path records FailedToolboxNames; the value confirms the resolver ran. + await service.StartAsync(CancellationToken.None); + + // Assert: open failed, status reflects that (resolver was reached), and + // the failed name matches — i.e. we attempted the right toolbox. + Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus); + Assert.Single(service.FailedToolboxNames); + Assert.Equal("my-toolbox", service.FailedToolboxNames[0]); + } + finally + { + Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved); + } + } + + [Fact] + public async Task StartAsync_TrailingSlashOnProjectEndpoint_AttemptsOpenAsync() + { + var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT"); + Environment.SetEnvironmentVariable( + "FOUNDRY_PROJECT_ENDPOINT", + "https://example.invalid/api/projects/proj/"); + try + { + var options = new FoundryToolboxOptions(); + options.ToolboxNames.Add("tb"); + var service = new FoundryToolboxService( + Options.Create(options), + Mock.Of()); + + await service.StartAsync(CancellationToken.None); + + // Arrange/Act: when trailing-slash normalization works the open still fails + // (host is unreachable), but FailedToolboxNames records the attempted name — + // proof that the resolver did not throw on the slash and the URL was built. + Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus); + Assert.Single(service.FailedToolboxNames); + Assert.Equal("tb", service.FailedToolboxNames[0]); + } + finally + { + Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved); + } + } + + [Fact] + public async Task StartAsync_EndpointOverrideWinsOverEnvAsync() + { + var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT"); + Environment.SetEnvironmentVariable( + "FOUNDRY_PROJECT_ENDPOINT", + "https://from-env.invalid/api/projects/proj"); + try + { + // EndpointOverride should take precedence over the env var. + var options = new FoundryToolboxOptions + { + EndpointOverride = "http://127.0.0.1:1/from-override", + }; + options.ToolboxNames.Add("tb"); + + var service = new FoundryToolboxService( + Options.Create(options), + Mock.Of()); + + await service.StartAsync(CancellationToken.None); + + // Override URL is unreachable; we expect Unhealthy (proving Start did try to open + // a toolbox, i.e. did not fall into the NoEndpoint branch). + Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus); + Assert.Single(service.FailedToolboxNames); + } + finally + { + Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved); + } + } + + [Fact] + public async Task StartAsync_WithEndpointButFailingToolbox_RecordsFailureAndStaysReachableAsync() + { + // Arrange: a syntactically valid but unreachable endpoint forces OpenToolboxAsync + // to throw inside the catch-and-log path. The service must still complete StartAsync + // (so the host doesn't crash) and surface the failure via StartupStatus. + var options = new FoundryToolboxOptions + { + EndpointOverride = "http://127.0.0.1:1/unreachable", + }; + options.ToolboxNames.Add("broken-toolbox"); + + var service = new FoundryToolboxService( + Options.Create(options), + Mock.Of()); + + // Act + await service.StartAsync(CancellationToken.None); + + // Assert + Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus); + Assert.Single(service.FailedToolboxNames); + Assert.Equal("broken-toolbox", service.FailedToolboxNames[0]); + Assert.Empty(service.Tools); + } + + [Fact] + public async Task StartAsync_WithEndpointAndNoToolboxes_ReportsHealthyAsync() + { + // No pre-registered toolboxes is a legitimate "lazy-only" setup. Health-check + // should report Healthy so the readiness probe passes. + var options = new FoundryToolboxOptions + { + EndpointOverride = "http://127.0.0.1:1/unused", + }; + + var service = new FoundryToolboxService( + Options.Create(options), + Mock.Of()); + + await service.StartAsync(CancellationToken.None); + + Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus); + Assert.Empty(service.FailedToolboxNames); + Assert.Empty(service.Tools); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs index bcab777af07..f49c669f66d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs @@ -2,9 +2,15 @@ using System; using System.Linq; +using System.Net; +using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Moq; using OpenAI.Responses; @@ -135,4 +141,73 @@ public void MeaiOpenAIResponsesChatClient_TypeFullName_ReflectionGuard() Assert.True(typeof(IChatClient).IsAssignableFrom(meaiType!), $"Expected MEAI {meaiType!.FullName} to implement IChatClient."); } + + // ── /readiness auto-mapping (Foundry container-image-spec §2) ──────────────── + + [Fact] + public async Task MapFoundryResponses_MapsReadinessEndpoint_WhenTier3HostHasNotMappedItAsync() + { + // Arrange: Tier 3 host (WebApplication.CreateBuilder, no AgentHost) — Core SDK does + // NOT map /readiness in this case, so MapFoundryResponses must cover the gap. + using var host = await BuildTestHostAsync(static app => app.MapFoundryResponses()); + + // Act + var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task MapFoundryResponses_DoesNotDuplicateReadiness_WhenAlreadyMappedAsync() + { + // Arrange: developer already mapped /readiness with a custom body. The auto-map + // must detect the existing route and leave it untouched (no AmbiguousMatchException + // at runtime, no override of the developer's response). + const string CustomBody = "ready-from-developer"; + using var host = await BuildTestHostAsync(static app => + { + app.MapGet("/readiness", () => Results.Text("ready-from-developer")); + app.MapFoundryResponses(); + }); + + // Act + var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(CustomBody, body); + } + + [Fact] + public async Task MapFoundryResponses_CalledTwice_StillOnlyMapsReadinessOnceAsync() + { + // Arrange: defensive coverage for callers that map the responses pipeline twice + // (e.g. once at the root and once under "openai/v1" in the existing AF samples). + using var host = await BuildTestHostAsync(static app => + { + app.MapFoundryResponses(); + app.MapFoundryResponses("openai/v1"); + }); + + // Act + Assert: a single GET /readiness must succeed without ambiguous-match throw. + var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private static async Task BuildTestHostAsync(Action configure) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var mockAgent = new Mock(); + mockAgent.SetupGet(a => a.Name).Returns("test-agent"); + builder.Services.AddFoundryResponses(mockAgent.Object); + + var app = builder.Build(); + configure(app); + await app.StartAsync(); + return app; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs index 2404a254e3f..cfdc8198725 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/GitHubCopilotAgentTests.cs @@ -10,57 +10,86 @@ namespace Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests; +[Trait("Category", "Integration")] public class GitHubCopilotAgentTests { - private const string SkipReason = "Integration tests require GitHub Copilot CLI installed. For local execution only."; + private static void SkipIfCopilotNotConfigured() + { + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("COPILOT_GITHUB_TOKEN"))) + { + Assert.Skip("COPILOT_GITHUB_TOKEN not set; skipping GitHub Copilot integration tests."); + } + } private static Task OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation) => Task.FromResult(PermissionDecision.ApproveOnce()); - [Fact(Skip = SkipReason)] + [Fact] public async Task RunAsync_WithSimplePrompt_ReturnsResponseAsync() { // Arrange + SkipIfCopilotNotConfigured(); + await using CopilotClient client = new(new CopilotClientOptions()); await client.StartAsync(); await using GitHubCopilotAgent agent = new(client, sessionConfig: null); + AgentSession session = await agent.CreateSessionAsync(); - // Act - AgentResponse response = await agent.RunAsync("What is 2 + 2? Answer with just the number."); + try + { + // Act + AgentResponse response = await agent.RunAsync("What is 2 + 2? Answer with just the number.", session); - // Assert - Assert.NotNull(response); - Assert.NotEmpty(response.Messages); - Assert.Contains("4", response.Text); + // Assert + Assert.NotNull(response); + Assert.NotEmpty(response.Messages); + Assert.Contains("4", response.Text); + } + finally + { + await DeleteSessionAsync(client, session); + } } - [Fact(Skip = SkipReason)] + [Fact] public async Task RunStreamingAsync_WithSimplePrompt_ReturnsUpdatesAsync() { // Arrange + SkipIfCopilotNotConfigured(); + await using CopilotClient client = new(new CopilotClientOptions()); await client.StartAsync(); await using GitHubCopilotAgent agent = new(client, sessionConfig: null); + AgentSession session = await agent.CreateSessionAsync(); - // Act - List updates = []; - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("What is 2 + 2? Answer with just the number.")) + try { - updates.Add(update); - } + // Act + List updates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("What is 2 + 2? Answer with just the number.", session)) + { + updates.Add(update); + } - // Assert - Assert.NotEmpty(updates); - string fullText = string.Join("", updates.Select(u => u.Text)); - Assert.Contains("4", fullText); + // Assert + Assert.NotEmpty(updates); + string fullText = string.Join("", updates.Select(u => u.Text)); + Assert.Contains("4", fullText); + } + finally + { + await DeleteSessionAsync(client, session); + } } - [Fact(Skip = SkipReason)] + [Fact] public async Task RunAsync_WithFunctionTool_InvokesToolAsync() { // Arrange + SkipIfCopilotNotConfigured(); + bool toolInvoked = false; AIFunction weatherTool = AIFunctionFactory.Create((string location) => @@ -72,24 +101,42 @@ public async Task RunAsync_WithFunctionTool_InvokesToolAsync() await using CopilotClient client = new(new CopilotClientOptions()); await client.StartAsync(); - await using GitHubCopilotAgent agent = new( - client, - tools: [weatherTool], - instructions: "You are a helpful weather agent. Use the GetWeather tool to answer weather questions."); + SessionConfig sessionConfig = new() + { + Tools = [weatherTool], + OnPermissionRequest = OnPermissionRequestAsync, + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Append, + Content = "You are a weather assistant. Always use the GetWeather tool to answer weather questions.", + }, + }; - // Act - AgentResponse response = await agent.RunAsync("What's the weather like in Seattle?"); + await using GitHubCopilotAgent agent = new(client, sessionConfig); + AgentSession session = await agent.CreateSessionAsync(); - // Assert - Assert.NotNull(response); - Assert.NotEmpty(response.Messages); - Assert.True(toolInvoked); + try + { + // Act + AgentResponse response = await agent.RunAsync("What's the weather like in Seattle?", session); + + // Assert + Assert.NotNull(response); + Assert.NotEmpty(response.Messages); + Assert.True(toolInvoked); + } + finally + { + await DeleteSessionAsync(client, session); + } } - [Fact(Skip = SkipReason)] + [Fact] public async Task RunAsync_WithSession_MaintainsContextAsync() { // Arrange + SkipIfCopilotNotConfigured(); + await using CopilotClient client = new(new CopilotClientOptions()); await client.StartAsync(); @@ -99,23 +146,32 @@ public async Task RunAsync_WithSession_MaintainsContextAsync() AgentSession session = await agent.CreateSessionAsync(); - // Act - First turn - AgentResponse response1 = await agent.RunAsync("My name is Alice.", session); - Assert.NotNull(response1); + try + { + // Act - First turn + AgentResponse response1 = await agent.RunAsync("My name is Alice.", session); + Assert.NotNull(response1); - // Act - Second turn using same session - AgentResponse response2 = await agent.RunAsync("What is my name?", session); + // Act - Second turn using same session + AgentResponse response2 = await agent.RunAsync("What is my name?", session); - // Assert - Assert.NotNull(response2); - Assert.Contains("Alice", response2.Text, StringComparison.OrdinalIgnoreCase); + // Assert + Assert.NotNull(response2); + Assert.Contains("Alice", response2.Text, StringComparison.OrdinalIgnoreCase); + } + finally + { + await DeleteSessionAsync(client, session); + } } - [Fact(Skip = SkipReason)] + [Fact] public async Task RunAsync_WithSessionResume_ContinuesConversationAsync() { // Arrange - First agent instance starts a conversation - string? sessionId; + SkipIfCopilotNotConfigured(); + + string? sessionId = null; await using CopilotClient client1 = new(new CopilotClientOptions()); await client1.StartAsync(); @@ -125,31 +181,44 @@ public async Task RunAsync_WithSessionResume_ContinuesConversationAsync() instructions: "You are a helpful assistant. Keep your answers short."); AgentSession session1 = await agent1.CreateSessionAsync(); - await agent1.RunAsync("Remember this number: 42.", session1); - sessionId = ((GitHubCopilotAgentSession)session1).SessionId; - Assert.NotNull(sessionId); + try + { + await agent1.RunAsync("Remember this number: 42.", session1); - // Act - Second agent instance resumes the session - await using CopilotClient client2 = new(new CopilotClientOptions()); - await client2.StartAsync(); + sessionId = ((GitHubCopilotAgentSession)session1).SessionId; + Assert.NotNull(sessionId); - await using GitHubCopilotAgent agent2 = new( - client2, - instructions: "You are a helpful assistant. Keep your answers short."); + // Act - Second agent instance resumes the session + await using CopilotClient client2 = new(new CopilotClientOptions()); + await client2.StartAsync(); - AgentSession session2 = await agent2.CreateSessionAsync(sessionId); - AgentResponse response = await agent2.RunAsync("What number did I ask you to remember?", session2); + await using GitHubCopilotAgent agent2 = new( + client2, + instructions: "You are a helpful assistant. Keep your answers short."); - // Assert - Assert.NotNull(response); - Assert.Contains("42", response.Text); + AgentSession session2 = await agent2.CreateSessionAsync(sessionId); + AgentResponse response = await agent2.RunAsync("What number did I ask you to remember?", session2); + + // Assert + Assert.NotNull(response); + Assert.Contains("42", response.Text); + } + finally + { + if (sessionId is not null) + { + await client1.DeleteSessionAsync(sessionId); + } + } } - [Fact(Skip = SkipReason)] + [Fact] public async Task RunAsync_WithShellPermissions_ExecutesCommandAsync() { // Arrange + SkipIfCopilotNotConfigured(); + await using CopilotClient client = new(new CopilotClientOptions()); await client.StartAsync(); @@ -159,20 +228,30 @@ public async Task RunAsync_WithShellPermissions_ExecutesCommandAsync() }; await using GitHubCopilotAgent agent = new(client, sessionConfig); + AgentSession session = await agent.CreateSessionAsync(); - // Act - AgentResponse response = await agent.RunAsync("Run a shell command to print 'hello world'"); + try + { + // Act + AgentResponse response = await agent.RunAsync("Run a shell command to print 'hello world'", session); - // Assert - Assert.NotNull(response); - Assert.NotEmpty(response.Messages); - Assert.Contains("hello", response.Text, StringComparison.OrdinalIgnoreCase); + // Assert + Assert.NotNull(response); + Assert.NotEmpty(response.Messages); + Assert.Contains("hello", response.Text, StringComparison.OrdinalIgnoreCase); + } + finally + { + await DeleteSessionAsync(client, session); + } } - [Fact(Skip = SkipReason)] + [Fact] public async Task RunAsync_WithUrlPermissions_FetchesContentAsync() { // Arrange + SkipIfCopilotNotConfigured(); + await using CopilotClient client = new(new CopilotClientOptions()); await client.StartAsync(); @@ -182,20 +261,30 @@ public async Task RunAsync_WithUrlPermissions_FetchesContentAsync() }; await using GitHubCopilotAgent agent = new(client, sessionConfig); + AgentSession session = await agent.CreateSessionAsync(); - // Act - AgentResponse response = await agent.RunAsync( - "Fetch https://learn.microsoft.com/agent-framework/tutorials/quick-start and summarize its contents in one sentence"); + try + { + // Act + AgentResponse response = await agent.RunAsync( + "Fetch https://learn.microsoft.com/agent-framework/tutorials/quick-start and summarize its contents in one sentence", session); - // Assert - Assert.NotNull(response); - Assert.Contains("Agent Framework", response.Text, StringComparison.OrdinalIgnoreCase); + // Assert + Assert.NotNull(response); + Assert.Contains("Agent Framework", response.Text, StringComparison.OrdinalIgnoreCase); + } + finally + { + await DeleteSessionAsync(client, session); + } } - [Fact(Skip = SkipReason)] + [Fact] public async Task RunAsync_WithLocalMcpServer_UsesServerToolsAsync() { // Arrange + SkipIfCopilotNotConfigured(); + await using CopilotClient client = new(new CopilotClientOptions()); await client.StartAsync(); @@ -214,20 +303,31 @@ public async Task RunAsync_WithLocalMcpServer_UsesServerToolsAsync() }; await using GitHubCopilotAgent agent = new(client, sessionConfig); + AgentSession session = await agent.CreateSessionAsync(); - // Act - AgentResponse response = await agent.RunAsync("List the files in the current directory"); + try + { + // Act + AgentResponse response = await agent.RunAsync("List the files in the current directory", session); - // Assert - Assert.NotNull(response); - Assert.NotEmpty(response.Messages); - Assert.NotEmpty(response.Text); + // Assert + Assert.NotNull(response); + Assert.NotEmpty(response.Messages); + Assert.NotEmpty(response.Text); + } + finally + { + await DeleteSessionAsync(client, session); + } } - [Fact(Skip = SkipReason)] + [Fact] + [Trait("Category", "IntegrationDisabled")] public async Task RunAsync_WithRemoteMcpServer_UsesServerToolsAsync() { // Arrange + SkipIfCopilotNotConfigured(); + await using CopilotClient client = new(new CopilotClientOptions()); await client.StartAsync(); @@ -245,12 +345,28 @@ public async Task RunAsync_WithRemoteMcpServer_UsesServerToolsAsync() }; await using GitHubCopilotAgent agent = new(client, sessionConfig); + AgentSession session = await agent.CreateSessionAsync(); - // Act - AgentResponse response = await agent.RunAsync("Search Microsoft Learn for 'Azure Functions' and summarize the top result"); + try + { + // Act + AgentResponse response = await agent.RunAsync("Search Microsoft Learn for 'Azure Functions' and summarize the top result", session); - // Assert - Assert.NotNull(response); - Assert.Contains("Azure Functions", response.Text, StringComparison.OrdinalIgnoreCase); + // Assert + Assert.NotNull(response); + Assert.Contains("Azure Functions", response.Text, StringComparison.OrdinalIgnoreCase); + } + finally + { + await DeleteSessionAsync(client, session); + } + } + + private static async Task DeleteSessionAsync(CopilotClient client, AgentSession session) + { + if (session is GitHubCopilotAgentSession { SessionId: { } sessionId }) + { + await client.DeleteSessionAsync(sessionId); + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs index 38abc903d39..6b857101c72 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs @@ -115,6 +115,24 @@ public async Task ProcessContentAsync_WithScopeIdentifier_IncludesIfNoneMatchHea Assert.Equal("\"test-scope-123\"", this._handler.IfNoneMatchHeader); } + [Fact] + public async Task ProcessContentAsync_WithProcessInline_IncludesPreferHeaderAsync() + { + // Arrange + var request = CreateValidProcessContentRequest(); + request.ProcessInline = true; + var expectedResponse = new ProcessContentResponse { Id = "test-id" }; + + this._handler.StatusCodeToReturn = HttpStatusCode.OK; + this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse))); + + // Act + await this._client.ProcessContentAsync(request, CancellationToken.None); + + // Assert + Assert.Equal("evaluateInline", this._handler.PreferHeader); + } + [Fact] public async Task ProcessContentAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync() { @@ -530,6 +548,7 @@ internal sealed class PurviewClientHttpMessageHandlerStub : HttpMessageHandler public HttpMethod? RequestMethod { get; private set; } public string? AuthorizationHeader { get; private set; } public string? IfNoneMatchHeader { get; private set; } + public string? PreferHeader { get; private set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { @@ -547,6 +566,11 @@ protected override async Task SendAsync(HttpRequestMessage this.IfNoneMatchHeader = string.Join(", ", ifNoneMatchValues); } + if (request.Headers.TryGetValues("Prefer", out var preferValues)) + { + this.PreferHeader = string.Join(", ", preferValues); + } + // Throw HttpRequestException if configured if (this.ShouldThrowHttpRequestException) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs index 3527cc9884a..3cfc81face3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -3,12 +3,14 @@ using System; using System.Collections.Generic; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; using Microsoft.Agents.AI.Purview.Models.Common; using Microsoft.Agents.AI.Purview.Models.Jobs; using Microsoft.Agents.AI.Purview.Models.Requests; using Microsoft.Agents.AI.Purview.Models.Responses; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; using Moq; namespace Microsoft.Agents.AI.Purview.UnitTests; @@ -50,10 +52,6 @@ public async Task ProcessMessagesAsync_WithBlockAccessAction_ReturnsShouldBlockT this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) .ReturnsAsync(tokenInfo); - this._mockCacheProvider.Setup(x => x.GetAsync( - It.IsAny(), It.IsAny())) - .ReturnsAsync((ProtectionScopesResponse?)null); - var psResponse = new ProtectionScopesResponse { Scopes = @@ -70,8 +68,8 @@ public async Task ProcessMessagesAsync_WithBlockAccessAction_ReturnsShouldBlockT ] }; - this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( - It.IsAny(), It.IsAny())) + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) .ReturnsAsync(psResponse); var pcResponse = new ProcessContentResponse @@ -109,10 +107,6 @@ public async Task ProcessMessagesAsync_WithRestrictionActionBlock_ReturnsShouldB this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) .ReturnsAsync(tokenInfo); - this._mockCacheProvider.Setup(x => x.GetAsync( - It.IsAny(), It.IsAny())) - .ReturnsAsync((ProtectionScopesResponse?)null); - var psResponse = new ProtectionScopesResponse { Scopes = @@ -129,8 +123,8 @@ public async Task ProcessMessagesAsync_WithRestrictionActionBlock_ReturnsShouldB ] }; - this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( - It.IsAny(), It.IsAny())) + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) .ReturnsAsync(psResponse); var pcResponse = new ProcessContentResponse @@ -168,10 +162,6 @@ public async Task ProcessMessagesAsync_WithNoBlockingActions_ReturnsShouldBlockF this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) .ReturnsAsync(tokenInfo); - this._mockCacheProvider.Setup(x => x.GetAsync( - It.IsAny(), It.IsAny())) - .ReturnsAsync((ProtectionScopesResponse?)null); - var psResponse = new ProtectionScopesResponse { Scopes = @@ -188,8 +178,8 @@ public async Task ProcessMessagesAsync_WithNoBlockingActions_ReturnsShouldBlockF ] }; - this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( - It.IsAny(), It.IsAny())) + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) .ReturnsAsync(psResponse); var pcResponse = new ProcessContentResponse @@ -213,6 +203,99 @@ public async Task ProcessMessagesAsync_WithNoBlockingActions_ReturnsShouldBlockF Assert.Equal("user-123", result.userId); } + [Fact] + public async Task ProcessMessagesAsync_DeduplicatesCombinedPolicyActionsByActionAndRestrictionAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Test message") + ]; + PurviewSettings settings = CreateValidPurviewSettings(); + TokenInfo tokenInfo = new() { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + DlpActionInfo processContentAction = new() { Action = DlpAction.BlockAccess, RestrictionAction = RestrictionAction.Block }; + DlpActionInfo duplicateScopeAction = new() { Action = DlpAction.BlockAccess, RestrictionAction = RestrictionAction.Block }; + DlpActionInfo restrictionOnlyAction = new() { RestrictionAction = RestrictionAction.Block }; + ProcessContentResponse pcResponse = new() + { + PolicyActions = + [ + processContentAction + ] + }; + ProtectionScopesResponse psResponse = new() + { + Scopes = + [ + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = + [ + new("microsoft.graph.policyLocationApplication", "app-123") + ], + ExecutionMode = ExecutionMode.EvaluateInline, + PolicyActions = + [ + duplicateScopeAction, + restrictionOnlyAction + ] + } + ] + }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(psResponse); + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(pcResponse); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.NotNull(pcResponse.PolicyActions); + Assert.Equal(2, pcResponse.PolicyActions.Count); + Assert.Same(processContentAction, pcResponse.PolicyActions[0]); + Assert.Same(restrictionOnlyAction, pcResponse.PolicyActions[1]); + } + + [Fact] + public void CheckApplicableScopes_MatchesAnyLocationInScope() + { + // Arrange + ProcessContentRequest pcRequest = CreateProcessContentRequest(); + ProtectionScopesResponse psResponse = new() + { + Scopes = + [ + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = + [ + new("microsoft.graph.policyLocationApplication", "app-123"), + new("microsoft.graph.policyLocationApplication", "different-app") + ], + ExecutionMode = ExecutionMode.EvaluateInline + } + ] + }; + + // Act + (bool shouldProcess, _, ExecutionMode executionMode) = ScopedContentProcessor.CheckApplicableScopes(pcRequest, psResponse); + + // Assert + Assert.True(shouldProcess); + Assert.Equal(ExecutionMode.EvaluateInline, executionMode); + } + [Fact] public async Task ProcessMessagesAsync_UsesCachedProtectionScopes_WhenAvailableAsync() { @@ -279,12 +362,9 @@ public async Task ProcessMessagesAsync_InvalidatesCache_WhenProtectionScopeModif this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) .ReturnsAsync(tokenInfo); - this._mockCacheProvider.Setup(x => x.GetAsync( - It.IsAny(), It.IsAny())) - .ReturnsAsync((ProtectionScopesResponse?)null); - var psResponse = new ProtectionScopesResponse { + ScopeIdentifier = "etag-1", Scopes = [ new() @@ -299,8 +379,8 @@ public async Task ProcessMessagesAsync_InvalidatesCache_WhenProtectionScopeModif ] }; - this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( - It.IsAny(), It.IsAny())) + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) .ReturnsAsync(psResponse); var pcResponse = new ProcessContentResponse @@ -336,10 +416,6 @@ public async Task ProcessMessagesAsync_SendsContentActivities_WhenNoApplicableSc this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) .ReturnsAsync(tokenInfo); - this._mockCacheProvider.Setup(x => x.GetAsync( - It.IsAny(), It.IsAny())) - .ReturnsAsync((ProtectionScopesResponse?)null); - var psResponse = new ProtectionScopesResponse { Scopes = @@ -355,8 +431,8 @@ public async Task ProcessMessagesAsync_SendsContentActivities_WhenNoApplicableSc ] }; - this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( - It.IsAny(), It.IsAny())) + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) .ReturnsAsync(psResponse); // Act @@ -432,13 +508,9 @@ public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAdditionalProper this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) .ReturnsAsync(tokenInfo); + var psResponse = new ProtectionScopesResponse { Scopes = [] }; this._mockCacheProvider.Setup(x => x.GetAsync( It.IsAny(), It.IsAny())) - .ReturnsAsync((ProtectionScopesResponse?)null); - - var psResponse = new ProtectionScopesResponse { Scopes = [] }; - this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( - It.IsAny(), It.IsAny())) .ReturnsAsync(psResponse); // Act @@ -467,13 +539,9 @@ public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAuthorName_WhenV this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) .ReturnsAsync(tokenInfo); + var psResponse = new ProtectionScopesResponse { Scopes = [] }; this._mockCacheProvider.Setup(x => x.GetAsync( It.IsAny(), It.IsAny())) - .ReturnsAsync((ProtectionScopesResponse?)null); - - var psResponse = new ProtectionScopesResponse { Scopes = [] }; - this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( - It.IsAny(), It.IsAny())) .ReturnsAsync(psResponse); // Act @@ -484,10 +552,260 @@ public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAuthorName_WhenV Assert.Equal(userId, result.userId); } + [Fact] + public async Task ProcessMessagesAsync_CacheMiss_QueuesScopeRetrievalJobAndCallsProcessContentAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessContentResponse()); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert: ProcessContent runs in the foreground; GetProtectionScopes is queued as a background job. + this._mockPurviewClient.Verify(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny()), Times.Once); + this._mockPurviewClient.Verify(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny()), Times.Never); + this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessMessagesAsync_CacheMiss_WithProcessContentBlockAction_ReturnsShouldBlockTrueAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + var pcResponse = new ProcessContentResponse + { + PolicyActions = + [ + new() { Action = DlpAction.BlockAccess } + ] + }; + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(pcResponse); + + // Act + var result = await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + Assert.True(result.shouldBlock); + this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessMessagesAsync_CacheMiss_StillCallsProcessContentWhenScopeJobCannotQueueAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync((ProtectionScopesResponse?)null); + + this._mockChannelHandler.Setup(x => x.QueueJob(It.IsAny())) + .Throws(new PurviewJobException("queue unavailable")); + + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessContentResponse()); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert: scope warmup is attempted, and ProcessContent still runs when it can't be queued. + this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); + this._mockPurviewClient.Verify(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessMessagesAsync_WithCachedPaymentRequiredState_ThrowsPaymentRequiredAsync() + { + // Arrange + var messages = new List + { + new (ChatRole.User, "Test message") + }; + var settings = CreateValidPurviewSettings(); + var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new PaymentRequiredCacheEntry("Payment required")); + + // Act + Assert + await Assert.ThrowsAsync(() => + this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None)); + + this._mockPurviewClient.Verify(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny()), Times.Never); + this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Never); + } + + [Fact] + public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSubsequentCallsAsync() + { + // Arrange + Func, Task>? runner = null; + Mock channelHandler = new(); + Mock purviewClient = new(); + Mock cacheProvider = new(); + PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 }; + ProtectionScopesRequest request = new("user-123", "tenant-123") + { + Activities = ProtectionScopeActivities.UploadText, + Locations = + [ + new("microsoft.graph.policyLocationApplication", "app-123") + ] + }; + ProtectionScopesCacheKey cacheKey = new(request); + Channel channel = Channel.CreateUnbounded(); + + channelHandler.Setup(x => x.AddRunner(It.IsAny, Task>>())) + .Callback, Task>>(callback => runner = callback); + + purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new PurviewPaymentRequiredException("Payment required")); + + _ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings); + + // Act + Assert.NotNull(runner); + await channel.Writer.WriteAsync(new ScopeRetrievalJob(request, cacheKey, CreateProcessContentRequest())); + channel.Writer.Complete(); + await runner(channel); + + // Assert + cacheProvider.Verify(x => x.SetAsync( + It.Is(key => key.TenantId == "tenant-123"), + It.Is(entry => entry.Message == "Payment required"), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_QueuesContentActivityJobAsync() + { + // Arrange + Func, Task>? runner = null; + Mock channelHandler = new(); + Mock purviewClient = new(); + Mock cacheProvider = new(); + PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 }; + ProtectionScopesRequest request = CreateProtectionScopesRequest(); + ScopeRetrievalJob job = new(request, new ProtectionScopesCacheKey(request), CreateProcessContentRequest()); + Channel channel = Channel.CreateUnbounded(); + + channelHandler.Setup(x => x.AddRunner(It.IsAny, Task>>())) + .Callback, Task>>(callback => runner = callback); + + purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProtectionScopesResponse { Scopes = [] }); + + _ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings); + + // Act + Assert.NotNull(runner); + await channel.Writer.WriteAsync(job); + channel.Writer.Complete(); + await runner(channel); + + // Assert + channelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); + } + #endregion #region Helper Methods + private static ProtectionScopesRequest CreateProtectionScopesRequest() + { + return new ProtectionScopesRequest("user-123", "tenant-123") + { + Activities = ProtectionScopeActivities.UploadText, + Locations = + [ + new("microsoft.graph.policyLocationApplication", "app-123") + ] + }; + } + + private static ProcessContentRequest CreateProcessContentRequest() + { + PurviewTextContent content = new("Test content"); + ProcessConversationMetadata metadata = new(content, "msg-123", false, "Test message", "test-correlation-id"); + ActivityMetadata activityMetadata = new(Activity.UploadText); + DeviceMetadata deviceMetadata = new() + { + OperatingSystemSpecifications = new() + { + OperatingSystemPlatform = "Windows", + OperatingSystemVersion = "10" + } + }; + IntegratedAppMetadata integratedAppMetadata = new() + { + Name = "TestApp", + Version = "1.0" + }; + PolicyLocation policyLocation = new("microsoft.graph.policyLocationApplication", "app-123"); + ProtectedAppMetadata protectedAppMetadata = new(policyLocation) + { + Name = "TestApp", + Version = "1.0" + }; + ContentToProcess contentToProcess = new( + [metadata], + activityMetadata, + deviceMetadata, + integratedAppMetadata, + protectedAppMetadata); + + return new ProcessContentRequest(contentToProcess, "user-123", "tenant-123"); + } + private static PurviewSettings CreateValidPurviewSettings() { return new PurviewSettings("TestApp") diff --git a/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/Microsoft.Agents.AI.Valkey.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/Microsoft.Agents.AI.Valkey.UnitTests.csproj new file mode 100644 index 00000000000..4dd4ff1f6c8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/Microsoft.Agents.AI.Valkey.UnitTests.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/TestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/TestHelpers.cs new file mode 100644 index 00000000000..1f320ec34fe --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/TestHelpers.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Valkey.UnitTests; + +internal sealed class TestAgentSession : AgentSession +{ + public TestAgentSession() + { + this.StateBag = new AgentSessionStateBag(); + } +} + +internal static class TestHelpers +{ + internal static readonly AIAgent MockAgent = new Mock().Object; + + internal static ChatHistoryProvider.InvokingContext CreateChatHistoryInvokingContext( + IEnumerable? requestMessages = null) + { +#pragma warning disable MAAI001 + return new ChatHistoryProvider.InvokingContext( + MockAgent, + new TestAgentSession(), + requestMessages ?? [new ChatMessage(ChatRole.User, "test")]); +#pragma warning restore MAAI001 + } + + internal static ChatHistoryProvider.InvokedContext CreateChatHistoryInvokedContext( + IEnumerable requestMessages, + IEnumerable responseMessages) + { +#pragma warning disable MAAI001 + return new ChatHistoryProvider.InvokedContext( + MockAgent, + new TestAgentSession(), + requestMessages, + responseMessages); +#pragma warning restore MAAI001 + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/ValkeyChatHistoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/ValkeyChatHistoryProviderTests.cs new file mode 100644 index 00000000000..d624b58fa1f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/ValkeyChatHistoryProviderTests.cs @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; +using Valkey.Glide; + +namespace Microsoft.Agents.AI.Valkey.UnitTests; + +/// +/// Unit tests for . +/// +public sealed class ValkeyChatHistoryProviderTests +{ + private static Mock CreateMockConnection(Mock? dbMock = null) + { + var mockConnection = new Mock(); + dbMock ??= new Mock(); + mockConnection.Setup(c => c.GetDatabase()).Returns(dbMock.Object); + return mockConnection; + } + + // --- Constructor tests --- + + [Fact] + public void Constructor_WithConnection_SetsProperties() + { + // Arrange & Act + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection().Object, + static (_) => new ValkeyChatHistoryProvider.State("conv-1"), + new ValkeyChatHistoryProviderOptions { KeyPrefix = "test_prefix" }); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void Constructor_WithConnection_NullConnection_Throws() + { + // Act & Assert + Assert.Throws(() => + new ValkeyChatHistoryProvider( + null!, + static (_) => new ValkeyChatHistoryProvider.State("conv-1"))); + } + + [Fact] + public void Constructor_WithConnection_NullStateInitializer_Throws() + { + // Act & Assert + Assert.Throws(() => + new ValkeyChatHistoryProvider( + CreateMockConnection().Object, + null!)); + } + + // --- State tests --- + + [Fact] + public void State_NullConversationId_Throws() + { + Assert.Throws(() => new ValkeyChatHistoryProvider.State(null!)); + } + + [Fact] + public void State_EmptyConversationId_Throws() + { + Assert.Throws(() => new ValkeyChatHistoryProvider.State("")); + } + + [Fact] + public void State_ValidConversationId_SetsProperty() + { + var state = new ValkeyChatHistoryProvider.State("my-conversation"); + Assert.Equal("my-conversation", state.ConversationId); + } + + [Fact] + public void State_JsonConstructor_RoundTrips() + { + // Arrange + var original = new ValkeyChatHistoryProvider.State("test-conv"); + + // Act + var json = JsonSerializer.Serialize(original); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal("test-conv", deserialized.ConversationId); + } + + // --- StateKeys tests --- + + [Fact] + public void StateKeys_ReturnsProviderTypeName() + { + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection().Object, + _ => new ValkeyChatHistoryProvider.State("conv-1")); + + var keys = provider.StateKeys; + Assert.Single(keys); + Assert.Equal(nameof(ValkeyChatHistoryProvider), keys[0]); + } + + [Fact] + public void StateKeys_WithCustomKey_ReturnsCustomKey() + { + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection().Object, + _ => new ValkeyChatHistoryProvider.State("conv-1"), + new ValkeyChatHistoryProviderOptions { StateKey = "custom_key" }); + + var keys = provider.StateKeys; + Assert.Single(keys); + Assert.Equal("custom_key", keys[0]); + } + + // --- ProvideChatHistoryAsync tests --- + + [Fact] + public async Task ProvideChatHistoryAsync_ReturnsDeserializedMessagesAsync() + { + // Arrange + var dbMock = new Mock(); + var msg1 = new ChatMessage(ChatRole.User, "hello"); + var msg2 = new ChatMessage(ChatRole.Assistant, "hi there"); + var values = new ValkeyValue[] + { + JsonSerializer.Serialize(msg1), + JsonSerializer.Serialize(msg2) + }; + dbMock.Setup(d => d.ListRangeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(values); + + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection(dbMock).Object, + _ => new ValkeyChatHistoryProvider.State("conv-1")); + + var context = TestHelpers.CreateChatHistoryInvokingContext(); + + // Act — should not throw + var result = await provider.InvokingAsync(context); + var messages = result.ToList(); + + // Assert — only the valid message + request message + Assert.True(messages.Count >= 1); + } + + [Fact] + public async Task ProvideChatHistoryAsync_WithMaxMessagesToRetrieve_UsesRangeQueryAsync() + { + // Arrange + var dbMock = new Mock(); + dbMock.Setup(d => d.ListRangeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection(dbMock).Object, + _ => new ValkeyChatHistoryProvider.State("conv-1"), + new ValkeyChatHistoryProviderOptions { MaxMessagesToRetrieve = 5 }); + + var context = TestHelpers.CreateChatHistoryInvokingContext(); + + // Act + await provider.InvokingAsync(context); + + // Assert — should use -5, -1 range + dbMock.Verify(d => d.ListRangeAsync( + It.IsAny(), -5, -1), Times.Once); + } + + [Fact] + public async Task ProvideChatHistoryAsync_CancellationToken_ThrowsAsync() + { + // Arrange + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection().Object, + _ => new ValkeyChatHistoryProvider.State("conv-1")); + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var context = TestHelpers.CreateChatHistoryInvokingContext(); + + // Act & Assert + await Assert.ThrowsAsync(() => + provider.InvokingAsync(context, cts.Token).AsTask()); + } + + // --- StoreChatHistoryAsync tests --- + + [Fact] + public async Task StoreChatHistoryAsync_BatchPushesMessagesAsync() + { + // Arrange + var dbMock = new Mock(); + dbMock.Setup(d => d.ListRightPushAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(2); + + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection(dbMock).Object, + _ => new ValkeyChatHistoryProvider.State("conv-1")); + + var context = TestHelpers.CreateChatHistoryInvokedContext( + [new ChatMessage(ChatRole.User, "hello")], + [new ChatMessage(ChatRole.Assistant, "hi")]); + + // Act + await provider.InvokedAsync(context); + + // Assert — batch push called once with array + dbMock.Verify(d => d.ListRightPushAsync( + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task StoreChatHistoryAsync_WithMaxMessages_TrimsAsync() + { + // Arrange + var dbMock = new Mock(); + dbMock.Setup(d => d.ListRightPushAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(1); + dbMock.Setup(d => d.ListTrimAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection(dbMock).Object, + _ => new ValkeyChatHistoryProvider.State("conv-1"), + new ValkeyChatHistoryProviderOptions { MaxMessages = 10 }); + + var context = TestHelpers.CreateChatHistoryInvokedContext( + [new ChatMessage(ChatRole.User, "hello")], + [new ChatMessage(ChatRole.Assistant, "hi")]); + + // Act + await provider.InvokedAsync(context); + + // Assert — trim called unconditionally when MaxMessages is set + dbMock.Verify(d => d.ListTrimAsync( + It.IsAny(), -10, -1), Times.Once); + } +} 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 index 1327c3df48f..1470e4f0f56 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs @@ -321,6 +321,189 @@ await handler.InvokeToolAsync( #endregion + #region ComputeHeadersHash Tests + + [Fact] + public void ComputeHeadersHash_WithNullHeaders_ReturnsEmptyString() + { + // Act + string result = DefaultMcpToolHandler.ComputeHeadersHash(null); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void ComputeHeadersHash_WithEmptyHeaders_ReturnsEmptyString() + { + // Act + string result = DefaultMcpToolHandler.ComputeHeadersHash(new Dictionary()); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void ComputeHeadersHash_SameHeadersDifferentOrder_ReturnsSameHash() + { + // Arrange + Dictionary headers1 = new() + { + ["Authorization"] = "Bearer token123", + ["X-Custom"] = "value1" + }; + Dictionary headers2 = new() + { + ["X-Custom"] = "value1", + ["Authorization"] = "Bearer token123" + }; + + // Act + string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1); + string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2); + + // Assert + hash1.Should().Be(hash2); + } + + [Fact] + public void ComputeHeadersHash_SameKeysDifferentCaseKeys_ReturnsSameHash() + { + // Arrange — RFC 7230: header names are case-insensitive + Dictionary headers1 = new() { ["Authorization"] = "Bearer token" }; + Dictionary headers2 = new() { ["authorization"] = "Bearer token" }; + + // Act + string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1); + string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2); + + // Assert + hash1.Should().Be(hash2); + } + + [Fact] + public void ComputeHeadersHash_SameKeysDifferentCaseValues_ReturnsDifferentHash() + { + // Arrange — RFC 7235: credentials are case-sensitive + Dictionary headers1 = new() { ["Authorization"] = "Bearer ABC" }; + Dictionary headers2 = new() { ["Authorization"] = "Bearer abc" }; + + // Act + string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1); + string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2); + + // Assert + hash1.Should().NotBe(hash2); + } + + [Fact] + public void ComputeHeadersHash_DifferentHeaders_ReturnsDifferentHash() + { + // Arrange + Dictionary headers1 = new() { ["Authorization"] = "Bearer token1" }; + Dictionary headers2 = new() { ["Authorization"] = "Bearer token2" }; + + // Act + string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1); + string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2); + + // Assert + hash1.Should().NotBe(hash2); + } + + #endregion + + #region Cache Key Discrimination Tests + + // These tests exercise BuildCacheKey directly because the integration path + // (InvokeToolAsync against a fake server) doesn't surface cache-hit behavior + // without standing up a real MCP server — McpClient.CreateAsync fails before + // _clients[key] = newClient runs, so nothing ever gets cached. + // Tuple equality on the returned 4-tuple verifies that the dimensions + // collectively discriminate cache entries. + + [Fact] + public void BuildCacheKey_SameInputs_ReturnsEqualKeys() + { + // Arrange + Dictionary headers = new() { ["Authorization"] = "Bearer token" }; + + // Act + var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers); + var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers); + + // Assert + key1.Should().Be(key2); + } + + [Fact] + public void BuildCacheKey_DifferentConnectionName_ReturnsDifferentKeys() + { + // Act + var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-a", null); + var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-b", null); + + // Assert + key1.Should().NotBe(key2); + key1.Connection.Should().Be("connection-a"); + key2.Connection.Should().Be("connection-b"); + } + + [Fact] + public void BuildCacheKey_DifferentServerLabel_ReturnsDifferentKeys() + { + // Act + var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-a", null, null); + var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-b", null, null); + + // Assert + key1.Should().NotBe(key2); + key1.Label.Should().Be("label-a"); + key2.Label.Should().Be("label-b"); + } + + [Fact] + public void BuildCacheKey_CaseSensitiveUrlPath_ReturnsDifferentKeys() + { + // Arrange — RFC 3986: URL path is case-sensitive + // Act + var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/Tools", null, null, null); + var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/tools", null, null, null); + + // Assert + key1.Should().NotBe(key2); + } + + [Fact] + public void BuildCacheKey_HeaderValuesCaseSensitive_ReturnsDifferentKeys() + { + // Arrange — RFC 7235: credentials are case-sensitive + Dictionary headers1 = new() { ["Authorization"] = "Bearer ABC" }; + Dictionary headers2 = new() { ["Authorization"] = "Bearer abc" }; + + // Act + var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers1); + var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers2); + + // Assert — header value case must propagate into the cache key + key1.Should().NotBe(key2); + key1.HeadersHash.Should().NotBe(key2.HeadersHash); + } + + [Fact] + public void BuildCacheKey_NullLabelAndConnection_NormalizesToEmptyString() + { + // Act + var key = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, null); + + // Assert — verifies null-safety contract callers rely on + key.Label.Should().BeEmpty(); + key.Connection.Should().BeEmpty(); + key.HeadersHash.Should().BeEmpty(); + } + + #endregion + #region Reserved Tools/List Tests [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs index b00339ea3b5..845f2a18718 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -1,11 +1,21 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +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 Microsoft.PowerFx.Types; +using Moq; +using ApprovalSnapshot = Microsoft.Agents.AI.Workflows.Declarative.ObjectModel.InvokeFunctionToolExecutor.ApprovalSnapshot; namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; @@ -261,6 +271,323 @@ public async Task InvokeFunctionToolCaptureResponseWithMultipleFunctionResultsAs #endregion + #region Approval Snapshot Security Tests + + /// + /// Verifies that mutating the function-name variable after approval does not change + /// which function is actually invoked. The originally-approved name must be used. + /// + [Fact] + public async Task InvokeFunctionToolCaptureResponseUsesApprovedFunctionNameNotMutatedAsync() + { + // Arrange + const string ApprovedFunctionName = "safe_readonly_query"; + const string MutatedFunctionName = "dangerous_admin_tool"; + + this.State.Set("TargetFunction", FormulaValue.New(ApprovedFunctionName)); + this.State.InitializeSystem(); + this.State.Bind(); + + InvokeFunctionTool model = this.CreateModelWithVariableFunctionName( + displayName: nameof(InvokeFunctionToolCaptureResponseUsesApprovedFunctionNameNotMutatedAsync), + variableName: "TargetFunction"); + + string? capturedFunctionName = null; + TestFunctionAgentProvider testAgentProvider = new( + [ + AIFunctionFactory.Create(() => "safe-result", name: ApprovedFunctionName), + AIFunctionFactory.Create(() => "dangerous-result", name: MutatedFunctionName), + ], + onInvoke: name => capturedFunctionName = name); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + // Act - trigger ExecuteAsync to store the approval snapshot + Mock mockContext = CreateMockWorkflowContext(); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + // Simulate parallel branch mutating state during the approval window + this.State.Set("TargetFunction", FormulaValue.New(MutatedFunctionName)); + this.State.Bind(); + + // User clicks approve (they saw "safe_readonly_query" in the approval UI) + ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true); + + // Resume after approval + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the originally-approved function must be invoked, not the mutated one + Assert.NotNull(capturedFunctionName); + Assert.Equal(ApprovedFunctionName, capturedFunctionName); + } + + /// + /// Verifies that mutating an argument variable after approval does not change + /// the arguments actually passed to the invoked function. + /// + [Fact] + public async Task InvokeFunctionToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync() + { + // Arrange + const string FunctionName = "process_query"; + const string ArgumentKey = "query"; + const string ApprovedQuery = "SELECT * FROM users LIMIT 10"; + const string MutatedQuery = "DROP TABLE users CASCADE; --"; + + this.State.Set("SqlQuery", FormulaValue.New(ApprovedQuery)); + this.State.InitializeSystem(); + this.State.Bind(); + + InvokeFunctionTool model = this.CreateModelWithVariableArgument( + displayName: nameof(InvokeFunctionToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync), + functionName: FunctionName, + argumentKey: ArgumentKey, + variableName: "SqlQuery"); + + AIFunctionArguments? capturedArguments = null; + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create((string query) => $"executed:{query}", name: FunctionName)], + onInvokeArguments: args => capturedArguments = args); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + // Act - trigger ExecuteAsync to store the approval snapshot + Mock mockContext = CreateMockWorkflowContext(); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + // Simulate parallel branch mutating state during the approval window + this.State.Set("SqlQuery", FormulaValue.New(MutatedQuery)); + this.State.Bind(); + + // User clicks approve + ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true); + + // Resume after approval + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the originally-approved argument must be used, not the mutated one + Assert.NotNull(capturedArguments); + Assert.Equal(ApprovedQuery, capturedArguments[ArgumentKey]?.ToString()); + } + + /// + /// Verifies that the approval snapshot survives a checkpoint/restore cycle. + /// After restore, the originally-approved function must still be used even if state was mutated. + /// + [Fact] + public async Task InvokeFunctionToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync() + { + // Arrange + const string ApprovedFunctionName = "safe_readonly_query"; + const string MutatedFunctionName = "dangerous_admin_tool"; + + this.State.Set("TargetFunction", FormulaValue.New(ApprovedFunctionName)); + this.State.InitializeSystem(); + this.State.Bind(); + + InvokeFunctionTool model = this.CreateModelWithVariableFunctionName( + displayName: nameof(InvokeFunctionToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync), + variableName: "TargetFunction"); + + string? capturedFunctionName = null; + TestFunctionAgentProvider testAgentProvider = new( + [ + AIFunctionFactory.Create(() => "safe-result", name: ApprovedFunctionName), + AIFunctionFactory.Create(() => "dangerous-result", name: MutatedFunctionName), + ], + onInvoke: name => capturedFunctionName = name); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + // Act - trigger ExecuteAsync to store the approval snapshot + Mock mockContext = CreateMockWorkflowContextWithStateStore(); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + // Simulate checkpoint: persist to state store + await InvokeProtectedMethodAsync(action, "OnCheckpointingAsync", mockContext.Object, CancellationToken.None); + + // Simulate restore on a "new" executor instance by clearing the in-memory field via reflection + // (In production, a new executor instance would be created with _approvalSnapshot == null) + typeof(InvokeFunctionToolExecutor) + .GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(action, null); + + // Restore from state store + await InvokeProtectedMethodAsync(action, "OnCheckpointRestoredAsync", mockContext.Object, CancellationToken.None); + + // Mutate state after restore (simulating parallel branch) + this.State.Set("TargetFunction", FormulaValue.New(MutatedFunctionName)); + this.State.Bind(); + + // User clicks approve + ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true); + + // Resume after approval + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the originally-approved function must be invoked, not the mutated one + Assert.NotNull(capturedFunctionName); + Assert.Equal(ApprovedFunctionName, capturedFunctionName); + } + + /// + /// Verifies that the approval snapshot is cleared after a completed approval cycle, + /// both in-memory and in the persisted state store. This prevents stale data from + /// influencing a subsequent execution of the same executor instance. + /// + [Fact] + public async Task InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletionAsync() + { + // Arrange + const string FunctionName = "any_function"; + + this.State.InitializeSystem(); + this.State.Bind(); + + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletionAsync), + functionName: FunctionName, + requireApproval: true); + + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => "result", name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + // Act - run the full approval cycle + Dictionary stateStore = []; + Mock mockContext = CreateMockWorkflowContextWithStateStore(stateStore); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + // Sanity: snapshot was captured + FieldInfo snapshotField = typeof(InvokeFunctionToolExecutor) + .GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)!; + Assert.NotNull(snapshotField.GetValue(action)); + + ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true); + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - both in-memory field and persisted state are cleared + Assert.Null(snapshotField.GetValue(action)); + Assert.True(stateStore.ContainsKey("_approvalSnapshot")); + Assert.Null(stateStore["_approvalSnapshot"]); + } + + private static ExternalInputResponse CreateApprovalResponse(string actionId, bool approved) + { + FunctionCallContent functionCall = new(callId: actionId, name: "ignored"); + ToolApprovalRequestContent approvalRequest = new(actionId, functionCall); + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved); + return new ExternalInputResponse(new ChatMessage(ChatRole.User, [approvalResponse])); + } + + private static Mock CreateMockWorkflowContext() + { + Mock mockContext = new(); + mockContext.Setup(c => c.AddEventAsync(It.IsAny(), It.IsAny())) + .Returns(default(ValueTask)); + mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(default(ValueTask)); + mockContext.Setup(c => c.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(default(ValueTask)); + return mockContext; + } + + /// + /// Creates a mock workflow context that actually stores state values (for checkpoint/restore tests). + /// Optionally accepts an externally-owned dictionary so callers can inspect the persisted state. + /// + private static Mock CreateMockWorkflowContextWithStateStore(Dictionary? stateStore = null) + { + stateStore ??= []; + Mock mockContext = new(); + mockContext.Setup(c => c.AddEventAsync(It.IsAny(), It.IsAny())) + .Returns(default(ValueTask)); + mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((key, value, _, _) => stateStore[key] = value) + .Returns(default(ValueTask)); + mockContext.Setup(c => c.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(default(ValueTask)); + mockContext.Setup(c => c.ReadStateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((key, _, _) => + new ValueTask(stateStore.TryGetValue(key, out object? val) ? val as ApprovalSnapshot : null)); + mockContext.Setup(c => c.ReadStateKeysAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new HashSet()); + return mockContext; + } + + /// + /// Invokes a protected method on the executor via reflection (for testing checkpoint hooks). + /// + private static async ValueTask InvokeProtectedMethodAsync(InvokeFunctionToolExecutor action, string methodName, IWorkflowContext context, CancellationToken cancellationToken) + { + MethodInfo method = typeof(InvokeFunctionToolExecutor) + .GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance)!; + ValueTask result = (ValueTask)method.Invoke(action, [context, cancellationToken])!; + await result.ConfigureAwait(false); + } + + /// + /// Minimal concrete that exposes an injected + /// registry and records which function got invoked. + /// Used by the framework-invoke approval branch (InvokeRegisteredFunctionAsync). + /// + private sealed class TestFunctionAgentProvider : ResponseAgentProvider + { + private readonly Action? _onInvoke; + private readonly Action? _onInvokeArguments; + + public TestFunctionAgentProvider( + IEnumerable functions, + Action? onInvoke = null, + Action? onInvokeArguments = null) + { + this._onInvoke = onInvoke; + this._onInvokeArguments = onInvokeArguments; + this.Functions = functions.Select(f => (AIFunction)new RecordingAIFunction(f, this)).ToList(); + } + + internal void RecordInvocation(string name, AIFunctionArguments? arguments) + { + this._onInvoke?.Invoke(name); + if (arguments is not null) + { + this._onInvokeArguments?.Invoke(arguments); + } + } + + public override Task CreateConversationAsync(CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public override Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public override Task GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public override IAsyncEnumerable InvokeAgentAsync( + string agentId, string? agentVersion, string? conversationId, + IEnumerable? messages, IDictionary? inputArguments, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public override IAsyncEnumerable GetMessagesAsync( + string conversationId, int? limit = null, string? after = null, string? before = null, + bool newestFirst = false, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + private sealed class RecordingAIFunction(AIFunction inner, TestFunctionAgentProvider owner) : AIFunction + { + public override string Name => inner.Name; + public override string Description => inner.Description; + public override JsonElement JsonSchema => inner.JsonSchema; + + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + owner.RecordInvocation(inner.Name, arguments); + return inner.InvokeAsync(arguments, cancellationToken); + } + } + } + + #endregion + #region Helper Methods private async Task ExecuteTestAsync(InvokeFunctionTool model) @@ -318,5 +645,33 @@ private InvokeFunctionTool CreateModel( return AssignParent(builder); } + private InvokeFunctionTool CreateModelWithVariableFunctionName(string displayName, string variableName) + { + InvokeFunctionTool.Builder builder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + FunctionName = new StringExpression.Builder( + StringExpression.Variable(PropertyPath.TopicVariable(variableName))), + RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)), + }; + return AssignParent(builder); + } + + private InvokeFunctionTool CreateModelWithVariableArgument( + string displayName, string functionName, string argumentKey, string variableName) + { + InvokeFunctionTool.Builder builder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + FunctionName = new StringExpression.Builder(StringExpression.Literal(functionName)), + RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)), + }; + builder.Arguments.Add(argumentKey, + ValueExpression.Variable(PropertyPath.TopicVariable(variableName))); + return AssignParent(builder); + } + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestrationTests.cs index 30be5bd8736..7c5260f507e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestrationTests.cs @@ -419,6 +419,82 @@ public async Task RunCoordinationRound_Forwards_Participant_Reply_To_ManagerAsyn "final-answer synthesis must see what participants actually said"); } + [Fact] + public async Task Participant_Receives_Prior_Participant_Response_Not_InstructionAsync() + { + // Regression: each participant must see prior participants' *responses* (the running conversation), + // not their *instructions*. Previously the orchestrator broadcast the per-round instruction to every + // participant (untargeted fan-out) and never broadcast replies, so a later speaker received the earlier + // speaker's instruction and never its answer. + const string HealthInstruction = "HEALTH_CHECKER_INSTRUCTION_check_framework"; + const string DatabaseInstruction = "DATABASE_CHECKER_INSTRUCTION_check_database"; + const string HealthEchoPrefix = "HC_RESPONSE::"; + const string DatabaseEchoPrefix = "DB_RESPONSE::"; + + List facts = CreatePlanResponse("Facts"); + List plan = CreatePlanResponse("Plan"); + List round1Ledger = CreateProgressLedgerResponse( + isRequestSatisfied: false, + isInLoop: false, + isProgressBeingMade: true, + nextSpeaker: "HealthChecker", + instructionOrQuestion: HealthInstruction); + List round2Ledger = CreateProgressLedgerResponse( + isRequestSatisfied: false, + isInLoop: false, + isProgressBeingMade: true, + nextSpeaker: "DatabaseChecker", + instructionOrQuestion: DatabaseInstruction); + List round3Ledger = CreateProgressLedgerResponse( + isRequestSatisfied: true, + isInLoop: false, + isProgressBeingMade: true, + nextSpeaker: "DatabaseChecker", + instructionOrQuestion: "Done"); + List finalAnswer = CreateFinalAnswerResponse("All systems checked"); + + TestReplayAgent manager = new( + [facts, plan, round1Ledger, round2Ledger, round3Ledger, finalAnswer], + name: "Manager"); + RecordingEchoAgent healthChecker = new(name: "HealthChecker", prefix: HealthEchoPrefix); + RecordingEchoAgent databaseChecker = new(name: "DatabaseChecker", prefix: DatabaseEchoPrefix); + + Workflow workflow = new MagenticWorkflowBuilder(manager) + .AddParticipants(healthChecker, databaseChecker) + .RequirePlanSignoff(false) + .Build(); + + WorkflowRunResult runResult = await RunMagenticWorkflowAsync( + workflow, + [new ChatMessage(ChatRole.User, "Check system health")]); + + runResult.Result.Should().NotBeNull(); + runResult.Result![0].Text.Should().Contain("All systems checked"); + + // Each participant takes exactly one turn. + healthChecker.RecordedInputs.Should().ContainSingle(); + databaseChecker.RecordedInputs.Should().ContainSingle(); + + // The first speaker receives its own instruction. + List healthInput = healthChecker.RecordedInputs[0]; + healthInput.Should().Contain(m => m.Text.Contains(HealthInstruction), "the first speaker receives its own instruction"); + + // The second speaker must see the first speaker's RESPONSE (authored by HealthChecker, carrying the echo + // prefix that only the response — not the raw instruction — has), plus its own instruction. + List databaseInput = databaseChecker.RecordedInputs[0]; + databaseInput.Should().Contain( + m => m.AuthorName == "HealthChecker" && m.Text.Contains(HealthEchoPrefix), + "the next speaker must receive the prior participant's response (the running conversation)"); + databaseInput.Should().Contain(m => m.Text.Contains(DatabaseInstruction), + "the next speaker must receive its own instruction"); + + // The leaked-instruction bug: the second speaker must not receive HealthChecker's instruction as a + // bare message (it should only appear, if at all, embedded in HealthChecker's prefixed response). + databaseInput.Should().NotContain( + m => m.AuthorName != "HealthChecker" && m.Text.Trim() == HealthInstruction, + "the prior speaker's instruction must not leak into the next speaker's context as a standalone message"); + } + [Fact] public async Task PlanReview_Revised_Triggers_ReplanAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RecordingEchoAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RecordingEchoAgent.cs new file mode 100644 index 00000000000..83a0e994a14 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RecordingEchoAgent.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// A that records the input messages it receives on each call. +/// Used by tests that need to assert what context a participant was actually handed - for example, +/// that a later speaker sees prior participants' responses (the running conversation) rather +/// than their instructions. +/// +internal sealed class RecordingEchoAgent(string? id = null, string? name = null, string? prefix = null) + : TestEchoAgent(id, name, prefix) +{ + public List> RecordedInputs { get; } = []; + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Materialize once so the deferred input is recorded and replayed identically. + List recorded = messages.ToList(); + this.RecordedInputs.Add(recorded); + + await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync(recorded, session, options, cancellationToken)) + { + yield return update; + } + } +} diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 85da8aef5f9..fa637607519 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.8.1] - 2026-06-09 + +### Added +- **agent-framework-core**: Add MCP client OTel spans per GenAI semantic conventions ([#6349](https://github.com/microsoft/agent-framework/pull/6349)) +- **agent-framework-core**: Add MCP long-running task support ([#6319](https://github.com/microsoft/agent-framework/pull/6319)) + +### Changed +- **agent-framework-claude**: Bump `claude-agent-sdk` to 0.2.87 ([#6248](https://github.com/microsoft/agent-framework/pull/6248)) +- **agent-framework-core**: Document checkpoint storage security model and deserialization trust boundaries ([#6295](https://github.com/microsoft/agent-framework/pull/6295)) +- **agent-framework-azurefunctions**: Document checkpoint storage security model and deserialization trust boundaries ([#6295](https://github.com/microsoft/agent-framework/pull/6295)) + +### Fixed +- **agent-framework-core**: Filter MCP tool kwargs to declared params via allowlist ([#6399](https://github.com/microsoft/agent-framework/pull/6399)) +- **agent-framework-core**: Fix per-service-call history persistence with server-storing clients ([#6310](https://github.com/microsoft/agent-framework/pull/6310)) +- **agent-framework-openai**: Use `getattr` for non-OpenAI provider response compatibility ([#6270](https://github.com/microsoft/agent-framework/pull/6270)) +- **agent-framework-foundry-hosting**: Refactor workflow-as-agent pending request handling ([#6259](https://github.com/microsoft/agent-framework/pull/6259)) +- **agent-framework-gemini**: Make Gemini honor declarative `outputSchema`, not just JSON mode ([#5893](https://github.com/microsoft/agent-framework/pull/5893)) +- **agent-framework-mem0**: Isolate entity retrieval and correct `app_id` payload ([#6242](https://github.com/microsoft/agent-framework/pull/6242)) +- **agent-framework-ag-ui**: Match AG-UI approval responses to requested arguments ([#6376](https://github.com/microsoft/agent-framework/pull/6376)) + ## [1.8.0] - 2026-06-04 ### Added @@ -1169,7 +1189,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.8.0...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.8.1...HEAD +[1.8.1]: https://github.com/microsoft/agent-framework/compare/python-1.8.0...python-1.8.1 [1.8.0]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...python-1.8.0 [1.7.0]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...python-1.7.0 [1.6.0]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...python-1.6.0 diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 4a0f8a94f50..4f0ff83aa7b 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.0rc3" +version = "1.0.0rc4" 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.6.0,<2", + "agent-framework-core>=1.8.1,<2", "ag-ui-protocol>=0.1.16,<0.2", "fastapi>=0.115.0,<0.133.1", "uvicorn[standard]>=0.30.0,<1" diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_serialization.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_serialization.py index 4ed080ecebb..27730fb441e 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_serialization.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_serialization.py @@ -14,6 +14,24 @@ - reconstruct_to_type: for HITL responses where external data (without type markers) needs to be reconstructed to a known type - resolve_type: resolves 'module:class' type keys to Python types + +Security Model +-------------- +The underlying Azure Durable Functions storage (Azure Storage account) is the +trusted persistence layer for serialized checkpoint data. The +``RestrictedUnpickler`` in the core encoding module provides defense-in-depth +type filtering, but checkpoint storage itself must be properly access-controlled: + +- Ensure the Azure Storage account used by Durable Functions is not publicly + writable and uses appropriate RBAC / shared-access policies. +- Never route untrusted user input directly into ``deserialize_value`` without + first calling :func:`strip_pickle_markers` to neutralize injection of + pickle markers into the data path. +- Configure your checkpoint storage with ``allowed_checkpoint_types`` (or call + ``decode_checkpoint_value(..., allowed_types=...)`` directly) to restrict the set of types that can be deserialized. + +See :mod:`agent_framework._workflows._checkpoint_encoding` for the full +security model documentation. """ from __future__ import annotations diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index 77cfdd0c402..a68ada4c250 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.0b260604" +version = "1.0.0b260609" 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.8.0,<2", + "agent-framework-core>=1.8.1,<2", "agent-framework-durabletask>=1.0.0b260604,<2", "azure-functions>=1.24.0,<2", "azure-functions-durable>=1.3.1,<2", diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index c258a113f96..252dbb261bc 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.0b260521" +version = "1.0.0b260609" 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.6.0,<2", + "agent-framework-core>=1.8.1,<2", "claude-agent-sdk>=0.1.36,<0.3", ] diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index ca0c5843a3f..92e83c5df4d 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -82,6 +82,7 @@ agent_framework/ - **`MCPStdioTool`** / **`MCPStreamableHTTPTool`** / **`MCPWebsocketTool`** - Transport-specific subclasses. - **Argument allowlist (`_prepare_call_kwargs`)** - Before each `tools/call`, kwargs are filtered to an **allowlist** built from the tool's declared parameters (`inputSchema.properties`) plus any user-configured extras. Framework runtime kwargs injected through the function-invocation pipeline (e.g. `thread`, `conversation_id`, `chat_options`, `options`, `response_format`) are stripped by default rather than forwarded. A tool that declares no usable `properties` (including schemas with `additionalProperties: true`) forwards only the configured extras. The `_MCP_FRAMEWORK_DENYLIST` is a safety net for framework-named params a server *declares* in its schema (those are dropped); names explicitly opted in via `additional_tool_argument_names` always win. The reserved `_meta` key is extracted as MCP request metadata, never forwarded as an argument. - **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins. +- **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`. - **`MCPTaskOptions`** (experimental, `MCP_LONG_RUNNING_TASKS` feature, **frozen**) - Per-tool-instance options controlling the SEP-2663 long-running task lifecycle. When the server advertises a tool with `execution.taskSupport == "required"`, `MCPTool.call_tool` transparently routes through `call_tool_as_task`, which sends an augmented `tools/call`, polls `tasks/get` until terminal, and reinterprets `tasks/result` as a normal `CallToolResult`. Instances are immutable; replace via `MCPTool.task_options = MCPTaskOptions(...)`. Fields: - `default_ttl: timedelta | None` — forwarded to the server as `params.task.ttl` (milliseconds). When `None`, the server's default applies. - `cancel_remote_task_on_local_cancellation: bool = True` — only gates the `CancelledError` path. Abandonment paths (see below) always cancel. diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 33d16cd9bbb..b287b4be579 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -124,7 +124,7 @@ TodoSessionStore, TodoStore, ) -from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPTaskOptions, MCPWebsocketTool +from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPTaskOptions, MCPWebsocketTool, SamplingApprovalCallback from ._middleware import ( AgentContext, AgentMiddleware, @@ -472,6 +472,7 @@ "RunContext", "Runner", "RunnerContext", + "SamplingApprovalCallback", "SecretString", "SelectiveToolCallCompactionStrategy", "SessionContext", diff --git a/python/packages/core/agent_framework/_harness/_agent.py b/python/packages/core/agent_framework/_harness/_agent.py index 5896f721418..0ae0c730321 100644 --- a/python/packages/core/agent_framework/_harness/_agent.py +++ b/python/packages/core/agent_framework/_harness/_agent.py @@ -66,23 +66,45 @@ def _assemble_instructions( def _assemble_compaction_provider( *, disable_compaction: bool, - max_context_window_tokens: int, - max_output_tokens: int, + max_context_window_tokens: int | None, + max_output_tokens: int | None, history_source_id: str, before_compaction_strategy: CompactionStrategy | None, after_compaction_strategy: CompactionStrategy | None, tokenizer: TokenizerProtocol | None, ) -> CompactionProvider | None: - """Build the compaction provider from parameters or defaults.""" + """Build the compaction provider from parameters or defaults. + + The token-budget defaults (``ContextWindowCompactionStrategy`` for the before phase and + ``ToolResultCompactionStrategy`` for the after phase) are only applied when the token + params are provided. Caller-supplied strategies are always honored. Either phase may end + up ``None``, which ``CompactionProvider`` interprets as "skip that phase". + + Returns None when compaction is explicitly disabled, or when neither phase has a strategy + (no custom strategies and no token budget to build the defaults). + """ if disable_compaction: return None - before_strategy = before_compaction_strategy or ContextWindowCompactionStrategy( - max_context_window_tokens=max_context_window_tokens, - max_output_tokens=max_output_tokens, - tokenizer=tokenizer, - ) - after_strategy = after_compaction_strategy or ToolResultCompactionStrategy(keep_last_tool_call_groups=2) + # Resolve the before-strategy: custom strategy wins; otherwise fall back to the + # token-budget-aware default when token params are available. + before_strategy = before_compaction_strategy + if before_strategy is None and max_context_window_tokens is not None and max_output_tokens is not None: + before_strategy = ContextWindowCompactionStrategy( + max_context_window_tokens=max_context_window_tokens, + max_output_tokens=max_output_tokens, + tokenizer=tokenizer, + ) + + # Resolve the after-strategy: custom strategy wins; otherwise fall back to the default + # when token params are available. + after_strategy = after_compaction_strategy + if after_strategy is None and max_context_window_tokens is not None and max_output_tokens is not None: + after_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=2) + + # Nothing to compact in either phase: skip the provider entirely. + if before_strategy is None and after_strategy is None: + return None return CompactionProvider( before_strategy=before_strategy, @@ -157,8 +179,8 @@ def create_harness_agent( harness_instructions: str | None = None, agent_instructions: str | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - max_context_window_tokens: int, - max_output_tokens: int, + max_context_window_tokens: int | None = None, + max_output_tokens: int | None = None, history_provider: HistoryProvider | None = None, disable_compaction: bool = False, before_compaction_strategy: CompactionStrategy | None = None, @@ -206,8 +228,6 @@ def create_harness_agent( agent = create_harness_agent( OpenAIChatClient(model="gpt-4o"), - max_context_window_tokens=128_000, - max_output_tokens=16_384, ) session = agent.create_session() response = await agent.run("Plan a weekend trip to Seattle", session=session) @@ -243,13 +263,21 @@ def create_harness_agent( (e.g., "You are a research assistant focused on academic sources."). tools: Additional tools to include in the agent's toolset. max_context_window_tokens: Maximum tokens the model's context window supports. + Used to construct the default token-budget-aware compaction strategies. When None + (default) and no custom ``before_compaction_strategy`` / ``after_compaction_strategy`` + is provided, compaction is automatically disabled. max_output_tokens: Maximum output tokens per response. + Used to construct the default compaction strategies and sets a default max_tokens + chat option. When None (default), no default max_tokens option is set, and unless a + custom compaction strategy is provided, compaction is automatically disabled. history_provider: Custom history provider. When None, an InMemoryHistoryProvider is used. disable_compaction: When True, skip compaction provider setup. - before_compaction_strategy: Custom before-run compaction strategy. - Defaults to ContextWindowCompactionStrategy (token-budget aware). - after_compaction_strategy: Custom after-run compaction strategy. - Defaults to ToolResultCompactionStrategy. + before_compaction_strategy: Custom before-run compaction strategy. When provided, + compaction runs even if token params are omitted. Defaults to + ContextWindowCompactionStrategy (token-budget aware) when token params are provided. + after_compaction_strategy: Custom after-run compaction strategy. When provided, + compaction runs even if token params are omitted. Defaults to + ToolResultCompactionStrategy when token params are provided. tokenizer: Custom tokenizer for compaction strategies. disable_todo: When True, skip the TodoProvider. todo_provider: Custom TodoProvider instance. Ignored when disable_todo is True. @@ -283,14 +311,19 @@ def create_harness_agent( A fully configured :class:`~agent_framework.Agent` instance. Raises: - ValueError: If max_context_window_tokens <= 0 or max_output_tokens < 0 - or max_output_tokens >= max_context_window_tokens. + ValueError: If max_context_window_tokens is provided and <= 0, or + max_output_tokens is provided and <= 0, or max_output_tokens >= + max_context_window_tokens when both are provided. """ - if max_context_window_tokens <= 0: + if max_context_window_tokens is not None and max_context_window_tokens <= 0: raise ValueError("max_context_window_tokens must be positive.") - if max_output_tokens < 0: - raise ValueError("max_output_tokens must be non-negative.") - if max_output_tokens >= max_context_window_tokens: + if max_output_tokens is not None and max_output_tokens <= 0: + raise ValueError("max_output_tokens must be positive.") + if ( + max_context_window_tokens is not None + and max_output_tokens is not None + and max_output_tokens >= max_context_window_tokens + ): raise ValueError("max_output_tokens must be less than max_context_window_tokens.") # Build history provider. @@ -347,7 +380,8 @@ def create_harness_agent( # Build default options dict. default_opts: dict[str, Any] = dict(default_options) if default_options else {} - default_opts.setdefault("max_tokens", max_output_tokens) + if max_output_tokens is not None: + default_opts.setdefault("max_tokens", max_output_tokens) agent = Agent( client, diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 784c618302d..2fc79e85a5a 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -16,6 +16,7 @@ from dataclasses import dataclass from datetime import timedelta from functools import partial +from inspect import isawaitable from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast from opentelemetry import propagate @@ -99,6 +100,22 @@ class MCPSpecificApproval(TypedDict, total=False): MCP_DEFAULT_TIMEOUT = 30 MCP_DEFAULT_SSE_READ_TIMEOUT = 60 * 5 +# Default safety limits applied to server-initiated MCP sampling requests +# (``sampling/createMessage``). MCP servers are untrusted third parties, so the +# default ``sampling_callback`` denies requests unless an approval callback is +# supplied, and bounds the cost of any approved request. +# - ``_DEFAULT_SAMPLING_MAX_TOKENS`` clamps the server-requested ``maxTokens``. +# - ``_DEFAULT_SAMPLING_MAX_REQUESTS`` caps the number of sampling requests per +# session connection (the counter resets on reconnect). +_DEFAULT_SAMPLING_MAX_TOKENS = 4096 +_DEFAULT_SAMPLING_MAX_REQUESTS = 25 + +# A user-supplied gate invoked before each server-initiated sampling request is +# forwarded to the chat client. It receives the raw ``CreateMessageRequestParams`` +# and returns (or awaits to) a truthy value to approve the request or a falsy +# value to deny it. Both synchronous and asynchronous callables are supported. +SamplingApprovalCallback = Callable[["types.CreateMessageRequestParams"], "bool | Coroutine[Any, Any, bool]"] + # region: Helpers LOG_LEVEL_MAPPING: dict[str, int] = { @@ -345,6 +362,9 @@ def __init__( session: ClientSession | None = None, request_timeout: int | None = None, client: SupportsChatGetResponse | None = None, + sampling_approval_callback: SamplingApprovalCallback | None = None, + sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS, + sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS, additional_properties: dict[str, Any] | None = None, task_options: MCPTaskOptions | None = None, additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, @@ -378,6 +398,20 @@ def __init__( session: An existing MCP client session to use. request_timeout: Timeout in seconds for MCP requests. client: A chat client for sampling callbacks. + sampling_approval_callback: Optional gate invoked before each server-initiated + ``sampling/createMessage`` request is forwarded to ``client``. It receives the + raw ``CreateMessageRequestParams`` and may be synchronous or asynchronous; + returning a truthy value approves the request and a falsy value denies it. When + ``None`` (the default), every sampling request is **denied** because MCP servers + are untrusted third parties (confused-deputy risk). To restore the legacy + auto-approve behavior, pass ``lambda params: True`` as an explicit, conscious + opt-in. + sampling_max_tokens: Upper bound applied to the server-requested ``maxTokens`` for an + approved sampling request. The effective value is ``min(requested, cap)``. Set to + ``None`` to disable the cap. Defaults to ``_DEFAULT_SAMPLING_MAX_TOKENS``. + sampling_max_requests: Maximum number of sampling requests allowed per session + connection; further requests are rejected. The counter resets on reconnect. Set + to ``None`` to disable the limit. Defaults to ``_DEFAULT_SAMPLING_MAX_REQUESTS``. additional_properties: Additional properties for the tool. task_options: Options controlling how long-running MCP tasks are driven for tools that advertise ``execution.taskSupport == "required"``. When ``None``, @@ -410,6 +444,10 @@ def __init__( self.session = session self.request_timeout = request_timeout self.client = client + self.sampling_approval_callback = sampling_approval_callback + self.sampling_max_tokens = sampling_max_tokens + self.sampling_max_requests = sampling_max_requests + self._sampling_request_count = 0 self._functions: list[FunctionTool] = [] self._tool_call_meta_by_name: dict[str, dict[str, Any]] = {} self._tool_task_support_by_name: dict[str, str] = {} @@ -539,6 +577,9 @@ def _parse_tool_result_from_mcp( case _: result.append(Content.from_text(str(item))) + if mcp_type.structuredContent is not None: + result.append(Content.from_text(json.dumps(mcp_type.structuredContent, default=str))) + if not result: result.append(Content.from_text("null")) return result @@ -840,6 +881,7 @@ def _reset_session_state(self) -> None: self._supports_prompts = True self._supports_logging = None self._ping_available = True + self._sampling_request_count = 0 def _set_server_capabilities(self, capabilities: types.ServerCapabilities | None) -> None: self._server_capabilities = capabilities @@ -994,6 +1036,49 @@ async def _connect_on_owner(self, *, reset: bool = False, load_configured: bool except Exception as exc: logger.warning("Failed to set log level to %s", logger.level, exc_info=exc) + async def _sampling_request_approved(self, params: types.CreateMessageRequestParams) -> bool: + """Run the configured sampling approval gate. + + Returns ``True`` only when an approval callback is configured and approves the request. + When no callback is set, the request is denied (safe default for untrusted servers). + """ + callback = self.sampling_approval_callback + if callback is None: + logger.warning( + "Denying MCP sampling request from '%s': no 'sampling_approval_callback' configured.", + self.name, + ) + return False + try: + outcome = callback(params) + if isawaitable(outcome): + outcome = await outcome + except Exception as ex: + logger.warning( + "Denying MCP sampling request from '%s': approval callback raised %s.", + self.name, + ex, + exc_info=True, + ) + return False + approved = bool(outcome) + if not approved: + logger.warning("MCP sampling request from '%s' was denied by the approval callback.", self.name) + return approved + + def _capped_sampling_max_tokens(self, requested: int) -> int: + """Clamp the server-requested ``maxTokens`` to ``sampling_max_tokens`` when configured.""" + cap = self.sampling_max_tokens + if cap is not None and requested > cap: + logger.warning( + "Capping MCP sampling maxTokens for '%s' from %d to %d.", + self.name, + requested, + cap, + ) + return cap + return requested + async def sampling_callback( self, context: RequestContext[ClientSession, Any], @@ -1001,20 +1086,32 @@ async def sampling_callback( ) -> types.CreateMessageResult | types.ErrorData: """Callback function for sampling. - This function is called when the MCP server needs to get a message completed. - It uses the configured chat client to generate responses. + This function is called when the MCP server sends a ``sampling/createMessage`` + request. It enforces safety guardrails and, if the request is approved, uses the + configured chat client to generate a response. + + Safety: + MCP servers are untrusted third parties, so forwarding server-controlled prompts + to the chat client without review is a confused-deputy risk. This callback + therefore applies, in order: a per-session rate limit + (``sampling_max_requests``), an approval gate (``sampling_approval_callback``, + which **denies by default** when not configured), and a ``maxTokens`` cap + (``sampling_max_tokens``). To allow sampling, pass a ``sampling_approval_callback`` + that returns a truthy value (use ``lambda params: True`` to auto-approve as an + explicit opt-in). Note: - This is a simple version of this function. It can be overridden to allow - more complex sampling. It gets added to the session at initialization time, - so overriding it is the best way to customize this behavior. + This is the default implementation. It can be overridden to allow more complex + sampling. It gets added to the session at initialization time, so overriding it is + the best way to customize this behavior. Args: context: The request context from the MCP server. params: The message creation request parameters. Returns: - Either a CreateMessageResult with the generated message or ErrorData if generation fails. + Either a CreateMessageResult with the generated message or ErrorData if the request + is denied, rate limited, or generation fails. """ from mcp import types @@ -1023,7 +1120,38 @@ async def sampling_callback( code=types.INTERNAL_ERROR, message="No chat client available. Please set a chat client.", ) - logger.debug("Sampling callback called with params: %s", params) + + logger.warning( + "MCP server '%s' sent a sampling/createMessage request (%d message(s), maxTokens=%s).", + self.name, + len(params.messages), + params.maxTokens, + ) + + if self.sampling_max_requests is not None: + if self._sampling_request_count >= self.sampling_max_requests: + logger.warning( + "Denying MCP sampling request from '%s': per-session limit of %d reached.", + self.name, + self.sampling_max_requests, + ) + return types.ErrorData( + code=types.INVALID_REQUEST, + message="Sampling rate limit exceeded for this MCP session.", + ) + self._sampling_request_count += 1 + + if not await self._sampling_request_approved(params): + if self.sampling_approval_callback is None: + message = ( + "Sampling request denied. MCP sampling is disabled by default for untrusted " + "servers; provide a 'sampling_approval_callback' that approves the request to " + "enable it." + ) + else: + message = "Sampling request denied by the 'sampling_approval_callback'." + return types.ErrorData(code=types.INVALID_REQUEST, message=message) + messages: list[Message] = [] for msg in params.messages: messages.append(self._parse_message_from_mcp(msg)) @@ -1045,7 +1173,7 @@ async def sampling_callback( if params.temperature is not None: options["temperature"] = params.temperature - options["max_tokens"] = params.maxTokens + options["max_tokens"] = self._capped_sampling_max_tokens(params.maxTokens) if params.stopSequences is not None: options["stop"] = params.stopSequences @@ -2219,6 +2347,9 @@ def __init__( env: dict[str, str] | None = None, encoding: str | None = None, client: SupportsChatGetResponse | None = None, + sampling_approval_callback: SamplingApprovalCallback | None = None, + sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS, + sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS, additional_properties: dict[str, Any] | None = None, task_options: MCPTaskOptions | None = None, additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, @@ -2266,6 +2397,16 @@ def __init__( env: The environment variables to set for the command. encoding: The encoding to use for the command output. client: The chat client to use for sampling. + sampling_approval_callback: Optional gate run before each server-initiated + ``sampling/createMessage`` request reaches ``client``. Receives the raw + ``CreateMessageRequestParams`` (sync or async); a truthy return approves the + request, a falsy return denies it. When ``None`` (the default) every sampling + request is **denied**, since MCP servers are untrusted (confused-deputy risk). + Pass ``lambda params: True`` to auto-approve as an explicit opt-in. + sampling_max_tokens: Cap applied to an approved request's ``maxTokens`` + (``min(requested, cap)``); ``None`` disables it. + sampling_max_requests: Per-session cap on the number of sampling requests; further + requests are rejected. Resets on reconnect. ``None`` disables it. task_options: Options for tools that advertise ``execution.taskSupport == "required"``. See :class:`MCPTaskOptions`. additional_tool_argument_names: Extra argument names to forward to the MCP server in @@ -2300,6 +2441,9 @@ def __init__( request_timeout=request_timeout, task_options=task_options, additional_tool_argument_names=additional_tool_argument_names, + sampling_approval_callback=sampling_approval_callback, + sampling_max_tokens=sampling_max_tokens, + sampling_max_requests=sampling_max_requests, ) self.command = command self.args = args or [] @@ -2375,6 +2519,9 @@ def __init__( allowed_tools: Collection[str] | None = None, terminate_on_close: bool | None = None, client: SupportsChatGetResponse | None = None, + sampling_approval_callback: SamplingApprovalCallback | None = None, + sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS, + sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS, additional_properties: dict[str, Any] | None = None, http_client: AsyncClient | None = None, header_provider: Callable[[dict[str, Any]], dict[str, str]] | None = None, @@ -2423,6 +2570,16 @@ def __init__( additional_properties: Additional properties. terminate_on_close: Close the transport when the MCP client is terminated. client: The chat client to use for sampling. + sampling_approval_callback: Optional gate run before each server-initiated + ``sampling/createMessage`` request reaches ``client``. Receives the raw + ``CreateMessageRequestParams`` (sync or async); a truthy return approves the + request, a falsy return denies it. When ``None`` (the default) every sampling + request is **denied**, since MCP servers are untrusted (confused-deputy risk). + Pass ``lambda params: True`` to auto-approve as an explicit opt-in. + sampling_max_tokens: Cap applied to an approved request's ``maxTokens`` + (``min(requested, cap)``); ``None`` disables it. + sampling_max_requests: Per-session cap on the number of sampling requests; further + requests are rejected. Resets on reconnect. ``None`` disables it. http_client: Optional asyncClient to use. If not provided, the ``streamable_http_client`` API will create and manage a default client. To configure headers, timeouts, or other HTTP client settings, create @@ -2466,6 +2623,9 @@ def __init__( request_timeout=request_timeout, task_options=task_options, additional_tool_argument_names=additional_tool_argument_names, + sampling_approval_callback=sampling_approval_callback, + sampling_max_tokens=sampling_max_tokens, + sampling_max_requests=sampling_max_requests, ) self.url = url self.terminate_on_close = terminate_on_close @@ -2590,6 +2750,9 @@ def __init__( approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None, allowed_tools: Collection[str] | None = None, client: SupportsChatGetResponse | None = None, + sampling_approval_callback: SamplingApprovalCallback | None = None, + sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS, + sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS, additional_properties: dict[str, Any] | None = None, task_options: MCPTaskOptions | None = None, additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, @@ -2635,6 +2798,16 @@ def __init__( allowed_tools: A list of tools that are allowed to use this tool. additional_properties: Additional properties. client: The chat client to use for sampling. + sampling_approval_callback: Optional gate run before each server-initiated + ``sampling/createMessage`` request reaches ``client``. Receives the raw + ``CreateMessageRequestParams`` (sync or async); a truthy return approves the + request, a falsy return denies it. When ``None`` (the default) every sampling + request is **denied**, since MCP servers are untrusted (confused-deputy risk). + Pass ``lambda params: True`` to auto-approve as an explicit opt-in. + sampling_max_tokens: Cap applied to an approved request's ``maxTokens`` + (``min(requested, cap)``); ``None`` disables it. + sampling_max_requests: Per-session cap on the number of sampling requests; further + requests are rejected. Resets on reconnect. ``None`` disables it. task_options: Options for tools that advertise ``execution.taskSupport == "required"``. See :class:`MCPTaskOptions`. additional_tool_argument_names: Extra argument names to forward to the MCP server in @@ -2669,6 +2842,9 @@ def __init__( request_timeout=request_timeout, task_options=task_options, additional_tool_argument_names=additional_tool_argument_names, + sampling_approval_callback=sampling_approval_callback, + sampling_max_tokens=sampling_max_tokens, + sampling_max_requests=sampling_max_requests, ) self.url = url self._client_kwargs = kwargs diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index 97afe66cea9..91bdb619143 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -3516,9 +3516,7 @@ async def get_content(self) -> str: result = await self._client.read_resource(_mcp_any_url(self._skill_md_uri)) text = _mcp_join_text(result) if not text: - raise ValueError( - f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'." - ) + raise ValueError(f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'.") self._content = text return text @@ -3572,11 +3570,7 @@ def _validate_resource_name(name: str) -> str | None: or ``None`` if the name is unsafe. """ normalized = name.replace("\\", "/") - if ( - normalized.startswith("/") - or "://" in normalized - or any(seg == ".." for seg in normalized.split("/")) - ): + if normalized.startswith("/") or "://" in normalized or any(seg == ".." for seg in normalized.split("/")): logger.debug("Rejecting resource name with unsafe path components: %r", name) return None return normalized diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py index dd1fb3d7044..c66faae75e2 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py @@ -13,6 +13,35 @@ value types (primitives, datetime, uuid, ...), all ``agent_framework`` internal types, and all ``openai.types`` types. Callers can extend the set by passing additional ``"module:qualname"`` strings. + +Security Model +-------------- +Checkpoint storage is treated as a **trusted data source**. The serialization +format uses Python's ``pickle`` module which can execute arbitrary code during +deserialization. The ``RestrictedUnpickler`` provides a defense-in-depth +allowlist that limits instantiable classes, but it is **not** a security +boundary — certain allowlisted builtins (e.g. ``getattr``) are required for +legitimate object reconstruction (enums, named tuples) and cannot be removed +without breaking compatibility. + +Developers **must** ensure that: + +1. The checkpoint storage backend (file system, Cosmos DB, Azure Blob, Durable + Functions storage) is access-controlled and not writable by untrusted + parties. +2. Data flowing into ``decode_checkpoint_value`` originates exclusively from + the application's own checkpoint storage — never from user-supplied HTTP + requests, message payloads, or other untrusted sources. +3. The ``allowed_types`` parameter is specified whenever possible to restrict + the set of reconstructible types to the minimum required by the application. +4. Never pass untrusted external input to ``decode_checkpoint_value``. If you + must accept external JSON that might contain checkpoint markers, sanitize it + first (for example, :func:`agent_framework_azurefunctions._serialization.strip_pickle_markers`). + +The allowlist is a mitigation that reduces attack surface but does not +eliminate the inherent risks of deserializing untrusted pickle data. Treat +your checkpoint storage with the same access controls you would apply to +application secrets or database credentials. """ from __future__ import annotations diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 39b180a23e8..bc03bb02dda 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.8.0" +version = "1.8.1" 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_harness_agent.py b/python/packages/core/tests/core/test_harness_agent.py index 58ef3f5f2d0..7da1bdbf36c 100644 --- a/python/packages/core/tests/core/test_harness_agent.py +++ b/python/packages/core/tests/core/test_harness_agent.py @@ -194,6 +194,63 @@ def test_create_harness_agent_returns_full_agent() -> None: assert isinstance(agent, FullAgent) +def test_create_harness_agent_no_token_params_disables_compaction() -> None: + """When token params are omitted, compaction is automatically disabled.""" + agent = create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + ) + provider_types = [type(p) for p in agent.context_providers] + assert CompactionProvider not in provider_types + + +def test_create_harness_agent_no_token_params_skips_max_tokens_option() -> None: + """When max_output_tokens is omitted, max_tokens should not be set in default options.""" + agent = create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + ) + assert agent.default_options.get("max_tokens") is None + + +def test_create_harness_agent_custom_before_strategy_enables_compaction_without_tokens() -> None: + """A custom before_compaction_strategy enables compaction even when token params are omitted.""" + from agent_framework import ToolResultCompactionStrategy + + agent = create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + before_compaction_strategy=ToolResultCompactionStrategy(), + ) + provider_types = [type(p) for p in agent.context_providers] + assert CompactionProvider in provider_types + + +def test_create_harness_agent_disable_compaction_overrides_custom_before_strategy() -> None: + """disable_compaction=True wins even when a custom before strategy is provided.""" + from agent_framework import ToolResultCompactionStrategy + + agent = create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + before_compaction_strategy=ToolResultCompactionStrategy(), + disable_compaction=True, + ) + provider_types = [type(p) for p in agent.context_providers] + assert CompactionProvider not in provider_types + + +def test_create_harness_agent_custom_after_strategy_enables_compaction_without_tokens() -> None: + """A custom after_compaction_strategy enables compaction even when token params are omitted.""" + from agent_framework import ToolResultCompactionStrategy + + agent = create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + after_compaction_strategy=ToolResultCompactionStrategy(), + ) + compaction_providers = [p for p in agent.context_providers if isinstance(p, CompactionProvider)] + assert len(compaction_providers) == 1 + # Before phase is skipped (no token budget, no custom before strategy), after phase is set. + assert compaction_providers[0].before_strategy is None + assert compaction_providers[0].after_strategy is not None + + # --- Validation Tests --- @@ -207,14 +264,15 @@ def test_create_harness_agent_rejects_invalid_context_tokens() -> None: ) -def test_create_harness_agent_rejects_negative_output_tokens() -> None: - """max_output_tokens must be non-negative.""" - with pytest.raises(ValueError, match="max_output_tokens must be non-negative"): - create_harness_agent( - client=_FakeChatClient(), # type: ignore[arg-type] - max_context_window_tokens=1000, - max_output_tokens=-1, - ) +def test_create_harness_agent_rejects_non_positive_output_tokens() -> None: + """max_output_tokens must be positive when provided.""" + for invalid_value in (0, -1): + with pytest.raises(ValueError, match="max_output_tokens must be positive"): + create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=1000, + max_output_tokens=invalid_value, + ) def test_create_harness_agent_rejects_output_gte_context() -> None: diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 7c45296cbb0..a40c1c9b54f 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -342,6 +342,69 @@ def test_parse_tool_result_from_mcp_resource_link_text_resource_and_unknown(): assert result[1].text == "Embedded result" +def test_parse_tool_result_from_mcp_structured_content_only(): + """Test that structuredContent is parsed when content list is empty.""" + mcp_result = types.CallToolResult( + content=[], + structuredContent={"Tables": [{"Name": "Sales", "Columns": ["Amount", "Date"]}]}, + ) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + parsed = json.loads(result[0].text) + assert parsed == {"Tables": [{"Name": "Sales", "Columns": ["Amount", "Date"]}]} + + +def test_parse_tool_result_from_mcp_structured_content_with_text(): + """Test that structuredContent is appended alongside regular content items.""" + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="Summary")], + structuredContent={"data": [1, 2, 3]}, + ) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) + + assert isinstance(result, list) + assert len(result) == 2 + assert result[0].type == "text" + assert result[0].text == "Summary" + assert result[1].type == "text" + parsed = json.loads(result[1].text) + assert parsed == {"data": [1, 2, 3]} + + +def test_parse_tool_result_from_mcp_structured_content_none(): + """Test that None structuredContent does not affect results.""" + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="Hello")], + structuredContent=None, + ) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert result[0].text == "Hello" + + +def test_parse_tool_result_from_mcp_structured_content_non_serializable(): + """Test that non-JSON-serializable values in structuredContent degrade gracefully.""" + mcp_result = types.CallToolResult( + content=[], + structuredContent={"data": b"raw bytes", "count": 42}, + ) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + parsed = json.loads(result[0].text) + assert parsed["count"] == 42 + # bytes should be converted to string representation via default=str + assert "raw bytes" in parsed["data"] + + def test_mcp_content_types_to_ai_content_text(): """Test conversion of MCP text content to AI content.""" mcp_content = types.TextContent(type="text", text="Sample text") @@ -1813,6 +1876,18 @@ async def blocking_load_tools(): assert len(tool._pending_reload_tasks) == 0 +def _approve(_params: object) -> bool: + """Approving sampling gate used by tests that exercise forwarding behavior.""" + return True + + +def _make_sampling_response(text: str = "response", model: str = "test-model") -> Mock: + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text(text)])] + mock_response.model = model + return mock_response + + async def test_mcp_tool_sampling_callback_no_client(): """Test sampling callback error path when no chat client is available.""" tool = MCPStdioTool(name="test_tool", command="python") @@ -1828,9 +1903,190 @@ async def test_mcp_tool_sampling_callback_no_client(): assert "No chat client available" in result.message +async def test_mcp_tool_sampling_callback_denies_by_default(): + """Sampling is denied when no approval callback is configured (safe default).""" + tool = MCPStdioTool(name="test_tool", command="python") + mock_chat_client = AsyncMock() + tool.client = mock_chat_client + + params = Mock() + params.messages = [] + params.maxTokens = 128 + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.ErrorData) + assert result.code == types.INVALID_REQUEST + assert "denied" in result.message + assert "sampling_approval_callback" in result.message + mock_chat_client.get_response.assert_not_called() + + +async def test_mcp_tool_sampling_callback_denied_by_callback(): + """Sampling is denied when the approval callback returns a falsy value.""" + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=lambda params: False) + mock_chat_client = AsyncMock() + tool.client = mock_chat_client + + params = Mock() + params.messages = [] + params.maxTokens = 128 + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.ErrorData) + assert result.code == types.INVALID_REQUEST + assert "denied by the 'sampling_approval_callback'" in result.message + mock_chat_client.get_response.assert_not_called() + + +async def test_mcp_tool_sampling_callback_callback_exception_denies(): + """An approval callback that raises results in denial, not an LLM call.""" + + def boom(_params: object) -> bool: + raise RuntimeError("approval error") + + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=boom) + mock_chat_client = AsyncMock() + tool.client = mock_chat_client + + params = Mock() + params.messages = [] + params.maxTokens = 128 + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.ErrorData) + assert result.code == types.INVALID_REQUEST + mock_chat_client.get_response.assert_not_called() + + +async def test_mcp_tool_sampling_callback_async_approval(): + """An async approval callback that approves allows the request through.""" + + async def approve(_params: object) -> bool: + return True + + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=approve) + mock_chat_client = AsyncMock() + mock_chat_client.get_response.return_value = _make_sampling_response("ok") + tool.client = mock_chat_client + + params = Mock() + params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))] + params.temperature = None + params.maxTokens = 100 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + assert result.content.text == "ok" + mock_chat_client.get_response.assert_awaited_once() + + +async def test_mcp_tool_sampling_callback_clamps_max_tokens(): + """An approved request's maxTokens is clamped to sampling_max_tokens.""" + tool = MCPStdioTool( + name="test_tool", + command="python", + sampling_approval_callback=_approve, + sampling_max_tokens=512, + ) + mock_chat_client = AsyncMock() + mock_chat_client.get_response.return_value = _make_sampling_response() + tool.client = mock_chat_client + + params = Mock() + params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))] + params.temperature = None + params.maxTokens = 1_000_000 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + options = mock_chat_client.get_response.call_args.kwargs.get("options") or {} + assert options["max_tokens"] == 512 + + +async def test_mcp_tool_sampling_callback_does_not_clamp_under_cap(): + """A request below the cap keeps its requested maxTokens.""" + tool = MCPStdioTool( + name="test_tool", + command="python", + sampling_approval_callback=_approve, + sampling_max_tokens=512, + ) + mock_chat_client = AsyncMock() + mock_chat_client.get_response.return_value = _make_sampling_response() + tool.client = mock_chat_client + + params = Mock() + params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))] + params.temperature = None + params.maxTokens = 100 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + options = mock_chat_client.get_response.call_args.kwargs.get("options") or {} + assert options["max_tokens"] == 100 + + +async def test_mcp_tool_sampling_callback_rate_limited(): + """Sampling requests beyond sampling_max_requests are rejected per session.""" + tool = MCPStdioTool( + name="test_tool", + command="python", + sampling_approval_callback=_approve, + sampling_max_requests=2, + ) + mock_chat_client = AsyncMock() + mock_chat_client.get_response.return_value = _make_sampling_response() + tool.client = mock_chat_client + + def make_params() -> Mock: + params = Mock() + params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))] + params.temperature = None + params.maxTokens = 100 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + return params + + first = await tool.sampling_callback(Mock(), make_params()) + second = await tool.sampling_callback(Mock(), make_params()) + third = await tool.sampling_callback(Mock(), make_params()) + + assert isinstance(first, types.CreateMessageResult) + assert isinstance(second, types.CreateMessageResult) + assert isinstance(third, types.ErrorData) + assert third.code == types.INVALID_REQUEST + assert "rate limit" in third.message.lower() + assert mock_chat_client.get_response.await_count == 2 + + # The counter resets on a session reset. + tool._reset_session_state() + fourth = await tool.sampling_callback(Mock(), make_params()) + assert isinstance(fourth, types.CreateMessageResult) + + async def test_mcp_tool_sampling_callback_chat_client_exception(): """Test sampling callback when chat client raises exception.""" - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) # Mock chat client that raises exception mock_chat_client = AsyncMock() @@ -1846,7 +2102,7 @@ async def test_mcp_tool_sampling_callback_chat_client_exception(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = None params.tools = None @@ -1863,7 +2119,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): """Test sampling callback when response has no valid content types.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) # Mock chat client with response containing only invalid content types mock_chat_client = AsyncMock() @@ -1892,7 +2148,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = None params.tools = None @@ -1905,18 +2161,18 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): assert "Failed to get right content types from the response." in result.message mock_chat_client.get_response.assert_awaited_once() _, kwargs = mock_chat_client.get_response.await_args - assert kwargs["options"] == {"max_tokens": None} + assert kwargs["options"] == {"max_tokens": 100} async def test_mcp_tool_sampling_callback_no_response_and_successful_message_creation(): """Test sampling callback when the chat client returns no response and then valid content.""" - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) tool.client = AsyncMock() params = Mock() params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = None params.tools = None @@ -1955,7 +2211,7 @@ async def test_mcp_tool_sampling_callback_forwards_system_prompt(): """Test sampling callback passes systemPrompt as instructions in options.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -1972,7 +2228,7 @@ async def test_mcp_tool_sampling_callback_forwards_system_prompt(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = "You are a helpful assistant" params.tools = None @@ -1990,7 +2246,7 @@ async def test_mcp_tool_sampling_callback_forwards_tools(): """Test sampling callback converts MCP tools to FunctionTools and passes them in options.""" from agent_framework import FunctionTool, Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -2013,7 +2269,7 @@ async def test_mcp_tool_sampling_callback_forwards_tools(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = None params.tools = [mcp_tool] @@ -2036,7 +2292,7 @@ async def test_mcp_tool_sampling_callback_forwards_tool_choice(): """Test sampling callback passes toolChoice mode in options.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -2053,7 +2309,7 @@ async def test_mcp_tool_sampling_callback_forwards_tool_choice(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = None params.tools = None @@ -2071,7 +2327,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt(): """Test sampling callback forwards empty string systemPrompt as instructions.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -2088,7 +2344,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = "" params.tools = None @@ -2106,7 +2362,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_tools_list(): """Test sampling callback forwards empty tools list in options.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -2123,7 +2379,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_tools_list(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = None params.tools = [] @@ -2141,7 +2397,7 @@ async def test_mcp_tool_sampling_callback_forwards_generation_params_in_options( """Test sampling callback passes temperature, max_tokens, and stop in options.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -2182,7 +2438,7 @@ async def test_mcp_tool_sampling_callback_omits_temperature_when_none(): """Test sampling callback does not set temperature in options when it is None.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -2219,7 +2475,7 @@ async def test_mcp_tool_sampling_callback_always_passes_max_tokens(): """Test sampling callback always sets max_tokens in options since maxTokens is a required int field.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() diff --git a/python/packages/core/tests/core/test_mcp_observability.py b/python/packages/core/tests/core/test_mcp_observability.py index 226e9761205..e32bce1b022 100644 --- a/python/packages/core/tests/core/test_mcp_observability.py +++ b/python/packages/core/tests/core/test_mcp_observability.py @@ -76,6 +76,7 @@ def _make_call_tool_result(text: str = "result", is_error: bool = False) -> Mock result = Mock() result.isError = is_error result.content = [types.TextContent(type="text", text=text)] + result.structuredContent = None return result @@ -281,9 +282,7 @@ async def test_mcp_prompts_get_creates_client_span(span_exporter: InMemorySpanEx async def test_mcp_prompts_get_mcp_error_sets_error_type(span_exporter: InMemorySpanExporter): """When session.get_prompt() raises McpError, the span should have error.type and ERROR status.""" tool = _make_connected_mcp_tool() - tool.session.get_prompt = AsyncMock( - side_effect=McpError(ErrorData(code=-32602, message="prompt not found")) - ) + tool.session.get_prompt = AsyncMock(side_effect=McpError(ErrorData(code=-32602, message="prompt not found"))) span_exporter.clear() with pytest.raises(ToolExecutionException): diff --git a/python/packages/core/tests/core/test_mcp_skills.py b/python/packages/core/tests/core/test_mcp_skills.py index 3e7c67662af..74993997d0e 100644 --- a/python/packages/core/tests/core/test_mcp_skills.py +++ b/python/packages/core/tests/core/test_mcp_skills.py @@ -35,26 +35,22 @@ Body content here. """ -SAMPLE_SKILL_INDEX = json.dumps( - { - "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", - "skills": [ - { - "name": "unit-converter", - "type": "skill-md", - "description": "Convert between common units.", - "url": "skill://unit-converter/SKILL.md", - } - ], - } -) +SAMPLE_SKILL_INDEX = json.dumps({ + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": [ + { + "name": "unit-converter", + "type": "skill-md", + "description": "Convert between common units.", + "url": "skill://unit-converter/SKILL.md", + } + ], +}) def _make_text_result(text: str, uri: str = "skill://test") -> ReadResourceResult: """Create a ReadResourceResult with a single TextResourceContents.""" - return ReadResourceResult( - contents=[TextResourceContents(uri=AnyUrl(uri), text=text, mimeType="text/markdown")] - ) + return ReadResourceResult(contents=[TextResourceContents(uri=AnyUrl(uri), text=text, mimeType="text/markdown")]) def _make_blob_result( @@ -230,12 +226,10 @@ async def test_get_content_raises_on_empty(self) -> None: @pytest.mark.asyncio async def test_get_resource_text(self) -> None: - client = _make_client( - **{ - "skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD), - "skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"), - } - ) + client = _make_client(**{ + "skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD), + "skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"), + }) from agent_framework import SkillFrontmatter fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.") @@ -249,12 +243,10 @@ async def test_get_resource_text(self) -> None: @pytest.mark.asyncio async def test_get_resource_binary(self) -> None: data = bytes([0x01, 0x02, 0x03, 0x04]) - client = _make_client( - **{ - "skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD), - "skill://unit-converter/assets/icon.bin": _make_blob_result(data), - } - ) + client = _make_client(**{ + "skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD), + "skill://unit-converter/assets/icon.bin": _make_blob_result(data), + }) from agent_framework import SkillFrontmatter fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.") @@ -345,12 +337,10 @@ class TestMCPSkillsSource: @pytest.mark.asyncio async def test_index_based_discovery_returns_skill(self) -> None: - client = _make_client( - **{ - "skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"), - "skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD), - } - ) + client = _make_client(**{ + "skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"), + "skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD), + }) source = MCPSkillsSource(client=client) skills = await source.get_skills() @@ -373,9 +363,7 @@ async def test_no_index_returns_empty(self) -> None: async def test_does_not_read_skill_md_during_discovery(self) -> None: # Index points to a skill, but SKILL.md is not registered on the server. # Discovery should succeed because it only reads the index. - client = _make_client( - **{"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json")} - ) + client = _make_client(**{"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json")}) source = MCPSkillsSource(client=client) skills = await source.get_skills() @@ -384,19 +372,17 @@ async def test_does_not_read_skill_md_during_discovery(self) -> None: @pytest.mark.asyncio async def test_invalid_name_is_skipped(self) -> None: - index_json = json.dumps( - { - "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", - "skills": [ - { - "name": "UnitConverter", # Invalid: uppercase - "type": "skill-md", - "description": "Convert between common units.", - "url": "skill://UnitConverter/SKILL.md", - } - ], - } - ) + index_json = json.dumps({ + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": [ + { + "name": "UnitConverter", # Invalid: uppercase + "type": "skill-md", + "description": "Convert between common units.", + "url": "skill://UnitConverter/SKILL.md", + } + ], + }) client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")}) source = MCPSkillsSource(client=client) skills = await source.get_skills() @@ -404,18 +390,16 @@ async def test_invalid_name_is_skipped(self) -> None: @pytest.mark.asyncio async def test_missing_required_fields_is_skipped(self) -> None: - index_json = json.dumps( - { - "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", - "skills": [ - { - "name": "unit-converter", - "type": "skill-md", - # Missing description and url - } - ], - } - ) + index_json = json.dumps({ + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": [ + { + "name": "unit-converter", + "type": "skill-md", + # Missing description and url + } + ], + }) client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")}) source = MCPSkillsSource(client=client) skills = await source.get_skills() @@ -423,19 +407,17 @@ async def test_missing_required_fields_is_skipped(self) -> None: @pytest.mark.asyncio async def test_unsupported_type_is_skipped(self) -> None: - index_json = json.dumps( - { - "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", - "skills": [ - { - "name": "some-skill", - "type": "archive", - "description": "Packaged skill.", - "url": "skill://some-skill.tar.gz", - } - ], - } - ) + index_json = json.dumps({ + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": [ + { + "name": "some-skill", + "type": "archive", + "description": "Packaged skill.", + "url": "skill://some-skill.tar.gz", + } + ], + }) client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")}) source = MCPSkillsSource(client=client) skills = await source.get_skills() @@ -443,18 +425,16 @@ async def test_unsupported_type_is_skipped(self) -> None: @pytest.mark.asyncio async def test_template_type_is_skipped(self) -> None: - index_json = json.dumps( - { - "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", - "skills": [ - { - "type": "mcp-resource-template", - "description": "Per-product documentation skill", - "url": "skill://docs/{product}/SKILL.md", - } - ], - } - ) + index_json = json.dumps({ + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": [ + { + "type": "mcp-resource-template", + "description": "Per-product documentation skill", + "url": "skill://docs/{product}/SKILL.md", + } + ], + }) client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")}) source = MCPSkillsSource(client=client) skills = await source.get_skills() @@ -462,31 +442,25 @@ async def test_template_type_is_skipped(self) -> None: @pytest.mark.asyncio async def test_empty_index_returns_empty(self) -> None: - client = _make_client( - **{"skill://index.json": _make_text_result('{"skills": []}', uri="skill://index.json")} - ) + client = _make_client(**{"skill://index.json": _make_text_result('{"skills": []}', uri="skill://index.json")}) source = MCPSkillsSource(client=client) skills = await source.get_skills() assert skills == [] @pytest.mark.asyncio async def test_malformed_index_json_returns_empty(self) -> None: - client = _make_client( - **{"skill://index.json": _make_text_result("not valid json", uri="skill://index.json")} - ) + client = _make_client(**{"skill://index.json": _make_text_result("not valid json", uri="skill://index.json")}) source = MCPSkillsSource(client=client) skills = await source.get_skills() assert skills == [] @pytest.mark.asyncio async def test_sibling_text_resource(self) -> None: - client = _make_client( - **{ - "skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"), - "skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD), - "skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"), - } - ) + client = _make_client(**{ + "skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"), + "skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD), + "skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"), + }) source = MCPSkillsSource(client=client) skill = (await source.get_skills())[0] resource = await skill.get_resource("references/checklist.md") @@ -497,13 +471,11 @@ async def test_sibling_text_resource(self) -> None: @pytest.mark.asyncio async def test_sibling_binary_resource(self) -> None: data = bytes([0x01, 0x02, 0x03, 0x04]) - client = _make_client( - **{ - "skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"), - "skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD), - "skill://unit-converter/assets/icon.bin": _make_blob_result(data), - } - ) + client = _make_client(**{ + "skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"), + "skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD), + "skill://unit-converter/assets/icon.bin": _make_blob_result(data), + }) source = MCPSkillsSource(client=client) skill = (await source.get_skills())[0] resource = await skill.get_resource("assets/icon.bin") @@ -649,9 +621,7 @@ async def test_get_resource_generic_mcp_error_propagates(self) -> None: from agent_framework import SkillFrontmatter client = AsyncMock() - client.read_resource = AsyncMock( - side_effect=McpError(error=ErrorData(code=0, message="Handler error")) - ) + client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=0, message="Handler error"))) fm = SkillFrontmatter(name="test-skill", description="Test.") skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client) with pytest.raises(McpError): diff --git a/python/packages/foundry/pyproject.toml b/python/packages/foundry/pyproject.toml index ab7b4d1dd63..f9a0788273e 100644 --- a/python/packages/foundry/pyproject.toml +++ b/python/packages/foundry/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.8.0" +version = "1.8.1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,8 +23,8 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.8.0,<2", - "agent-framework-openai>=1.8.0,<2", + "agent-framework-core>=1.8.1,<2", + "agent-framework-openai>=1.8.1,<2", "azure-ai-inference>=1.0.0b9,<1.0.0b10", "azure-ai-projects>=2.2.0,<3.0", ] diff --git a/python/packages/foundry_hosting/pyproject.toml b/python/packages/foundry_hosting/pyproject.toml index 82246af0249..59cc347d137 100644 --- a/python/packages/foundry_hosting/pyproject.toml +++ b/python/packages/foundry_hosting/pyproject.toml @@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0a260604" +version = "1.0.0a260609" 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.8.0,<2", + "agent-framework-core>=1.8.1,<2", "azure-ai-agentserver-core>=2.0.0b3,<3", "azure-ai-agentserver-responses>=1.0.0b7,<2", "azure-ai-agentserver-invocations>=1.0.0b3,<2", diff --git a/python/packages/gemini/pyproject.toml b/python/packages/gemini/pyproject.toml index 9a8ad94723e..52621705754 100644 --- a/python/packages/gemini/pyproject.toml +++ b/python/packages/gemini/pyproject.toml @@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0a260521" +version = "1.0.0a260609" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/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.6.0,<2.0", + "agent-framework-core>=1.8.1,<2.0", "google-genai>=1.65.0,<2.0.0", ] diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index d7db0a2b95b..77f934c892a 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.0b260521" +version = "1.0.0b260609" 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.6.0,<2", + "agent-framework-core>=1.8.1,<2", "mem0ai>=1.0.0,<2", ] diff --git a/python/packages/mem0/tests/test_mem0_context_provider.py b/python/packages/mem0/tests/test_mem0_context_provider.py index a047af1638f..d5c9e2fe677 100644 --- a/python/packages/mem0/tests/test_mem0_context_provider.py +++ b/python/packages/mem0/tests/test_mem0_context_provider.py @@ -198,12 +198,7 @@ async def test_oss_client_all_scoping_params_except_app_id(self, mock_oss_mem0_c """OSS client with all scoping parameters passes them as isolated concurrent kwargs.""" mock_oss_mem0_client.search.return_value = [] - provider = Mem0ContextProvider( - source_id="mem0", - mem0_client=mock_oss_mem0_client, - user_id="u1", - agent_id="a1" - ) + provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1", agent_id="a1") mock_context = MagicMock(spec=SessionContext) mock_msg = MagicMock() diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index d998875d1ca..2d5cda9ee5d 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -1997,7 +1997,11 @@ def _parse_response_from_openai( metadata: dict[str, Any] = response.metadata or {} contents: list[Content] = [] local_shell_tool_name = self._get_local_shell_tool_name(options.get("tools")) - for item in response.output: # type: ignore[reportUnknownMemberType] + try: + response_outputs = response.output # type: ignore[reportUnknownMemberType] + except AttributeError: + response_outputs = [] + for item in response_outputs: # type: ignore[reportUnknownVariableType] match item.type: # types: # ParsedResponseOutputMessage[Unknown] | diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index a6878c9f2d2..0fd14aa2ef5 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -788,13 +788,13 @@ def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | Non def _get_metadata_from_chat_response(self, response: ChatCompletion) -> dict[str, Any]: """Get metadata from a chat response.""" return { - "system_fingerprint": response.system_fingerprint, + "system_fingerprint": getattr(response, "system_fingerprint", None), } def _get_metadata_from_streaming_chat_response(self, response: ChatCompletionChunk) -> dict[str, Any]: """Get metadata from a streaming chat response.""" return { - "system_fingerprint": response.system_fingerprint, + "system_fingerprint": getattr(response, "system_fingerprint", None), } def _get_metadata_from_chat_choice(self, choice: Choice | ChunkChoice) -> dict[str, Any]: diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index ce44efeabda..46d7cc16495 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.8.0" +version = "1.8.1" 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.8.0,<2", + "agent-framework-core>=1.8.1,<2", "openai>=1.99.0,<3", ] diff --git a/python/packages/purview/README.md b/python/packages/purview/README.md index a802cd9615a..0a78e07605d 100644 --- a/python/packages/purview/README.md +++ b/python/packages/purview/README.md @@ -320,4 +320,5 @@ except (PurviewAuthenticationError, PurviewRateLimitError, PurviewRequestError, - **Streaming Responses**: Post-response policy evaluation presently applies only to non-streaming chat responses. - **Error Handling**: Use `ignore_exceptions` and `ignore_payment_required` settings for graceful degradation. When enabled, errors are logged but don't fail the request. - **Caching**: Protection scopes responses and 402 errors are cached by default with a 4-hour TTL. Cache is automatically invalidated when protection scope state changes. +- **Cold-cache parallelization**: On a `ProtectionScopes` cache miss, scopes are refreshed in the background while `ProcessContent` runs in the foreground. - **Background Processing**: Content Activities and offline Process Content requests are handled asynchronously using background tasks to avoid blocking the main execution flow. diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index 241de80d612..eb949287fd8 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -231,18 +231,19 @@ async def _process_with_scopes(self, pc_request: ProcessContentRequest) -> Proce cached_ps_resp = await self._cache.get(cache_key) if cached_ps_resp is not None and isinstance(cached_ps_resp, ProtectionScopesResponse): - ps_resp = cached_ps_resp - else: - ttl = self._settings.get("cache_ttl_seconds") - ttl_seconds = ttl if ttl is not None else 14400 - try: - ps_resp = await self._client.get_protection_scopes(ps_req) - await self._cache.set(cache_key, ps_resp, ttl_seconds=ttl_seconds) - except PurviewPaymentRequiredError as ex: - # Cache the exception at tenant level so all subsequent requests for this tenant fail fast - await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=ttl_seconds) - raise + return await self._process_with_cached_scopes(pc_request, cached_ps_resp, cache_key) + task = asyncio.create_task(self._refresh_protection_scopes_background(ps_req, cache_key, pc_request)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + return await self._call_process_content(pc_request, cache_key, dlp_actions=[]) + + async def _process_with_cached_scopes( + self, + pc_request: ProcessContentRequest, + ps_resp: ProtectionScopesResponse, + cache_key: str, + ) -> ProcessContentResponse: if ps_resp.scope_identifier: pc_request.scope_identifier = ps_resp.scope_identifier @@ -259,13 +260,7 @@ async def _process_with_scopes(self, pc_request: ProcessContentRequest) -> Proce task.add_done_callback(self._background_tasks.discard) return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id) - pc_resp = await self._client.process_content(pc_request) - - if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED: - await self._cache.remove(cache_key) - - pc_resp.policy_actions = self._combine_policy_actions(pc_resp.policy_actions, dlp_actions) - return pc_resp + return await self._call_process_content(pc_request, cache_key, dlp_actions=dlp_actions) # No applicable scopes - send content activities in background ca_req = ContentActivitiesRequest( @@ -281,12 +276,52 @@ async def _process_with_scopes(self, pc_request: ProcessContentRequest) -> Proce # Respond with HttpStatusCode 204(No Content) return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id) + async def _call_process_content( + self, + pc_request: ProcessContentRequest, + cache_key: str, + dlp_actions: list[DlpActionInfo], + ) -> ProcessContentResponse: + pc_resp = await self._client.process_content(pc_request) + + if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED: + await self._cache.remove(cache_key) + + if dlp_actions: + pc_resp.policy_actions = self._combine_policy_actions(pc_resp.policy_actions, dlp_actions) + return pc_resp + + async def _refresh_protection_scopes_background( + self, ps_req: ProtectionScopesRequest, cache_key: str, pc_request: ProcessContentRequest + ) -> None: + """Fetch protection scopes and warm the cache without blocking the foreground call.""" + ttl = self._settings.get("cache_ttl_seconds") + ttl_seconds = ttl if ttl is not None else 14400 + try: + ps_resp = await self._client.get_protection_scopes(ps_req) + await self._cache.set(cache_key, ps_resp, ttl_seconds=ttl_seconds) + should_process, _, _ = self._check_applicable_scopes(pc_request, ps_resp) + if not should_process: + ca_req = ContentActivitiesRequest( + user_id=pc_request.user_id, + tenant_id=pc_request.tenant_id, + content_to_process=pc_request.content_to_process, + correlation_id=pc_request.correlation_id, + ) + await self._send_content_activities_background(ca_req) + except PurviewPaymentRequiredError as ex: + tenant_payment_cache_key = f"purview:payment_required:{ps_req.tenant_id}" + await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=ttl_seconds) + logger.warning("Background protection scopes refresh failed with payment required: %s", ex) + except Exception as ex: + logger.warning("Background protection scopes refresh failed: %s", ex) + async def _process_content_background(self, pc_request: ProcessContentRequest, cache_key: str) -> None: """Process content in background for offline execution mode.""" try: pc_resp = await self._client.process_content(pc_request) - # If protection scope state is modified, make another PC request and invalidate cache + # If protection scopes changed, invalidate cache and retry once. if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED: await self._cache.remove(cache_key) await self._client.process_content(pc_request) @@ -306,14 +341,10 @@ async def _send_content_activities_background(self, ca_req: ContentActivitiesReq def _combine_policy_actions( existing: list[DlpActionInfo] | None, new_actions: list[DlpActionInfo] ) -> list[DlpActionInfo]: - by_key: dict[str, DlpActionInfo] = {} - for a in existing or []: - if a.action: - by_key[a.action] = a - for a in new_actions: - if a.action: - by_key[a.action] = a - return list(by_key.values()) + combined: dict[tuple[DlpAction | None, RestrictionAction | None], DlpActionInfo] = {} + for action_info in (existing or []) + new_actions: + combined.setdefault((action_info.action, action_info.restriction_action), action_info) + return list(combined.values()) @staticmethod def _check_applicable_scopes( diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index 285fb338d8c..0cc9d7a8a99 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -2,6 +2,7 @@ """Tests for Purview processor.""" +import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -217,10 +218,38 @@ async def test_combine_policy_actions(self, processor: ScopedContentProcessor) - assert action1 in combined assert action2 in combined + async def test_combine_policy_actions_preserves_restriction_only_actions( + self, processor: ScopedContentProcessor + ) -> None: + """Test _combine_policy_actions keeps actions that only set restrictionAction.""" + existing_action = DlpActionInfo(action=DlpAction.OTHER, restrictionAction=RestrictionAction.OTHER) + restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK) + + combined = processor._combine_policy_actions([existing_action], [restriction_only_action]) + + assert combined == [existing_action, restriction_only_action] + + async def test_combine_policy_actions_deduplicates_by_action_and_restriction( + self, processor: ScopedContentProcessor + ) -> None: + """Test _combine_policy_actions removes exact duplicate actions.""" + block_action = DlpActionInfo(action=DlpAction.BLOCK_ACCESS, restriction_action=RestrictionAction.BLOCK) + duplicate_block_action = DlpActionInfo( + action=DlpAction.BLOCK_ACCESS, restriction_action=RestrictionAction.BLOCK + ) + restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK) + + combined = processor._combine_policy_actions( + [block_action], + [duplicate_block_action, restriction_only_action], + ) + + assert combined == [block_action, restriction_only_action] + async def test_process_with_scopes_calls_client_methods( self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory ) -> None: - """Test _process_with_scopes calls get_protection_scopes when scopes response is empty.""" + """Test _process_with_scopes calls process_content immediately and warms scopes in background on cache miss.""" from agent_framework_purview._models import ( ContentActivitiesResponse, ProtectionScopesResponse, @@ -236,38 +265,91 @@ async def test_process_with_scopes_calls_client_methods( response = await processor._process_with_scopes(request) + # On cache miss, ProcessContent runs in the foreground and the response is returned. + assert response.id == "response-123" + mock_client.process_content.assert_called_once() + + # Protection scopes are refreshed in a background task. + await asyncio.gather(*list(processor._background_tasks)) mock_client.get_protection_scopes.assert_called_once() - # When no scopes apply, process_content is not called (activities are sent in background) - mock_client.process_content.assert_not_called() - # The response should have id=204 (No Content) when no scopes apply - assert response.id == "204" + mock_client.send_content_activities.assert_called_once() - async def test_process_with_scopes_ignores_unexpected_cached_value_type( + async def test_process_with_scopes_preserves_restriction_only_policy_actions( self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory ) -> None: - """Test that a corrupted cache entry does not crash processing.""" + """Test cold-cache ProcessContent actions are not dropped when they only contain restrictionAction.""" + from agent_framework_purview._models import ProtectionScopesResponse + + request = process_content_request_factory() + restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK) + + mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": []})) + mock_client.process_content = AsyncMock( + return_value=ProcessContentResponse( + id="response-123", + protection_scope_state="notModified", + policy_actions=[restriction_only_action], + ) + ) + + response = await processor._process_with_scopes(request) + + assert response.policy_actions == [restriction_only_action] + await asyncio.gather(*list(processor._background_tasks)) + + async def test_process_with_cached_scopes_preserves_restriction_only_policy_actions( + self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory + ) -> None: + """Test cached ProtectionScopes actions are not dropped when they only contain restrictionAction.""" from agent_framework_purview._models import ( ExecutionMode, PolicyLocation, PolicyScope, - ProcessContentResponse, ProtectionScopeActivities, ProtectionScopesResponse, ) request = process_content_request_factory() + restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK) + process_content_action = DlpActionInfo(action=DlpAction.OTHER, restriction_action=RestrictionAction.OTHER) + scope_location = PolicyLocation( + data_type="microsoft.graph.policyLocationApplication", + value="app-id", + ) + scope = PolicyScope( + activities=ProtectionScopeActivities.UPLOAD_TEXT, + locations=[scope_location], + policy_actions=[restriction_only_action], + execution_mode=ExecutionMode.EVALUATE_INLINE, + ) - # Return a valid, inline scope so we stay on the normal (non-background) path. - scope_location = PolicyLocation(**{ - "@odata.type": "microsoft.graph.policyLocationApplication", - "value": "app-id", - }) - scope = PolicyScope(**{ - "activities": ProtectionScopeActivities.UPLOAD_TEXT, - "locations": [scope_location], - "execution_mode": ExecutionMode.EVALUATE_INLINE, - }) - mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": [scope]})) + processor._cache.get = AsyncMock( + side_effect=[ + None, + ProtectionScopesResponse(scope_identifier="scope-123", scopes=[scope]), + ] + ) # type: ignore[method-assign] + mock_client.process_content = AsyncMock( + return_value=ProcessContentResponse( + id="response-123", + protection_scope_state="notModified", + policy_actions=[process_content_action], + ) + ) + + response = await processor._process_with_scopes(request) + + assert response.policy_actions == [process_content_action, restriction_only_action] + + async def test_process_with_scopes_ignores_unexpected_cached_value_type( + self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory + ) -> None: + """Test that a corrupted cache entry does not crash processing.""" + from agent_framework_purview._models import ProtectionScopesResponse + + request = process_content_request_factory() + + mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": []})) mock_client.process_content = AsyncMock( return_value=ProcessContentResponse(**{"id": "ok", "protectionScopeState": "notModified"}) ) @@ -279,8 +361,9 @@ async def test_process_with_scopes_ignores_unexpected_cached_value_type( response = await processor._process_with_scopes(request) assert response.id == "ok" - mock_client.get_protection_scopes.assert_called_once() mock_client.process_content.assert_called_once() + await asyncio.gather(*list(processor._background_tasks)) + mock_client.get_protection_scopes.assert_called_once() async def test_process_with_scopes_uses_tenant_payment_exception_cache( self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory @@ -301,8 +384,6 @@ async def test_process_content_background_retries_on_modified_state( self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory ) -> None: """Test offline background processing invalidates cache and retries when scope state changes.""" - from agent_framework_purview._models import ProcessContentResponse - request = process_content_request_factory() request.scope_identifier = "etag-1" @@ -319,6 +400,36 @@ async def test_process_content_background_retries_on_modified_state( processor._cache.remove.assert_called_once_with("purview:protection_scopes:abc") assert mock_client.process_content.call_count == 2 + async def test_background_scope_refresh_caches_payment_required( + self, mock_client: AsyncMock, process_content_request_factory + ) -> None: + """402 raised during background scope refresh is cached at the tenant level.""" + from agent_framework_purview._cache import InMemoryCacheProvider + from agent_framework_purview._exceptions import PurviewPaymentRequiredError + + settings = PurviewSettings( + app_name="Test App", + tenant_id="12345678-1234-1234-1234-123456789012", + purview_app_location=PurviewAppLocation( + location_type=PurviewLocationType.APPLICATION, location_value="app-id" + ), + ) + + cache = InMemoryCacheProvider() + processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache) + + mock_client.get_protection_scopes = AsyncMock(side_effect=PurviewPaymentRequiredError("nope")) + mock_client.process_content = AsyncMock( + return_value=ProcessContentResponse(**{"id": "pc-1", "protectionScopeState": "notModified"}) + ) + + request = process_content_request_factory() + await processor._process_with_scopes(request) + await asyncio.gather(*list(processor._background_tasks)) + + cached = await cache.get(f"purview:payment_required:{request.tenant_id}") + assert isinstance(cached, PurviewPaymentRequiredError) + async def test_map_messages_with_user_id_in_additional_properties(self, mock_client: AsyncMock) -> None: """Test user_id extraction from message additional_properties.""" settings = PurviewSettings( @@ -387,6 +498,8 @@ async def test_process_content_sends_activities_when_not_applicable( self, mock_client: AsyncMock, process_content_request_factory ) -> None: """Test that response is returned when scopes don't apply (activities sent in background).""" + from agent_framework_purview._models import ProtectionScopesResponse + settings = PurviewSettings( app_name="Test App", tenant_id="12345678-1234-1234-1234-123456789012", @@ -398,10 +511,8 @@ async def test_process_content_sends_activities_when_not_applicable( pc_request = process_content_request_factory() - # Mock get_protection_scopes to return no applicable scopes - mock_ps_response = MagicMock() - mock_ps_response.scopes = [] - mock_client.get_protection_scopes.return_value = mock_ps_response + mock_ps_response = ProtectionScopesResponse(scopes=[]) + processor._cache.get = AsyncMock(side_effect=[None, mock_ps_response]) # type: ignore[method-assign] # Mock send_content_activities to return success (called in background) mock_ca_response = MagicMock() @@ -410,8 +521,10 @@ async def test_process_content_sends_activities_when_not_applicable( response = await processor._process_with_scopes(pc_request) - mock_client.get_protection_scopes.assert_called_once() + mock_client.get_protection_scopes.assert_not_called() mock_client.process_content.assert_not_called() + await asyncio.gather(*list(processor._background_tasks)) + mock_client.send_content_activities.assert_called_once() # Response should have id=204 when no scopes apply assert response.id == "204" @@ -419,6 +532,8 @@ async def test_process_content_handles_activities_error( self, mock_client: AsyncMock, process_content_request_factory ) -> None: """Test that errors in background activities don't affect the response.""" + from agent_framework_purview._models import ProtectionScopesResponse + settings = PurviewSettings( app_name="Test App", tenant_id="12345678-1234-1234-1234-123456789012", @@ -430,10 +545,8 @@ async def test_process_content_handles_activities_error( pc_request = process_content_request_factory() - # Mock get_protection_scopes to return no applicable scopes - mock_ps_response = MagicMock() - mock_ps_response.scopes = [] - mock_client.get_protection_scopes.return_value = mock_ps_response + mock_ps_response = ProtectionScopesResponse(scopes=[]) + processor._cache.get = AsyncMock(side_effect=[None, mock_ps_response]) # type: ignore[method-assign] # Mock send_content_activities to return error (called in background task) mock_ca_response = MagicMock() @@ -445,6 +558,8 @@ async def test_process_content_handles_activities_error( # Since activities are sent in background, errors don't affect the response # Response should have id=204 when no scopes apply assert response.id == "204" + await asyncio.gather(*list(processor._background_tasks)) + mock_client.send_content_activities.assert_called_once() class TestUserIdResolution: @@ -656,10 +771,12 @@ async def test_protection_scopes_cached_on_first_call( mock_client.get_protection_scopes.return_value = ProtectionScopesResponse( scope_identifier="scope-123", scopes=[] ) + mock_client.process_content.return_value = ProcessContentResponse(id="ok", protection_scope_state="notModified") messages = [Message(role="user", contents=["Test"])] await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012") + await asyncio.gather(*list(processor._background_tasks)) mock_client.get_protection_scopes.assert_called_once() @@ -670,7 +787,7 @@ async def test_protection_scopes_cached_on_first_call( async def test_payment_required_exception_cached_at_tenant_level( self, mock_client: AsyncMock, settings: PurviewSettings ) -> None: - """Test that 402 payment required exceptions are cached at tenant level.""" + """Test that background scope 402 returns once, then throws from the tenant-level cache.""" from agent_framework_purview._cache import InMemoryCacheProvider from agent_framework_purview._exceptions import PurviewPaymentRequiredError @@ -678,13 +795,12 @@ async def test_payment_required_exception_cached_at_tenant_level( processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache_provider) mock_client.get_protection_scopes.side_effect = PurviewPaymentRequiredError("Payment required") + mock_client.process_content.return_value = ProcessContentResponse(id="ok", protection_scope_state="notModified") messages = [Message(role="user", contents=["Test"])] - with pytest.raises(PurviewPaymentRequiredError): - await processor.process_messages( - messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012" - ) + await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012") + await asyncio.gather(*list(processor._background_tasks)) mock_client.get_protection_scopes.assert_called_once() diff --git a/python/pyproject.toml b/python/pyproject.toml index 1cdeec22826..0a4e6f34a93 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.8.0" +version = "1.8.1" 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.8.0", + "agent-framework-core[all]==1.8.1", ] [dependency-groups] diff --git a/python/samples/02-agents/harness/README.md b/python/samples/02-agents/harness/README.md index 3bf0f091102..15424e1422d 100644 --- a/python/samples/02-agents/harness/README.md +++ b/python/samples/02-agents/harness/README.md @@ -45,13 +45,23 @@ python samples/02-agents/harness/harness_research.py ### Minimal Setup -`create_harness_agent` requires only a chat client and token budget parameters: +`create_harness_agent` requires only a chat client: ```python from agent_framework import create_harness_agent from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential +agent = create_harness_agent( + client=FoundryChatClient(credential=AzureCliCredential()), +) +``` + +### With Compaction + +Provide token budget parameters to enable automatic context-window compaction: + +```python agent = create_harness_agent( client=FoundryChatClient(credential=AzureCliCredential()), max_context_window_tokens=128_000, @@ -59,7 +69,7 @@ agent = create_harness_agent( ) ``` -### Customization +### Further Customization Disable or customize any feature: diff --git a/python/samples/02-agents/harness/console/agent_runner.py b/python/samples/02-agents/harness/console/agent_runner.py index 3b7c685dbd6..743ad1e1328 100644 --- a/python/samples/02-agents/harness/console/agent_runner.py +++ b/python/samples/02-agents/harness/console/agent_runner.py @@ -313,9 +313,7 @@ async def _collect_follow_up_actions( """ actions: list[FollowUpAction] = [] for observer in self._observers: - observer_actions = await observer.on_stream_complete( - self._ux, self._agent, session - ) + observer_actions = await observer.on_stream_complete(self._ux, self._agent, session) if observer_actions: actions.extend(observer_actions) return actions diff --git a/python/samples/02-agents/harness/console/app.py b/python/samples/02-agents/harness/console/app.py index c56360c661b..e2260eb2005 100644 --- a/python/samples/02-agents/harness/console/app.py +++ b/python/samples/02-agents/harness/console/app.py @@ -182,18 +182,12 @@ def __init__( if command_handlers is None: from .commands import build_default_command_handlers - self._command_handlers = build_default_command_handlers( - agent, mode_colors=mode_colors - ) + self._command_handlers = build_default_command_handlers(agent, mode_colors=mode_colors) else: self._command_handlers = command_handlers # Compute help text from command handlers - help_parts = [ - h.get_help_text() - for h in self._command_handlers - if h.get_help_text() is not None - ] + help_parts = [h.get_help_text() for h in self._command_handlers if h.get_help_text() is not None] help_text = ", ".join(help_parts) if help_parts else None # State and driver diff --git a/python/samples/02-agents/harness/console/commands/todo_handler.py b/python/samples/02-agents/harness/console/commands/todo_handler.py index 73703e6db34..e32ffd3f6a4 100644 --- a/python/samples/02-agents/harness/console/commands/todo_handler.py +++ b/python/samples/02-agents/harness/console/commands/todo_handler.py @@ -45,9 +45,7 @@ async def try_handle( ux.append_info_line("TodoProvider is not available.") return True - todos = await self._todo_provider.store.load_items( - session, source_id=self._todo_provider.source_id - ) + todos = await self._todo_provider.store.load_items(session, source_id=self._todo_provider.source_id) if not todos: ux.append_info_line("No todos yet.") diff --git a/python/samples/02-agents/harness/console/components/scroll_panel.py b/python/samples/02-agents/harness/console/components/scroll_panel.py index a9cf15a7749..35b478b54bc 100644 --- a/python/samples/02-agents/harness/console/components/scroll_panel.py +++ b/python/samples/02-agents/harness/console/components/scroll_panel.py @@ -72,7 +72,7 @@ def set_streaming_entry(self, entry: OutputEntry) -> None: # Truncate lines back to where streaming started if len(self.lines) > self._streaming_line_start: - del self.lines[self._streaming_line_start:] + del self.lines[self._streaming_line_start :] from textual.geometry import Size self.virtual_size = Size(self._widest_line_width, len(self.lines)) diff --git a/python/samples/02-agents/harness/console/observers/planning_models.py b/python/samples/02-agents/harness/console/observers/planning_models.py index 9b4a92e5757..d4c425b0783 100644 --- a/python/samples/02-agents/harness/console/observers/planning_models.py +++ b/python/samples/02-agents/harness/console/observers/planning_models.py @@ -41,8 +41,7 @@ class PlanningQuestion(BaseModel): choices: list[str] | None = Field( default=None, description=( - "For clarifications, this has a list of options that the user can " - "choose from. null for approvals." + "For clarifications, this has a list of options that the user can choose from. null for approvals." ), ) diff --git a/python/samples/02-agents/mcp/README.md b/python/samples/02-agents/mcp/README.md index de57286320e..53af7d31a83 100644 --- a/python/samples/02-agents/mcp/README.md +++ b/python/samples/02-agents/mcp/README.md @@ -14,6 +14,7 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to | **API Key Authentication** | [`mcp_api_key_auth.py`](mcp_api_key_auth.py) | Demonstrates API key authentication with MCP servers using `header_provider`, runtime invocation kwargs, and a command-line API key argument | | **GitHub Integration with PAT** | [`mcp_github_pat.py`](mcp_github_pat.py) | Demonstrates connecting to GitHub's MCP server using Personal Access Token (PAT) authentication | | **Long-Running Task** | [`mcp_long_running_task.py`](mcp_long_running_task.py) | Demonstrates transparent SEP-2663 long-running task handling for MCP tools that advertise `taskSupport=required`. Self-spawns a stdio MCP child server | +| **Sampling Approval** | [`mcp_sampling_approval.py`](mcp_sampling_approval.py) | Demonstrates gating server-initiated `sampling/createMessage` requests with a `sampling_approval_callback`, plus the `sampling_max_tokens` and `sampling_max_requests` guardrails. MCP sampling is denied by default | ## Prerequisites diff --git a/python/samples/02-agents/mcp/mcp_sampling_approval.py b/python/samples/02-agents/mcp/mcp_sampling_approval.py new file mode 100644 index 00000000000..0d359b7aecb --- /dev/null +++ b/python/samples/02-agents/mcp/mcp_sampling_approval.py @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent, MCPStreamableHTTPTool +from agent_framework.openai import OpenAIChatClient +from dotenv import load_dotenv +from mcp import types + +# Load environment variables from .env file +load_dotenv() + +""" +MCP Sampling Approval Example + +MCP servers can send the client a ``sampling/createMessage`` request, asking the +client to run an LLM completion on the server's behalf. Because remote MCP +servers are untrusted third parties, forwarding these server-controlled prompts +to your chat client without review is a confused-deputy risk: a malicious server +could exfiltrate context, force tool calls, or burn through your token budget. + +For that reason Agent Framework **denies MCP sampling by default**. To allow it, +pass a ``sampling_approval_callback`` to the MCP tool. The callback receives the +raw ``CreateMessageRequestParams`` and returns ``True`` to approve or ``False`` +to deny. It may be synchronous or asynchronous, so you can implement a +human-in-the-loop prompt, a policy check, or an audit log. + +Two further guardrails apply to approved requests: +- ``sampling_max_tokens`` caps the server-requested ``maxTokens``. +- ``sampling_max_requests`` limits how many sampling requests a single session + may make. + +To restore the legacy "always approve" behavior (only do this for servers you +trust), pass ``sampling_approval_callback=lambda params: True``. +""" + + +async def approve_sampling(params: types.CreateMessageRequestParams) -> bool: + """Human-in-the-loop approval gate for server-initiated sampling. + + Shows the server-supplied system prompt and messages, then asks the user to + approve or deny. Returning ``False`` rejects the request. + """ + print("\n--- MCP server requested a sampling/createMessage ---") + if params.systemPrompt: + print(f"System prompt: {params.systemPrompt}") + for message in params.messages: + text = getattr(message.content, "text", message.content) + print(f"{message.role}: {text}") + answer = await asyncio.to_thread(input, "Approve this sampling request? [y/N]: ") + return answer.strip().lower() in {"y", "yes"} + + +async def main() -> None: + """Run an agent against an MCP server with a sampling approval gate.""" + async with Agent( + client=OpenAIChatClient(), + name="Agent", + instructions="You are a helpful assistant. Use your MCP tool when answering the user's question.", + tools=MCPStreamableHTTPTool( + name="MCP tool", + description="MCP tool description.", + url="", + # Passing ``client`` enables sampling; the approval callback gates it. + client=OpenAIChatClient(), + sampling_approval_callback=approve_sampling, + sampling_max_tokens=2048, + sampling_max_requests=5, + ), + ) as agent: + query = "Use your MCP tool to help answer this question." + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result.text}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/05-end-to-end/purview_agent/README.md b/python/samples/05-end-to-end/purview_agent/README.md index 1cdb7e3ef44..12293ec3068 100644 --- a/python/samples/05-end-to-end/purview_agent/README.md +++ b/python/samples/05-end-to-end/purview_agent/README.md @@ -3,7 +3,7 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluation to an Agent Framework `Agent` using the **middleware** approach. **What this sample demonstrates:** -1. Configure an Azure OpenAI chat client +1. Configure a Foundry chat client 2. Add Purview policy enforcement middleware (`PurviewPolicyMiddleware`) 3. Add Purview policy enforcement at the chat client level (`PurviewChatPolicyMiddleware`) 4. Implement a custom cache provider for advanced caching scenarios @@ -17,8 +17,8 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat | Variable | Required | Purpose | |----------|----------|---------| -| `AZURE_OPENAI_ENDPOINT` | Yes | Azure OpenAI endpoint (https://.openai.azure.com) | -| `AZURE_OPENAI_MODEL` | Optional | Model deployment name (defaults inside SDK if omitted) | +| `FOUNDRY_PROJECT_ENDPOINT` | Yes | Azure AI Foundry project endpoint, for example `https://.services.ai.azure.com/api/projects/` | +| `FOUNDRY_MODEL` | Optional | Model deployment name (defaults to `gpt-4o-mini`) | | `PURVIEW_CLIENT_APP_ID` | Yes* | Client (application) ID used for Purview authentication | | `PURVIEW_USE_CERT_AUTH` | Optional (`true`/`false`) | Switch between certificate and interactive auth | | `PURVIEW_TENANT_ID` | Yes (when cert auth on) | Tenant ID for certificate authentication | @@ -31,7 +31,8 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat Opens a browser on first run to sign in. ```powershell -$env:AZURE_OPENAI_ENDPOINT = "https://your-openai-instance.openai.azure.com" +$env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" +$env:FOUNDRY_MODEL = "gpt-4o-mini" $env:PURVIEW_CLIENT_APP_ID = "00000000-0000-0000-0000-000000000000" ``` @@ -64,22 +65,27 @@ If interactive auth is used, a browser window will appear the first time. ## 4. How It Works -The sample demonstrates three different scenarios: +The sample demonstrates four integration scenarios. Each scenario runs the same three-message sequence via `run_policy_flow(...)`: + +1. **good (cold cache)** - a benign prompt that exercises the cold-cache parallel ProtectionScopes warmup + foreground ProcessContent path. +2. **expected block** - a sensitive prompt containing the Visa test credit card number `4111 1111 1111 1111`. If the tenant has a DLP policy for `Microsoft 365 Copilot and AI apps` targeting the Credit Card sensitive info type with a Block action, this prompt returns the configured `blocked_prompt_message` (default: `Prompt blocked by policy`). If no DLP policy applies, the prompt is allowed (the LLM may still decline on its own, but that is a model-level response, not a Purview block). +3. **good (warm cache)** - a second benign prompt that exercises the warm-cache path. The custom cache provider scenario prints `Cache HIT` for the same protection-scopes key, confirming the cache and middleware state survive a prior block. ### A. Agent Middleware (`run_with_agent_middleware`) -1. Builds an Azure OpenAI chat client (using the environment endpoint / deployment) +1. Builds a Foundry chat client (using the environment project endpoint / deployment) 2. Chooses credential mode (certificate vs interactive) 3. Creates `PurviewPolicyMiddleware` with `PurviewSettings` 4. Injects middleware into the agent at construction -5. Sends two user messages sequentially -6. Prints results (or policy block messages) +5. Runs the three-message `good -> block -> good` orchestration +6. Prints `ALLOWED` or `BLOCKED` per message, plus the model response 7. Uses default caching automatically ### B. Chat Client Middleware (`run_with_chat_middleware`) 1. Creates a chat client with `PurviewChatPolicyMiddleware` attached directly 2. Policy evaluation happens at the chat client level rather than agent level 3. Demonstrates an alternative integration point for Purview policies -4. Uses default caching automatically +4. Runs the same `good -> block -> good` orchestration +5. Uses default caching automatically ### C. Custom Cache Provider (`run_with_custom_cache_provider`) 1. Implements the `CacheProvider` protocol with a custom class (`SimpleDictCacheProvider`) @@ -88,9 +94,27 @@ The sample demonstrates three different scenarios: - `async def get(self, key: str) -> Any | None` - `async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None` - `async def remove(self, key: str) -> None` +4. Runs the `good -> block -> good` orchestration and prints `Cache MISS`/`Cache HIT` traces alongside policy outcomes, showing the cold-cache warmup populating the cache and warm-cache requests skipping ProtectionScopes. + +### D. Default Cache (`run_with_default_cache`) +1. Same as the agent middleware path but with explicit cache TTL and size limits in `PurviewSettings` +2. Uses the default in-memory `CacheProvider` +3. Runs the `good -> block -> good` orchestration **Policy Behavior:** -Prompt blocks set a system-level message: `Prompt blocked by policy` and terminate the run early. Response blocks rewrite the output to `Response blocked by policy`. +Prompt blocks substitute the configured `blocked_prompt_message` (default `Prompt blocked by policy`) and terminate the agent run early. Response blocks substitute `blocked_response_message`. The LLM is never called for a blocked prompt. + +**Seeing a real `BLOCKED` outcome:** +The middle prompt only returns `BLOCKED` if the tenant actually has a Purview DLP policy that matches the request. Specifically, all of the following must be true: + +1. The Entra app id used by `PURVIEW_CLIENT_APP_ID` (the same id Agent Framework sends as `policyLocationApplication.value`) is registered as an integrated AI app in Purview (Settings -> AI app and agent locations). +2. A DLP policy in the tenant targets the location `Microsoft 365 Copilot and AI apps`, scoped to that app id (or `All apps`). +3. The policy has a rule with the condition `Content contains -> Sensitive info types -> Credit Card Number` and an action of `Restrict access to Microsoft 365 Copilot and AI apps -> Block`. +4. The policy is `On` (not `Test mode without notifications`). +5. The signed-in user is in the policy's user scope. +6. Required Graph delegated permissions are admin-consented: `ProtectionScopes.Compute.All`, `Content.Process.All`, `ContentActivity.Write`. + +If any of those are missing, the credit card prompt is allowed at the Purview layer. The model itself may still decline on its own; that response is a model-level refusal, not a Purview block. The cold/warm cache orchestration is still demonstrated either way - the `Cache MISS -> Cache HIT` trace from the custom cache scenario does not depend on a block firing. --- diff --git a/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py b/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py index 5eb28458869..7305ea12e8a 100644 --- a/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py +++ b/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py @@ -11,8 +11,8 @@ Note: Caching is automatic and enabled by default. Environment variables: -- AZURE_OPENAI_ENDPOINT (required) -- AZURE_OPENAI_MODEL (optional, defaults to gpt-4o-mini) +- FOUNDRY_PROJECT_ENDPOINT (required) - Azure AI Foundry project endpoint URL +- FOUNDRY_MODEL (optional, defaults to gpt-4o-mini) - PURVIEW_CLIENT_APP_ID (required) - PURVIEW_USE_CERT_AUTH (optional, set to "true" for certificate auth) - PURVIEW_TENANT_ID (required if certificate auth) @@ -45,6 +45,37 @@ JOKER_NAME = "Joker" JOKER_INSTRUCTIONS = "You are good at telling jokes. Keep responses concise." +# Sequential prompts to demonstrate good -> block -> good orchestration. +# The sensitive prompt contains a Visa test credit card number that matches Purview's +# built-in Credit Card sensitive information type. If the tenant has a DLP policy that +# blocks credit card content for Microsoft 365 Copilot and AI apps, the second message +# will be blocked and the third will verify that subsequent calls still flow normally +# after a block. +GOOD_PROMPT_PRIMARY = "Tell me a joke about a pirate." +SENSITIVE_PROMPT = "My corporate credit card is 4111 1111 1111 1111. Please confirm receipt." +GOOD_PROMPT_FOLLOWUP = "Another light joke please." + + +async def run_policy_flow( + label: str, + agent: Agent, + user_id: str | None, + blocked_text: str, +) -> None: + """Run a good -> block candidate -> good sequence and report each outcome.""" + blocked_marker = blocked_text.lower() + prompts = [ + ("good (cold cache)", GOOD_PROMPT_PRIMARY), + ("expected block", SENSITIVE_PROMPT), + ("good (warm cache)", GOOD_PROMPT_FOLLOWUP), + ] + for tag, text in prompts: + response: AgentResponse = await agent.run( + Message("user", [text], additional_properties={"user_id": user_id}) + ) + outcome = "BLOCKED" if blocked_marker in str(response).lower() else "ALLOWED" + print(f"[{label}] {tag}: {outcome}\n{response}\n") + # Custom Cache Provider Implementation class SimpleDictCacheProvider: @@ -138,21 +169,17 @@ def build_credential() -> Any: async def run_with_agent_middleware() -> None: - endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") + endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") if not endpoint: - print("Skipping run: AZURE_OPENAI_ENDPOINT not set") + print("Skipping run: FOUNDRY_PROJECT_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini") + deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) + client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential()) - purview_agent_middleware = PurviewPolicyMiddleware( - build_credential(), - PurviewSettings( - app_name="Agent Framework Sample App", - ), - ) + settings = PurviewSettings(app_name="Agent Framework Sample App") + purview_agent_middleware = PurviewPolicyMiddleware(build_credential(), settings) agent = Agent( client=client, @@ -162,39 +189,26 @@ async def run_with_agent_middleware() -> None: ) print("-- Agent MiddlewareTypes Path --") - first: AgentResponse = await agent.run( - Message("user", ["Tell me a joke about a pirate."], additional_properties={"user_id": user_id}) - ) - print("First response (agent middleware):\n", first) - - second: AgentResponse = await agent.run( - Message( - role="user", contents=["That was funny. Tell me another one."], additional_properties={"user_id": user_id} - ) - ) - print("Second response (agent middleware):\n", second) + blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy" + await run_policy_flow("agent middleware", agent, user_id, blocked_text) async def run_with_chat_middleware() -> None: - endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") + endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") if not endpoint: - print("Skipping chat middleware run: AZURE_OPENAI_ENDPOINT not set") + print("Skipping chat middleware run: FOUNDRY_PROJECT_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_MODEL", default="gpt-4o-mini") + deployment = os.environ.get("FOUNDRY_MODEL", default="gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") + settings = PurviewSettings(app_name="Agent Framework Sample App (Chat)") client = FoundryChatClient( model=deployment, - endpoint=endpoint, + project_endpoint=endpoint, credential=AzureCliCredential(), middleware=[ - PurviewChatPolicyMiddleware( - build_credential(), - PurviewSettings( - app_name="Agent Framework Sample App (Chat)", - ), - ) + PurviewChatPolicyMiddleware(build_credential(), settings) ], ) @@ -205,43 +219,27 @@ async def run_with_chat_middleware() -> None: ) print("-- Chat MiddlewareTypes Path --") - first: AgentResponse = await agent.run( - Message( - role="user", - contents=["Give me a short clean joke."], - additional_properties={"user_id": user_id}, - ) - ) - print("First response (chat middleware):\n", first) - - second: AgentResponse = await agent.run( - Message( - role="user", - contents=["One more please."], - additional_properties={"user_id": user_id}, - ) - ) - print("Second response (chat middleware):\n", second) + blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy" + await run_policy_flow("chat middleware", agent, user_id, blocked_text) async def run_with_custom_cache_provider() -> None: """Demonstrate implementing and using a custom cache provider.""" - endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") + endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") if not endpoint: - print("Skipping custom cache provider run: AZURE_OPENAI_ENDPOINT not set") + print("Skipping custom cache provider run: FOUNDRY_PROJECT_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini") + deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) + client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential()) custom_cache = SimpleDictCacheProvider() + settings = PurviewSettings(app_name="Agent Framework Sample App (Custom Provider)") purview_agent_middleware = PurviewPolicyMiddleware( build_credential(), - PurviewSettings( - app_name="Agent Framework Sample App (Custom Provider)", - ), + settings, cache_provider=custom_cache, ) @@ -254,38 +252,28 @@ async def run_with_custom_cache_provider() -> None: print("-- Custom Cache Provider Path --") print("Using SimpleDictCacheProvider") + blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy" + await run_policy_flow("custom cache", agent, user_id, blocked_text) - first: AgentResponse = await agent.run( - Message( - role="user", contents=["Tell me a joke about a programmer."], additional_properties={"user_id": user_id} - ) - ) - print("First response (custom provider):\n", first) - - second: AgentResponse = await agent.run( - Message("user", ["That's hilarious! One more?"], additional_properties={"user_id": user_id}) - ) - print("Second response (custom provider):\n", second) +async def run_with_default_cache() -> None: """Demonstrate using the default built-in cache.""" - endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") + endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") if not endpoint: - print("Skipping default cache run: AZURE_OPENAI_ENDPOINT not set") + print("Skipping default cache run: FOUNDRY_PROJECT_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini") + deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) + client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential()) # No cache_provider specified - uses default InMemoryCacheProvider - purview_agent_middleware = PurviewPolicyMiddleware( - build_credential(), - PurviewSettings( - app_name="Agent Framework Sample App (Default Cache)", - cache_ttl_seconds=3600, - max_cache_size_bytes=100 * 1024 * 1024, # 100MB - ), + settings = PurviewSettings( + app_name="Agent Framework Sample App (Default Cache)", + cache_ttl_seconds=3600, + max_cache_size_bytes=100 * 1024 * 1024, # 100MB ) + purview_agent_middleware = PurviewPolicyMiddleware(build_credential(), settings) agent = Agent( client=client, @@ -296,16 +284,8 @@ async def run_with_custom_cache_provider() -> None: print("-- Default Cache Path --") print("Using default InMemoryCacheProvider with settings-based configuration") - - first: AgentResponse = await agent.run( - Message("user", ["Tell me a joke about AI."], additional_properties={"user_id": user_id}) - ) - print("First response (default cache):\n", first) - - second: AgentResponse = await agent.run( - Message("user", ["Nice! Another AI joke please."], additional_properties={"user_id": user_id}) - ) - print("Second response (default cache):\n", second) + blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy" + await run_policy_flow("default cache", agent, user_id, blocked_text) async def main() -> None: @@ -326,6 +306,11 @@ async def main() -> None: except Exception as ex: # pragma: no cover - demo resilience print(f"Custom cache provider path failed: {ex}") + try: + await run_with_default_cache() + except Exception as ex: # pragma: no cover - demo resilience + print(f"Default cache path failed: {ex}") + if __name__ == "__main__": asyncio.run(main()) diff --git a/python/uv.lock b/python/uv.lock index 35007ebace1..72aa2d01ad6 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -115,7 +115,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.8.0" +version = "1.8.1" source = { virtual = "." } dependencies = [ { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -185,7 +185,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0rc3" +version = "1.0.0rc4" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -279,7 +279,7 @@ requires-dist = [ [[package]] name = "agent-framework-azurefunctions" -version = "1.0.0b260604" +version = "1.0.0b260609" source = { editable = "packages/azurefunctions" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -333,7 +333,7 @@ requires-dist = [ [[package]] name = "agent-framework-claude" -version = "1.0.0b260521" +version = "1.0.0b260609" source = { editable = "packages/claude" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -363,7 +363,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.8.0" +version = "1.8.1" source = { editable = "packages/core" } dependencies = [ { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -529,7 +529,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260518" }] [[package]] name = "agent-framework-foundry" -version = "1.8.0" +version = "1.8.1" source = { editable = "packages/foundry" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -548,7 +548,7 @@ requires-dist = [ [[package]] name = "agent-framework-foundry-hosting" -version = "1.0.0a260604" +version = "1.0.0a260609" source = { editable = "packages/foundry_hosting" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -584,7 +584,7 @@ requires-dist = [ [[package]] name = "agent-framework-gemini" -version = "1.0.0a260521" +version = "1.0.0a260609" source = { editable = "packages/gemini" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -714,7 +714,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b260521" +version = "1.0.0b260609" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -774,7 +774,7 @@ requires-dist = [ [[package]] name = "agent-framework-openai" -version = "1.8.0" +version = "1.8.1" source = { editable = "packages/openai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },