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