diff --git a/.github/workflows/dotnet-integration-tests.yml b/.github/workflows/dotnet-integration-tests.yml index 5b08752abb..4a10694bda 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 52adbcb8e4..c33d3e016a 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 9cb714813a..258a8c0704 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/readme-banner.png b/docs/assets/readme-banner.png index defc110e49..0b79ce6cc9 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 dbdd6d37d1..4fd3b57727 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 e4e4b83e71..76a4444bc1 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 e395627bc9..3160eb6de7 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -24,7 +24,6 @@ - @@ -129,6 +128,7 @@ + @@ -194,6 +194,8 @@ + + @@ -344,6 +346,9 @@ + + + @@ -622,6 +627,7 @@ + @@ -675,5 +681,6 @@ + diff --git a/dotnet/eng/verify-samples/AgentsSamples.cs b/dotnet/eng/verify-samples/AgentsSamples.cs index 589b3a6fb8..66d68248f6 100644 --- a/dotnet/eng/verify-samples/AgentsSamples.cs +++ b/dotnet/eng/verify-samples/AgentsSamples.cs @@ -50,19 +50,6 @@ internal static class AgentsSamples ], }, - new SampleDefinition - { - Name = "Agent_With_AzureAIAgentsPersistent", - ProjectPath = "samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent", - RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"], - OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ExpectedOutputDescription = - [ - "The output should contain a joke about a pirate.", - "The output should not contain error messages or stack traces.", - ], - }, - new SampleDefinition { Name = "Agent_With_AzureAIProject", diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 28bc03d112..c5c14856da 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/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs deleted file mode 100644 index af41e69c77..0000000000 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#pragma warning disable CS0618 // Type or member is obsolete - sample uses deprecated PersistentAgentsClientExtensions - -// This sample shows how to create and use a simple AI agent with Microsoft Foundry Agents as the backend. - -using Azure.AI.Agents.Persistent; -using Azure.Identity; -using Microsoft.Agents.AI; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; - -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - -// Get a client to create/retrieve server side agents with. -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -var persistentAgentsClient = new PersistentAgentsClient(endpoint, new DefaultAzureCredential()); - -// You can create a server side persistent agent with the Azure.AI.Agents.Persistent SDK. -var agentMetadata = await persistentAgentsClient.Administration.CreateAgentAsync( - model: deploymentName, - name: JokerName, - instructions: JokerInstructions); - -// You can retrieve an already created server side persistent agent as an AIAgent. -AIAgent agent1 = await persistentAgentsClient.GetAIAgentAsync(agentMetadata.Value.Id); - -// You can also create a server side persistent agent and return it as an AIAgent directly. -AIAgent agent2 = await persistentAgentsClient.CreateAIAgentAsync( - model: deploymentName, - name: JokerName, - instructions: JokerInstructions); - -// You can then invoke the agent like any other AIAgent. -AgentSession session = await agent1.CreateSessionAsync(); -Console.WriteLine(await agent1.RunAsync("Tell me a joke about a pirate.", session)); - -// Cleanup for sample purposes. -await persistentAgentsClient.Administration.DeleteAgentAsync(agent1.Id); -await persistentAgentsClient.Administration.DeleteAgentAsync(agent2.Id); diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md deleted file mode 100644 index dbe7c2c12f..0000000000 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Classic Foundry Agents - -This sample demonstrates how to create an agent using the classic Foundry Agents experience. - -# Classic vs New Foundry Agents - -Below is a comparison between the classic and new Foundry Agents approaches: - -[Migration Guide](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/migrate?view=foundry) - -# Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini -``` diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs index 233705d4af..d523e3c8bd 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs @@ -8,8 +8,8 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Foundry; -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; const string JokerName = "JokerAgent"; diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md index 0e225751fb..4a1412838d 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/README.md @@ -1,4 +1,4 @@ -# New Foundry Agents +# New Foundry Agents This sample demonstrates how to create an agent using the new Foundry Agents experience. @@ -21,6 +21,6 @@ Before you begin, ensure you have the following prerequisites: Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint +$env:FOUNDRY_MODEL="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/Program.cs index 556b52bf17..bc55293037 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/Program.cs @@ -13,7 +13,7 @@ using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); -var model = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "Phi-4-mini-instruct"; +var model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "Phi-4-mini-instruct"; // Since we are using the OpenAI Client SDK, we need to override the default endpoint to point to Microsoft Foundry. var clientOptions = new OpenAIClientOptions() { Endpoint = new Uri(endpoint) }; diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/README.md b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/README.md index 9bc4d60881..9e518e9456 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/README.md +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureFoundryModel/README.md @@ -1,4 +1,4 @@ -## Overview +## Overview This sample shows how to use the OpenAI SDK to create and use a simple AI agent with any model hosted in Microsoft Foundry. @@ -13,7 +13,7 @@ Before you begin, ensure you have the following prerequisites: - .NET 10 SDK or later - Microsoft Foundry resource - A model deployment in your Microsoft Foundry resource. This example defaults to using the `Phi-4-mini-instruct` model, -so if you want to use a different model, ensure that you set your `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment +so if you want to use a different model, ensure that you set your `FOUNDRY_MODEL` environment variable to the name of your deployed model. - An API key or role based authentication to access the Microsoft Foundry resource @@ -30,5 +30,5 @@ $env:AZURE_OPENAI_ENDPOINT="https://ai-foundry-.services.ai.azur $env:AZURE_OPENAI_API_KEY="************" # Optional, defaults to Phi-4-mini-instruct -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="Phi-4-mini-instruct" +$env:FOUNDRY_MODEL="Phi-4-mini-instruct" ``` diff --git a/dotnet/samples/02-agents/AgentProviders/README.md b/dotnet/samples/02-agents/AgentProviders/README.md index 5584fdc810..7c0e003713 100644 --- a/dotnet/samples/02-agents/AgentProviders/README.md +++ b/dotnet/samples/02-agents/AgentProviders/README.md @@ -16,7 +16,6 @@ See the README.md for each sample for the prerequisites for that sample. |---|---| |[Creating an AIAgent with A2A](./Agent_With_A2A/)|This sample demonstrates how to create AIAgent for an existing A2A agent.| |[Creating an AIAgent with Anthropic](./Agent_With_Anthropic/)|This sample demonstrates how to create an AIAgent using Anthropic Claude models as the underlying inference service| -|[Creating an AIAgent with Foundry Agents using Azure.AI.Agents.Persistent](./Agent_With_AzureAIAgentsPersistent/)|This sample demonstrates how to create a Foundry Persistent agent and expose it as an AIAgent using the Azure.AI.Agents.Persistent SDK| |[Creating an AIAgent with Foundry Agents using Azure.AI.Project](./Agent_With_AzureAIProject/)|This sample demonstrates how to create an Foundry Project agent and expose it as an AIAgent using the Azure.AI.Project SDK| |[Creating an AIAgent with Foundry Model](./Agent_With_AzureFoundryModel/)|This sample demonstrates how to use any model deployed to Microsoft Foundry to create an AIAgent| |[Creating an AIAgent with Azure OpenAI ChatCompletion](./Agent_With_AzureOpenAIChatCompletion/)|This sample demonstrates how to create an AIAgent using Azure OpenAI ChatCompletion as the underlying inference service| diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs index c9dc86e3b4..83c6823089 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs @@ -26,6 +26,9 @@ var skillsProvider = new AgentSkillsProvider( SubprocessScriptRunner.RunAsync); // --- Agent Setup --- +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetResponsesClient() .AsAIAgent(new ChatClientAgentOptions diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Program.cs index 8c1cfa33bb..059ec7e4a4 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Program.cs @@ -67,6 +67,9 @@ var unitConverterSkill = new AgentInlineSkill( var skillsProvider = new AgentSkillsProvider(unitConverterSkill); // --- Agent Setup --- +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetResponsesClient() .AsAIAgent(new ChatClientAgentOptions diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs index aa70c4461e..d255f1be6e 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs @@ -22,6 +22,9 @@ var unitConverter = new UnitConverterSkill(); var skillsProvider = new AgentSkillsProvider(unitConverter); // --- Agent Setup --- +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetResponsesClient() .AsAIAgent(new ChatClientAgentOptions diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs index 28d5cb9ee9..a67658b444 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs @@ -64,6 +64,9 @@ var skillsProvider = new AgentSkillsProviderBuilder() .Build(); // --- Agent Setup --- +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetResponsesClient() .AsAIAgent(new ChatClientAgentOptions diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs index 251503a918..7c5e594fd2 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs @@ -80,6 +80,9 @@ var weightSkill = new WeightConverterSkill(); var skillsProvider = new AgentSkillsProvider(distanceSkill, weightSkill); // --- Agent Setup --- +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetResponsesClient() .AsAIAgent( diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs index ed3b1315cf..7737eed3c2 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs @@ -16,6 +16,9 @@ var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(guestPath)); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs index 3ae1faccf2..563fee388b 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs @@ -39,6 +39,9 @@ options.Tools = [fetchDocs, queryData, sendEmail]; using var codeAct = new HyperlightCodeActProvider(options); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs index fae83b14fd..cae0613429 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs @@ -31,6 +31,9 @@ var instructions = + "and calling `execute_code` instead of computing values yourself.\n\n" + executeCode.BuildInstructions(toolsVisibleToModel: false); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) 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 0000000000..1217591bc1 --- /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 0000000000..6faa02a0f3 --- /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 0000000000..08f65ecffa --- /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 0000000000..274ac15c97 --- /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 0000000000..6f3027681f --- /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 0000000000..06d4012bd9 --- /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/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs index 402ae47a2d..6129a097c6 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -13,9 +13,9 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Foundry; -string foundryEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string foundryEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? "memory-store-sample"; -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; string embeddingModelName = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002"; // Create an AIProjectClient for Foundry with Azure Identity authentication. diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md index e863b2eada..f0ed4e43cd 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md @@ -1,4 +1,4 @@ -# Agent with Memory Using Microsoft Foundry +# Agent with Memory Using Microsoft Foundry This sample demonstrates how to create and run an agent that uses Microsoft Foundry's managed memory service to extract and retrieve individual memories across sessions. @@ -22,11 +22,11 @@ This sample demonstrates how to create and run an agent that uses Microsoft Foun ```bash # Microsoft Foundry project endpoint and memory store name -export AZURE_AI_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project" +export FOUNDRY_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project" export AZURE_AI_MEMORY_STORE_ID="my_memory_store" # Model deployment names (models deployed in your Foundry project) -export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +export FOUNDRY_MODEL="gpt-5.4-mini" export AZURE_AI_EMBEDDING_DEPLOYMENT_NAME="text-embedding-ada-002" ``` diff --git a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs index 8fc21174a1..0d3cb06fd4 100644 --- a/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs +++ b/dotnet/samples/02-agents/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs @@ -13,8 +13,8 @@ using OpenAI.Files; using OpenAI.Responses; using OpenAI.VectorStores; -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // Create an AI Project client and get an OpenAI client that works with the foundry service. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. 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 a25c626323..5476ec3221 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/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs index 82b9e16fdd..37c0d68d6a 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs @@ -10,8 +10,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using ModelContextProtocol.Server; -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md index 14b0835151..18dfabd9c7 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/README.md @@ -1,4 +1,4 @@ -This sample demonstrates how to expose an existing AI agent as an MCP tool. +This sample demonstrates how to expose an existing AI agent as an MCP tool. ## Run the sample @@ -21,9 +21,9 @@ To use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) ``` 1. Open a web browser and navigate to the URL displayed in the terminal. If not opened automatically, this will open the MCP Inspector interface. 1. In the MCP Inspector interface, add the following environment variables to allow your MCP server to access Microsoft Foundry Project to create and run the agent: - - AZURE_AI_PROJECT_ENDPOINT = https://your-resource.openai.azure.com/ # Replace with your Microsoft Foundry Project endpoint - - AZURE_AI_MODEL_DEPLOYMENT_NAME = gpt-5.4-mini # Replace with your model deployment name + - FOUNDRY_PROJECT_ENDPOINT = https://your-resource.openai.azure.com/ # Replace with your Microsoft Foundry Project endpoint + - FOUNDRY_MODEL = gpt-5.4-mini # Replace with your model deployment name 1. Find and click the `Connect` button in the MCP Inspector interface to connect to the MCP server. 1. As soon as the connection is established, open the `Tools` tab in the MCP Inspector interface and select the `Joker` tool from the list. 1. Specify your prompt as a value for the `query` argument, for example: `Tell me a joke about a pirate` and click the `Run Tool` button to run the tool. -1. The agent will process the request and return a response in accordance with the provided instructions that instruct it to always start each joke with 'Aye aye, captain!'. \ No newline at end of file +1. The agent will process the request and return a response in accordance with the provided instructions that instruct it to always start each joke with 'Aye aye, captain!'. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs index 92222f64e7..e4ed51ddac 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs @@ -8,9 +8,9 @@ using Azure.AI.Agents.Persistent; using Azure.Identity; using Microsoft.Agents.AI; -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); var deepResearchDeploymentName = Environment.GetEnvironmentVariable("AZURE_AI_REASONING_DEPLOYMENT_NAME") ?? "o3-deep-research"; -var modelDeploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +var modelDeploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; var bingConnectionId = Environment.GetEnvironmentVariable("AZURE_AI_BING_CONNECTION_ID") ?? throw new InvalidOperationException("AZURE_AI_BING_CONNECTION_ID is not set."); // Configure extended network timeout for long-running Deep Research tasks. diff --git a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md index ee3c0935a2..ca2ffac65b 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/README.md @@ -1,4 +1,4 @@ -# What this sample demonstrates +# What this sample demonstrates This sample demonstrates how to create an Azure AI Agent with the Deep Research Tool, which leverages the o3-deep-research reasoning model to perform comprehensive research on complex topics. @@ -37,7 +37,7 @@ Set the following environment variables: ```powershell # Replace with your Microsoft Foundry project endpoint -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/" # Replace with your Bing Grounding connection ID (full ARM resource URI) $env:AZURE_AI_BING_CONNECTION_ID="/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/" @@ -46,4 +46,4 @@ $env:AZURE_AI_BING_CONNECTION_ID="/subscriptions//resourceGroups//pr $env:AZURE_AI_REASONING_DEPLOYMENT_NAME="o3-deep-research" # Optional, defaults to gpt-5.4-mini -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_MODEL="gpt-5.4-mini" diff --git a/dotnet/samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment/Program.cs index 447dfe92ee..9731f464db 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment/Program.cs @@ -40,6 +40,9 @@ using OpenAI.Chat; 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"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. var chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs index 0803418ca4..7328366433 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/Program.cs @@ -9,13 +9,16 @@ using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI.Foundry; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; const string JokerName = "JokerAgent"; // Create the AIProjectClient to manage server-side agents. -AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential()); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Create a server-side agent version using the native SDK. ProjectsAgentVersion agentVersion = await aiProjectClient.AgentAdministrationClient.CreateAgentVersionAsync( diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md index 8179c3b299..50b00dc4b7 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step00_FoundryAgentLifecycle/README.md @@ -1,4 +1,4 @@ -# Agent Step 00 - FoundryAgent Lifecycle +# Agent Step 00 - FoundryAgent Lifecycle This sample demonstrates the full lifecycle of a `FoundryAgent` backed by a server-side versioned agent in Microsoft Foundry: create → run → delete. @@ -6,14 +6,14 @@ This sample demonstrates the full lifecycle of a `FoundryAgent` backed by a serv - A Microsoft Foundry project endpoint - A model deployment name (defaults to `gpt-5.4-mini`) -- Azure CLI installed and authenticated +- An authenticated Azure identity (for example, sign in with `az login`) ## Environment Variables | Variable | Description | Required | | --- | --- | --- | -| `AZURE_AI_PROJECT_ENDPOINT` | Microsoft Foundry project endpoint | Yes | -| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Model deployment name | No (defaults to `gpt-5.4-mini`) | +| `FOUNDRY_PROJECT_ENDPOINT` | Microsoft Foundry project endpoint | Yes | +| `FOUNDRY_MODEL` | Model deployment name | No (defaults to `gpt-5.4-mini`) | ## Running the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs index 403bae05c2..200a9bf530 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/Program.cs @@ -6,8 +6,8 @@ using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md index 612bd21891..09bd0f7c02 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step01_Basics/README.md @@ -14,15 +14,15 @@ Before you begin, ensure you have the following prerequisites: - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) +- An authenticated Azure identity (for example, sign in with `az login`) -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). +**Note**: This sample uses `DefaultAzureCredential`. `az login` is the easiest local development path, but Visual Studio, VS Code, and managed identity credentials also work when available. Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs index e00982199f..5bdda025c7 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/Program.cs @@ -7,8 +7,8 @@ using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md index f34c486b53..7394ff432a 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.1_MultiturnConversation/README.md @@ -15,15 +15,15 @@ Before you begin, ensure you have the following prerequisites: - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) +- An authenticated Azure identity (for example, sign in with `az login`) -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). +**Note**: This sample uses `DefaultAzureCredential`. `az login` is the easiest local development path, but Visual Studio, VS Code, and managed identity credentials also work when available. Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs index 317474aa6e..c41d9713f3 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/Program.cs @@ -9,8 +9,8 @@ using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md index ee91d935ef..4cc1f635ec 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step02.2_MultiturnWithServerConversations/README.md @@ -15,15 +15,15 @@ Before you begin, ensure you have the following prerequisites: - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) +- An authenticated Azure identity (for example, sign in with `az login`) -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). +**Note**: This sample uses `DefaultAzureCredential`. `az login` is the easiest local development path, but Visual Studio, VS Code, and managed identity credentials also work when available. Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs index e1b4548a04..94492f6cbc 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/Program.cs @@ -15,8 +15,8 @@ static string GetWeather([Description("The location to get the weather for.")] s // Define the function tool. AITool tool = AIFunctionFactory.Create(GetWeather); -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md index dfad8d0b5c..4692b4838f 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step03_UsingFunctionTools/README.md @@ -16,15 +16,15 @@ Before you begin, ensure you have the following prerequisites: - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) +- An authenticated Azure identity (for example, sign in with `az login`) -**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Microsoft Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). +**Note**: This sample uses `DefaultAzureCredential`. `az login` is the easiest local development path, but Visual Studio, VS Code, and managed identity credentials also work when available. Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs index 3943f32295..4e470845b8 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs @@ -12,8 +12,8 @@ using Microsoft.Extensions.AI; static string GetWeather([Description("The location to get the weather for.")] string location) => $"The weather in {location} is cloudy with a high of 15°C."; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md index a832d308e9..27db13b748 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step04_UsingFunctionToolsWithApprovals/README.md @@ -13,13 +13,13 @@ This sample demonstrates how to use function tools that require human-in-the-loo - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs index 07636f12dd..e887b00a6d 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/Program.cs @@ -12,8 +12,8 @@ using SampleApp; #pragma warning disable CA5399 -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md index f2770d6055..48e5697f60 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step05_StructuredOutput/README.md @@ -12,13 +12,13 @@ This sample demonstrates how to configure an agent to produce structured output - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs index 18ce97ef88..d99007c972 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/Program.cs @@ -7,8 +7,8 @@ using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md index 42074f2972..3b572503e8 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step06_PersistedConversations/README.md @@ -13,13 +13,13 @@ This sample demonstrates how to persist and resume agent conversations using ses - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs index e4b451fea3..c1066fb0a0 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/Program.cs @@ -10,8 +10,8 @@ using OpenTelemetry; using OpenTelemetry.Trace; string? applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // Create TracerProvider with console exporter. string sourceName = Guid.NewGuid().ToString("N"); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md index 70e10d805b..760f2d27dc 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step07_Observability/README.md @@ -13,13 +13,13 @@ This sample demonstrates how to add OpenTelemetry observability to an agent usin - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" $env:APPLICATIONINSIGHTS_CONNECTION_STRING="..." # Optional ``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs index 019323e56f..7ce6d1eab6 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/Program.cs @@ -9,8 +9,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using SampleApp; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md index 52bb3f591e..cb20f43bbf 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step08_DependencyInjection/README.md @@ -13,13 +13,13 @@ This sample demonstrates how to register a `ChatClientAgent` in a dependency inj - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs index b07917ee01..62682d5cc0 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/Program.cs @@ -9,8 +9,8 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using ModelContextProtocol.Client; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // Connect to the Microsoft Learn MCP server via HTTP (Streamable HTTP transport). Console.WriteLine("Connecting to MCP server at https://learn.microsoft.com/api/mcp ..."); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md index ae7fffcb2a..378d72c408 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step09_UsingMcpClientAsTools/README.md @@ -12,14 +12,14 @@ This sample shows how to use MCP (Model Context Protocol) client tools with a `C - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) - Node.js installed (for npx/MCP server) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs index 076237c072..8a2126dbdb 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/Program.cs @@ -7,8 +7,8 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md index 12d5fb6284..8bd095e99a 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step10_UsingImages/README.md @@ -13,13 +13,13 @@ This sample demonstrates how to use image multi-modality with an agent. - .NET 10 SDK or later - Microsoft Foundry service endpoint and a vision-capable model deployment (e.g., `gpt-5.4-mini`) -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs index 06d2a4dc18..892c27c427 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/Program.cs @@ -12,8 +12,8 @@ using Microsoft.Extensions.AI; static string GetWeather([Description("The location to get the weather for.")] string location) => $"The weather in {location} is cloudy with a high of 15°C."; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md index 4fe155d76d..793235b5ac 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step11_AsFunctionTool/README.md @@ -13,13 +13,13 @@ This sample demonstrates how to use one agent as a function tool for another age - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs index 27240b8372..eaa7cbd07e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/Program.cs @@ -20,8 +20,8 @@ static string GetWeather([Description("The location to get the weather for.")] s static string GetDateTime() => DateTimeOffset.Now.ToString(); -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md index 45543329bc..58d59086a7 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step12_Middleware/README.md @@ -14,13 +14,13 @@ This sample demonstrates multiple middleware layers working together: PII filter - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs index 966a7bba12..5d67220fae 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/Program.cs @@ -16,8 +16,8 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using SampleApp; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; const string AssistantInstructions = "You are a helpful assistant that helps people find information."; const string AssistantName = "PluginAssistant"; diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md index e10025b7f3..1c8d078204 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step13_Plugins/README.md @@ -13,13 +13,13 @@ This sample shows how to use plugins with a `ChatClientAgent` using the Response - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs index c4661ae49e..9dec5a9eca 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/Program.cs @@ -12,8 +12,8 @@ using OpenAI.Assistants; const string AgentInstructions = "You are a personal math tutor. When asked a math question, write and run code using the python tool to answer the question."; const string AgentName = "CoderAgent-RAPI"; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md index db63f82e9c..e339acb934 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/README.md @@ -12,13 +12,13 @@ This sample shows how to use the Code Interpreter tool with a `ChatClientAgent` - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs index 00e4e02843..865dfac806 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/Program.cs @@ -10,9 +10,12 @@ using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using OpenAI.Responses; -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") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_COMPUTER_USE_DEPLOYMENT_NAME") ?? "computer-use-preview"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential()); using IHostedFileClient fileClient = projectClient.GetProjectOpenAIClient().AsIHostedFileClient(); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md index eee05e2a69..85ceb4c415 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step15_ComputerUse/README.md @@ -1,4 +1,4 @@ -# Computer Use with the Responses API +# Computer Use with the Responses API This sample shows how to use the Computer Use tool with `AIProjectClient.AsAIAgent(...)`. @@ -39,12 +39,12 @@ The model receives a screenshot as input, analyzes it, and responds with a compu - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" $env:AZURE_AI_COMPUTER_USE_DEPLOYMENT_NAME="computer-use-preview" ``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs index 1a2d870342..adb3376993 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/Program.cs @@ -9,8 +9,8 @@ using Microsoft.Extensions.AI; using OpenAI.Assistants; using OpenAI.Files; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; const string AgentInstructions = "You are a helpful assistant that can search through uploaded files to answer questions."; diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md index 45818ca354..6009492113 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step16_FileSearch/README.md @@ -13,13 +13,13 @@ This sample shows how to use the File Search tool with a `ChatClientAgent` using - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs index d47f4b0078..6c6ba4b111 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/Program.cs @@ -9,8 +9,8 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code."; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md index 05227fdd98..0bc08c21f2 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step17_OpenAPITools/README.md @@ -13,13 +13,13 @@ This sample shows how to use OpenAPI tools with a `ChatClientAgent` using the Re - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs index 11048c8154..e3ad81c95c 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/Program.cs @@ -21,8 +21,8 @@ BingCustomSearchToolOptions bingCustomSearchToolParameters = new([ new BingCustomSearchConfiguration(connectionId, instanceName) ]); -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md index 18bffeacd6..8174ca32f8 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step18_BingCustomSearch/README.md @@ -12,14 +12,14 @@ This sample shows how to use the Bing Custom Search tool with a `ChatClientAgent - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) - Bing Custom Search resource configured with a connection ID Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" $env:AZURE_AI_CUSTOM_SEARCH_CONNECTION_ID="your-connection-id" # The full ARM resource URI, e.g., "/subscriptions/.../connections/your-bing-connection" $env:AZURE_AI_CUSTOM_SEARCH_INSTANCE_NAME="your-instance-name" # The Bing Custom Search configuration name (from Azure portal) ``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs index 186acb9da5..9abb8d5e78 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/Program.cs @@ -19,8 +19,8 @@ const string AgentInstructions = """ var sharepointOptions = new SharePointGroundingToolOptions(); sharepointOptions.ProjectConnections.Add(new ToolProjectConnection(sharepointConnectionId)); -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md index 1049eb4ebc..12d58cf6c1 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step19_SharePoint/README.md @@ -12,14 +12,14 @@ This sample shows how to use the SharePoint Grounding tool with a `ChatClientAge - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) - SharePoint connection configured in your Microsoft Foundry project Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" $env:SHAREPOINT_PROJECT_CONNECTION_ID="your-sharepoint-connection-id" # The full ARM resource URI, e.g., "/subscriptions/.../connections/SharepointTestTool" ``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs index ccc3c0dcf0..1d1e27fa5b 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/Program.cs @@ -16,8 +16,8 @@ const string AgentInstructions = "You are a helpful assistant with access to Mic var fabricToolOptions = new FabricDataAgentToolOptions(); fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection(fabricConnectionId)); -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md index 03536262d2..0d848ca1e5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step20_MicrosoftFabric/README.md @@ -12,14 +12,14 @@ This sample shows how to use the Microsoft Fabric tool with a `ChatClientAgent` - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) - Microsoft Fabric connection configured in your Microsoft Foundry project Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" $env:FABRIC_PROJECT_CONNECTION_ID="your-fabric-connection-id" # The full ARM resource URI, e.g., "/subscriptions/.../connections/FabricTestTool" ``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs index da1652536b..509d2dfdea 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/Program.cs @@ -11,8 +11,8 @@ using OpenAI.Responses; const string AgentInstructions = "You are a helpful assistant that can search the web to find current information and answer questions accurately."; const string AgentName = "WebSearchAgent-RAPI"; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md index 81d37e6ff5..3b4ea1b429 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step21_WebSearch/README.md @@ -12,13 +12,13 @@ This sample shows how to use the Web Search tool with a `ChatClientAgent` using - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs index 5ba9ccedb1..908d03f7bd 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Program.cs @@ -14,8 +14,8 @@ using Microsoft.Agents.AI.Foundry; using Microsoft.Extensions.AI; using OpenAI.Responses; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; string embeddingModelName = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002"; string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? $"foundry-memory-sample-{Guid.NewGuid():N}"; diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md index 63af9cd9c8..b6293eb674 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/README.md @@ -13,14 +13,14 @@ This sample demonstrates how to use the Memory Search tool with a `ChatClientAge - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) - A memory store created beforehand via Azure Portal or Python SDK Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" $env:AZURE_AI_MEMORY_STORE_ID="your-memory-store-name" ``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs index 772d1a17f9..89d5b0029b 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Program.cs @@ -30,8 +30,8 @@ Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => // Wrap each MCP tool with a DelegatingAIFunction to log local invocations. List wrappedTools = mcpTools.Select(tool => (AITool)new LoggingMcpTool(tool)).ToList(); -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md index c3464efe5d..3c37a9e50a 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/README.md @@ -13,13 +13,13 @@ This sample demonstrates how to use a local MCP (Model Context Protocol) client - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Program.cs index 79fac0d5d4..3d33c91c9d 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Program.cs @@ -12,8 +12,8 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI.Responses; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/README.md index 4d50b98ca8..a74123d711 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/README.md @@ -35,13 +35,13 @@ The container ID and file ID are available from the `ContainerFileCitationMessag - .NET 10 SDK or later - Microsoft Foundry service endpoint and deployment configured -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-4o-mini" # Optional, defaults to gpt-4o-mini ``` ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs index 01a1f36d1b..e611c34502 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs @@ -24,9 +24,9 @@ using OpenAI.Responses; const string ToolboxName = "research_toolbox"; const string Query = "What tools do you have access to?"; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; TokenCredential credential = new DefaultAzureCredential(); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/README.md index 8a9d22e28a..3120647778 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/README.md @@ -12,18 +12,18 @@ This sample shows how to use a Foundry Toolbox by pointing an `McpClient` at the ## Prerequisites - A Microsoft Foundry project with a toolbox configured (or let the sample create one for you) -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` The sample creates a toolbox named `research_toolbox` in your Foundry project on startup, then connects to its MCP endpoint at -`{AZURE_AI_PROJECT_ENDPOINT}/toolboxes/research_toolbox/mcp?api-version=v{version}`. +`{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/research_toolbox/mcp?api-version=v{version}`. ## Run the sample diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Program.cs index 6efb8ce40e..d8fff025c5 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Program.cs @@ -14,9 +14,9 @@ using Microsoft.Agents.AI; using ModelContextProtocol.Client; // --- Configuration --- -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; string toolboxMcpServerUrl = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_MCP_SERVER_URL") ?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_MCP_SERVER_URL is not set."); diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/README.md index 2e8efef8cc..fc7ff655cc 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/README.md @@ -15,13 +15,13 @@ and inject them as `AIContextProviders` so the agent can discover and use them a - A Microsoft Foundry project with a toolbox already configured - The toolbox MCP endpoint must expose `skill://index.json` with `skill-md` entries (SEP-2640). If the resource is absent, the sample runs but the skills provider will be empty. -- Azure CLI installed and authenticated (`az login`) +- An authenticated Azure identity (for example, sign in with `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" $env:FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://your-foundry-service.services.ai.azure.com/api/projects/your-project/toolboxes/your-toolbox/mcp?api-version=v1" ``` diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/README.md index dda1e476f0..e90f92c2a2 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/README.md @@ -4,12 +4,12 @@ These samples demonstrate how to use Microsoft Foundry with Agent Framework. ## Quick start -The simplest way to create a Foundry agent is using the `FoundryAgent` type directly: +You can create a Foundry agent directly with the `FoundryAgent` type: ```csharp FoundryAgent agent = new( new Uri(endpoint), - new AzureCliCredential(), + new DefaultAzureCredential(), model: "gpt-5.4-mini", instructions: "You are good at telling jokes.", name: "JokerAgent"); @@ -32,13 +32,13 @@ FoundryAgent agent = aiProjectClient.AsAIAgent( - .NET 10 SDK or later - Foundry project endpoint -- Azure CLI installed and authenticated +- An authenticated Azure identity (for example, sign in with `az login`) Set: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" ``` Some samples require extra tool-specific environment variables. See each sample for details. @@ -78,7 +78,11 @@ Some samples require extra tool-specific environment variables. See each sample ## Running the samples +Use the basics sample for a quick smoke test: + ```powershell cd dotnet/samples/02-agents/AgentsWithFoundry -dotnet run --project .\FoundryAgent_Step01 -``` \ No newline at end of file +dotnet run --project .\Agent_Step01_Basics +``` + +If you want to exercise the full create-run-delete lifecycle, run `Agent_Step00_FoundryAgentLifecycle`. diff --git a/dotnet/samples/02-agents/Evaluation/Evaluation_CustomEvals/Program.cs b/dotnet/samples/02-agents/Evaluation/Evaluation_CustomEvals/Program.cs index a5fa9cc945..0c263d1620 100644 --- a/dotnet/samples/02-agents/Evaluation/Evaluation_CustomEvals/Program.cs +++ b/dotnet/samples/02-agents/Evaluation/Evaluation_CustomEvals/Program.cs @@ -8,8 +8,8 @@ using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/Evaluation/Evaluation_CustomEvals/README.md b/dotnet/samples/02-agents/Evaluation/Evaluation_CustomEvals/README.md index da4c9c652f..691537efeb 100644 --- a/dotnet/samples/02-agents/Evaluation/Evaluation_CustomEvals/README.md +++ b/dotnet/samples/02-agents/Evaluation/Evaluation_CustomEvals/README.md @@ -1,4 +1,4 @@ -# Evaluation - Custom Evals +# Evaluation - Custom Evals This sample demonstrates writing custom domain-specific evaluation functions using `FunctionEvaluator.Create`. Custom evaluators run locally with no cloud evaluator service needed — useful for enforcing business rules, format requirements, or safety guardrails. @@ -13,13 +13,13 @@ This sample demonstrates writing custom domain-specific evaluation functions usi ## Prerequisites - .NET 10 SDK or later -- Azure CLI installed and authenticated (`az login`) +- Azure authentication available to `DefaultAzureCredential` (for local development, run `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-4o-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/Program.cs b/dotnet/samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/Program.cs index 96f41bd835..8a9de240be 100644 --- a/dotnet/samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/Program.cs +++ b/dotnet/samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/Program.cs @@ -6,10 +6,13 @@ using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o-mini"; // Create a math tutor agent. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) .AsAIAgent( model: deploymentName, diff --git a/dotnet/samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/README.md b/dotnet/samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/README.md index 34f16865d2..1e82cda2e5 100644 --- a/dotnet/samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/README.md +++ b/dotnet/samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/README.md @@ -1,4 +1,4 @@ -# Evaluation - Expected Outputs +# Evaluation - Expected Outputs This sample demonstrates evaluating agent responses against expected outputs using built-in checks. @@ -16,8 +16,8 @@ This sample demonstrates evaluating agent responses against expected outputs usi Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-4o-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/Evaluation/Evaluation_SimpleEval/Program.cs b/dotnet/samples/02-agents/Evaluation/Evaluation_SimpleEval/Program.cs index f43a1253e7..8a11f5c016 100644 --- a/dotnet/samples/02-agents/Evaluation/Evaluation_SimpleEval/Program.cs +++ b/dotnet/samples/02-agents/Evaluation/Evaluation_SimpleEval/Program.cs @@ -10,8 +10,8 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI.Evaluation; using FoundryEvals = Microsoft.Agents.AI.Foundry.FoundryEvals; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/02-agents/Evaluation/Evaluation_SimpleEval/README.md b/dotnet/samples/02-agents/Evaluation/Evaluation_SimpleEval/README.md index 35bb11c3bd..d130661fae 100644 --- a/dotnet/samples/02-agents/Evaluation/Evaluation_SimpleEval/README.md +++ b/dotnet/samples/02-agents/Evaluation/Evaluation_SimpleEval/README.md @@ -1,4 +1,4 @@ -# Evaluation - Simple Eval +# Evaluation - Simple Eval The simplest agent evaluation: create a Foundry agent, run it against test questions, and use Foundry quality evaluators (Relevance, Coherence) to score the responses. @@ -11,14 +11,14 @@ The simplest agent evaluation: create a Foundry agent, run it against test quest ## Prerequisites - .NET 10 SDK or later -- Azure CLI installed and authenticated (`az login`) +- Azure authentication available to `DefaultAzureCredential` (for local development, run `az login`) - A deployed model in your Azure AI Foundry project Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-4o-mini" ``` ## Run the sample diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs index a1d8aca1b8..904a4ab7e4 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/Program.cs @@ -25,8 +25,8 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using SampleApp; -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4"; +var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4"; const int MaxContextWindowTokens = 1_050_000; const int MaxOutputTokens = 128_000; diff --git a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md index 7adb0a311f..9511178cec 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md +++ b/dotnet/samples/02-agents/Harness/Harness_Step01_Research/README.md @@ -1,4 +1,4 @@ -# What this sample demonstrates +# What this sample demonstrates This sample demonstrates how to use a `HarnessAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Azure AI Foundry. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and context-window compaction. @@ -30,7 +30,7 @@ Set the following environment variables: export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/" # Optional: Model deployment name (defaults to gpt-5.4) -export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4" +export FOUNDRY_MODEL="gpt-5.4" ``` ## Running the Sample diff --git a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Program.cs index 654c169850..6c372f8ed1 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Program.cs @@ -20,8 +20,8 @@ using Harness.Shared.Console.OpenAI; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4"; +var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4"; const int MaxContextWindowTokens = 1_050_000; const int MaxOutputTokens = 128_000; @@ -30,6 +30,9 @@ const string TracingSourceName = "Harness.SubAgents"; // Set up OpenTelemetry tracing that writes spans to a text file. using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // Create the AIProjectClient for communicating with the Foundry responses service. var projectClient = new AIProjectClient( new Uri(endpoint), diff --git a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/README.md b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/README.md index c04f68d13c..4b61a77b76 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/README.md +++ b/dotnet/samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/README.md @@ -1,4 +1,4 @@ -# Harness Step 02 — BackgroundAgents (Stock Price Research) +# Harness Step 02 — BackgroundAgents (Stock Price Research) This sample demonstrates how to use the **BackgroundAgentsProvider** to delegate work from a parent agent to background agents. Both agents use `HarnessAgent` for pre-configured function invocation, per-service-call persistence, and context-window compaction. @@ -35,7 +35,7 @@ A parent agent receives a list of stock tickers and uses a web-search background - An Azure AI Foundry endpoint with an OpenAI model deployment - Set the following environment variables: - `AZURE_FOUNDRY_OPENAI_ENDPOINT` — Your Foundry OpenAI endpoint URL - - `AZURE_AI_MODEL_DEPLOYMENT_NAME` — Model deployment name (defaults to `gpt-5.4`) + - `FOUNDRY_MODEL` — Model deployment name (defaults to `gpt-5.4`) ## Running the Sample diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs index 6b88d708f2..e2f9b46307 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/Program.cs @@ -22,8 +22,8 @@ using Harness.Shared.Console; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4"; +var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4"; const int MaxContextWindowTokens = 1_050_000; const int MaxOutputTokens = 128_000; @@ -57,6 +57,9 @@ var instructions = - Always explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process. """; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // Create the agent using AsHarnessAgent. The FileAccessStore is explicitly set to the // sample's working/ folder (copied to the output directory) so it works regardless of cwd. // Unused features are disabled. diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md index a9d6cba384..d06348fa72 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md +++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md @@ -1,4 +1,4 @@ -# What this sample demonstrates +# What this sample demonstrates This sample demonstrates how to use a `HarnessAgent` with the default `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, in-loop compaction, tool approval, and OpenTelemetry — so the sample only needs to supply the chat client, token limits, custom instructions, and opt out of unused features. @@ -27,7 +27,7 @@ Set the following environment variables: export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/" # Optional: Model deployment name (defaults to gpt-5.4) -export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4" +export FOUNDRY_MODEL="gpt-5.4" ``` ## Running the Sample diff --git a/dotnet/samples/02-agents/Harness/Harness_Step04_CodeExecution/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step04_CodeExecution/Program.cs index 908e43abd7..46f4c32c81 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step04_CodeExecution/Program.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Step04_CodeExecution/Program.cs @@ -27,8 +27,8 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hyperlight; using Microsoft.Extensions.AI; -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4"; +var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4"; const int MaxContextWindowTokens = 1_050_000; const int MaxOutputTokens = 128_000; @@ -78,6 +78,9 @@ var instructions = - If applicable, save final results to file memory. """; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // Create the agent with ALL HarnessAgent features enabled plus Hyperlight CodeAct. // No Disable* flags are set — TodoProvider, AgentModeProvider, FileMemory, FileAccess, // ToolApproval, WebSearch, and AgentSkillsProvider are all active. diff --git a/dotnet/samples/02-agents/Harness/Harness_Step04_CodeExecution/README.md b/dotnet/samples/02-agents/Harness/Harness_Step04_CodeExecution/README.md index 0d1b109bee..daebc0a19b 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step04_CodeExecution/README.md +++ b/dotnet/samples/02-agents/Harness/Harness_Step04_CodeExecution/README.md @@ -1,4 +1,4 @@ -# Harness Step 04 — Code Execution (Hyperlight + Skills) +# Harness Step 04 — Code Execution (Hyperlight + Skills) This sample demonstrates a HarnessAgent with **all features enabled**, plus: @@ -17,8 +17,8 @@ The agent can plan tasks, manage modes, store memories, read/write files, search | Variable | Description | |----------|-------------| -| `AZURE_AI_PROJECT_ENDPOINT` | Your Azure AI Foundry project endpoint | -| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Model deployment name (default: `gpt-5.4`) | +| `FOUNDRY_PROJECT_ENDPOINT` | Your Azure AI Foundry project endpoint | +| `FOUNDRY_MODEL` | Model deployment name (default: `gpt-5.4`) | ## Running diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj b/dotnet/samples/02-agents/Harness/Harness_Step05_Loop/Harness_Step05_Loop.csproj similarity index 65% rename from dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj rename to dotnet/samples/02-agents/Harness/Harness_Step05_Loop/Harness_Step05_Loop.csproj index d40e93232b..f5d6f368b6 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj +++ b/dotnet/samples/02-agents/Harness/Harness_Step05_Loop/Harness_Step05_Loop.csproj @@ -9,12 +9,13 @@ - + - + + diff --git a/dotnet/samples/02-agents/Harness/Harness_Step05_Loop/Program.cs b/dotnet/samples/02-agents/Harness/Harness_Step05_Loop/Program.cs new file mode 100644 index 0000000000..1e19dabc22 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step05_Loop/Program.cs @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to wrap a HarnessAgent with the LoopAgent decorator to re-invoke +// the agent until a configured LoopEvaluator decides to stop. It covers the common looping patterns +// through one decorator, each driven by a different evaluator: +// +// 1. Completion-marker (Ralph-style) loop — keep refining until the agent emits a completion +// marker, restarting each pass from a fresh context (CompletionMarkerLoopEvaluator + +// FreshContextPerIteration). +// 2. Delegate predicate (todos remaining) — loop while the built-in TodoProvider still has open +// items (DelegateLoopEvaluator). +// 3. AI judge — a second chat client decides whether the original request was answered, and the +// loop continues while the answer is "no" (AIJudgeLoopEvaluator). +// 4. Approval heuristics + loop — combine the LoopAgent with the ToolApprovalAgent auto-approval +// heuristics so a looped agent auto-approves tool calls instead of stalling on approval. +// +// The demos run sequentially and print each loop's final response. + +#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage. +#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments. + +using System.ClientModel.Primitives; +using System.ComponentModel; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4"; + +// The HarnessAgent pre-configures function invocation, per-service-call chat history persistence, and +// context-window compaction. These bounds size the in-loop compaction window. +const int MaxContextWindowTokens = 1_050_000; +const int MaxOutputTokens = 32_000; + +// Build a single Foundry-backed IChatClient factory shared by every demo. Each call returns a fresh +// IChatClient over the same Responses endpoint. +var projectClient = new AIProjectClient( + new Uri(endpoint), + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + new DefaultAzureCredential(), + new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }); + +IChatClient CreateChatClient() => + projectClient.GetProjectOpenAIClient().GetResponsesClient().AsIChatClient(deploymentName); + +await RalphLoopAsync(); +await TodoLoopAsync(); +await JudgeLoopAsync(); +await ApprovalLoopAsync(); + +// Pattern 1: a "Ralph"-style loop that refines until the agent signals completion. +async Task RalphLoopAsync() +{ + Console.WriteLine("\n=== 1. Completion-marker (Ralph-style) loop — refine until COMPLETE (max 5) ==="); + + // Build a lean HarnessAgent: no todo or mode providers for this iterative-refinement task. + AIAgent harnessAgent = CreateLeanHarnessAgent( + name: "ralph", + instructions: + """ + You are iteratively refining a product name for a note-taking app. Each turn, build on the + feedback so far: propose an improved candidate with a short reason. When you are confident the + name is final, end your message with the exact marker COMPLETE. + """); + + // CompletionMarkerLoopEvaluator stops once the marker appears in the response; until then it + // re-invokes the agent. FreshContextPerIteration restarts each pass from the original task plus the + // aggregated feedback log on a brand-new session. Because each pass starts fresh, the agent has no + // memory of its prior suggestion — so the feedback template includes the {last_response} placeholder + // to echo the previous candidate back to it. + AIAgent loopAgent = new LoopAgent( + harnessAgent, + new CompletionMarkerLoopEvaluator("COMPLETE", options: new() + { + FeedbackMessageTemplate = + "Your previous suggestion was:\n" + CompletionMarkerLoopEvaluator.LastResponsePlaceholder + + "\n\nContinue to refine the name and remember to reply with " + + CompletionMarkerLoopEvaluator.CompletionMarkerPlaceholder + " when happy.", + }), + new LoopAgentOptions { MaxIterations = 5, FreshContextPerIteration = true }); + + AgentResponse response = await StreamLoopAsync(loopAgent, "Suggest a name for a note-taking app."); + Console.WriteLine($"\nFinal response:\n{response.Text}"); +} + +// Pattern 2: loop while the built-in TodoProvider still has open items. +async Task TodoLoopAsync() +{ + Console.WriteLine("\n=== 2. Delegate predicate — loop while todos remain (max 6) ==="); + + // Keep the built-in TodoProvider enabled (only the mode provider is disabled) so the agent has + // todo tools to plan and track work. + AIAgent harnessAgent = CreateLeanHarnessAgent( + name: "planner", + instructions: + """ + You are a planning assistant. First break the task into todo items using your todo tools. + Then, on each turn, make progress and mark completed items as done. When all items are + complete, summarize the result. + """, + disableTodoProvider: false); + + // The predicate re-invokes the agent while any todo item is still open. The evaluator fetches the + // built-in TodoProvider from context.Agent (via GetService, which forwards through the harness + // decorators to the underlying ChatClientAgent's context providers), keeping the delegate + // self-contained, then queries it against the loop's current session. When items remain, it returns + // feedback telling the agent to finish them. MaxIterations guarantees the loop stops even if the + // agent stalls. + AIAgent loopAgent = new LoopAgent( + harnessAgent, + new DelegateLoopEvaluator(async (context, cancellationToken) => + { + var todoProvider = context.Agent.GetService() + ?? throw new InvalidOperationException("The agent did not expose a TodoProvider."); + var remaining = await todoProvider.GetRemainingTodosAsync(context.Session).ConfigureAwait(false); + return remaining.Count > 0 + ? LoopEvaluation.Continue($"Not all todos are complete yet ({remaining.Count} remaining). Please complete the remaining todo items.") + : LoopEvaluation.Stop(); + }), + new LoopAgentOptions { MaxIterations = 6 }); + + // The LoopAgent creates a single session up front and reuses it across iterations (non-fresh + // mode), so the todo state persists; the predicate reads it via context.Session. + AgentResponse response = await StreamLoopAsync( + loopAgent, + "Plan and outline a 3-section blog post about Rayleigh scattering."); + Console.WriteLine($"\nFinal response:\n{response.Text}"); +} + +// Pattern 3: a second chat client judges whether the original request was answered. +async Task JudgeLoopAsync() +{ + Console.WriteLine("\n=== 3. AI judge — loop until the request is answered (max 4) ==="); + + AIAgent harnessAgent = CreateLeanHarnessAgent( + name: "answerer", + instructions: "You are a helpful assistant. Answer the user's question thoroughly."); + + // The judge uses its own IChatClient. AIJudgeLoopEvaluator asks it (via a JudgeVerdict structured + // output) whether the original request has been fully addressed and continues while the answer is + // "no", injecting the judge's gap analysis as the next iteration's input. Judge loops use a small + // MaxIterations cap because each pass costs an extra model call. + AIAgent loopAgent = new LoopAgent( + harnessAgent, + new AIJudgeLoopEvaluator(CreateChatClient()), + new LoopAgentOptions { MaxIterations = 4 }); + + AgentResponse response = await StreamLoopAsync( + loopAgent, + "Explain why the sky is blue, then also explain why sunsets are red."); + Console.WriteLine($"\nFinal response:\n{response.Text}"); +} + +// Pattern 4: combine the loop with the ToolApprovalAgent auto-approval heuristics. +async Task ApprovalLoopAsync() +{ + Console.WriteLine("\n=== 4. Approval heuristics + loop — auto-approve tool calls in the loop (max 2) ==="); + + var deployTool = new ApprovalRequiredAIFunction( + AIFunctionFactory.Create(DeploymentTools.DeployService)); + + // Configure the HarnessAgent's built-in ToolApprovalAgent with an auto-approval rule. The rule + // approves the deploy_service call without prompting, so the inner agent resolves the approval + // internally and never surfaces a pending approval to the LoopAgent — letting the loop proceed. + AIAgent harnessAgent = CreateLeanHarnessAgent( + name: "operator", + instructions: "You are a deployment operator. Use the DeployService tool to fulfil requests.", + tools: [deployTool], + toolApprovalAgentOptions: new ToolApprovalAgentOptions + { + AutoApprovalRules = + [ + functionCall => + { + Console.WriteLine($" Auto-approving: {functionCall.Name}"); + return ValueTask.FromResult(true); + }, + ], + }); + + // Drive a short loop that continues until the response confirms the deployment. + AIAgent loopAgent = new LoopAgent( + harnessAgent, + new DelegateLoopEvaluator((context, _) => + new ValueTask( + context.LastResponse.Text.Contains("deployed", StringComparison.OrdinalIgnoreCase) + ? LoopEvaluation.Stop() + : LoopEvaluation.Continue())), + new LoopAgentOptions { MaxIterations = 2 }); + + // The LoopAgent reuses a single session across iterations, so the approval response flows back in. + AgentResponse response = await StreamLoopAsync(loopAgent, "Deploy the billing service."); + Console.WriteLine($"\nFinal response:\n{response.Text}"); +} + +// Streams a loop run to the console, printing updates live and marking each new inner run (detected +// via a change in ResponseId) with an "--- run N ---" header so you can see when the LoopAgent +// re-invokes the inner agent. Each message is prefixed with "User:" or "Agent:" based on its role, so +// the loop's on-behalf-of feedback (User) is visually distinct from the agent's responses (Agent). +// Returns the aggregated final response. +static async Task StreamLoopAsync(AIAgent loopAgent, string input, AgentSession? session = null) +{ + string? currentResponseId = null; + ChatRole? currentRole = null; + var runCount = 0; + var updates = new List(); + + await foreach (var update in loopAgent.RunStreamingAsync(input, session)) + { + // A new ResponseId signals the start of another inner run (loop iteration). + if (update.ResponseId is { } responseId && responseId != currentResponseId) + { + currentResponseId = responseId; + currentRole = null; + Console.WriteLine($"\n--- run {++runCount} ---"); + } + + // Print a role-based prefix whenever the speaker changes — for example the loop's on-behalf-of + // user feedback versus the agent's response. + if (update.Role is { } role && role != currentRole) + { + currentRole = role; + var prefix = role == ChatRole.User ? "User" : role == ChatRole.Assistant ? "Agent" : role.Value; + Console.Write($"\n{prefix}: "); + } + + Console.Write(update.Text); + updates.Add(update); + } + + Console.WriteLine(); + return updates.ToAgentResponse(); +} + +// Creates a HarnessAgent with the agent-mode provider always disabled (and the todo provider disabled +// by default), plus all other heavyweight providers turned off so each loop demo stays focused. +AIAgent CreateLeanHarnessAgent( + string name, + string instructions, + bool disableTodoProvider = true, + IList? tools = null, + ToolApprovalAgentOptions? toolApprovalAgentOptions = null) => + CreateChatClient().AsHarnessAgent(new HarnessAgentOptions + { + Name = name, + MaxContextWindowTokens = MaxContextWindowTokens, + MaxOutputTokens = MaxOutputTokens, + DisableAgentModeProvider = true, + DisableTodoProvider = disableTodoProvider, + DisableFileMemory = true, + DisableFileAccess = true, + DisableWebSearch = true, + ToolApprovalAgentOptions = toolApprovalAgentOptions, + ChatOptions = new ChatOptions + { + Instructions = instructions, + Tools = tools, + MaxOutputTokens = MaxOutputTokens, + }, + }); + +/// Tool used by the approval-handling demo. +internal static class DeploymentTools +{ + [Description("Deploy a service to production (requires approval).")] + public static string DeployService([Description("The name of the service to deploy.")] string service) => + $"Deployed {service} to production."; +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Step05_Loop/README.md b/dotnet/samples/02-agents/Harness/Harness_Step05_Loop/README.md new file mode 100644 index 0000000000..4a19f66e57 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step05_Loop/README.md @@ -0,0 +1,59 @@ +# What this sample demonstrates + +This sample demonstrates how to wrap a `HarnessAgent` with the **`LoopAgent`** decorator to re-invoke the agent until a configured **`LoopEvaluator`** decides to stop. A single decorator covers the common looping patterns — you just plug in a different evaluator (and optionally switch on fresh-context mode). + +The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and in-loop compaction, so each demo only supplies the chat client, token limits, and instructions, then wraps the result with a `LoopAgent`. + +## Looping patterns showcased + +The program runs four demos sequentially, each driven by a different evaluator: + +| # | Pattern | Evaluator | Notes | +| --- | --- | --- | --- | +| 1 | Completion-marker ("Ralph"-style) loop | `CompletionMarkerLoopEvaluator` | Re-invokes until the agent emits `COMPLETE`. Uses `FreshContextPerIteration = true` to restart each pass from the original task plus the aggregated feedback log on a new session, and includes the `{last_response}` placeholder in the feedback template so the agent sees its previous suggestion even though each pass starts fresh. | +| 2 | Delegate predicate (todos remaining) | `DelegateLoopEvaluator` | Loops while the built-in `TodoProvider` still has open items. The provider is fetched from the agent via `GetService()` and queried against the loop's current session. | +| 3 | AI judge | `AIJudgeLoopEvaluator` | A second `IChatClient` judges whether the original request was fully answered and continues while the answer is "no", injecting its gap analysis as the next input. | +| 4 | Approval heuristics + loop | `DelegateLoopEvaluator` + `ToolApprovalAgent` | Combines the `ToolApprovalAgent` auto-approval heuristics (`AutoApprovalRules`) with the loop, so a looped agent auto-approves tool calls instead of stalling on a pending approval. | + +`MaxIterations` caps every loop so it always terminates even if the evaluator never stops. + +### Evaluator mapping (Python → .NET) + +The Python sample in [microsoft/agent-framework#6174](https://github.com/microsoft/agent-framework/pull/6174) exposes several distinct loop classes. In .NET these collapse into one `LoopAgent` that consumes evaluators: + +| Python | .NET | +| --- | --- | +| Ralph loop (completion marker) | `LoopAgent` + `CompletionMarkerLoopEvaluator` | +| Ralph loop (fresh context each pass) | `LoopAgent` + `CompletionMarkerLoopEvaluator` + `FreshContextPerIteration = true` | +| Callable / predicate loop | `LoopAgent` + `DelegateLoopEvaluator` | +| AI judge loop | `LoopAgent` + `AIJudgeLoopEvaluator` | + +## Prerequisites + +Before running this sample, ensure you have: + +1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`) +2. Azure CLI installed and authenticated (`az login`) + +## Environment Variables + +Set the following environment variables: + +```bash +# Required: Your Azure AI Foundry project endpoint +export AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project" + +# Optional: Model deployment name (defaults to gpt-5.4) +export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4" +``` + +## Running the Sample + +```bash +cd dotnet +dotnet run --project samples/02-agents/Harness/Harness_Step05_Loop +``` + +## What to Expect + +The program runs the four demos in order. Each loop is executed with `RunStreamingAsync`, so output is printed live and every re-invocation of the inner agent is marked with a `--- run N ---` header (detected via a change in the streamed `ResponseId`) — this lets you see exactly when the `LoopAgent` loops. Each streamed message is prefixed with `User:` or `Agent:` based on its role, so the loop's on-behalf-of feedback messages (surfaced as `User` turns) are visually distinct from the agent's responses (`Agent`). Each demo finishes by printing its aggregated final response. Demo 4 also prints an `Auto-approving: ...` line each time the `ToolApprovalAgent` heuristic approves the `DeployService` tool call, showing how approval-aware agents integrate with the loop. diff --git a/dotnet/samples/02-agents/Harness/README.md b/dotnet/samples/02-agents/Harness/README.md index 16fad9ac62..61981827c4 100644 --- a/dotnet/samples/02-agents/Harness/README.md +++ b/dotnet/samples/02-agents/Harness/README.md @@ -9,3 +9,4 @@ Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Ag | [Harness_Step01_Research](./Harness_Step01_Research/README.md) | Using a ChatClientAgent with TodoProvider and AgentModeProvider for research, showcasing planning mode and todo management | | [Harness_Step02_Research_WithBackgroundAgents](./Harness_Step02_Research_WithBackgroundAgents/README.md) | Using BackgroundAgentsProvider to delegate stock price lookups to a web-search background agent concurrently | | [Harness_Step03_DataProcessing](./Harness_Step03_DataProcessing/README.md) | Using FileAccessProvider to give an agent access to CSV data files for reading, analysis, and output generation | +| [Harness_Step05_Loop](./Harness_Step05_Loop/README.md) | Wrapping a HarnessAgent with the LoopAgent decorator to re-invoke it until a configured LoopEvaluator (completion marker, predicate, AI judge, or approval-aware loop) decides to stop | diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs index ce27d036f9..7494cc9c0c 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs @@ -11,8 +11,8 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI.Responses; -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var model = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +var model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // Get a client to create/retrieve server side agents with. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md index c1a62a9080..1ef0ca0f17 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/README.md @@ -1,4 +1,4 @@ -# Prerequisites +# Prerequisites Before you begin, ensure you have the following prerequisites: @@ -11,6 +11,6 @@ Before you begin, ensure you have the following prerequisites: Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Microsoft Foundry resource endpoint +$env:FOUNDRY_MODEL="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` diff --git a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs index 91d52398f9..5c2f02f733 100644 --- a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs +++ b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs @@ -23,10 +23,13 @@ public static class Program private static async Task Main() { // Set up the Azure AI Project client - var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; - var aiProjectClient = new AIProjectClient(new Uri(endpoint), new AzureCliCredential()); + var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); + var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); // Create agents AIAgent frenchAgent = await CreateTranslationAgentAsync("French", aiProjectClient, deploymentName); diff --git a/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs b/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs index 38e89653d6..43a04f5da1 100644 --- a/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs +++ b/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs @@ -33,10 +33,13 @@ public static class Program private static async Task Main() { // Set up the Azure AI Project client - var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); - var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; - var chatClient = new AIProjectClient(new Uri(endpoint), new AzureCliCredential()) + var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); + var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. + var chatClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()) .ProjectOpenAIClient.GetChatClient(deploymentName).AsIChatClient(); // Create the executors diff --git a/dotnet/samples/03-workflows/Declarative/ExecuteCode/Program.cs b/dotnet/samples/03-workflows/Declarative/ExecuteCode/Program.cs index 67d467266b..0566a5ff55 100644 --- a/dotnet/samples/03-workflows/Declarative/ExecuteCode/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/ExecuteCode/Program.cs @@ -17,7 +17,7 @@ namespace Demo.DeclarativeCode; /// /// /// Configuration -/// Define AZURE_AI_PROJECT_ENDPOINT as a user-secret or environment variable that +/// Define FOUNDRY_PROJECT_ENDPOINT as a user-secret or environment variable that /// points to your Foundry project endpoint. /// internal sealed class Program diff --git a/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/Program.cs b/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/Program.cs index f7e4dea673..041b6b10bf 100644 --- a/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/ExecuteWorkflow/Program.cs @@ -19,7 +19,7 @@ namespace Demo.DeclarativeWorkflow; /// /// /// Configuration -/// Define AZURE_AI_PROJECT_ENDPOINT as a user-secret or environment variable that +/// Define FOUNDRY_PROJECT_ENDPOINT as a user-secret or environment variable that /// points to your Foundry project endpoint. /// Usage /// Provide the path to the workflow definition file as the first argument. diff --git a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs index a871d233ca..29fc74a62d 100644 --- a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs @@ -21,7 +21,7 @@ namespace Demo.DeclarativeWorkflow; /// /// /// Configuration -/// Define AZURE_AI_PROJECT_ENDPOINT as a user-secret or environment variable that +/// Define FOUNDRY_PROJECT_ENDPOINT as a user-secret or environment variable that /// points to your Foundry project endpoint. /// Usage /// Provide the path to the workflow definition file as the first argument. diff --git a/dotnet/samples/03-workflows/Declarative/README.md b/dotnet/samples/03-workflows/Declarative/README.md index 6bd2c85824..1fe87a6a78 100644 --- a/dotnet/samples/03-workflows/Declarative/README.md +++ b/dotnet/samples/03-workflows/Declarative/README.md @@ -18,8 +18,8 @@ The configuraton required by the samples is: |Setting Name| Description| |:--|:--| -|AZURE_AI_PROJECT_ENDPOINT| The endpoint URL of your Microsoft Foundry Project.| -|AZURE_AI_MODEL_DEPLOYMENT_NAME| The name of the model deployment to use +|FOUNDRY_PROJECT_ENDPOINT| The endpoint URL of your Microsoft Foundry Project.| +|FOUNDRY_MODEL| The name of the model deployment to use |AZURE_AI_BING_CONNECTION_ID| The name of the Bing Grounding connection configured in your Microsoft Foundry Project.| To set your secrets with .NET Secret Manager: @@ -45,13 +45,13 @@ To set your secrets with .NET Secret Manager: 4. Define setting that identifies your Microsoft Foundry Project (endpoint): ``` - dotnet user-secrets set "AZURE_AI_PROJECT_ENDPOINT" "https://..." + dotnet user-secrets set "FOUNDRY_PROJECT_ENDPOINT" "https://..." ``` 5. Define setting that identifies your Microsoft Foundry Model Deployment (endpoint): ``` - dotnet user-secrets set "AZURE_AI_MODEL_DEPLOYMENT_NAME" "gpt-5" + dotnet user-secrets set "FOUNDRY_MODEL" "gpt-5" ``` 6. Define setting that identifies your Bing Grounding connection: @@ -63,8 +63,8 @@ To set your secrets with .NET Secret Manager: You may alternatively set your secrets as an environment variable (PowerShell): ```pwsh -$env:AZURE_AI_PROJECT_ENDPOINT="https://..." -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5" +$env:FOUNDRY_PROJECT_ENDPOINT="https://..." +$env:FOUNDRY_MODEL="gpt-5" $env:AZURE_AI_BING_CONNECTION_ID="mybinggrounding" ``` @@ -96,4 +96,4 @@ To run the sampes from the command line: dotnet run "An eco-friendly stainless steel water bottle that keeps drinks cold for 24 hours." dotnet run c:/myworkflows/Marketing.yaml ``` - > The sample will allow for interactive input in the absence of an input argument. \ No newline at end of file + > The sample will allow for interactive input in the absence of an input argument. diff --git a/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Program.cs b/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Program.cs index ce37dd89f6..6755074d7f 100644 --- a/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Program.cs +++ b/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Program.cs @@ -8,10 +8,13 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o-mini"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Create two agents: a planner and an executor. diff --git a/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowEval/README.md b/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowEval/README.md index 7a550f8833..64dfd72e7b 100644 --- a/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowEval/README.md +++ b/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowEval/README.md @@ -1,4 +1,4 @@ -# Evaluation - Workflow Eval +# Evaluation - Workflow Eval This sample demonstrates evaluating a multi-agent workflow with per-agent breakdown. @@ -13,13 +13,13 @@ This sample demonstrates evaluating a multi-agent workflow with per-agent breakd ## Prerequisites - .NET 10 SDK or later -- Azure CLI installed and authenticated (`az login`) +- Azure authentication available to `DefaultAzureCredential` (for local development, run `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-4o-mini" ``` ## Run the sample diff --git a/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/Program.cs b/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/Program.cs index 30fa79faa8..b008f5043f 100644 --- a/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/Program.cs +++ b/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/Program.cs @@ -10,9 +10,9 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using FoundryEvals = Microsoft.Agents.AI.Foundry.FoundryEvals; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/README.md b/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/README.md index 9390e91e4c..0462f618dc 100644 --- a/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/README.md +++ b/dotnet/samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/README.md @@ -1,4 +1,4 @@ -# Evaluation - Workflow Expected Outputs +# Evaluation - Workflow Expected Outputs This sample demonstrates evaluating a multi-agent workflow's final answer against a golden expected output using Foundry's reference-based **Similarity** @@ -20,13 +20,13 @@ Evals API. ## Prerequisites - .NET 10 SDK or later -- Azure CLI installed and authenticated (`az login`) +- Azure authentication available to `DefaultAzureCredential` (for local development, run `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-4o-mini" ``` ## Run the sample diff --git a/dotnet/samples/03-workflows/Orchestration/Handoff/Program.cs b/dotnet/samples/03-workflows/Orchestration/Handoff/Program.cs index 69cf8c168b..c5fefe191f 100644 --- a/dotnet/samples/03-workflows/Orchestration/Handoff/Program.cs +++ b/dotnet/samples/03-workflows/Orchestration/Handoff/Program.cs @@ -6,9 +6,9 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/03-workflows/Orchestration/Magentic/Program.cs b/dotnet/samples/03-workflows/Orchestration/Magentic/Program.cs index 4cb148b2cd..0382745d7d 100644 --- a/dotnet/samples/03-workflows/Orchestration/Magentic/Program.cs +++ b/dotnet/samples/03-workflows/Orchestration/Magentic/Program.cs @@ -33,9 +33,9 @@ public static class Program private static async Task Main() { - string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); - string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; + string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); + string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/03-workflows/Orchestration/Magentic/README.md b/dotnet/samples/03-workflows/Orchestration/Magentic/README.md index e8314759f1..739229df26 100644 --- a/dotnet/samples/03-workflows/Orchestration/Magentic/README.md +++ b/dotnet/samples/03-workflows/Orchestration/Magentic/README.md @@ -15,8 +15,8 @@ This sample showcases the Magentic Orchestration Pattern in .NET, setting up a t ## Prerequisites -- `AZURE_AI_PROJECT_ENDPOINT` set to your Azure AI Foundry project endpoint -- `AZURE_AI_MODEL_DEPLOYMENT_NAME` set to your model deployment name (defaults to `gpt-5.4-mini`) +- `FOUNDRY_PROJECT_ENDPOINT` set to your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL` set to your model deployment name (defaults to `gpt-5.4-mini`) - `az login` completed before running the sample ## Running the Sample diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs index 51b9fb4d7f..b9108b4b29 100644 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs @@ -27,6 +27,9 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYM string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) ? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey)) + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. : new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); ChatClient chatClient = client.GetChatClient(deploymentName); 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 5fcfddab22..404f88d074 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/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/.env.example index 79fac42841..c40c94eb4a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/.env.example @@ -1,7 +1,7 @@ -AZURE_AI_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o AGENT_NAME=hosted-agent-skills SKILL_NAMES=support-style,escalation-policy # Set to true to provision sample skills to Foundry on startup (first-run convenience). diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs index 90dc325120..b7a3925841 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs @@ -34,9 +34,9 @@ using Microsoft.Extensions.AI; // 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 deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o"; string skillNames = Environment.GetEnvironmentVariable("SKILL_NAMES") ?? throw new InvalidOperationException("SKILL_NAMES is not set. Provide a comma-separated list of skill names (e.g., support-style,escalation-policy)."); @@ -56,6 +56,9 @@ foreach (string name in requestedSkills) } } +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 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). TokenCredential credential = new ChainedTokenCredential( 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 28917a19e0..747e1dec4f 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md @@ -1,4 +1,4 @@ -# What this sample demonstrates +# What this sample demonstrates An [Agent Framework](https://github.com/microsoft/agent-framework) agent that loads its behavioral guidelines from [**Foundry Skills**](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/skills) at startup, hosted using the **Responses protocol**. Skills are authored once as `SKILL.md` files, uploaded to your Foundry project through the Skills REST API, and downloaded by the agent on boot so updates ship without code changes. @@ -54,8 +54,8 @@ Your identity (or the Managed Identity running the container in production) need Set the required environment variables and run the sample with `dotnet run`: ```bash -export AZURE_AI_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o" +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export FOUNDRY_MODEL="gpt-4o" export SKILL_NAMES="support-style,escalation-policy" export PROVISION_SAMPLE_SKILLS="true" # First run only — provisions skills to Foundry ``` @@ -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-AgentSkills/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml index 6be5e63017..83f442a637 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml @@ -26,8 +26,8 @@ template: cpu: "0.25" memory: 0.5Gi environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: FOUNDRY_MODEL + value: "{{FOUNDRY_MODEL}}" - name: SKILL_NAMES value: "{{SKILL_NAMES}}" parameters: @@ -38,4 +38,4 @@ parameters: resources: - kind: model id: gpt-4.1-mini - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + name: FOUNDRY_MODEL diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.yaml index 363107d0ea..a63017f828 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.yaml @@ -8,7 +8,7 @@ resources: cpu: "0.25" memory: 0.5Gi environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: FOUNDRY_MODEL + value: ${FOUNDRY_MODEL} - name: SKILL_NAMES value: ${SKILL_NAMES} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/scripts/smoke.ps1 b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/scripts/smoke.ps1 index 09094706a1..e13c94cfd6 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/scripts/smoke.ps1 +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/scripts/smoke.ps1 @@ -11,7 +11,7 @@ Prerequisites: - Docker - az login (token is fetched from the host) - - .env populated with AZURE_AI_PROJECT_ENDPOINT and model deployment + - .env populated with FOUNDRY_PROJECT_ENDPOINT and model deployment - Skills provisioned to Foundry (set PROVISION_SAMPLE_SKILLS=true on first run) .NOTES This script is for local Docker debugging only. The Foundry platform supplies the @@ -30,7 +30,7 @@ $ErrorActionPreference = 'Stop' Set-Location -Path $PSScriptRoot/.. if (-not (Test-Path .env)) { - throw '.env not found. Copy .env.example to .env and fill in AZURE_AI_PROJECT_ENDPOINT.' + throw '.env not found. Copy .env.example to .env and fill in FOUNDRY_PROJECT_ENDPOINT.' } Write-Host '==> Publishing sample for linux-musl-x64 ...' diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/.env.example index 3b63f9d218..fe9adaaad8 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/.env.example @@ -1,5 +1,5 @@ -AZURE_AI_PROJECT_ENDPOINT= -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_PROJECT_ENDPOINT= +FOUNDRY_MODEL=gpt-4o AZURE_SEARCH_ENDPOINT= AZURE_SEARCH_INDEX_NAME=contoso-outdoors AZURE_BEARER_TOKEN_FOUNDRY=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/Program.cs index 4b97324134..51b827467b 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/Program.cs @@ -23,15 +23,18 @@ using OpenAI.Chat; // Load .env file if present (for local development) Env.TraversePath().Load(); -string projectEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +string projectEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o"; string searchEndpoint = Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT") ?? throw new InvalidOperationException("AZURE_SEARCH_ENDPOINT is not set."); string searchIndexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME") ?? throw new InvalidOperationException("AZURE_SEARCH_INDEX_NAME is not set."); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 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). The dev credential is scope aware so a single instance serves both Foundry and 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 ede4db1010..638ca559df 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md @@ -1,4 +1,4 @@ -# Hosted-AzureSearchRag +# Hosted-AzureSearchRag A hosted agent with **Retrieval Augmented Generation (RAG)** capabilities backed by **Azure AI Search**. The agent grounds its answers in product documentation by running a keyword search against an Azure AI Search index before each model invocation, then citing the source in its response. @@ -77,8 +77,8 @@ cp .env.example .env Edit `.env`: ```env -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_MODEL=gpt-4o AZURE_SEARCH_ENDPOINT=https://.search.windows.net AZURE_SEARCH_INDEX_NAME=contoso-outdoors AZURE_BEARER_TOKEN_FOUNDRY=DefaultAzureCredential @@ -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/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.env.example index 984e8625cf..99a2f75c03 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.env.example @@ -1,6 +1,6 @@ -AZURE_AI_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o AGENT_NAME=hosted-chat-client-agent AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Program.cs index b4b08ba5a8..b25889b93a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Program.cs @@ -11,14 +11,17 @@ using Microsoft.Agents.AI.Foundry.Hosting; // Load .env file if present (for local development) Env.TraversePath().Load(); -var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? throw new InvalidOperationException("AGENT_NAME is not set."); -var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +var deployment = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 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 running in foundry). TokenCredential credential = new ChainedTokenCredential( 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 ace8892572..89c9bb8592 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md @@ -1,4 +1,4 @@ -# Hosted-ChatClientAgent +# Hosted-ChatClientAgent A simple general-purpose AI assistant hosted as a Foundry Hosted Agent using the Agent Framework instance hosting pattern. The agent is created inline via `AIProjectClient.AsAIAgent(model, instructions)` and served using the Responses protocol. @@ -19,10 +19,10 @@ cp .env.example .env Edit `.env` and set your Azure AI Foundry project endpoint: ```env -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o ``` > **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. @@ -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/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example index b8fe9e8e7a..04335e65b8 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/.env.example @@ -1,5 +1,5 @@ -AZURE_AI_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs index 3f79a0eb7d..4472370e9a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/Program.cs @@ -20,8 +20,8 @@ // indirect prompt injection in an uploaded file. // // Required environment variables: -// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint -// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o) +// FOUNDRY_PROJECT_ENDPOINT - Azure AI Foundry project endpoint +// FOUNDRY_MODEL - Model deployment name (default: gpt-4o) // // Optional: // AGENT_NAME - Agent name (default: hosted-files) @@ -46,10 +46,13 @@ Env.TraversePath().Load(); // Bypass SampleEnvironment alias (which prompts on missing env vars) for optional values. string? GetOptionalEnv(string key) => System.Environment.GetEnvironmentVariable(key); -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = GetOptionalEnv("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = GetOptionalEnv("FOUNDRY_MODEL") ?? "gpt-4o"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 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). TokenCredential credential = new ChainedTokenCredential( 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 729aca5c5f..d9c77c073d 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/README.md @@ -1,4 +1,4 @@ -# Hosted-Files +# Hosted-Files A hosted agent that demonstrates **two distinct file knowledge sources** through scoped, security-hardened tools: @@ -57,10 +57,10 @@ cp .env.example .env Edit `.env`: ```env -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o ``` > `.env` is gitignored. The `.env.example` template is checked in as a reference. @@ -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). @@ -125,4 +153,4 @@ Drop additional text files into [`resources/`](./resources/). The csproj `/resources` (`/app/resources/` in container) | -| `HOME` | The per-session sandbox volume root the session-files tools read from. Set by the Foundry platform; can be overridden for local testing. | `/home/session` | \ No newline at end of file +| `HOME` | The per-session sandbox volume root the session-files tools read from. Set by the Foundry platform; can be overridden for local testing. | `/home/session` | diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/.env.example index c72380d125..aaeb71a9e4 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/.env.example @@ -1,4 +1,4 @@ -AZURE_AI_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development AGENT_NAME= diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Program.cs index f83a67f66d..1c0f1768ee 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Program.cs @@ -12,11 +12,14 @@ using Microsoft.Agents.AI.Foundry.Hosting; // Load .env file if present (for local development) Env.TraversePath().Load(); -var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? throw new InvalidOperationException("AGENT_NAME is not set."); +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 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 running in foundry). TokenCredential credential = new ChainedTokenCredential( 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 8265a80632..a4894e0d24 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md @@ -1,4 +1,4 @@ -# Hosted-FoundryAgent +# Hosted-FoundryAgent A hosted agent that delegates to a **Foundry-managed agent definition**. Instead of defining the model, instructions, and tools inline in code, this sample retrieves an existing agent registered in the Foundry platform via `AIProjectClient.AsAIAgent(agentRecord)` and hosts it using the Responses protocol. @@ -21,7 +21,7 @@ cp .env.example .env Edit `.env` and set your Azure AI Foundry project endpoint: ```env -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development ``` @@ -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/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/.env.example index b8fe9e8e7a..04335e65b8 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/.env.example @@ -1,5 +1,5 @@ -AZURE_AI_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Program.cs index 8a665d38a3..19922f19e5 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Program.cs @@ -19,10 +19,13 @@ using Microsoft.Extensions.AI; // 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 deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 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). TokenCredential credential = new ChainedTokenCredential( 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 8016ff7ae9..246c2a495a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md @@ -1,4 +1,4 @@ -# Hosted-LocalTools +# Hosted-LocalTools A hosted agent with **local C# function tools** for hotel search. Demonstrates how to define and wire local tools that the LLM can invoke — a key advantage of code-based hosted agents over prompt agents. @@ -21,10 +21,10 @@ cp .env.example .env Edit `.env` and set your Azure AI Foundry project endpoint: ```env -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o ``` > **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. @@ -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/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/.env.example index b8fe9e8e7a..04335e65b8 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/.env.example @@ -1,5 +1,5 @@ -AZURE_AI_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Program.cs index 1eed2126f7..ae11482133 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Program.cs @@ -28,10 +28,13 @@ using ModelContextProtocol.Client; // Load .env file if present (for local development) Env.TraversePath().Load(); -var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); -var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); +var deployment = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 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). TokenCredential credential = new ChainedTokenCredential( 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 3773d9760d..d635de65bd 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md @@ -1,4 +1,4 @@ -# Hosted-McpTools +# Hosted-McpTools A hosted agent demonstrating **two layers of MCP (Model Context Protocol) tool integration**: @@ -33,8 +33,8 @@ cp .env.example .env Edit `.env`: ```env -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_MODEL=gpt-4o ``` ## Running directly (contributors) @@ -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/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/.env.example index 29d86f5ef1..9eb77d2a5b 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/.env.example @@ -1,7 +1,7 @@ -AZURE_AI_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o AZURE_AI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-ada-002 AZURE_AI_MEMORY_STORE_ID=hosted-memory-sample AGENT_NAME=hosted-memory-agent diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Program.cs index 22bfd316b3..1221dcbc9d 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/Program.cs @@ -26,14 +26,17 @@ using Microsoft.Extensions.AI; // Load .env file if present (for local development). Env.TraversePath().Load(); -var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? throw new InvalidOperationException("AGENT_NAME is not set."); -var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +var deployment = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o"; var embeddingDeployment = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002"; var memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? "hosted-memory-sample"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 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 foundry). TokenCredential credential = new ChainedTokenCredential( 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 d9b3a11825..69c8e34ec5 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/README.md @@ -1,4 +1,4 @@ -# Hosted-MemoryAgent +# Hosted-MemoryAgent A hosted Foundry agent that uses **FoundryMemoryProvider** to remember user-private details across requests and across sessions, scoped per end user via the Foundry platform's isolation keys. The @@ -31,8 +31,8 @@ cp .env.example .env Required: ```env -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_MODEL=gpt-4o AZURE_AI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-ada-002 AZURE_AI_MEMORY_STORE_ID=hosted-memory-sample AGENT_NAME=hosted-memory-agent @@ -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-MemoryAgent/scripts/smoke.ps1 b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/scripts/smoke.ps1 index 4f85fb3873..fd35751839 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/scripts/smoke.ps1 +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/scripts/smoke.ps1 @@ -11,7 +11,7 @@ Prerequisites: - Docker - az login (token is fetched from the host) - - .env populated with AZURE_AI_PROJECT_ENDPOINT and model deployments + - .env populated with FOUNDRY_PROJECT_ENDPOINT and model deployments .NOTES This script is for local Docker debugging only. The Foundry platform supplies the isolation keys for every inbound request in production and the dev fallback used here must not be @@ -29,7 +29,7 @@ $ErrorActionPreference = 'Stop' Set-Location -Path $PSScriptRoot/.. if (-not (Test-Path .env)) { - throw '.env not found. Copy .env.example to .env and fill in AZURE_AI_PROJECT_ENDPOINT.' + throw '.env not found. Copy .env.example to .env and fill in FOUNDRY_PROJECT_ENDPOINT.' } Write-Host '==> Publishing sample for linux-musl-x64 ...' diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/.env.example index 4a6101948c..46900211af 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/.env.example @@ -1,7 +1,7 @@ -AZURE_AI_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o AZURE_BEARER_TOKEN=DefaultAzureCredential # Capture prompt / completion / tool argument content on GenAI spans. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/Program.cs index fa57fc03a2..135fe92aac 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/Program.cs @@ -18,10 +18,13 @@ using Microsoft.Extensions.AI; // 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 deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 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). TokenCredential credential = new ChainedTokenCredential( 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 889eacca82..20e6a7f2b1 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/README.md @@ -1,4 +1,4 @@ -# Hosted-Observability +# Hosted-Observability A hosted [Agent Framework](https://github.com/microsoft/agent-framework) agent that demonstrates how the Foundry hosting pipeline emits OpenTelemetry traces, metrics and logs with no extra wiring. @@ -39,10 +39,10 @@ cp .env.example .env Edit `.env` and set your Azure AI Foundry project endpoint: ```env -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true ``` @@ -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/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/.env.example index b8fe9e8e7a..04335e65b8 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/.env.example @@ -1,5 +1,5 @@ -AZURE_AI_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Program.cs index a374f81fd7..0194971576 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Program.cs @@ -17,10 +17,13 @@ using OpenAI.Chat; // 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 deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 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). TokenCredential credential = new ChainedTokenCredential( 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 5e4e5140c0..2e7511fb6b 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md @@ -1,4 +1,4 @@ -# Hosted-TextRag +# Hosted-TextRag A hosted agent with **Retrieval Augmented Generation (RAG)** capabilities using `TextSearchProvider`. The agent grounds its answers in product documentation by running a search before each model invocation, then citing the source in its response. @@ -21,10 +21,10 @@ cp .env.example .env Edit `.env` and set your Azure AI Foundry project endpoint: ```env -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o AZURE_BEARER_TOKEN= ``` @@ -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 0000000000..e4302a5a9a --- /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 0000000000..b803098b6a --- /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 0000000000..bbeb4a098b --- /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 0000000000..da484bb9e1 --- /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 0000000000..7bf9baeaf5 --- /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 0000000000..e1cf78fc24 --- /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 0000000000..77336ea78f --- /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 0000000000..87cca57bb7 --- /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 3f6c0a70a7..3adae07004 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,29 @@ // 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_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) +// FOUNDRY_PROJECT_ENDPOINT (hosted runtime) OR AZURE_AI_PROJECT_ENDPOINT (local-dev) +// - Azure AI Foundry project endpoint. The Foundry hosted +// runtime auto-injects FOUNDRY_PROJECT_ENDPOINT; locally +// set AZURE_AI_PROJECT_ENDPOINT. +// FOUNDRY_MODEL - Model deployment name (default: gpt-4o) // // 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 +// FOUNDRY_TOOLBOX_NAME - Name of the toolbox to load (default: my-toolset) +// FOUNDRY_AGENT_TOOLSET_ENDPOINT - Foundry Toolsets proxy base URL +// (injected automatically by Foundry platform at runtime) +// 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,11 +36,19 @@ using Microsoft.Agents.AI.Foundry.Hosting; // 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 deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; -string toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME") ?? "my-toolset"; +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("FOUNDRY_MODEL") + ?? Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +string toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME") + ?? Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "my-toolset"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 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). TokenCredential credential = new ChainedTokenCredential( @@ -45,12 +61,12 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential) .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 +77,8 @@ builder.Services.AddFoundryResponses(agent); 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 0000000000..37d4dd7d28 --- /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/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/.env.example index 5c312b3f8e..0f603ea20c 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/.env.example @@ -1,6 +1,6 @@ -AZURE_AI_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5 +FOUNDRY_MODEL=gpt-5 FOUNDRY_TOOLBOX_NAME= AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Program.cs index f8ca3f4991..99dbdc6573 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Program.cs @@ -7,9 +7,9 @@ // AgentSkillsProviderBuilder.UseMcpSkills(). // // Required environment variables: -// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint +// FOUNDRY_PROJECT_ENDPOINT - Azure AI Foundry project endpoint // FOUNDRY_TOOLBOX_NAME - Name of the Foundry Toolbox to connect to -// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-5) +// FOUNDRY_MODEL - Model deployment name (default: gpt-5) using System.Net.Http.Headers; using Azure.AI.Projects; @@ -24,15 +24,18 @@ using ModelContextProtocol.Client; // Load .env file if present (for local development) Env.TraversePath().Load(); -var projectEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5"; +var projectEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +var deployment = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5"; var toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME") ?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_NAME is not set."); // Build the Toolbox MCP URL from the project endpoint and toolbox name. var toolboxMcpServerUrl = $"{projectEndpoint.TrimEnd('/')}/toolboxes/{toolboxName}/mcp?api-version=v1"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 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). TokenCredential credential = new ChainedTokenCredential( 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 0c6b5ba60e..7b09109520 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md @@ -1,4 +1,4 @@ -# Hosted-ToolboxMcpSkills +# Hosted-ToolboxMcpSkills A hosted agent that discovers **MCP-based skills from a Foundry Toolbox** and makes them available to the agent using `AgentSkillsProviderBuilder.UseMcpSkills(mcpClient)`. @@ -28,10 +28,10 @@ cp .env.example .env Edit `.env` and set your Azure AI Foundry project endpoint and toolbox name: ```env -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5 +FOUNDRY_MODEL=gpt-5 FOUNDRY_TOOLBOX_NAME=my-toolbox ``` @@ -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-ToolboxMcpSkills/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml index 2887336252..ad85fb1bca 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml @@ -28,8 +28,8 @@ template: cpu: "0.25" memory: 0.5Gi environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" + - name: FOUNDRY_MODEL + value: "{{FOUNDRY_MODEL}}" - name: FOUNDRY_TOOLBOX_NAME value: "{{FOUNDRY_TOOLBOX_NAME}}" parameters: @@ -40,4 +40,4 @@ parameters: resources: - kind: model id: gpt-5 - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + name: FOUNDRY_MODEL diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml index 5f53abb2e2..140a5fc4c6 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml @@ -8,7 +8,7 @@ resources: cpu: "0.25" memory: 0.5Gi environment_variables: - - name: AZURE_AI_MODEL_DEPLOYMENT_NAME - value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} + - name: FOUNDRY_MODEL + value: ${FOUNDRY_MODEL} - name: FOUNDRY_TOOLBOX_NAME value: ${FOUNDRY_TOOLBOX_NAME} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Program.cs index 30d9d43616..e89ad78210 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Program.cs @@ -38,6 +38,9 @@ var builder = WebApplication.CreateBuilder(args); var endpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.")); var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. var azureClient = new AzureOpenAIClient(endpoint, new ChainedTokenCredential( new DevTemporaryTokenCredential(), new DefaultAzureCredential())); 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 643af74551..341564ad73 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/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/.env.example index b8fe9e8e7a..04335e65b8 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/.env.example @@ -1,5 +1,5 @@ -AZURE_AI_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_ENDPOINT= ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=gpt-4o AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Program.cs index d0fb8ee129..b4d54bb977 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Program.cs @@ -18,10 +18,13 @@ using Microsoft.Extensions.AI; // 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 deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o"; +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. // 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). TokenCredential credential = new ChainedTokenCredential( 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 d91d27445d..cdbe36911d 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 @@ -1,11 +1,11 @@ -# Hosted-Workflow-Simple +# Hosted-Workflow-Simple A hosted agent that demonstrates **multi-agent workflow orchestration**. Three translation agents are composed into a sequential pipeline: English → French → Spanish → English, showing how agents can be chained as workflow executors using `WorkflowBuilder`. ## 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 @@ -19,10 +19,10 @@ cp .env.example .env Edit `.env` and set your Azure AI Foundry project endpoint: ```env -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ ASPNETCORE_URLS=http://+:8088 ASPNETCORE_ENVIRONMENT=Development -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_MODEL=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 FOUNDRY_MODEL 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/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs index 0c2ba2d038..716a0b37c4 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/Program.cs @@ -10,10 +10,10 @@ using Microsoft.Agents.AI.Foundry; // Load .env file if present (for local development) Env.TraversePath().Load(); -// AZURE_AI_PROJECT_ENDPOINT is the Foundry project endpoint. Shape: +// FOUNDRY_PROJECT_ENDPOINT is the Foundry project endpoint. Shape: // https:///api/projects/ -Uri projectEndpoint = new(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); +Uri projectEndpoint = new(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); // AZURE_AI_AGENT_NAME is the registered server-side agent name. string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME") diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md index 356416596a..043cc8fa7d 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/README.md @@ -1,4 +1,4 @@ -# SessionFilesClient +# SessionFilesClient A thin chat REPL that connects to a deployed [`Hosted-Files`](../../Hosted-Files/) agent via `FoundryAgent` and lets you ask questions whose answers come from the files bundled with that agent. Same shape as [`SimpleAgent`](../SimpleAgent/) — point it at an `AGENT_ENDPOINT`, build a `FoundryAgent`, run. @@ -13,17 +13,17 @@ The agent's container-side `ListFiles` and `ReadFile` tools surface the bundled ## Configuration ```env -AZURE_AI_PROJECT_ENDPOINT=https:///api/projects/ +FOUNDRY_PROJECT_ENDPOINT=https:///api/projects/ AZURE_AI_AGENT_NAME=hosted-files ``` -Both are required. `AZURE_AI_PROJECT_ENDPOINT` is the Foundry project endpoint URL and `AZURE_AI_AGENT_NAME` is the registered server-side agent name. The sample builds the per-agent OpenAI endpoint URL from these. +Both are required. `FOUNDRY_PROJECT_ENDPOINT` is the Foundry project endpoint URL and `AZURE_AI_AGENT_NAME` is the registered server-side agent name. The sample builds the per-agent OpenAI endpoint URL from these. ## Run ```bash cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient -$env:AZURE_AI_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local" +$env:FOUNDRY_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local" $env:AZURE_AI_AGENT_NAME = "hosted-files" dotnet run ``` diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/Program.cs index eedf136abb..b74e85e19d 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/Program.cs @@ -10,10 +10,10 @@ using Microsoft.Agents.AI.Foundry; // Load .env file if present (for local development) Env.TraversePath().Load(); -// AZURE_AI_PROJECT_ENDPOINT is the Foundry project endpoint. Shape: +// FOUNDRY_PROJECT_ENDPOINT is the Foundry project endpoint. Shape: // https:///api/projects/ -Uri projectEndpoint = new(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); +Uri projectEndpoint = new(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); // AZURE_AI_AGENT_NAME is the registered server-side agent name. string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME") diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs index c694351599..26e2460dd1 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs @@ -33,7 +33,7 @@ IConfigurationRoot configuration = new ConfigurationBuilder() string? apiKey = configuration["OPENAI_API_KEY"]; string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-5.4-mini"; -string? endpoint = configuration["AZURE_AI_PROJECT_ENDPOINT"]; +string? endpoint = configuration["FOUNDRY_PROJECT_ENDPOINT"]; string[] agentUrls = (builder.Configuration["urls"] ?? "http://localhost:5000").Split(';'); var invoiceQueryPlugin = new InvoiceQuery(); diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/README.md b/dotnet/samples/05-end-to-end/A2AClientServer/README.md index 0efb17e748..d0771ff4e1 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/README.md +++ b/dotnet/samples/05-end-to-end/A2AClientServer/README.md @@ -1,4 +1,4 @@ -# A2A Client and Server samples +# A2A Client and Server samples > **Warning** > The [A2A protocol](https://google.github.io/A2A/) is still under development and changing fast. @@ -84,7 +84,7 @@ You must create the agents in a Microsoft Foundry project and then provide the p ``` ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://ai-foundry-your-project.services.ai.azure.com/api/projects/ai-proj-ga-your-project" # Replace with your Foundry Project endpoint +$env:FOUNDRY_PROJECT_ENDPOINT="https://ai-foundry-your-project.services.ai.azure.com/api/projects/ai-proj-ga-your-project" # Replace with your Foundry Project endpoint ``` Use the following commands to run each A2A server diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/Program.cs b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/Program.cs index d50213a9f7..ecef13f275 100644 --- a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/Program.cs +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/Program.cs @@ -13,6 +13,9 @@ builder.AddServiceDefaults(); builder.AddAzureChatCompletionsClient(connectionName: "foundry", configureSettings: settings => { + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. settings.TokenCredential = new DefaultAzureCredential(); settings.EnableSensitiveTelemetryData = builder.Environment.IsDevelopment(); }) diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/Program.cs b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/Program.cs index 72f3215453..f8061d73da 100644 --- a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/Program.cs +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/Program.cs @@ -10,6 +10,9 @@ builder.AddServiceDefaults(); builder.AddAzureChatCompletionsClient(connectionName: "foundry", configureSettings: settings => { + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid + // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. settings.TokenCredential = new DefaultAzureCredential(); settings.EnableSensitiveTelemetryData = builder.Environment.IsDevelopment(); }) diff --git a/dotnet/samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Program.cs b/dotnet/samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Program.cs index a4cd3c5257..76f930bc42 100644 --- a/dotnet/samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Program.cs +++ b/dotnet/samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Program.cs @@ -9,8 +9,8 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Evaluation; using FoundryEvals = Microsoft.Agents.AI.Foundry.FoundryEvals; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/README.md b/dotnet/samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/README.md index b2c220a9ba..829ff982ca 100644 --- a/dotnet/samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/README.md +++ b/dotnet/samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/README.md @@ -1,4 +1,4 @@ -# Evaluation - Conversation Splits +# Evaluation - Conversation Splits This sample demonstrates multi-turn conversation evaluation with different split strategies. @@ -14,13 +14,13 @@ This sample demonstrates multi-turn conversation evaluation with different split ## Prerequisites - .NET 10 SDK or later -- Azure CLI installed and authenticated (`az login`) +- Azure authentication available to `DefaultAzureCredential` (for local development, run `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-4o-mini" ``` ## Run the sample @@ -28,4 +28,4 @@ $env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" ```powershell cd dotnet/samples/05-end-to-end/Evaluation dotnet run --project .\Evaluation_ConversationSplits -``` \ No newline at end of file +``` diff --git a/dotnet/samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/Program.cs b/dotnet/samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/Program.cs index 8d1a150f47..2aaec605f1 100644 --- a/dotnet/samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/Program.cs +++ b/dotnet/samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/Program.cs @@ -9,8 +9,8 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI.Evaluation; using FoundryEvals = Microsoft.Agents.AI.Foundry.FoundryEvals; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/README.md b/dotnet/samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/README.md index 53b67cec0c..0f86359943 100644 --- a/dotnet/samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/README.md +++ b/dotnet/samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/README.md @@ -1,4 +1,4 @@ -# Evaluation - Foundry Quality +# Evaluation - Foundry Quality This sample demonstrates agent evaluation using MEAI quality evaluators (Relevance, Coherence) via `FoundryEvals`. @@ -13,13 +13,13 @@ This sample demonstrates agent evaluation using MEAI quality evaluators (Relevan ## Prerequisites - .NET 10 SDK or later -- Azure CLI installed and authenticated (`az login`) +- Azure authentication available to `DefaultAzureCredential` (for local development, run `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-4o-mini" ``` ## Run the sample diff --git a/dotnet/samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/Program.cs b/dotnet/samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/Program.cs index 6c1c163317..7c15887421 100644 --- a/dotnet/samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/Program.cs +++ b/dotnet/samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/Program.cs @@ -8,8 +8,8 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI.Evaluation; using FoundryEvals = Microsoft.Agents.AI.Foundry.FoundryEvals; -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid diff --git a/dotnet/samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/README.md b/dotnet/samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/README.md index 1346635868..712cf0d783 100644 --- a/dotnet/samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/README.md +++ b/dotnet/samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/README.md @@ -1,4 +1,4 @@ -# Evaluation - Mixed Providers +# Evaluation - Mixed Providers This sample demonstrates mixing local and cloud evaluators in a single evaluation run. @@ -14,13 +14,13 @@ This sample demonstrates mixing local and cloud evaluators in a single evaluatio ## Prerequisites - .NET 10 SDK or later -- Azure CLI installed and authenticated (`az login`) +- Azure authentication available to `DefaultAzureCredential` (for local development, run `az login`) Set the following environment variables: ```powershell -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" -$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-4o-mini" ``` ## Run the sample @@ -28,4 +28,4 @@ $env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" ```powershell cd dotnet/samples/05-end-to-end/Evaluation dotnet run --project .\Evaluation_MixedProviders -``` \ No newline at end of file +``` diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs index d345297276..23e2858322 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 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler 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 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler 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 0000000000..e4e297d63b --- /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 78430f40bf..de3ccbf2ad 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 7a8bc71e02..44b3d123b8 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 sealed class FoundryToolboxService : IHostedService, IAsyncDisposable /// public async Task StartAsync(CancellationToken cancellationToken) { - this._resolvedEndpoint = this._options.EndpointOverride - ?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT"); + // 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(this._resolvedEndpoint)) + 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 sealed class FoundryToolboxService : IHostedService, IAsyncDisposable { 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 sealed class FoundryToolboxService : IHostedService, IAsyncDisposable "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 sealed class FoundryToolboxService : IHostedService, IAsyncDisposable 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 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable 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 0000000000..821287192d --- /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 71d9af8f71..ec348b2167 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 a0f53b342e..bd61b9f4eb 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 System.Runtime.CompilerServices; 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 class FoundryHostingExtensions { ArgumentNullException.ThrowIfNull(services); services.AddResponsesServer(); + services.AddHealthChecks(); services.TryAddSingleton(_ => FileSystemAgentSessionStore.CreateDefault()); services.TryAddSingleton(); return services; @@ -84,6 +87,7 @@ public static class FoundryHostingExtensions ArgumentNullException.ThrowIfNull(agent); services.AddResponsesServer(); + services.AddHealthChecks(); agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault(); if (!string.IsNullOrWhiteSpace(agent.Name)) @@ -109,10 +113,10 @@ public static class FoundryHostingExtensions /// /// 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 class FoundryHostingExtensions // 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 class FoundryHostingExtensions { 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/GitHubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs index 053a08f725..a85c126fd6 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs @@ -145,11 +145,12 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable // Ensure the client is started await this.EnsureClientStartedAsync(cancellationToken).ConfigureAwait(false); - // Create or resume a session with streaming enabled + // Create or resume a session with streaming enabled by default SessionConfig sessionConfig = this._sessionConfig != null ? CopySessionConfig(this._sessionConfig) : new SessionConfig { Streaming = true }; + bool isStreaming = sessionConfig.Streaming ?? true; CopilotSession copilotSession; if (typedSession.SessionId is not null) { @@ -178,7 +179,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable break; case AssistantMessageEvent assistantMessage: - channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage)); + channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage, isStreaming)); break; case AssistantUsageEvent usageEvent: @@ -271,19 +272,20 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable } /// - /// Copies all supported properties from a source into a new instance - /// with set to true. + /// Copies all supported properties from a source into a new instance, + /// preserving from the source (defaulting to true if unset). /// internal static SessionConfig CopySessionConfig(SessionConfig source) { SessionConfig copy = source.Clone(); - copy.Streaming = true; + copy.Streaming = source.Streaming ?? true; return copy; } /// /// Copies all supported properties from a source into a new - /// with set to true. + /// , preserving + /// from the source (defaulting to true if unset). /// internal static ResumeSessionConfig CopyResumeSessionConfig(SessionConfig? source) { @@ -306,7 +308,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable SkillDirectories = source?.SkillDirectories, DisabledSkills = source?.DisabledSkills, InfiniteSessions = source?.InfiniteSessions, - Streaming = true + Streaming = source?.Streaming ?? true }; } @@ -325,12 +327,18 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable }; } - internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage) + /// + /// Converts an to an . + /// When streaming is enabled, text was already delivered via delta events, so only raw metadata is emitted. + /// When streaming is disabled, the full message text is emitted as . + /// + internal AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage, bool isStreaming) { - AIContent content = new() - { - RawRepresentation = assistantMessage - }; + // When streaming, text was already delivered via AssistantMessageDeltaEvent. + // When not streaming, this is the only opportunity to emit the response text. + AIContent content = isStreaming + ? new AIContent { RawRepresentation = assistantMessage } + : new TextContent(assistantMessage.Data?.Content ?? string.Empty) { RawRepresentation = assistantMessage }; return new AgentResponseUpdate(ChatRole.Assistant, [content]) { 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 8b868b5f77..dad0b31f59 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 0000000000..87bc8ad53e --- /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 0000000000..04a777550f --- /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 85a4fa54c3..03f73d8007 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 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner 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 0000000000..6bd9d40853 --- /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 0000000000..3c9ad4f813 --- /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 0000000000..c23553f185 --- /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 f8e9602cef..d41a3a2090 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 @@ internal sealed class ProcessContentRequest /// [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 28013f524e..43b564b58f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/PurviewClient.cs @@ -130,6 +130,11 @@ internal sealed class PurviewClient : IPurviewClient 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 1a9fc70725..bcd1a26192 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 3fb7aa6c4d..3e280014a0 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 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor { 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; - } - else - { - psResponse = await this._purviewClient.GetProtectionScopesAsync(psRequest, cancellationToken).ConfigureAwait(false); - await this._cacheProvider.SetAsync(cacheKey, psResponse, cancellationToken).ConfigureAwait(false); + return await this.ProcessWithCachedScopesAsync(pcRequest, cacheResponse, cacheKey, cancellationToken).ConfigureAwait(false); } + try + { + 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 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor 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 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor { 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 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor /// 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 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor 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 320fbcd3b6..0be4c59267 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 0000000000..e819c3f51c --- /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 0000000000..088d66ed47 --- /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 0000000000..eabe4680cf --- /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 c133b38bbd..8b0a410d8b 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 sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable 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 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable 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 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable 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 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable } } + /// + /// 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 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable 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 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable 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; - foreach (KeyValuePair kvp in sorted) + // Sort by lower-cased key for deterministic ordering, preserving value case. + SortedDictionary sorted = new(StringComparer.Ordinal); + foreach (KeyValuePair header in headers) { - hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key); - hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value); + sorted[header.Key.ToLowerInvariant()] = header.Value; } - return hashCode.ToString(CultureInfo.InvariantCulture); + StringBuilder payload = new(); + foreach (KeyValuePair kvp in sorted) + { + 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 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 6ca429c648..08d57a6b6e 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.Kit; 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 @@ internal sealed class InvokeFunctionToolExecutor( // 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 @@ internal sealed class InvokeFunctionToolExecutor( // 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 @@ internal sealed class InvokeFunctionToolExecutor( 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 @@ internal sealed class InvokeFunctionToolExecutor( }; } - 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 @@ internal sealed class InvokeFunctionToolExecutor( 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 2ff41cc43d..b71a50390c 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 31e80d8725..5b685dc835 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 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List team, Ta 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 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List team, Ta 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 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List team, Ta { 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 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List team, Ta 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 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List team, Ta 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 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List team, Ta 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/src/Microsoft.Agents.AI/Harness/Loop/AIJudgeLoopEvaluator.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/AIJudgeLoopEvaluator.cs new file mode 100644 index 0000000000..b482d6c93e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/AIJudgeLoopEvaluator.cs @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A that uses a separate judge chat client to decide whether the user's original request +/// has been fully addressed, continuing the loop (with the judge's gap analysis as feedback) while the answer is "no". +/// +/// +/// +/// After each iteration the judge is queried directly (without any agent tools, session, or middleware) with the +/// original request and the agent's latest response, and asked for a structured . If the +/// judge client does not honor structured output, the verdict falls back to parsing the raw text for the +/// non-overlapping / markers (with +/// winning, so the loop keeps running, when the verdict is ambiguous or absent). +/// +/// +/// When the request is not yet answered, the evaluator returns feedback built from +/// with the judge's gap analysis substituted for +/// . How that feedback is delivered to the agent (and whether the session is +/// reset) is decided by the that consumes this evaluator. +/// +/// +/// The judge instructions act as a template: any occurrence of is replaced with the +/// rendered (or removed when no criteria are supplied), letting +/// callers add bespoke standards the response must satisfy. +/// +/// +/// LLM-judged loops are costly and probabilistic, so consider setting a stricter +/// on the owning . +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AIJudgeLoopEvaluator : LoopEvaluator +{ + /// The default system instructions used to prompt the judge. + /// + /// Acts as a template: the trailing is replaced with the rendered + /// (or removed when none are supplied). + /// + public const string DefaultInstructions = + "You are an evaluator. You are given a user's original request and an agent's latest response. " + + "Decide whether the agent has fully addressed the original request. " + + "Set 'answered' to true if the request has been fully addressed, or false if more work is still required. " + + "When 'answered' is false, use 'gapAnalysis' to explain what is still missing or what work remains. " + + "If you cannot return structured output, reply with " + DoneVerdictMarker + " when the request has been fully " + + "addressed, or " + MoreVerdictMarker + " when more work is still required." + + CriteriaPlaceholder; + + /// + /// The verdict marker the judge is asked to emit (for clients that do not honor structured output) when the + /// original request has been fully addressed. + /// + /// + /// and are deliberately non-overlapping (neither is + /// a substring of the other), so the text fallback cannot misclassify one verdict as the other. When the marker is + /// ambiguous or absent, wins so the loop keeps running rather than stopping on an + /// incomplete answer. + /// + public const string DoneVerdictMarker = "VERDICT: DONE"; + + /// + /// The verdict marker the judge is asked to emit (for clients that do not honor structured output) when more work + /// is still required. Takes precedence over when both (or neither) are present. + /// + public const string MoreVerdictMarker = "VERDICT: MORE"; + + /// + /// The placeholder token within (or a custom + /// ) that is replaced with the rendered + /// . When no criteria are supplied, the placeholder is removed. + /// + public const string CriteriaPlaceholder = "{criteria}"; + + /// + /// The placeholder token within (or a custom + /// ) that is replaced with the judge's gap analysis. + /// + public const string GapAnalysisPlaceholder = "{gap_analysis}"; + + /// The default template used to build the feedback produced when the request is not yet answered. + public const string DefaultFeedbackMessageTemplate = + "Your previous response did not fully address the original request. " + + "The following is still missing or incomplete: " + GapAnalysisPlaceholder + " " + + "Please continue and fully address the original request."; + + /// The value substituted for the gap analysis when the judge did not provide one. + private const string UnknownGapAnalysis = ""; + + private readonly IChatClient _judgeClient; + private readonly string _instructions; + private readonly string _feedbackMessageTemplate; + + /// + /// Initializes a new instance of the class. + /// + /// The chat client used to judge whether the original request was answered. + /// Optional configuration for the judge. When , defaults are used. + /// is . + public AIJudgeLoopEvaluator(IChatClient judgeClient, AIJudgeLoopEvaluatorOptions? options = null) + { + this._judgeClient = Throw.IfNull(judgeClient); + this._instructions = (options?.Instructions ?? DefaultInstructions) + .Replace(CriteriaPlaceholder, RenderCriteria(options?.Criteria)); + this._feedbackMessageTemplate = options?.FeedbackMessageTemplate ?? DefaultFeedbackMessageTemplate; + } + + /// + public override async ValueTask EvaluateAsync(LoopContext context, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(context); + + // Build the judge's user message from AIContent so non-text request content (images, data, etc.) is + // preserved rather than flattened to text. The original request's contents are framed between header + // text segments, followed by the agent's latest response text. + var userContents = new List + { + new TextContent("# Has the original request been fully addressed?\n\n## Original request:\n"), + }; + foreach (ChatMessage message in context.InitialMessages) + { + userContents.AddRange(message.Contents); + } + + userContents.Add(new TextContent($"\n\n## Agent's latest response:\n{context.LastResponse.Text}")); + + List judgeMessages = + [ + new ChatMessage(ChatRole.System, this._instructions), + new ChatMessage(ChatRole.User, userContents), + ]; + + bool answered; + string gapAnalysis = UnknownGapAnalysis; + ChatResponse response = await this._judgeClient + .GetResponseAsync(judgeMessages, LoopJsonContext.Default.Options, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (response.TryGetResult(out JudgeVerdict? verdict) && verdict is not null) + { + answered = verdict.Answered; + if (!string.IsNullOrWhiteSpace(verdict.GapAnalysis)) + { + gapAnalysis = verdict.GapAnalysis; + } + } + else + { + // Fallback for clients that do not honor structured output: look for the explicit, non-overlapping verdict + // markers. MoreVerdictMarker wins so an ambiguous or marker-less reply keeps looping rather than stopping + // on an incomplete answer. + string text = response.Text.ToUpperInvariant(); + answered = !text.Contains(MoreVerdictMarker) && text.Contains(DoneVerdictMarker); + } + + // The request is answered: stop looping. + if (answered) + { + return LoopEvaluation.Stop(); + } + + // Not yet answered: continue, providing feedback describing what is still missing. + string feedback = this._feedbackMessageTemplate.Replace(GapAnalysisPlaceholder, gapAnalysis); + return LoopEvaluation.Continue(feedback); + } + + /// + /// Renders the supplied into a bullet block appended at , + /// or an empty string when no non-blank criteria are supplied. + /// + private static string RenderCriteria(IEnumerable? criteria) + { + if (criteria is null) + { + return string.Empty; + } + + var builder = new StringBuilder(); + foreach (string criterion in criteria) + { + if (!string.IsNullOrWhiteSpace(criterion)) + { + builder.Append("\n- ").Append(criterion); + } + } + + return builder.Length == 0 + ? string.Empty + : "\n\nThe response must satisfy all of the following criteria:" + builder; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/AIJudgeLoopEvaluatorOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/AIJudgeLoopEvaluatorOptions.cs new file mode 100644 index 0000000000..73285a924c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/AIJudgeLoopEvaluatorOptions.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Provides configuration options for . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AIJudgeLoopEvaluatorOptions +{ + /// + /// Gets or sets the system instructions used to prompt the judge, or to use + /// . + /// + /// + /// Any occurrence of in the instructions is replaced with + /// the rendered (or removed when no criteria are supplied). Instructions that omit the + /// placeholder do not receive the criteria. + /// + public string? Instructions { get; set; } + + /// + /// Gets or sets an optional list of additional criteria the agent's response must satisfy, evaluated by the judge + /// alongside the original request. + /// + /// + /// When supplied, the criteria are rendered into the judge instructions wherever + /// appears (including in + /// ). When or empty, the placeholder is + /// removed and no criteria are added. + /// + public IEnumerable? Criteria { get; set; } + + /// + /// Gets or sets the template used to build the feedback produced when the judge decides the original request was + /// not fully addressed, or to use + /// . + /// + /// + /// Any occurrence of in the template is replaced with the + /// judge's gap analysis (or a placeholder when none is available). + /// + public string? FeedbackMessageTemplate { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/CompletionMarkerLoopEvaluator.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/CompletionMarkerLoopEvaluator.cs new file mode 100644 index 0000000000..cd2d7c8aa6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/CompletionMarkerLoopEvaluator.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A that stops the loop once a configured marker string appears in the agent's latest +/// response, and otherwise continues with feedback asking the agent to keep working and to emit the marker when done. +/// +/// +/// The feedback produced while the marker is absent is built from a template (see +/// ) with the configured marker substituted +/// for , and the agent's latest response text substituted for +/// . How that feedback is delivered to the agent (and whether the session +/// is reset) is decided by the that consumes this evaluator. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class CompletionMarkerLoopEvaluator : LoopEvaluator +{ + /// + /// The placeholder token within (or a custom + /// ) that is replaced with the + /// configured completion marker. + /// + public const string CompletionMarkerPlaceholder = "{completion_marker}"; + + /// + /// The placeholder token within a custom + /// that is replaced with the text of the agent's latest response. This is substituted on each evaluation, so it lets + /// the feedback echo back what the agent previously produced — useful when the consuming + /// uses , where the agent would + /// otherwise have no record of its prior output. + /// + public const string LastResponsePlaceholder = "{last_response}"; + + /// The default template used to build the feedback produced while the completion marker is absent. + public const string DefaultFeedbackMessageTemplate = + "Continue working on the request. When you have fully completed the task, end your response with the marker '" + + CompletionMarkerPlaceholder + "' to indicate completion."; + + private readonly string _completionMarker; + private readonly string _feedbackMessageTemplate; + + /// + /// Initializes a new instance of the class. + /// + /// The marker string that stops the loop once it appears in the agent's latest response text. + /// Optional configuration for the feedback message. When , defaults are used. + /// is , empty, or whitespace. + public CompletionMarkerLoopEvaluator(string completionMarker, CompletionMarkerLoopEvaluatorOptions? options = null) + { + this._completionMarker = Throw.IfNullOrWhitespace(completionMarker); + + // The completion marker is fixed, so substitute it once here. The optional {last_response} placeholder depends + // on the per-iteration response text, so it is substituted later in EvaluateAsync. + this._feedbackMessageTemplate = (options?.FeedbackMessageTemplate ?? DefaultFeedbackMessageTemplate) + .Replace(CompletionMarkerPlaceholder, this._completionMarker); + } + + /// + public override ValueTask EvaluateAsync(LoopContext context, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(context); + + if (context.LastResponse.Text.Contains(this._completionMarker)) + { + return new ValueTask(LoopEvaluation.Stop()); + } + + string feedback = this._feedbackMessageTemplate.Replace(LastResponsePlaceholder, context.LastResponse.Text); + return new ValueTask(LoopEvaluation.Continue(feedback)); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/CompletionMarkerLoopEvaluatorOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/CompletionMarkerLoopEvaluatorOptions.cs new file mode 100644 index 0000000000..de3c394c48 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/CompletionMarkerLoopEvaluatorOptions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Provides configuration options for . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class CompletionMarkerLoopEvaluatorOptions +{ + /// + /// Gets or sets the template used to build the feedback produced when the completion marker has not yet appeared, + /// or to use . + /// + /// + /// Any occurrence of in the template is + /// replaced with the configured completion marker. Any occurrence of + /// is replaced, on each evaluation, with the + /// text of the agent's latest response — useful for echoing the agent's prior output back to it when the consuming + /// is used with a fresh context per iteration. + /// + public string? FeedbackMessageTemplate { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/DelegateLoopEvaluator.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/DelegateLoopEvaluator.cs new file mode 100644 index 0000000000..9c41b1a11c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/DelegateLoopEvaluator.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A that delegates the re-invocation decision and feedback to a user-supplied callback. +/// +/// +/// This is the most flexible evaluator: the supplied delegate receives the full and returns +/// a , so it can decide both whether to continue and what feedback (if any) to provide. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class DelegateLoopEvaluator : LoopEvaluator +{ + private readonly Func> _evaluate; + + /// + /// Initializes a new instance of the class. + /// + /// A callback that decides whether to re-invoke the agent and what feedback to provide. + /// is . + public DelegateLoopEvaluator(Func> evaluate) + { + this._evaluate = Throw.IfNull(evaluate); + } + + /// + public override ValueTask EvaluateAsync(LoopContext context, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(context); + return this._evaluate(context, cancellationToken); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/JudgeVerdict.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/JudgeVerdict.cs new file mode 100644 index 0000000000..19d802e2fc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/JudgeVerdict.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the structured verdict returned by the judge chat client used by . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class JudgeVerdict +{ + /// + /// Gets or sets a value indicating whether the agent has fully addressed the user's original request. + /// + [Description("True if the agent has fully addressed the original request, otherwise false.")] + public bool Answered { get; set; } + + /// + /// Gets or sets an explanation of what is still missing when the request has not been fully addressed. + /// + [Description("When 'answered' is false, explain what is still missing or what work remains to fully address the original request.")] + public string GapAnalysis { get; set; } = string.Empty; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs new file mode 100644 index 0000000000..c92de6a331 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs @@ -0,0 +1,548 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A that re-invokes the wrapped agent in a loop until the configured +/// set decides to stop. +/// +/// +/// +/// After each run of the wrapped agent, the configured evaluators are asked whether to re-invoke the agent and what +/// feedback to carry forward. This enables patterns such as iterative refinement, working through a task list, or +/// judging whether the original request was answered. Out-of-the-box evaluators include +/// , , and +/// . +/// +/// +/// When multiple evaluators are supplied they are evaluated in order after each iteration. The first evaluator that +/// asks to re-invoke wins: its feedback drives the next iteration and the remaining evaluators are not evaluated. The +/// loop stops only when every evaluator asks to stop. Consequently, evaluator order is priority order and +/// means "this evaluator does not request continuation" rather than a veto that +/// terminates the loop; place stop-only guards accordingly. +/// +/// +/// The caller's initial messages are sent to the wrapped agent exactly once. By default (when +/// is ) the loop reuses a single session +/// and sends only the winning evaluator's feedback as the next input, letting the agent continue from session history. +/// When is , each re-invocation restarts +/// from the original input messages plus an aggregated feedback log, and the session is reset for each iteration: a +/// loop-owned session is created anew, while a caller-supplied session is restored from a snapshot taken at the start +/// of the run (so the wrapped agent must support session serialization). An evaluator may instead supply the exact next +/// messages via , bypassing this construction. +/// +/// +/// The loop is bounded by a global safety cap () regardless of the +/// evaluators. If an iteration produces a pending tool-approval request, the loop stops and returns that response to +/// the caller rather than attempting to resolve the approval automatically. +/// +/// +/// A non-streaming run returns, by default, a single that aggregates the full transcript +/// in order: the on-behalf-of messages the loop injected for each re-invocation followed by that iteration's response +/// messages. The caller's original input messages are not echoed. Set +/// to instead return only the final iteration's +/// response. A streaming run always yields every iteration's updates, emitting the injected on-behalf-of messages as +/// updates before each re-invocation. The injected messages can be attributed with +/// , or omitted from the surfaced output entirely with +/// . +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class LoopAgent : DelegatingAIAgent +{ + /// The default value used for when none is specified. + public const int DefaultMaxIterations = 10; + + private readonly IReadOnlyList _evaluators; + private readonly int _maxIterations; + private readonly bool _freshContextPerIteration; + private readonly string? _onBehalfOfAuthorName; + private readonly bool _excludeOnBehalfOfMessages; + private readonly bool _nonStreamingReturnsLastResponseOnly; + private readonly System.Func? _sessionCreatedCallback; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class with a single evaluator. + /// + /// The underlying agent to invoke in a loop. + /// The that decides whether to re-invoke the agent. + /// Optional configuration for the loop. When , defaults are used. + /// Optional factory used to create the loop's logger. + /// or is . + /// is less than 1. + public LoopAgent(AIAgent innerAgent, LoopEvaluator evaluator, LoopAgentOptions? options = null, ILoggerFactory? loggerFactory = null) + : this(innerAgent, [Throw.IfNull(evaluator)], options, loggerFactory) + { + } + + /// + /// Initializes a new instance of the class with one or more evaluators. + /// + /// The underlying agent to invoke in a loop. + /// + /// The ordered set of that decide whether to re-invoke the agent. They are evaluated in + /// order after each iteration and the first that asks to re-invoke wins. + /// + /// Optional configuration for the loop. When , defaults are used. + /// Optional factory used to create the loop's logger. + /// or is , or contains a element. + /// is empty. + /// is less than 1. + public LoopAgent(AIAgent innerAgent, IEnumerable evaluators, LoopAgentOptions? options = null, ILoggerFactory? loggerFactory = null) + : base(innerAgent) + { + _ = Throw.IfNull(evaluators); + LoopEvaluator[] evaluatorArray = evaluators.ToArray(); + if (evaluatorArray.Length == 0) + { + throw new System.ArgumentException("At least one evaluator must be supplied.", nameof(evaluators)); + } + + foreach (LoopEvaluator item in evaluatorArray) + { + _ = Throw.IfNull(item, nameof(evaluators)); + } + + this._evaluators = evaluatorArray; + + this._maxIterations = Throw.IfLessThan(options?.MaxIterations ?? DefaultMaxIterations, 1); + this._freshContextPerIteration = options?.FreshContextPerIteration ?? false; + this._onBehalfOfAuthorName = options?.OnBehalfOfAuthorName; + this._excludeOnBehalfOfMessages = options?.ExcludeOnBehalfOfMessages ?? false; + this._nonStreamingReturnsLastResponseOnly = options?.NonStreamingReturnsLastResponseOnly ?? false; + this._sessionCreatedCallback = options?.SessionCreatedCallback; + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + // Capture the caller's initial messages (sent once) and ensure the loop always runs against a session. + IReadOnlyList initialMessages = messages as IReadOnlyList ?? messages.ToList(); + bool sessionProvidedByCaller = session is not null; + if (session is null) + { + session = await this.InnerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + await this.NotifyNewSessionAsync(session, cancellationToken).ConfigureAwait(false); + } + + // When a fresh context is requested over a caller-supplied session, snapshot the pristine session up front so + // each re-invocation can restart from a fresh clone (see CreateFreshIterationSessionAsync). Taken before the + // first iteration mutates the session. + JsonElement? initialSessionSnapshot = this._freshContextPerIteration && sessionProvidedByCaller + ? await this.InnerAgent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false) + : null; + + LoopContext? context = null; + List feedbackLog = []; + IEnumerable currentMessages = initialMessages; + int iteration = 0; + + // Aggregates the full transcript across iterations: each iteration's surfaced on-behalf-of input messages + // followed by that iteration's response messages. Unused when only the final response is returned. + List transcript = []; + + // The loop-synthesized on-behalf-of messages that drive the current iteration (none for the first iteration). + IReadOnlyList currentSurfaced = []; + + while (true) + { + // Run the wrapped agent using the context's session once it exists (it may have been replaced for a fresh + // context), otherwise the resolved session for the first run. + AgentSession activeSession = context?.Session ?? session; + AgentResponse response = await this.InnerAgent.RunAsync(currentMessages, activeSession, options, cancellationToken).ConfigureAwait(false); + iteration++; + + // Record this iteration's on-behalf-of input (before the response it elicited) and the response itself. + transcript.AddRange(currentSurfaced); + transcript.AddRange(response.Messages); + + // Create the context after the first run (so LastResponse is never null) and reuse it thereafter. + // Expose the feedback log as a read-only wrapper so evaluators cannot downcast and mutate it; the + // wrapper still reflects entries appended by the loop. + context ??= new LoopContext(this.InnerAgent, session, initialMessages, response, options) { Feedback = feedbackLog.AsReadOnly() }; + + context.Iteration = iteration; + context.LastResponse = response; + + // Stop and surface the response when the agent is waiting for a tool approval. + if (HasPendingApprovalRequests(response)) + { + return this.BuildResult(response, transcript); + } + + // Enforce the global safety cap regardless of what the evaluators want. + if (iteration >= this._maxIterations) + { + this.LogMaxIterationsReached(iteration); + return this.BuildResult(response, transcript); + } + + // Ask the evaluators whether to continue; stop when none of them request a re-invocation. + LoopNextStep step = await this.EvaluateAndBuildNextAsync(context, feedbackLog, initialSessionSnapshot, cancellationToken).ConfigureAwait(false); + if (!step.ShouldContinue) + { + return this.BuildResult(response, transcript); + } + + currentMessages = step.Messages; + currentSurfaced = step.SurfacedMessages; + } + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + // Capture the caller's initial messages (sent once) and ensure the loop always runs against a session. + IReadOnlyList initialMessages = messages as IReadOnlyList ?? messages.ToList(); + bool sessionProvidedByCaller = session is not null; + if (session is null) + { + session = await this.InnerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + await this.NotifyNewSessionAsync(session, cancellationToken).ConfigureAwait(false); + } + + // When a fresh context is requested over a caller-supplied session, snapshot the pristine session up front so + // each re-invocation can restart from a fresh clone (see CreateFreshIterationSessionAsync). Taken before the + // first iteration mutates the session. + JsonElement? initialSessionSnapshot = this._freshContextPerIteration && sessionProvidedByCaller + ? await this.InnerAgent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false) + : null; + + LoopContext? context = null; + List feedbackLog = []; + IEnumerable currentMessages = initialMessages; + int iteration = 0; + + // The loop-synthesized on-behalf-of messages that drive the current iteration (none for the first iteration). + IReadOnlyList currentSurfaced = []; + + while (true) + { + // Stream this iteration's updates to the caller while collecting them so the iteration's full + // response can be aggregated for evaluation (true per-iteration streaming). Uses the context's + // session once it exists (it may have been replaced for a fresh context), otherwise the resolved session. + AgentSession activeSession = context?.Session ?? session; + List updates = []; + + // The on-behalf-of messages that drive this iteration are surfaced before the response they elicit (none + // for the first iteration). They are flushed lazily on the first inner update so they can be stamped with + // that update's ResponseId/AgentId, keeping them grouped with the iteration for downstream mergers. + bool surfacedPending = currentSurfaced.Count > 0; + await foreach (var update in this.InnerAgent.RunStreamingAsync(currentMessages, activeSession, options, cancellationToken).ConfigureAwait(false)) + { + if (surfacedPending) + { + foreach (ChatMessage surfaced in currentSurfaced) + { + yield return CreateOnBehalfOfUpdate(surfaced, update.ResponseId); + } + + surfacedPending = false; + } + + updates.Add(update); + yield return update; + } + + // The inner agent produced no updates this iteration; surface the on-behalf-of messages anyway. Since there + // is no iteration response to inherit from, generate a ResponseId so they still group together downstream. + if (surfacedPending) + { + string fallbackResponseId = System.Guid.NewGuid().ToString("N"); + foreach (ChatMessage surfaced in currentSurfaced) + { + yield return CreateOnBehalfOfUpdate(surfaced, fallbackResponseId); + } + } + + // Aggregate this iteration's updates and record the result on the context. + iteration++; + AgentResponse response = updates.ToAgentResponse(); + + // Create the context after the first run (so LastResponse is never null) and reuse it thereafter. + // Expose the feedback log as a read-only wrapper so evaluators cannot downcast and mutate it; the + // wrapper still reflects entries appended by the loop. + context ??= new LoopContext(this.InnerAgent, session, initialMessages, response, options) { Feedback = feedbackLog.AsReadOnly() }; + + context.Iteration = iteration; + context.LastResponse = response; + + // Stop when the agent is waiting for a tool approval. + if (HasPendingApprovalRequests(response)) + { + yield break; + } + + // Enforce the global safety cap regardless of what the evaluators want. + if (iteration >= this._maxIterations) + { + this.LogMaxIterationsReached(iteration); + yield break; + } + + // Ask the evaluators whether to continue; stop when none of them request a re-invocation. + LoopNextStep step = await this.EvaluateAndBuildNextAsync(context, feedbackLog, initialSessionSnapshot, cancellationToken).ConfigureAwait(false); + if (!step.ShouldContinue) + { + yield break; + } + + currentMessages = step.Messages; + currentSurfaced = step.SurfacedMessages; + } + } + + /// + /// Evaluates the evaluators in order and, for the first one that requests a re-invocation, builds the next input + /// according to the loop's feedback and fresh-context policy. + /// + private async ValueTask EvaluateAndBuildNextAsync(LoopContext context, List feedbackLog, JsonElement? initialSessionSnapshot, CancellationToken cancellationToken) + { + // Evaluate in order; the first evaluator that requests a re-invocation wins. + LoopEvaluation? winner = null; + foreach (LoopEvaluator evaluator in this._evaluators) + { + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context, cancellationToken).ConfigureAwait(false); + if (evaluation.ShouldReinvoke) + { + winner = evaluation; + break; + } + } + + // Every evaluator asked to stop. + if (winner is null) + { + return LoopNextStep.Stop(); + } + + // Start the next iteration from a fresh session when a fresh context is requested, so no prior conversation + // history leaks across iterations. This applies regardless of how the next input is built (feedback or explicit + // ContinueWithMessages): a caller-supplied session is cloned from the pristine start-of-run snapshot; a + // loop-owned session is created anew. + if (this._freshContextPerIteration) + { + context.Session = await this.CreateFreshIterationSessionAsync(context, initialSessionSnapshot, cancellationToken).ConfigureAwait(false); + } + + // Record one feedback entry for this re-invoked iteration (null when none, including ContinueWithMessages + // iterations which carry no feedback string) so the log stays aligned: one entry per re-invoked iteration, with + // the last element always corresponding to the latest re-invoked iteration. Continue() normalizes whitespace to null. + feedbackLog.Add(winner.Feedback); + + // An evaluator supplied explicit messages: send them verbatim, bypassing feedback/message construction (the + // session is still reset above when a fresh context is requested). These are surfaced to the caller as-is (the + // evaluator owns them, including any author name). + if (winner.Messages is not null) + { + return LoopNextStep.Continue(winner.Messages, this.Surfaced(winner.Messages)); + } + + (List messages, List surfaced) = this.BuildNextMessages(context, feedbackLog); + return LoopNextStep.Continue(messages, this.Surfaced(surfaced)); + } + + /// + /// Returns the messages to surface to the caller, honoring . + /// + private IReadOnlyList Surfaced(IReadOnlyList surfaced) + => this._excludeOnBehalfOfMessages ? [] : surfaced; + + /// + /// Creates a streaming update for a surfaced on-behalf-of message, inheriting the driven iteration's + /// so downstream mergers group it with that iteration, and ensuring a unique + /// non-null . The is left + /// unset because the message is synthesized by the loop, not produced by the wrapped agent. + /// + private static AgentResponseUpdate CreateOnBehalfOfUpdate(ChatMessage message, string? responseId) + => new(message.Role, message.Contents) + { + AuthorName = message.AuthorName, + MessageId = message.MessageId is { Length: > 0 } messageId ? messageId : System.Guid.NewGuid().ToString("N"), + ResponseId = responseId, + }; + + /// + /// Builds the messages sent to the wrapped agent for the next iteration along with the subset that should be + /// surfaced to the caller (the loop-synthesized on-behalf-of feedback). Replayed caller input is excluded from the + /// surfaced subset. + /// + private (List Messages, List Surfaced) BuildNextMessages(LoopContext context, List feedback) + { + var messages = new List(); + var surfaced = new List(); + + if (this._freshContextPerIteration) + { + // Fresh context: re-send the original task plus an aggregated log of all feedback recorded so far. Only the + // synthesized feedback message is surfaced; the replayed caller input messages are not. + messages.AddRange(context.InitialMessages); + + ChatMessage? feedbackMessage = this.BuildAggregatedFeedbackMessage(feedback); + if (feedbackMessage is not null) + { + messages.Add(feedbackMessage); + surfaced.Add(feedbackMessage); + } + } + else + { + // Reused session: send only the latest feedback verbatim (the session already retains earlier turns). When + // the latest iteration produced no feedback, send no messages and let the agent continue from history. + string? latest = feedback.Count > 0 ? feedback[feedback.Count - 1] : null; + if (!string.IsNullOrWhiteSpace(latest)) + { + var feedbackMessage = new ChatMessage(ChatRole.User, latest) { AuthorName = this._onBehalfOfAuthorName, MessageId = System.Guid.NewGuid().ToString("N") }; + messages.Add(feedbackMessage); + surfaced.Add(feedbackMessage); + } + } + + return (messages, surfaced); + } + + private ChatMessage? BuildAggregatedFeedbackMessage(IReadOnlyList feedback) + { + var body = new StringBuilder("## Feedback\n"); + bool any = false; + foreach (string? entry in feedback) + { + if (!string.IsNullOrWhiteSpace(entry)) + { + body.Append("\n- ").Append(entry); + any = true; + } + } + + return any ? new ChatMessage(ChatRole.User, body.ToString()) { AuthorName = this._onBehalfOfAuthorName, MessageId = System.Guid.NewGuid().ToString("N") } : null; + } + + /// + /// Produces the non-streaming run result: either the final iteration's response (when configured) or an + /// aggregated response carrying the full transcript with the final response's metadata. + /// + private AgentResponse BuildResult(AgentResponse lastResponse, List transcript) + { + if (this._nonStreamingReturnsLastResponseOnly) + { + return lastResponse; + } + + return new AgentResponse(transcript) + { + AgentId = lastResponse.AgentId, + ResponseId = lastResponse.ResponseId, + CreatedAt = lastResponse.CreatedAt, + FinishReason = lastResponse.FinishReason, + Usage = lastResponse.Usage, + AdditionalProperties = lastResponse.AdditionalProperties, + ContinuationToken = lastResponse.ContinuationToken, + }; + } + + private static bool HasPendingApprovalRequests(AgentResponse response) + { + foreach (ChatMessage message in response.Messages) + { + foreach (AIContent content in message.Contents) + { + if (content is ToolApprovalRequestContent) + { + return true; + } + } + } + + return false; + } + + private void LogMaxIterationsReached(int iteration) + { + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation("LoopAgent reached the maximum of {MaxIterations} iterations and stopped.", iteration); + } + } + + /// + /// Creates the session used for the next iteration when a fresh context is requested. A caller-supplied session is + /// restored from the pristine start-of-run snapshot by deserializing a fresh clone; a loop-owned session (no + /// snapshot) is created anew. The configured session-created callback is notified of the new session. + /// + private async ValueTask CreateFreshIterationSessionAsync(LoopContext context, JsonElement? initialSessionSnapshot, CancellationToken cancellationToken) + { + AgentSession session = initialSessionSnapshot is { } snapshot + ? await this.InnerAgent.DeserializeSessionAsync(snapshot, cancellationToken: cancellationToken).ConfigureAwait(false) + : await context.Agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + + await this.NotifyNewSessionAsync(session, cancellationToken).ConfigureAwait(false); + return session; + } + + /// + /// Invokes the configured (if any) with a session the loop + /// has just created, so the caller can observe the latest session. + /// + private async ValueTask NotifyNewSessionAsync(AgentSession session, CancellationToken cancellationToken) + { + if (this._sessionCreatedCallback is not null) + { + await this._sessionCreatedCallback(session, cancellationToken).ConfigureAwait(false); + } + } + + /// Represents the loop's decision for the next iteration: stop, or continue with a set of messages. + private readonly struct LoopNextStep + { + private LoopNextStep(bool shouldContinue, IReadOnlyList messages, IReadOnlyList surfacedMessages) + { + this.ShouldContinue = shouldContinue; + this.Messages = messages; + this.SurfacedMessages = surfacedMessages; + } + + public bool ShouldContinue { get; } + + /// Gets the full set of messages sent to the wrapped agent for the next iteration. + public IReadOnlyList Messages { get; } + + /// + /// Gets the subset of the loop synthesized on the caller's behalf (feedback or + /// evaluator-supplied messages) that should be surfaced to the caller. Replayed caller input is excluded. + /// + public IReadOnlyList SurfacedMessages { get; } + + public static LoopNextStep Stop() => new(shouldContinue: false, [], []); + + public static LoopNextStep Continue(IReadOnlyList messages, IReadOnlyList surfacedMessages) + => new(shouldContinue: true, messages, surfacedMessages); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgentOptions.cs new file mode 100644 index 0000000000..ec009b4594 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgentOptions.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Provides configuration options for . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class LoopAgentOptions +{ + /// + /// Gets or sets the global safety cap on the number of times the wrapped agent is invoked in a single loop run, + /// or to use . + /// + /// + /// This is an absolute upper bound that applies regardless of the configured set. An + /// evaluator may stop the loop earlier, but no evaluator can cause the loop to exceed this cap, so raise this value + /// if you intend to allow longer loops. + /// + public int? MaxIterations { get; set; } + + /// + /// Gets or sets a value indicating whether each re-invocation restarts from a clean context: the original input + /// messages plus an aggregated feedback log, rather than the latest feedback appended to the prior conversation. + /// Defaults to . + /// + /// + /// + /// This rebuilds the input messages each iteration and resets the session before each re-invocation so no + /// prior conversation history leaks across iterations. When the loop owns the session it creates a new one each + /// iteration. When the caller supplies a session, serializes it once at the start of the run + /// and restores a fresh clone (by deserializing that snapshot) before each re-invocation; this requires the wrapped + /// agent to support session serialization. The first iteration still runs against the caller's supplied session. + /// + /// + /// Note that cloning will only result in a fresh context, if the chat history storage mechanism supports cloning. + /// For example the default in-memory storage supports cloning, since the messages are serialized as part of the snapshot. + /// + /// + /// However, if the Conversations service is used, which stores messages in a single threaded list of messages, + /// then the cloned session will still contain the full message history, since the snapshot only captures an id reference + /// to the conversation and not the individual messages. + /// + /// + /// On the other hand, if responses are used with response ids, cloning will work well, since response ids are + /// forkable. Each new response has its own id, and is based on the id of the previous response. + /// + /// + /// On iterations where an evaluator returns explicit messages via + /// , the session is still reset (a fresh or cloned session is + /// used); only the rebuild of the input messages from the feedback log is skipped, because the evaluator's explicit + /// messages are sent verbatim. + /// + /// + public bool FreshContextPerIteration { get; set; } + + /// + /// Gets or sets the author name stamped on the loop-synthesized "on-behalf-of" messages that the loop injects + /// into the wrapped agent for re-invocations, or to leave them unattributed. Defaults to + /// . + /// + /// + /// When the loop re-invokes the wrapped agent it sends feedback messages on the caller's behalf. Setting this name + /// marks those autonomous messages (for example with a value such as "loop") so that callers and the wrapped + /// agent can distinguish them from the caller's own turns. It is applied only to messages the loop synthesizes + /// itself; messages supplied explicitly by an evaluator via are + /// left untouched, and the caller's original input messages are never modified. + /// + public string? OnBehalfOfAuthorName { get; set; } + + /// + /// Gets or sets a value indicating whether the on-behalf-of messages the loop injects for re-invocations are + /// omitted from the output surfaced back to the caller. Defaults to . + /// + /// + /// When (the default) a streaming run emits the injected feedback / evaluator-supplied + /// messages as updates before each re-invocation, and a non-streaming run includes them in the aggregated + /// transcript, so callers can see the loop acting autonomously on their behalf. Set this to + /// to omit those messages from the returned output and surface only the wrapped agent's responses; the messages are + /// still sent to the wrapped agent. This setting has no effect when + /// causes a non-streaming run to return only the final response. + /// + public bool ExcludeOnBehalfOfMessages { get; set; } + + /// + /// Gets or sets a value indicating whether a non-streaming run returns only the final iteration's response instead + /// of the aggregated transcript of every iteration. Defaults to . + /// + /// + /// By default a non-streaming run returns a single that + /// aggregates, in order, the on-behalf-of messages the loop injected and the responses produced by every + /// iteration — mirroring the full sequence of updates yielded by a streaming run. Set this to + /// to instead return only the last iteration's . This setting affects non-streaming runs + /// only; streaming runs always yield every iteration's updates. + /// + public bool NonStreamingReturnsLastResponseOnly { get; set; } + + /// + /// Gets or sets an optional callback invoked whenever creates a new session, so the caller + /// can capture the latest session (for example to continue the conversation after the loop completes). Defaults to + /// . + /// + /// + /// The callback is invoked with each session the loop itself creates: the initial loop-owned session (when the + /// caller does not supply one) and, when is enabled, every session created + /// for a re-invocation — whether a brand-new loop-owned session or a fresh clone deserialized from the caller's + /// original session. It is not invoked for a caller-supplied session, since the caller already holds that one. When + /// it fires multiple times, the most recent invocation carries the session the loop is currently using. + /// + public Func? SessionCreatedCallback { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopContext.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopContext.cs new file mode 100644 index 0000000000..d0bdf03e7b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopContext.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides the per-run state that a uses to decide whether a +/// should re-invoke the wrapped agent and what feedback to provide. +/// +/// +/// A single instance is created for each run and is +/// reused across iterations, with and updated before +/// each call to . Because evaluator instances are expected to be +/// stateless and may be shared across concurrent runs, any per-run mutable state must be stored on this +/// context — for example via — rather than in fields on the evaluator itself. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class LoopContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The wrapped that is being looped. + /// The used for the loop. + /// The messages passed in for the first iteration of the loop. + /// The produced by the iteration that just completed. + /// The that were passed to the loop run, if any. + /// + /// , , , or + /// is . + /// + public LoopContext( + AIAgent agent, + AgentSession session, + IReadOnlyList initialMessages, + AgentResponse lastResponse, + AgentRunOptions? runOptions = null) + { + this.Agent = Throw.IfNull(agent); + this.Session = Throw.IfNull(session); + this.InitialMessages = Throw.IfNull(initialMessages); + this.LastResponse = Throw.IfNull(lastResponse); + this.RunOptions = runOptions; + } + + /// Gets the wrapped that is being looped. + public AIAgent Agent { get; } + + /// Gets the used for the loop. + /// + /// When the caller does not provide a session, creates one up front. By default the same + /// session is reused across every iteration so that conversation continuity is preserved and the original request + /// is not replayed. When is enabled, + /// resets the session before each re-invocation: a loop-owned session is created anew, while a caller-supplied + /// session is restored from a snapshot taken at the start of the run by deserializing a fresh clone. + /// + public AgentSession Session { get; internal set; } + + /// Gets the messages that were passed in for the first iteration of the loop. + public IReadOnlyList InitialMessages { get; } + + /// Gets the that were passed to the loop run, if any. + public AgentRunOptions? RunOptions { get; } + + /// Gets the number of completed agent runs so far (1-based after the first run). + public int Iteration { get; internal set; } + + /// Gets the produced by the iteration that just completed. + public AgentResponse LastResponse { get; internal set; } + + /// + /// Gets the feedback accumulated across iterations so far, one entry per re-invoked iteration in order. + /// + /// + /// Each entry is the feedback supplied by the evaluator that requested the corresponding re-invocation, or + /// when that iteration produced no feedback string (for example a plain + /// with no text, or a + /// that supplied explicit messages instead). The log records one entry per re-invoked iteration regardless of mode, + /// so the last entry always corresponds to the most recent re-invoked iteration. This log is owned and populated by + /// ; evaluators may read it to reason over prior feedback. + /// + public IReadOnlyList Feedback { get; internal set; } = []; + + /// + /// Gets a mutable bag of per-run state shared across iterations and available to every evaluator. + /// + /// + /// This dictionary is owned by the loop run (not by any evaluator instance) so that evaluators can remain + /// stateless. Evaluators can stash arbitrary per-run state here keyed by a collision-resistant key. + /// + public AdditionalPropertiesDictionary AdditionalProperties { get; } = new(); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopEvaluation.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopEvaluation.cs new file mode 100644 index 0000000000..2d8de152e8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopEvaluation.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the result produced by a after an agent iteration: whether the +/// should re-invoke the wrapped agent and, optionally, the feedback or explicit messages that +/// should inform the next iteration. +/// +/// +/// An evaluator is concerned only with the judgment (continue or stop) and what to carry forward. In the common case +/// it returns a feedback string and lets the decide how that feedback is turned into the next +/// input (and whether the session is reset). For full control, supplies the exact +/// messages to send next, bypassing the loop's feedback and message construction. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class LoopEvaluation +{ + private static readonly LoopEvaluation s_stop = new(shouldReinvoke: false, feedback: null, messages: null); + + private LoopEvaluation(bool shouldReinvoke, string? feedback, IReadOnlyList? messages) + { + this.ShouldReinvoke = shouldReinvoke; + this.Feedback = feedback; + this.Messages = messages; + } + + /// Gets a value indicating whether the loop should run the wrapped agent again. + public bool ShouldReinvoke { get; } + + /// + /// Gets the feedback describing what is missing or what the agent should do next, or when + /// no feedback was produced. + /// + /// This value is only meaningful when is . + public string? Feedback { get; } + + /// + /// Gets the explicit messages to send on the next iteration, or when the loop should build + /// the next input from feedback instead. + /// + /// + /// When non-, the sends these messages verbatim and does not apply + /// its feedback or message construction. The session is still reset when + /// is enabled. Only meaningful when + /// is . + /// + internal IReadOnlyList? Messages { get; } + + /// Creates an evaluation that stops the loop and returns the latest response to the caller. + /// An evaluation with set to . + public static LoopEvaluation Stop() => s_stop; + + /// Creates an evaluation that re-invokes the wrapped agent, optionally carrying feedback forward. + /// + /// Optional feedback to inform the next iteration. , empty, or whitespace is treated as no + /// feedback. + /// + /// An evaluation with set to . + public static LoopEvaluation Continue(string? feedback = null) => new(shouldReinvoke: true, string.IsNullOrWhiteSpace(feedback) ? null : feedback, messages: null); + + /// + /// Creates an evaluation that re-invokes the wrapped agent with the specified messages, bypassing the loop's + /// feedback and message construction. + /// + /// The messages to send to the wrapped agent on the next iteration. + /// An evaluation with set to . + /// is . + /// + /// Use this for full control over the next input (for example to send non-user roles, multiple messages, or + /// non-text content). The supplied messages are sent verbatim and the loop does not accumulate or inject feedback + /// for this iteration. + /// + public static LoopEvaluation ContinueWithMessages(IEnumerable messages) + { + _ = Throw.IfNull(messages); + return new LoopEvaluation(shouldReinvoke: true, feedback: null, messages: messages as IReadOnlyList ?? messages.ToList()); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopEvaluator.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopEvaluator.cs new file mode 100644 index 0000000000..328c99e80c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopEvaluator.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Provides the abstract base class for the component that decides, after each agent iteration, whether a +/// should re-invoke the wrapped agent and what feedback to provide. +/// +/// +/// +/// A is pure judgment: it inspects the and returns a +/// describing whether to continue and any feedback for the next iteration. It does not +/// manage the session or construct the next input messages — that is the responsibility of the +/// that consumes it. +/// +/// +/// Out-of-the-box implementations include , , +/// and . Implementations should be stateless and safe to share across +/// concurrent loop runs; any per-run state must be stored on the supplied . +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class LoopEvaluator +{ + /// + /// Evaluates the loop state after an iteration and decides whether to re-invoke the wrapped agent and what + /// feedback to provide. + /// + /// The per-run describing the current loop state. + /// The to monitor for cancellation requests. + /// + /// A value task whose result is a indicating whether to continue and, if so, the + /// feedback to carry forward to the next iteration. + /// + public abstract ValueTask EvaluateAsync(LoopContext context, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopJsonContext.cs b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopJsonContext.cs new file mode 100644 index 0000000000..8d69383e3f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopJsonContext.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI; + +/// +/// Source-generated for loop types that require JSON serialization, such as the +/// structured used by . +/// +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(JudgeVerdict))] +[ExcludeFromCodeCoverage] +internal sealed partial class LoopJsonContext : JsonSerializerContext; diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs index babfdfcddf..29d57d369a 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs @@ -54,7 +54,6 @@ builder.Services.AddFoundryResponses(agent); 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 0000000000..667bc46b59 --- /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 e619445481..eafa3c497a 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 class FoundryToolboxBearerTokenHandlerTests } [Fact] - public async Task SendAsync_InjectsFoundryFeaturesHeaderAsync() + public async Task SendAsync_UsesAiAzureComScopeAsync() + { + // 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"); + await invoker.SendAsync(request, CancellationToken.None); + + // 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_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: 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"); - 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); + 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_OmitsFeaturesHeaderWhenNullAsync() + public async Task SendAsync_DoesNotDuplicateMandatoryFlagAsync() { - var (handler, _) = CreateHandlerPair(featuresHeader: null); + // 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"); - using var response = await invoker.SendAsync(request, CancellationToken.None); + await invoker.SendAsync(request, CancellationToken.None); - Assert.False(request.Headers.Contains("Foundry-Features")); + 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 0000000000..7ea5d14e1c --- /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 cdcdf5ee8e..d8a56adcd6 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 @@ using Moq; namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; +[Collection(FoundryProjectEndpointEnvFixture.Name)] public class FoundryToolboxServiceTests { [Fact] @@ -39,15 +40,17 @@ public class FoundryToolboxServiceTests 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 class FoundryToolboxServiceTests 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 bcab777af0..f49c669f66 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 class ServiceCollectionExtensionsTests 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 2404a254e3..cfdc819872 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 @@ using Microsoft.Extensions.AI; 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 class GitHubCopilotAgentTests 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 class GitHubCopilotAgentTests 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 class GitHubCopilotAgentTests 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 class GitHubCopilotAgentTests }; 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 class GitHubCopilotAgentTests }; 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 class GitHubCopilotAgentTests }; 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 class GitHubCopilotAgentTests }; 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.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs index 944f2f30ab..8faa842eb0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/GitHubCopilotAgentTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using GitHub.Copilot; using GitHub.Copilot.Rpc; @@ -222,7 +223,73 @@ public sealed class GitHubCopilotAgentTests } [Fact] - public void ConvertToAgentResponseUpdate_AssistantMessageEvent_DoesNotEmitTextContent() + public void CopySessionConfig_WithStreamingDisabled_PreservesStreamingValue() + { + // Arrange + var source = new SessionConfig + { + Streaming = false, + Model = "gpt-4o", + }; + + // Act + SessionConfig result = GitHubCopilotAgent.CopySessionConfig(source); + + // Assert + Assert.False(result.Streaming); + } + + [Fact] + public void CopySessionConfig_WithStreamingNull_DefaultsToTrue() + { + // Arrange + var source = new SessionConfig + { + Model = "gpt-4o", + }; + + // Act + SessionConfig result = GitHubCopilotAgent.CopySessionConfig(source); + + // Assert + Assert.True(result.Streaming); + } + + [Fact] + public void CopyResumeSessionConfig_WithStreamingDisabled_PreservesStreamingValue() + { + // Arrange + var source = new SessionConfig + { + Streaming = false, + Model = "gpt-4o", + }; + + // Act + ResumeSessionConfig result = GitHubCopilotAgent.CopyResumeSessionConfig(source); + + // Assert + Assert.False(result.Streaming); + } + + [Fact] + public void CopyResumeSessionConfig_WithStreamingNull_DefaultsToTrue() + { + // Arrange + var source = new SessionConfig + { + Model = "gpt-4o", + }; + + // Act + ResumeSessionConfig result = GitHubCopilotAgent.CopyResumeSessionConfig(source); + + // Assert + Assert.True(result.Streaming); + } + + [Fact] + public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenStreaming_DoesNotEmitTextContent() { var assistantMessage = new AssistantMessageEvent { @@ -235,11 +302,84 @@ public sealed class GitHubCopilotAgentTests CopilotClient copilotClient = new(new CopilotClientOptions()); const string TestId = "agent-id"; var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null); - AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage); + AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage, isStreaming: true); - // result.Text need to be empty because the content was already delivered via delta events, and we want to avoid emitting duplicate content in the response update. - // The content should be delivered through TextContent in the Contents collection instead. + // result.Text should be empty because content was already delivered via delta events. Assert.Empty(result.Text); Assert.DoesNotContain(result.Contents, c => c is TextContent); } + + [Fact] + public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_EmitsTextContent() + { + // Arrange + const string ExpectedContent = "Full response text from non-streaming session"; + var assistantMessage = new AssistantMessageEvent + { + Data = new AssistantMessageData + { + MessageId = "msg-789", + Content = ExpectedContent + } + }; + CopilotClient copilotClient = new(new CopilotClientOptions()); + const string TestId = "agent-id"; + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null); + + // Act + AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage, isStreaming: false); + + // Assert - text must be emitted since no delta events precede it in non-streaming mode. + Assert.Equal(ExpectedContent, result.Text); + Assert.Contains(result.Contents, c => c is TextContent); + TextContent textContent = (TextContent)result.Contents.Single(c => c is TextContent); + Assert.Equal(ExpectedContent, textContent.Text); + Assert.Same(assistantMessage, textContent.RawRepresentation); + } + + [Fact] + public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_HandlesEmptyContent() + { + // Arrange + var assistantMessage = new AssistantMessageEvent + { + Data = new AssistantMessageData + { + MessageId = "msg-000", + Content = string.Empty + } + }; + CopilotClient copilotClient = new(new CopilotClientOptions()); + const string TestId = "agent-id"; + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null); + + // Act + AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage, isStreaming: false); + + // Assert - should emit empty TextContent rather than throwing. + Assert.Empty(result.Text); + Assert.Contains(result.Contents, c => c is TextContent); + } + + [Fact] + public void ConvertToAgentResponseUpdate_AssistantMessageEventWhenNotStreaming_HandlesNullData() + { + // Arrange + var assistantMessage = new AssistantMessageEvent + { + Data = null! + }; + CopilotClient copilotClient = new(new CopilotClientOptions()); + const string TestId = "agent-id"; + var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null); + + // Act + AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage, isStreaming: false); + + // Assert - null Data should produce empty TextContent via null-propagation fallback. + Assert.Empty(result.Text); + Assert.Contains(result.Contents, c => c is TextContent); + Assert.Null(result.MessageId); + Assert.Null(result.ResponseId); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs index effad5fc53..9e6539fa88 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs @@ -27,7 +27,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi #else private const string BuildConfiguration = "Release"; #endif - private static readonly HttpClient s_sharedHttpClient = new(); + private static readonly HttpClient s_sharedHttpClient = new() { Timeout = TimeSpan.FromMinutes(3) }; private static readonly IConfiguration s_configuration = new ConfigurationBuilder() .AddEnvironmentVariables() @@ -60,7 +60,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi await Task.CompletedTask; } - [RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")] + [RetryFact(2, 5000)] public async Task SingleAgentSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "01_SingleAgent"); @@ -148,7 +148,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi }); } - [RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")] + [RetryFact(2, 5000)] public async Task MultiAgentOrchestrationConcurrentSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "03_AgentOrchestration_Concurrency"); @@ -198,7 +198,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi }); } - [RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")] + [RetryFact(2, 5000)] public async Task MultiAgentOrchestrationConditionalsSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "04_AgentOrchestration_Conditionals"); @@ -216,7 +216,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi }); } - [RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")] + [RetryFact(2, 5000)] public async Task SingleAgentOrchestrationHITLSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL"); @@ -272,7 +272,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi }); } - [RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")] + [RetryFact(2, 5000)] public async Task LongRunningToolsSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools"); @@ -362,7 +362,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi }); } - [RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")] + [RetryFact(2, 5000)] public async Task AgentAsMcpToolAsync() { string samplePath = Path.Combine(s_samplesPath, "07_AgentAsMcpTool"); @@ -402,7 +402,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi }); } - [RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")] + [RetryFact(2, 5000)] public async Task ReliableStreamingSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "08_ReliableStreaming"); @@ -844,6 +844,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi throw new InvalidOperationException("The required AZURE_OPENAI_DEPLOYMENT_NAME env variable is not set."); // Set required environment variables for the function app (see local.settings.json for required settings) + startInfo.EnvironmentVariables["FUNCTIONS_WORKER_RUNTIME"] = "dotnet-isolated"; startInfo.EnvironmentVariables["AZURE_OPENAI_ENDPOINT"] = openAiEndpoint; startInfo.EnvironmentVariables["AZURE_OPENAI_DEPLOYMENT_NAME"] = openAiDeployment; startInfo.EnvironmentVariables["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"] = diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs index 2a51cb467e..554e7f6beb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -62,7 +62,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : return default; } - [Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")] + [Fact] public async Task SequentialWorkflowSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "01_SequentialWorkflow"); @@ -168,7 +168,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : }); } - [Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")] + [Fact] public async Task HITLWorkflowSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "03_WorkflowHITL"); @@ -277,7 +277,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : }); } - [Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")] + [Fact] public async Task WorkflowMcpToolSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "04_WorkflowMcpTool"); @@ -333,7 +333,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : }); } - [Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")] + [Fact] public async Task WorkflowAndAgentsSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "05_WorkflowAndAgents"); @@ -385,7 +385,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : }); } - [Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")] + [Fact] public async Task ConcurrentWorkflowSampleValidationAsync() { string samplePath = Path.Combine(s_samplesPath, "02_ConcurrentWorkflow"); @@ -619,6 +619,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : startInfo.EnvironmentVariables["AZURE_OPENAI_DEPLOYMENT"] = openAiDeployment; } + startInfo.EnvironmentVariables["FUNCTIONS_WORKER_RUNTIME"] = "dotnet-isolated"; startInfo.EnvironmentVariables["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"] = $"Endpoint=http://localhost:{DtsPort};TaskHub=default;Authentication=None"; startInfo.EnvironmentVariables["AzureWebJobsStorage"] = "UseDevelopmentStorage=true"; diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/PurviewClientTests.cs index 38abc903d3..6b857101c7 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 sealed class PurviewClientTests : IDisposable 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 @@ public sealed class PurviewClientTests : IDisposable 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 @@ public sealed class PurviewClientTests : IDisposable 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 3527cc9884..3cfc81face 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 sealed class ScopedContentProcessorTests 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 sealed class ScopedContentProcessorTests ] }; - 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 sealed class ScopedContentProcessorTests 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 sealed class ScopedContentProcessorTests ] }; - 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 sealed class ScopedContentProcessorTests 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 sealed class ScopedContentProcessorTests ] }; - 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 sealed class ScopedContentProcessorTests 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 sealed class ScopedContentProcessorTests 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 sealed class ScopedContentProcessorTests ] }; - 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 sealed class ScopedContentProcessorTests 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 sealed class ScopedContentProcessorTests ] }; - 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 sealed class ScopedContentProcessorTests 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 sealed class ScopedContentProcessorTests 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 sealed class ScopedContentProcessorTests 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.UnitTests/Harness/Loop/AIJudgeLoopEvaluatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/AIJudgeLoopEvaluatorTests.cs new file mode 100644 index 0000000000..d91494ba03 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/AIJudgeLoopEvaluatorTests.cs @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +using static Microsoft.Agents.AI.UnitTests.LoopTestHelpers; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class AIJudgeLoopEvaluatorTests +{ + /// + /// Verify that the evaluator stops when the judge reports the request was answered. + /// + [Fact] + public async Task EvaluateAsync_Answered_StopsAsync() + { + // Arrange + var judgeClient = CreateJudgeClient("{\"answered\":true}"); + var evaluator = new AIJudgeLoopEvaluator(judgeClient); + LoopContext context = CreateContext(); + + // Act + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context); + + // Assert + Assert.False(evaluation.ShouldReinvoke); + } + + /// + /// Verify that when not answered the evaluator continues with feedback carrying the judge's gap analysis. + /// + [Fact] + public async Task EvaluateAsync_NotAnswered_ContinuesWithGapAnalysisAsync() + { + // Arrange + var judgeClient = CreateJudgeClient("{\"answered\":false,\"gapAnalysis\":\"the cost estimate is missing\"}"); + var evaluator = new AIJudgeLoopEvaluator(judgeClient); + LoopContext context = CreateContext(); + + // Act + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context); + + // Assert + Assert.True(evaluation.ShouldReinvoke); + Assert.NotNull(evaluation.Feedback); + Assert.Contains("the cost estimate is missing", evaluation.Feedback!); + Assert.DoesNotContain(AIJudgeLoopEvaluator.GapAnalysisPlaceholder, evaluation.Feedback!); + } + + /// + /// Verify that the evaluator falls back to text parsing and stops when the DONE verdict marker is present. + /// + [Fact] + public async Task EvaluateAsync_TextFallback_StopsWhenAnsweredAsync() + { + // Arrange + var judgeClient = CreateJudgeClient(AIJudgeLoopEvaluator.DoneVerdictMarker); + var evaluator = new AIJudgeLoopEvaluator(judgeClient); + LoopContext context = CreateContext(); + + // Act + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context); + + // Assert + Assert.False(evaluation.ShouldReinvoke); + } + + /// + /// Verify that the gap-analysis placeholder is filled with a fallback token when no structured output is produced. + /// + [Fact] + public async Task EvaluateAsync_NotAnswered_TextFallback_InjectsUnknownGapAnalysisAsync() + { + // Arrange + var judgeClient = CreateJudgeClient(AIJudgeLoopEvaluator.MoreVerdictMarker); + var evaluator = new AIJudgeLoopEvaluator(judgeClient); + LoopContext context = CreateContext(); + + // Act + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context); + + // Assert + Assert.True(evaluation.ShouldReinvoke); + Assert.Contains("", evaluation.Feedback!); + } + + /// + /// Verify that the text fallback keeps looping for replies that merely contain the substring "ANSWERED" (for + /// example "UNANSWERED" or "NOT ANSWERED") rather than the explicit DONE verdict marker. + /// + [Theory] + [InlineData("UNANSWERED")] + [InlineData("NOT ANSWERED")] + [InlineData("The request is not yet answered.")] + public async Task EvaluateAsync_TextFallback_AmbiguousReply_ContinuesAsync(string reply) + { + // Arrange + var judgeClient = CreateJudgeClient(reply); + var evaluator = new AIJudgeLoopEvaluator(judgeClient); + LoopContext context = CreateContext(); + + // Act + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context); + + // Assert + Assert.True(evaluation.ShouldReinvoke); + } + + /// + /// Verify that custom judge instructions from options are sent to the judge client. + /// + [Fact] + public async Task EvaluateAsync_CustomInstructions_AreSentToJudgeAsync() + { + // Arrange + List? judgeMessages = null; + var judgeMock = new Mock(); + judgeMock.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((msgs, _, _) => judgeMessages = msgs.ToList()) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "{\"answered\":true}"))); + var evaluator = new AIJudgeLoopEvaluator(judgeMock.Object, new AIJudgeLoopEvaluatorOptions { Instructions = "CUSTOM JUDGE PROMPT" }); + LoopContext context = CreateContext(); + + // Act + await evaluator.EvaluateAsync(context); + + // Assert + Assert.NotNull(judgeMessages); + Assert.Contains(judgeMessages!, m => m.Role == ChatRole.System && m.Text == "CUSTOM JUDGE PROMPT"); + } + + /// + /// Verify that a custom feedback message template from options is honored. + /// + [Fact] + public async Task EvaluateAsync_CustomFeedbackMessageTemplate_IsHonoredAsync() + { + // Arrange + var judgeClient = CreateJudgeClient("{\"answered\":false,\"gapAnalysis\":\"add unit tests\"}"); + const string Template = "Please address: " + AIJudgeLoopEvaluator.GapAnalysisPlaceholder; + var evaluator = new AIJudgeLoopEvaluator(judgeClient, new AIJudgeLoopEvaluatorOptions { FeedbackMessageTemplate = Template }); + LoopContext context = CreateContext(); + + // Act + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context); + + // Assert + Assert.Equal("Please address: add unit tests", evaluation.Feedback); + } + + /// + /// Verify that non-text content in the original request (for example an image) is forwarded to the judge + /// rather than being silently dropped when flattening the request to text. + /// + [Fact] + public async Task EvaluateAsync_NonTextRequestContent_IsForwardedToJudgeAsync() + { + // Arrange + List? judgeMessages = null; + var judgeMock = new Mock(); + judgeMock.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((msgs, _, _) => judgeMessages = msgs.ToList()) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "{\"answered\":true}"))); + var evaluator = new AIJudgeLoopEvaluator(judgeMock.Object); + var imageContent = new DataContent(new byte[] { 1, 2, 3, 4 }, "image/png"); + var context = new LoopContext( + new Mock().Object, + new ChatClientAgentSession(), + [new ChatMessage(ChatRole.User, [imageContent])], + new AgentResponse([new ChatMessage(ChatRole.Assistant, "partial answer")])); + + // Act + await evaluator.EvaluateAsync(context); + + // Assert + Assert.NotNull(judgeMessages); + ChatMessage userMessage = Assert.Single(judgeMessages!, m => m.Role == ChatRole.User); + Assert.Contains(userMessage.Contents.OfType(), c => c.MediaType == "image/png"); + } + + /// + /// Verify that the constructor throws when the judge client is null. + /// + [Fact] + public void AIJudgeLoopEvaluator_NullClient_Throws() + { + // Act & Assert + Assert.Throws("judgeClient", () => new AIJudgeLoopEvaluator(null!)); + } + + /// + /// Verify that EvaluateAsync throws when the context is null. + /// + [Fact] + public async Task EvaluateAsync_NullContext_ThrowsAsync() + { + // Arrange + var evaluator = new AIJudgeLoopEvaluator(CreateJudgeClient("{\"answered\":true}")); + + // Act & Assert + await Assert.ThrowsAsync("context", async () => await evaluator.EvaluateAsync(null!)); + } + + /// + /// Verify that supplied criteria are rendered into the default judge instructions as a bullet list and the + /// placeholder is consumed. + /// + [Fact] + public async Task EvaluateAsync_Criteria_AreRenderedIntoDefaultInstructionsAsync() + { + // Arrange + var judgeClient = CreateCapturingJudgeClient("{\"answered\":true}", out List judgeMessages); + var options = new AIJudgeLoopEvaluatorOptions { Criteria = ["Must cite sources", "Must be under 200 words"] }; + var evaluator = new AIJudgeLoopEvaluator(judgeClient, options); + LoopContext context = CreateContext(); + + // Act + await evaluator.EvaluateAsync(context); + + // Assert + string system = judgeMessages.Single(static m => m.Role == ChatRole.System).Text; + Assert.Contains("The response must satisfy all of the following criteria:", system); + Assert.Contains("- Must cite sources", system); + Assert.Contains("- Must be under 200 words", system); + Assert.DoesNotContain(AIJudgeLoopEvaluator.CriteriaPlaceholder, system); + } + + /// + /// Verify that when no criteria are supplied the placeholder is removed and no criteria block is added to the + /// default instructions. + /// + [Fact] + public async Task EvaluateAsync_NoCriteria_LeavesDefaultInstructionsWithoutCriteriaBlockAsync() + { + // Arrange + var judgeClient = CreateCapturingJudgeClient("{\"answered\":true}", out List judgeMessages); + var evaluator = new AIJudgeLoopEvaluator(judgeClient); + LoopContext context = CreateContext(); + + // Act + await evaluator.EvaluateAsync(context); + + // Assert + string system = judgeMessages.Single(static m => m.Role == ChatRole.System).Text; + Assert.DoesNotContain(AIJudgeLoopEvaluator.CriteriaPlaceholder, system); + Assert.DoesNotContain("The response must satisfy all of the following criteria:", system); + } + + /// + /// Verify that criteria are injected at the placeholder location in custom instructions. + /// + [Fact] + public async Task EvaluateAsync_CustomInstructionsWithPlaceholder_InjectsCriteriaAsync() + { + // Arrange + var judgeClient = CreateCapturingJudgeClient("{\"answered\":true}", out List judgeMessages); + const string Instructions = "Judge the answer." + AIJudgeLoopEvaluator.CriteriaPlaceholder + " Be strict."; + var options = new AIJudgeLoopEvaluatorOptions { Instructions = Instructions, Criteria = ["Must include code"] }; + var evaluator = new AIJudgeLoopEvaluator(judgeClient, options); + LoopContext context = CreateContext(); + + // Act + await evaluator.EvaluateAsync(context); + + // Assert + string system = judgeMessages.Single(static m => m.Role == ChatRole.System).Text; + Assert.StartsWith("Judge the answer.", system); + Assert.EndsWith("Be strict.", system); + Assert.Contains("- Must include code", system); + Assert.DoesNotContain(AIJudgeLoopEvaluator.CriteriaPlaceholder, system); + } + + /// + /// Verify that custom instructions without the placeholder do not receive the criteria. + /// + [Fact] + public async Task EvaluateAsync_CustomInstructionsWithoutPlaceholder_OmitsCriteriaAsync() + { + // Arrange + var judgeClient = CreateCapturingJudgeClient("{\"answered\":true}", out List judgeMessages); + const string Instructions = "Judge the answer and be strict."; + var options = new AIJudgeLoopEvaluatorOptions { Instructions = Instructions, Criteria = ["Must include code"] }; + var evaluator = new AIJudgeLoopEvaluator(judgeClient, options); + LoopContext context = CreateContext(); + + // Act + await evaluator.EvaluateAsync(context); + + // Assert + string system = judgeMessages.Single(static m => m.Role == ChatRole.System).Text; + Assert.Equal(Instructions, system); + } + + private static LoopContext CreateContext() => new( + new Mock().Object, + new ChatClientAgentSession(), + [new ChatMessage(ChatRole.User, "original question")], + new AgentResponse([new ChatMessage(ChatRole.Assistant, "partial answer")])); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/CompletionMarkerLoopEvaluatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/CompletionMarkerLoopEvaluatorTests.cs new file mode 100644 index 0000000000..81f6cc532f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/CompletionMarkerLoopEvaluatorTests.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class CompletionMarkerLoopEvaluatorTests +{ + /// + /// Verify that the constructor throws when the marker is null, empty, or whitespace. + /// + /// The invalid marker value. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void CompletionMarkerLoopEvaluator_InvalidMarker_Throws(string? marker) + { + // Act & Assert + Assert.ThrowsAny(() => new CompletionMarkerLoopEvaluator(marker!)); + } + + /// + /// Verify that the evaluator stops the loop when the marker appears in the latest response. + /// + [Fact] + public async Task EvaluateAsync_MarkerPresent_StopsAsync() + { + // Arrange + var evaluator = new CompletionMarkerLoopEvaluator("DONE"); + LoopContext context = CreateContext("all DONE here"); + + // Act + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context); + + // Assert + Assert.False(evaluation.ShouldReinvoke); + } + + /// + /// Verify that the evaluator continues with default feedback (containing the marker) when the marker is absent. + /// + [Fact] + public async Task EvaluateAsync_MarkerAbsent_ContinuesWithDefaultFeedbackAsync() + { + // Arrange + var evaluator = new CompletionMarkerLoopEvaluator("DONE"); + LoopContext context = CreateContext("still working"); + + // Act + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context); + + // Assert + Assert.True(evaluation.ShouldReinvoke); + Assert.NotNull(evaluation.Feedback); + Assert.Contains("DONE", evaluation.Feedback!); + Assert.DoesNotContain(CompletionMarkerLoopEvaluator.CompletionMarkerPlaceholder, evaluation.Feedback!); + } + + /// + /// Verify that a custom feedback template is honored, with the completion marker substituted for the placeholder. + /// + [Fact] + public async Task EvaluateAsync_MarkerAbsent_CustomTemplate_IsHonoredAsync() + { + // Arrange + const string Template = "Keep going and finish with " + CompletionMarkerLoopEvaluator.CompletionMarkerPlaceholder + " when done."; + var evaluator = new CompletionMarkerLoopEvaluator("FINISHED", new CompletionMarkerLoopEvaluatorOptions { FeedbackMessageTemplate = Template }); + LoopContext context = CreateContext("still working"); + + // Act + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context); + + // Assert + Assert.True(evaluation.ShouldReinvoke); + Assert.Equal("Keep going and finish with FINISHED when done.", evaluation.Feedback); + } + + /// + /// Verify that a custom feedback template containing the last-response placeholder echoes the agent's latest + /// response text, with no leftover placeholder. + /// + [Fact] + public async Task EvaluateAsync_MarkerAbsent_CustomTemplate_SubstitutesLastResponseAsync() + { + // Arrange + const string Template = "Your previous attempt was: '" + CompletionMarkerLoopEvaluator.LastResponsePlaceholder + + "'. Improve it and finish with " + CompletionMarkerLoopEvaluator.CompletionMarkerPlaceholder + " when done."; + var evaluator = new CompletionMarkerLoopEvaluator("FINISHED", new CompletionMarkerLoopEvaluatorOptions { FeedbackMessageTemplate = Template }); + LoopContext context = CreateContext("candidate name: NoteNest"); + + // Act + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context); + + // Assert + Assert.True(evaluation.ShouldReinvoke); + Assert.Equal("Your previous attempt was: 'candidate name: NoteNest'. Improve it and finish with FINISHED when done.", evaluation.Feedback); + Assert.DoesNotContain(CompletionMarkerLoopEvaluator.LastResponsePlaceholder, evaluation.Feedback!); + } + + /// + /// Verify that the default feedback template does not include the agent's latest response text (the last-response + /// placeholder is opt-in via a custom template). + /// + [Fact] + public async Task EvaluateAsync_MarkerAbsent_DefaultTemplate_DoesNotIncludeLastResponseAsync() + { + // Arrange + var evaluator = new CompletionMarkerLoopEvaluator("DONE"); + LoopContext context = CreateContext("candidate name: NoteNest"); + + // Act + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context); + + // Assert + Assert.True(evaluation.ShouldReinvoke); + Assert.Equal(CompletionMarkerLoopEvaluator.DefaultFeedbackMessageTemplate.Replace(CompletionMarkerLoopEvaluator.CompletionMarkerPlaceholder, "DONE"), evaluation.Feedback); + Assert.DoesNotContain("NoteNest", evaluation.Feedback!); + } + + /// + /// Verify that EvaluateAsync throws when the context is null. + /// + [Fact] + public async Task EvaluateAsync_NullContext_ThrowsAsync() + { + // Arrange + var evaluator = new CompletionMarkerLoopEvaluator("DONE"); + + // Act & Assert + await Assert.ThrowsAsync("context", async () => await evaluator.EvaluateAsync(null!)); + } + + private static LoopContext CreateContext(string responseText) => new( + new Mock().Object, + new ChatClientAgentSession(), + [new ChatMessage(ChatRole.User, "go")], + new AgentResponse([new ChatMessage(ChatRole.Assistant, responseText)])); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/DelegateLoopEvaluatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/DelegateLoopEvaluatorTests.cs new file mode 100644 index 0000000000..8718fe9250 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/DelegateLoopEvaluatorTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class DelegateLoopEvaluatorTests +{ + /// + /// Verify that the constructor throws when the evaluate delegate is null. + /// + [Fact] + public void DelegateLoopEvaluator_NullDelegate_Throws() + { + // Act & Assert + Assert.Throws("evaluate", () => new DelegateLoopEvaluator(null!)); + } + + /// + /// Verify that EvaluateAsync throws when the context is null. + /// + [Fact] + public async Task EvaluateAsync_NullContext_ThrowsAsync() + { + // Arrange + var evaluator = new DelegateLoopEvaluator((_, _) => new ValueTask(LoopEvaluation.Stop())); + + // Act & Assert + await Assert.ThrowsAsync("context", async () => await evaluator.EvaluateAsync(null!)); + } + + /// + /// Verify that EvaluateAsync invokes the supplied delegate and returns the evaluation it produces. + /// + [Fact] + public async Task EvaluateAsync_InvokesDelegate_AndReturnsItsEvaluationAsync() + { + // Arrange + bool invoked = false; + var expected = LoopEvaluation.Continue("feedback"); + var evaluator = new DelegateLoopEvaluator((_, _) => + { + invoked = true; + return new ValueTask(expected); + }); + LoopContext context = CreateContext(); + + // Act + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context); + + // Assert + Assert.True(invoked); + Assert.Same(expected, evaluation); + } + + /// + /// Verify that EvaluateAsync passes the same context instance to the delegate. + /// + [Fact] + public async Task EvaluateAsync_PassesContextToDelegateAsync() + { + // Arrange + LoopContext? received = null; + var evaluator = new DelegateLoopEvaluator((ctx, _) => + { + received = ctx; + return new ValueTask(LoopEvaluation.Stop()); + }); + LoopContext context = CreateContext(); + + // Act + await evaluator.EvaluateAsync(context); + + // Assert + Assert.Same(context, received); + } + + /// + /// Verify that EvaluateAsync forwards the cancellation token to the delegate. + /// + [Fact] + public async Task EvaluateAsync_ForwardsCancellationTokenToDelegateAsync() + { + // Arrange + using var cts = new CancellationTokenSource(); + CancellationToken received = default; + var evaluator = new DelegateLoopEvaluator((_, ct) => + { + received = ct; + return new ValueTask(LoopEvaluation.Stop()); + }); + LoopContext context = CreateContext(); + + // Act + await evaluator.EvaluateAsync(context, cts.Token); + + // Assert + Assert.Equal(cts.Token, received); + } + + private static LoopContext CreateContext() => new( + new Mock().Object, + new ChatClientAgentSession(), + [new ChatMessage(ChatRole.User, "go")], + new AgentResponse([new ChatMessage(ChatRole.Assistant, "response")])); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopAgentTests.cs new file mode 100644 index 0000000000..428298f1d6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopAgentTests.cs @@ -0,0 +1,1231 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; +using Moq.Protected; + +using static Microsoft.Agents.AI.UnitTests.LoopTestHelpers; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class LoopAgentTests +{ + #region Constructor + + /// + /// Verify that the constructor throws when innerAgent is null. + /// + [Fact] + public void Constructor_NullInnerAgent_Throws() + { + // Arrange + var evaluator = While(static _ => false); + + // Act & Assert + Assert.Throws("innerAgent", () => new LoopAgent(null!, evaluator)); + } + + /// + /// Verify that the constructor throws when the evaluator is null. + /// + [Fact] + public void Constructor_NullEvaluator_Throws() + { + // Arrange + var innerAgent = new Mock().Object; + + // Act & Assert + Assert.Throws("evaluator", () => new LoopAgent(innerAgent, (LoopEvaluator)null!)); + } + + /// + /// Verify that the constructor throws when the evaluators collection is null. + /// + [Fact] + public void Constructor_NullEvaluators_Throws() + { + // Arrange + var innerAgent = new Mock().Object; + + // Act & Assert + Assert.Throws("evaluators", () => new LoopAgent(innerAgent, (IEnumerable)null!)); + } + + /// + /// Verify that the constructor throws when the evaluators collection is empty. + /// + [Fact] + public void Constructor_EmptyEvaluators_Throws() + { + // Arrange + var innerAgent = new Mock().Object; + + // Act & Assert + Assert.Throws("evaluators", () => new LoopAgent(innerAgent, Array.Empty())); + } + + /// + /// Verify that the constructor throws when the evaluators collection contains a null element. + /// + [Fact] + public void Constructor_NullEvaluatorElement_Throws() + { + // Arrange + var innerAgent = new Mock().Object; + + // Act & Assert + Assert.Throws("evaluators", () => new LoopAgent(innerAgent, new LoopEvaluator[] { null! })); + } + + /// + /// Verify that the constructor throws when MaxIterations is less than 1. + /// + [Fact] + public void Constructor_InvalidMaxIterations_Throws() + { + // Arrange + var innerAgent = new Mock().Object; + var evaluator = While(static _ => false); + var options = new LoopAgentOptions { MaxIterations = 0 }; + + // Act & Assert + Assert.Throws(() => new LoopAgent(innerAgent, evaluator, options)); + } + + /// + /// Verify that the constructor creates a valid instance with default options. + /// + [Fact] + public void Constructor_ValidArguments_CreatesInstance() + { + // Arrange + var innerAgent = new Mock().Object; + var evaluator = While(static _ => false); + + // Act + var agent = new LoopAgent(innerAgent, evaluator); + + // Assert + Assert.NotNull(agent); + } + + #endregion + + #region RunAsync - core loop behavior + + /// + /// Verify that when the evaluator stops immediately the inner agent is invoked exactly once. + /// + [Fact] + public async Task RunAsync_EvaluatorStopsImmediately_InvokesOnceAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")])); + var evaluator = While(static _ => false); + var agent = new LoopAgent(capture.Agent, evaluator); + + // Act + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], new ChatClientAgentSession()); + + // Assert + Assert.Equal("done", response.Text); + Assert.Equal(1, capture.CallCount); + } + + /// + /// Verify that the loop re-invokes while the predicate returns true and the aggregated response contains every + /// iteration's messages in order. + /// + [Fact] + public async Task RunAsync_PredicateLoopsUntilFalse_AggregatesAllIterationsAsync() + { + // Arrange + var capture = new InnerAgentCapture(call => + new AgentResponse([new ChatMessage(ChatRole.Assistant, $"iteration {call}")])); + + // Continue while the latest response is not "iteration 3". + var evaluator = While(ctx => ctx.LastResponse.Text != "iteration 3"); + var agent = new LoopAgent(capture.Agent, evaluator); + + // Act + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(3, capture.CallCount); + Assert.Equal(["iteration 1", "iteration 2", "iteration 3"], response.Messages.Select(static m => m.Text)); + } + + /// + /// Verify that returns only the final + /// iteration's response instead of the aggregated transcript. + /// + [Fact] + public async Task RunAsync_LastResponseOnly_ReturnsFinalResponseAsync() + { + // Arrange + var capture = new InnerAgentCapture(call => + new AgentResponse([new ChatMessage(ChatRole.Assistant, $"iteration {call}")])); + var evaluator = While(ctx => ctx.LastResponse.Text != "iteration 3"); + var options = new LoopAgentOptions { NonStreamingReturnsLastResponseOnly = true }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(3, capture.CallCount); + Assert.Equal("iteration 3", response.Text); + Assert.Single(response.Messages); + } + + /// + /// Verify that the caller's initial messages are sent once and a re-invocation without feedback sends none. + /// + [Fact] + public async Task RunAsync_ContinueWithoutFeedback_SendsInitialOnceThenNoneAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "ack")])); + var evaluator = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.Iteration < 2 ? LoopEvaluation.Continue() : LoopEvaluation.Stop())); + var agent = new LoopAgent(capture.Agent, evaluator); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "original")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(2, capture.CallCount); + Assert.Equal("original", capture.MessagesPerCall[0].Single().Text); + Assert.Empty(capture.MessagesPerCall[1]); + } + + /// + /// Verify that feedback supplied by the evaluator is injected verbatim on re-invocation (non-fresh mode). + /// + [Fact] + public async Task RunAsync_EvaluatorSuppliesFeedback_InjectsItVerbatimAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "ack")])); + var evaluator = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.Iteration < 2 ? LoopEvaluation.Continue("custom follow-up") : LoopEvaluation.Stop())); + var agent = new LoopAgent(capture.Agent, evaluator); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "original")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(2, capture.CallCount); + Assert.Equal("custom follow-up", capture.MessagesPerCall[1].Single().Text); + } + + /// + /// Verify that an evaluator using sends the messages verbatim and + /// records an aligned feedback entry (it carries no feedback string). + /// + [Fact] + public async Task RunAsync_ContinueWithMessages_SendsMessagesVerbatimAndRecordsNullFeedbackAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "ack")])); + IReadOnlyList? feedbackSnapshot = null; + var evaluator = new DelegateLoopEvaluator((ctx, _) => + { + if (ctx.Iteration < 2) + { + return new ValueTask(LoopEvaluation.ContinueWithMessages( + [new ChatMessage(ChatRole.System, "sys"), new ChatMessage(ChatRole.User, "explicit")])); + } + + feedbackSnapshot = ctx.Feedback.ToList(); + return new ValueTask(LoopEvaluation.Stop()); + }); + var agent = new LoopAgent(capture.Agent, evaluator); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "original")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(2, capture.CallCount); + Assert.Equal(["sys", "explicit"], capture.MessagesPerCall[1].Select(static m => m.Text)); + Assert.NotNull(feedbackSnapshot); + // One aligned entry for the single re-invoked iteration; null because ContinueWithMessages carries no feedback string. + Assert.Equal([null], feedbackSnapshot!); + } + + /// + /// Verify that the global safety cap stops the loop even when the evaluator always continues. + /// + [Fact] + public async Task RunAsync_AlwaysContinue_StopsAtGlobalCapAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "working")])); + var evaluator = While(static _ => true); + var options = new LoopAgentOptions { MaxIterations = 3 }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(3, capture.CallCount); + Assert.Equal(["working", "working", "working"], response.Messages.Select(static m => m.Text)); + } + + /// + /// Verify that a pending tool-approval request terminates the loop and returns that response. + /// + [Fact] + public async Task RunAsync_PendingApprovalRequest_StopsLoopAsync() + { + // Arrange + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])])); + var evaluator = While(static _ => true); + var agent = new LoopAgent(capture.Agent, evaluator); + + // Act + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(1, capture.CallCount); + Assert.Contains(response.Messages.SelectMany(static m => m.Contents), static c => c is ToolApprovalRequestContent); + } + + /// + /// Verify that when no session is supplied the loop creates one and invokes the agent. + /// + [Fact] + public async Task RunAsync_NoSessionSupplied_CreatesSessionAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")])); + capture.Mock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Returns(new ValueTask(new ChatClientAgentSession())); + var evaluator = While(static _ => false); + var agent = new LoopAgent(capture.Agent, evaluator); + + // Act + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "go")]); + + // Assert + Assert.Equal("done", response.Text); + capture.Mock.Protected().Verify("CreateSessionCoreAsync", Times.Once(), ItExpr.IsAny()); + } + + #endregion + + #region RunAsync - feedback log + + /// + /// Verify that in the default (non-fresh) mode the latest feedback is injected verbatim as the next input. + /// + [Fact] + public async Task RunAsync_NonFresh_InjectsLatestFeedbackVerbatimAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "partial")])); + var evaluator = new DelegateLoopEvaluator((_, _) => new ValueTask(LoopEvaluation.Continue("fix it"))); + var options = new LoopAgentOptions { MaxIterations = 2 }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "original")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(2, capture.CallCount); + Assert.Equal("fix it", capture.MessagesPerCall[1].Single().Text); + } + + /// + /// Verify that when the latest iteration produces no feedback, no stale earlier feedback is re-injected (non-fresh). + /// + [Fact] + public async Task RunAsync_NonFresh_LatestEmpty_DoesNotReinjectStaleFeedbackAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "partial")])); + + // Provide feedback only on the first iteration; the second records nothing. + var evaluator = new DelegateLoopEvaluator((ctx, _) => + new ValueTask(LoopEvaluation.Continue(ctx.Iteration == 1 ? "feedback 1" : null))); + var options = new LoopAgentOptions { MaxIterations = 3 }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "original")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(3, capture.CallCount); + Assert.Equal("feedback 1", capture.MessagesPerCall[1].Single().Text); + Assert.Empty(capture.MessagesPerCall[2]); + } + + /// + /// Verify that the accumulated feedback log is exposed read-only and shared across all evaluators in a run. + /// + [Fact] + public async Task RunAsync_FeedbackLog_IsSharedAcrossEvaluatorsAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "partial")])); + var observed = new List(); + var producer = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.Iteration < 3 ? LoopEvaluation.Continue($"fb {ctx.Iteration}") : LoopEvaluation.Stop())); + var observer = new DelegateLoopEvaluator((ctx, _) => + { + // The observer runs only when the producer stops; it sees the full feedback log. + observed.Add(ctx.Feedback.Count); + return new ValueTask(LoopEvaluation.Stop()); + }); + var options = new LoopAgentOptions { MaxIterations = 5 }; + var agent = new LoopAgent(capture.Agent, new LoopEvaluator[] { producer, observer }, options); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(3, capture.CallCount); + // On the third iteration the producer stops, the observer runs and sees two recorded feedback entries. + Assert.Equal([2], observed); + } + + /// + /// Verify that iterations driven by still record an (aligned) + /// entry in the feedback log, so the log stays one-entry-per-re-invoked-iteration. The explicit-messages iteration + /// contributes a entry since it carries no feedback string. + /// + [Fact] + public async Task RunAsync_ContinueWithMessages_RecordsNullFeedbackEntryAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "partial")])); + List? finalLog = null; + var evaluator = new DelegateLoopEvaluator((ctx, _) => + { + // Capture the log on the final evaluation, after both re-invocations have been recorded. + if (ctx.Iteration >= 3) + { + finalLog = ctx.Feedback.ToList(); + return new ValueTask(LoopEvaluation.Stop()); + } + + // Iteration 1 drives a feedback-string re-invocation; iteration 2 drives an explicit-messages one. + return new ValueTask(ctx.Iteration == 1 + ? LoopEvaluation.Continue("needs work") + : LoopEvaluation.ContinueWithMessages([new ChatMessage(ChatRole.User, "explicit")])); + }); + var options = new LoopAgentOptions { MaxIterations = 5 }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], new ChatClientAgentSession()); + + // Assert + Assert.NotNull(finalLog); + // One entry per re-invoked iteration: the feedback string, then null for the ContinueWithMessages iteration. + Assert.Equal(["needs work", null], finalLog!); + } + + #endregion + + #region RunAsync - fresh context + + /// + /// Verify that without fresh context the loop reuses a single session across all iterations. + /// + [Fact] + public async Task RunAsync_NonFresh_ReusesSameSessionAcrossIterationsAsync() + { + // Arrange + var loopSession = new ChatClientAgentSession(); + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "x")])); + capture.Mock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Returns(new ValueTask(loopSession)); + var evaluator = new DelegateLoopEvaluator((_, _) => new ValueTask(LoopEvaluation.Continue("more"))); + var options = new LoopAgentOptions { MaxIterations = 3 }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act (no session supplied by caller) + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")]); + + // Assert + Assert.Equal(3, capture.CallCount); + Assert.Same(loopSession, capture.SessionsPerCall[0]); + Assert.Same(loopSession, capture.SessionsPerCall[1]); + Assert.Same(loopSession, capture.SessionsPerCall[2]); + } + + /// + /// Verify that with fresh context each iteration is rebuilt from the original messages plus the aggregated feedback log. + /// + [Fact] + public async Task RunAsync_Fresh_RebuildsFromInitialMessagesAndAggregatedFeedbackAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "partial")])); + capture.Mock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Returns(() => new ValueTask(new ChatClientAgentSession())); + var evaluator = new DelegateLoopEvaluator((ctx, _) => new ValueTask(LoopEvaluation.Continue($"fb {ctx.Iteration}"))); + var options = new LoopAgentOptions { MaxIterations = 3, FreshContextPerIteration = true }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act (no session supplied by caller) + await agent.RunAsync([new ChatMessage(ChatRole.User, "original task")]); + + // Assert + Assert.Equal(3, capture.CallCount); + var secondCall = capture.MessagesPerCall[1]; + Assert.Contains(secondCall, static m => m.Text == "original task"); + Assert.Contains(secondCall, static m => m.Text.Contains("## Feedback") && m.Text.Contains("fb 1")); + var thirdCall = capture.MessagesPerCall[2]; + Assert.Contains(thirdCall, static m => m.Text == "original task"); + Assert.Contains(thirdCall, static m => m.Text.Contains("fb 1") && m.Text.Contains("fb 2")); + } + + /// + /// Verify that with fresh context and a loop-owned session, a new session is created for each iteration. + /// + [Fact] + public async Task RunAsync_Fresh_RecreatesSessionEachIterationAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "x")])); + capture.Mock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Returns(() => new ValueTask(new ChatClientAgentSession())); + var evaluator = new DelegateLoopEvaluator((_, _) => new ValueTask(LoopEvaluation.Continue("more"))); + var options = new LoopAgentOptions { MaxIterations = 3, FreshContextPerIteration = true }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act (no session supplied by caller) + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")]); + + // Assert + Assert.Equal(3, capture.CallCount); + Assert.NotSame(capture.SessionsPerCall[0], capture.SessionsPerCall[1]); + Assert.NotSame(capture.SessionsPerCall[1], capture.SessionsPerCall[2]); + } + + /// + /// Verify that with fresh context and a caller-supplied session, the caller's session is used for the first + /// iteration, then each re-invocation runs against a fresh clone restored from a snapshot taken at the start of + /// the run. The session is serialized once and deserialized once per re-invocation. + /// + [Fact] + public async Task RunAsync_Fresh_WithCallerSession_ClonesFromSerializedSnapshotAsync() + { + // Arrange + var callerSession = new ChatClientAgentSession(); + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "x")])); + using var snapshotDoc = JsonDocument.Parse("{}"); + JsonElement snapshot = snapshotDoc.RootElement; + + int serializeCount = 0; + capture.Mock + .Protected() + .Setup>("SerializeSessionCoreAsync", ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => { serializeCount++; return new ValueTask(snapshot); }); + + int deserializeCount = 0; + capture.Mock + .Protected() + .Setup>("DeserializeSessionCoreAsync", ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => { deserializeCount++; return new ValueTask(new ChatClientAgentSession()); }); + + var evaluator = new DelegateLoopEvaluator((_, _) => new ValueTask(LoopEvaluation.Continue("more"))); + var options = new LoopAgentOptions { MaxIterations = 3, FreshContextPerIteration = true }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], callerSession); + + // Assert + Assert.Equal(3, capture.CallCount); + + // The pristine session is snapshotted exactly once, before the first iteration mutates it. + Assert.Equal(1, serializeCount); + + // Re-invocations (iterations 2 and 3) each restore a fresh clone from the snapshot. + Assert.Equal(2, deserializeCount); + + // The first iteration runs against the caller's supplied session; later iterations use distinct clones. + Assert.Same(callerSession, capture.SessionsPerCall[0]); + Assert.NotSame(callerSession, capture.SessionsPerCall[1]); + Assert.NotSame(callerSession, capture.SessionsPerCall[2]); + Assert.NotSame(capture.SessionsPerCall[1], capture.SessionsPerCall[2]); + + // The loop never creates a new session for a caller-supplied one; it clones instead. + capture.Mock.Protected().Verify("CreateSessionCoreAsync", Times.Never(), ItExpr.IsAny()); + } + + /// + /// Verify that with fresh context and a loop-owned session, the session is reset for each iteration even when the + /// evaluator drives re-invocation via : the explicit messages are + /// still sent verbatim, but each iteration runs against a new session. + /// + [Fact] + public async Task RunAsync_Fresh_WithContinueWithMessages_RecreatesSessionAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "x")])); + capture.Mock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Returns(() => new ValueTask(new ChatClientAgentSession())); + var evaluator = new DelegateLoopEvaluator((_, _) => + new ValueTask(LoopEvaluation.ContinueWithMessages([new ChatMessage(ChatRole.User, "explicit")]))); + var options = new LoopAgentOptions { MaxIterations = 3, FreshContextPerIteration = true }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act (no session supplied by caller) + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")]); + + // Assert + Assert.Equal(3, capture.CallCount); + + // The explicit messages are sent verbatim on each re-invocation. + Assert.Equal(["explicit"], capture.MessagesPerCall[1].Select(static m => m.Text)); + Assert.Equal(["explicit"], capture.MessagesPerCall[2].Select(static m => m.Text)); + + // The session is still reset for each iteration despite using ContinueWithMessages. + Assert.NotSame(capture.SessionsPerCall[0], capture.SessionsPerCall[1]); + Assert.NotSame(capture.SessionsPerCall[1], capture.SessionsPerCall[2]); + } + + /// + /// Verify that with fresh context and a caller-supplied session, the session is cloned from the start-of-run + /// snapshot for each re-invocation even when the evaluator drives re-invocation via + /// . + /// + [Fact] + public async Task RunAsync_Fresh_WithCallerSession_AndContinueWithMessages_ClonesFromSnapshotAsync() + { + // Arrange + var callerSession = new ChatClientAgentSession(); + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "x")])); + using var snapshotDoc = JsonDocument.Parse("{}"); + JsonElement snapshot = snapshotDoc.RootElement; + + int serializeCount = 0; + capture.Mock + .Protected() + .Setup>("SerializeSessionCoreAsync", ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => { serializeCount++; return new ValueTask(snapshot); }); + + int deserializeCount = 0; + capture.Mock + .Protected() + .Setup>("DeserializeSessionCoreAsync", ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => { deserializeCount++; return new ValueTask(new ChatClientAgentSession()); }); + + var evaluator = new DelegateLoopEvaluator((_, _) => + new ValueTask(LoopEvaluation.ContinueWithMessages([new ChatMessage(ChatRole.User, "explicit")]))); + var options = new LoopAgentOptions { MaxIterations = 3, FreshContextPerIteration = true }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], callerSession); + + // Assert + Assert.Equal(3, capture.CallCount); + Assert.Equal(1, serializeCount); + Assert.Equal(2, deserializeCount); + + // First iteration uses the caller session; later iterations use distinct clones from the snapshot. + Assert.Same(callerSession, capture.SessionsPerCall[0]); + Assert.NotSame(callerSession, capture.SessionsPerCall[1]); + Assert.NotSame(capture.SessionsPerCall[1], capture.SessionsPerCall[2]); + capture.Mock.Protected().Verify("CreateSessionCoreAsync", Times.Never(), ItExpr.IsAny()); + } + + /// + /// Verify that the configured is invoked with the loop-owned + /// session the loop creates when the caller does not supply one, even without fresh context. + /// + [Fact] + public async Task RunAsync_SessionCreatedCallback_NotifiesLoopOwnedSessionAsync() + { + // Arrange + var created = new ChatClientAgentSession(); + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "x")])); + capture.Mock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Returns(() => new ValueTask(created)); + var observed = new List(); + var options = new LoopAgentOptions + { + SessionCreatedCallback = (s, _) => { observed.Add(s); return default; }, + }; + var agent = new LoopAgent(capture.Agent, While(static _ => false), options); + + // Act (no session supplied by caller) + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")]); + + // Assert + Assert.Equal(1, capture.CallCount); + Assert.Same(created, Assert.Single(observed)); + Assert.Same(created, capture.SessionsPerCall[0]); + } + + /// + /// Verify that the is not invoked when the caller supplies a + /// session and no fresh context is requested (no new session is created). + /// + [Fact] + public async Task RunAsync_SessionCreatedCallback_NotInvokedForCallerSessionAsync() + { + // Arrange + var callerSession = new ChatClientAgentSession(); + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "x")])); + var observed = new List(); + var options = new LoopAgentOptions + { + MaxIterations = 3, + SessionCreatedCallback = (s, _) => { observed.Add(s); return default; }, + }; + var evaluator = new DelegateLoopEvaluator((_, _) => new ValueTask(LoopEvaluation.Continue("more"))); + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], callerSession); + + // Assert + Assert.Equal(3, capture.CallCount); + Assert.Empty(observed); + } + + /// + /// Verify that with fresh context and a loop-owned session, the + /// is invoked for the initial session and for each session created for a re-invocation, in order. + /// + [Fact] + public async Task RunAsync_Fresh_SessionCreatedCallback_NotifiesEachCreatedSessionAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "x")])); + capture.Mock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Returns(() => new ValueTask(new ChatClientAgentSession())); + var observed = new List(); + var options = new LoopAgentOptions + { + MaxIterations = 3, + FreshContextPerIteration = true, + SessionCreatedCallback = (s, _) => { observed.Add(s); return default; }, + }; + var evaluator = new DelegateLoopEvaluator((_, _) => new ValueTask(LoopEvaluation.Continue("more"))); + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act (no session supplied by caller) + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")]); + + // Assert: one notification for the initial session plus one per re-invocation (iterations 2 and 3). + Assert.Equal(3, capture.CallCount); + Assert.Equal(3, observed.Count); + Assert.Equal(capture.SessionsPerCall, observed); + } + + /// + /// Verify that with fresh context and a caller-supplied session, the + /// is invoked only for the cloned sessions created for + /// re-invocations, not for the caller's own session. + /// + [Fact] + public async Task RunAsync_Fresh_WithCallerSession_SessionCreatedCallback_NotifiesClonesOnlyAsync() + { + // Arrange + var callerSession = new ChatClientAgentSession(); + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "x")])); + using var snapshotDoc = JsonDocument.Parse("{}"); + JsonElement snapshot = snapshotDoc.RootElement; + capture.Mock + .Protected() + .Setup>("SerializeSessionCoreAsync", ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => new ValueTask(snapshot)); + capture.Mock + .Protected() + .Setup>("DeserializeSessionCoreAsync", ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => new ValueTask(new ChatClientAgentSession())); + var observed = new List(); + var options = new LoopAgentOptions + { + MaxIterations = 3, + FreshContextPerIteration = true, + SessionCreatedCallback = (s, _) => { observed.Add(s); return default; }, + }; + var evaluator = new DelegateLoopEvaluator((_, _) => new ValueTask(LoopEvaluation.Continue("more"))); + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], callerSession); + + // Assert: the caller session is never reported; only the two clones used for re-invocations are. + Assert.Equal(3, capture.CallCount); + Assert.DoesNotContain(callerSession, observed); + Assert.Equal([capture.SessionsPerCall[1]!, capture.SessionsPerCall[2]!], observed); + } + [Fact] + public async Task RunAsync_MultipleEvaluators_FirstReinvokeWinsAndShortCircuitsAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "ack")])); + + var firstEvaluated = 0; + var secondEvaluated = 0; + var first = new DelegateLoopEvaluator((ctx, _) => + { + firstEvaluated++; + return new ValueTask( + ctx.Iteration < 2 ? LoopEvaluation.Continue("from first") : LoopEvaluation.Stop()); + }); + var second = new DelegateLoopEvaluator((_, _) => + { + secondEvaluated++; + return new ValueTask(LoopEvaluation.Stop()); + }); + var agent = new LoopAgent(capture.Agent, new LoopEvaluator[] { first, second }); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(2, capture.CallCount); + Assert.Equal("from first", capture.MessagesPerCall[1].Single().Text); + Assert.Equal(2, firstEvaluated); + // The second evaluator is only evaluated on the iteration where the first one stops. + Assert.Equal(1, secondEvaluated); + } + + /// + /// Verify that a later evaluator can cause re-invocation when an earlier evaluator asks to stop, confirming that + /// is not a veto. + /// + [Fact] + public async Task RunAsync_MultipleEvaluators_LaterEvaluatorCanContinueAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "ack")])); + var alwaysStop = While(static _ => false); + var continueOnce = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.Iteration < 2 ? LoopEvaluation.Continue("from second") : LoopEvaluation.Stop())); + var agent = new LoopAgent(capture.Agent, new LoopEvaluator[] { alwaysStop, continueOnce }); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(2, capture.CallCount); + Assert.Equal("from second", capture.MessagesPerCall[1].Single().Text); + } + + /// + /// Verify that the loop stops when every evaluator asks to stop. + /// + [Fact] + public async Task RunAsync_MultipleEvaluators_AllStop_StopsAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")])); + var first = While(static _ => false); + var second = While(static _ => false); + var agent = new LoopAgent(capture.Agent, new LoopEvaluator[] { first, second }); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "go")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(1, capture.CallCount); + } + + #endregion + + #region RunAsync - AIJudge evaluator integration + + /// + /// Verify that an (non-fresh) injects its templated feedback message verbatim + /// on re-invocation. + /// + [Fact] + public async Task RunAsync_WithAIJudgeEvaluator_NonFresh_InjectsTemplatedFeedbackMessageAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "partial")])); + var judgeClient = CreateJudgeClient("{\"answered\":false,\"gapAnalysis\":\"the cost estimate is missing\"}"); + var evaluator = new AIJudgeLoopEvaluator(judgeClient); + var options = new LoopAgentOptions { MaxIterations = 2 }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + string expected = AIJudgeLoopEvaluator.DefaultFeedbackMessageTemplate + .Replace(AIJudgeLoopEvaluator.GapAnalysisPlaceholder, "the cost estimate is missing"); + + // Act + await agent.RunAsync([new ChatMessage(ChatRole.User, "question")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(2, capture.CallCount); + Assert.Equal(expected, capture.MessagesPerCall[1].Single().Text); + } + + #endregion + + #region RunAsync - response shaping + + /// + /// Verify that a non-streaming run aggregates each iteration's on-behalf-of feedback message and response messages + /// in order, stamping the configured author name on the synthesized feedback while never echoing caller input. + /// + [Fact] + public async Task RunAsync_Aggregates_OnBehalfOfFeedbackAndResponsesAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "ack")])); + var evaluator = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.Iteration < 2 ? LoopEvaluation.Continue("fix it") : LoopEvaluation.Stop())); + var options = new LoopAgentOptions { OnBehalfOfAuthorName = "loop" }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "original")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(["ack", "fix it", "ack"], response.Messages.Select(static m => m.Text)); + ChatMessage feedbackMessage = response.Messages[1]; + Assert.Equal(ChatRole.User, feedbackMessage.Role); + Assert.Equal("loop", feedbackMessage.AuthorName); + + // The on-behalf-of author name is also stamped on the message actually sent to the wrapped agent. + Assert.Equal("loop", capture.MessagesPerCall[1].Single().AuthorName); + } + + /// + /// Verify that evaluator-supplied messages are surfaced verbatim and their author name is not overwritten by the + /// loop's on-behalf-of author name. + /// + [Fact] + public async Task RunAsync_ContinueWithMessages_AreSurfacedWithoutAuthorNameOverrideAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "ack")])); + var evaluator = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.Iteration < 2 + ? LoopEvaluation.ContinueWithMessages([new ChatMessage(ChatRole.User, "explicit") { AuthorName = "evaluator" }]) + : LoopEvaluation.Stop())); + var options = new LoopAgentOptions { OnBehalfOfAuthorName = "loop" }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "original")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(["ack", "explicit", "ack"], response.Messages.Select(static m => m.Text)); + Assert.Equal("evaluator", response.Messages[1].AuthorName); + } + + /// + /// Verify that in fresh-context mode only the synthesized aggregated feedback message is surfaced; the replayed + /// caller input messages are not echoed. + /// + [Fact] + public async Task RunAsync_FreshContext_SurfacesOnlyAggregatedFeedbackAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "ack")])); + capture.Mock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Returns(new ValueTask(new ChatClientAgentSession())); + var evaluator = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.Iteration < 2 ? LoopEvaluation.Continue("fix it") : LoopEvaluation.Stop())); + var options = new LoopAgentOptions { FreshContextPerIteration = true, OnBehalfOfAuthorName = "loop" }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act (no caller session so the loop owns and recreates the session each iteration). + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "original")]); + + // Assert + Assert.Equal(3, response.Messages.Count); + ChatMessage surfacedFeedback = response.Messages[1]; + Assert.Equal("loop", surfacedFeedback.AuthorName); + Assert.Contains("fix it", surfacedFeedback.Text); + + // The replayed caller input ("original") is sent to the agent but is not surfaced in the response. + Assert.DoesNotContain(response.Messages, static m => m.Text == "original"); + Assert.Equal(["original", surfacedFeedback.Text], capture.MessagesPerCall[1].Select(static m => m.Text)); + } + + /// + /// Verify that omits the injected on-behalf-of messages + /// from the aggregated non-streaming response while still sending them to the wrapped agent. + /// + [Fact] + public async Task RunAsync_ExcludeOnBehalfOfMessages_OmitsThemFromResponseAsync() + { + // Arrange + var capture = new InnerAgentCapture(_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "ack")])); + var evaluator = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.Iteration < 2 ? LoopEvaluation.Continue("fix it") : LoopEvaluation.Stop())); + var options = new LoopAgentOptions { ExcludeOnBehalfOfMessages = true }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "original")], new ChatClientAgentSession()); + + // Assert + Assert.Equal(["ack", "ack"], response.Messages.Select(static m => m.Text)); + + // The feedback is still sent to the wrapped agent even though it is not surfaced. + Assert.Equal("fix it", capture.MessagesPerCall[1].Single().Text); + } + + #endregion + + #region RunStreamingAsync + + /// + /// Verify that streaming surfaces updates from every iteration and stops when the evaluator stops. + /// + [Fact] + public async Task RunStreamingAsync_MultipleIterations_StreamsAllUpdatesAsync() + { + // Arrange + var capture = new InnerStreamingCapture(call => + [new AgentResponseUpdate(ChatRole.Assistant, $"chunk {call}")]); + var evaluator = While(ctx => ctx.Iteration < 3); + var agent = new LoopAgent(capture.Agent, evaluator); + + // Act + var texts = new List(); + await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "go")], new ChatClientAgentSession())) + { + texts.Add(update.Text); + } + + // Assert + Assert.Equal(3, capture.CallCount); + Assert.Equal(["chunk 1", "chunk 2", "chunk 3"], texts); + } + + /// + /// Verify that the streaming path enforces the global safety cap like the non-streaming path. + /// + [Fact] + public async Task RunStreamingAsync_AlwaysContinue_StopsAtGlobalCapAsync() + { + // Arrange + var capture = new InnerStreamingCapture(call => [new AgentResponseUpdate(ChatRole.Assistant, $"chunk {call}")]); + var evaluator = While(static _ => true); + var options = new LoopAgentOptions { MaxIterations = 4 }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "go")], new ChatClientAgentSession())) + { + } + + // Assert + Assert.Equal(4, capture.CallCount); + } + + /// + /// Verify that the streaming path sends the initial messages once and no messages on a feedback-less re-invocation. + /// + [Fact] + public async Task RunStreamingAsync_ContinueWithoutFeedback_SendsInitialOnceThenNoneAsync() + { + // Arrange + var capture = new InnerStreamingCapture(_ => [new AgentResponseUpdate(ChatRole.Assistant, "ack")]); + var evaluator = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.Iteration < 2 ? LoopEvaluation.Continue() : LoopEvaluation.Stop())); + var agent = new LoopAgent(capture.Agent, evaluator); + + // Act + await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "original")], new ChatClientAgentSession())) + { + } + + // Assert + Assert.Equal(2, capture.CallCount); + Assert.Equal("original", capture.MessagesPerCall[0].Single().Text); + Assert.Empty(capture.MessagesPerCall[1]); + } + + /// + /// Verify that the streaming path stops after the iteration that produces a pending approval request. + /// + [Fact] + public async Task RunStreamingAsync_PendingApprovalRequest_StopsLoopAsync() + { + // Arrange + var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool")); + var capture = new InnerStreamingCapture(_ => [new AgentResponseUpdate(ChatRole.Assistant, [approvalRequest])]); + var evaluator = While(static _ => true); + var agent = new LoopAgent(capture.Agent, evaluator); + + // Act + await foreach (var _ in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "go")], new ChatClientAgentSession())) + { + } + + // Assert + Assert.Equal(1, capture.CallCount); + } + + /// + /// Verify that the streaming path emits the loop's on-behalf-of feedback as an update (with the configured author + /// name) before streaming the re-invocation it drives. + /// + [Fact] + public async Task RunStreamingAsync_SurfacesOnBehalfOfFeedbackBeforeReinvocationAsync() + { + // Arrange + var capture = new InnerStreamingCapture(i => + [new AgentResponseUpdate(ChatRole.Assistant, "ack") { ResponseId = $"resp-{i}", AgentId = $"agent-{i}" }]); + var evaluator = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.Iteration < 2 ? LoopEvaluation.Continue("fix it") : LoopEvaluation.Stop())); + var options = new LoopAgentOptions { OnBehalfOfAuthorName = "loop" }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + var updates = new List(); + await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "original")], new ChatClientAgentSession())) + { + updates.Add(update); + } + + // Assert + Assert.Equal(["ack", "fix it", "ack"], updates.Select(static u => u.Text)); + AgentResponseUpdate feedbackUpdate = updates[1]; + Assert.Equal(ChatRole.User, feedbackUpdate.Role); + Assert.Equal("loop", feedbackUpdate.AuthorName); + // The surfaced on-behalf-of update inherits the re-invocation iteration's ResponseId so downstream mergers + // group it with the run it drives, and carries its own unique non-null MessageId. AgentId is left unset + // because the message is synthesized by the loop, not produced by the wrapped agent. + Assert.Equal("resp-2", feedbackUpdate.ResponseId); + Assert.True(string.IsNullOrEmpty(feedbackUpdate.AgentId)); + Assert.False(string.IsNullOrEmpty(feedbackUpdate.MessageId)); + } + + /// + /// Verify that omits the injected on-behalf-of updates + /// from the streamed output while still sending the feedback to the wrapped agent. + /// + [Fact] + public async Task RunStreamingAsync_ExcludeOnBehalfOfMessages_OmitsThemFromUpdatesAsync() + { + // Arrange + var capture = new InnerStreamingCapture(_ => [new AgentResponseUpdate(ChatRole.Assistant, "ack")]); + var evaluator = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.Iteration < 2 ? LoopEvaluation.Continue("fix it") : LoopEvaluation.Stop())); + var options = new LoopAgentOptions { ExcludeOnBehalfOfMessages = true }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + var texts = new List(); + await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "original")], new ChatClientAgentSession())) + { + texts.Add(update.Text); + } + + // Assert + Assert.Equal(["ack", "ack"], texts); + Assert.Equal("fix it", capture.MessagesPerCall[1].Single().Text); + } + + /// + /// Verify that a surfaced on-behalf-of streaming update is assigned a generated, unique + /// when the underlying evaluator-supplied message has none, inherits the driven iteration's ResponseId, and leaves AgentId unset. + /// + [Fact] + public async Task RunStreamingAsync_ContinueWithMessages_GetsGeneratedMessageIdAndInheritsIdsAsync() + { + // Arrange + var capture = new InnerStreamingCapture(i => + [new AgentResponseUpdate(ChatRole.Assistant, "ack") { ResponseId = $"resp-{i}", AgentId = $"agent-{i}" }]); + var evaluator = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.Iteration < 2 + ? LoopEvaluation.ContinueWithMessages([new ChatMessage(ChatRole.User, "explicit") { AuthorName = "evaluator" }]) + : LoopEvaluation.Stop())); + var agent = new LoopAgent(capture.Agent, evaluator); + + // Act + var updates = new List(); + await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "original")], new ChatClientAgentSession())) + { + updates.Add(update); + } + + // Assert + Assert.Equal(["ack", "explicit", "ack"], updates.Select(static u => u.Text)); + AgentResponseUpdate surfaced = updates[1]; + Assert.Equal("evaluator", surfaced.AuthorName); + Assert.False(string.IsNullOrEmpty(surfaced.MessageId)); + Assert.Equal("resp-2", surfaced.ResponseId); + Assert.True(string.IsNullOrEmpty(surfaced.AgentId)); + } + + /// + /// Verify that when the wrapped agent produces no updates for an iteration, the surfaced on-behalf-of update is + /// still assigned a generated (non-null) ResponseId so it can be grouped downstream. + /// + [Fact] + public async Task RunStreamingAsync_NoInnerUpdates_GeneratesResponseIdForOnBehalfOfAsync() + { + // Arrange (the re-invocation iteration produces no updates, so its surfaced feedback has no inner ResponseId + // to inherit and must fall back to a generated one). + var capture = new InnerStreamingCapture(i => + i < 2 ? [new AgentResponseUpdate(ChatRole.Assistant, "ack")] : []); + var evaluator = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.Iteration < 2 ? LoopEvaluation.Continue("fix it") : LoopEvaluation.Stop())); + var options = new LoopAgentOptions { OnBehalfOfAuthorName = "loop" }; + var agent = new LoopAgent(capture.Agent, evaluator, options); + + // Act + var updates = new List(); + await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "original")], new ChatClientAgentSession())) + { + updates.Add(update); + } + + // Assert (the first iteration's "ack" and then the surfaced feedback whose iteration produced no updates). + Assert.Equal(["ack", "fix it"], updates.Select(static u => u.Text)); + AgentResponseUpdate feedbackUpdate = updates[1]; + Assert.Equal("loop", feedbackUpdate.AuthorName); + Assert.False(string.IsNullOrEmpty(feedbackUpdate.ResponseId)); + Assert.True(string.IsNullOrEmpty(feedbackUpdate.AgentId)); + Assert.False(string.IsNullOrEmpty(feedbackUpdate.MessageId)); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopContextTests.cs new file mode 100644 index 0000000000..0047c5d4fd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopContextTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class, including its public constructor used to test custom evaluators. +/// +public class LoopContextTests +{ + /// + /// Verify that the constructor throws when the agent is null. + /// + [Fact] + public void Constructor_NullAgent_Throws() + { + // Act & Assert + Assert.Throws("agent", () => new LoopContext( + null!, new ChatClientAgentSession(), [], CreateResponse())); + } + + /// + /// Verify that the constructor throws when the session is null. + /// + [Fact] + public void Constructor_NullSession_Throws() + { + // Act & Assert + Assert.Throws("session", () => new LoopContext( + new Mock().Object, null!, [], CreateResponse())); + } + + /// + /// Verify that the constructor throws when the initial messages are null. + /// + [Fact] + public void Constructor_NullInitialMessages_Throws() + { + // Act & Assert + Assert.Throws("initialMessages", () => new LoopContext( + new Mock().Object, new ChatClientAgentSession(), null!, CreateResponse())); + } + + /// + /// Verify that the constructor throws when the last response is null. + /// + [Fact] + public void Constructor_NullLastResponse_Throws() + { + // Act & Assert + Assert.Throws("lastResponse", () => new LoopContext( + new Mock().Object, new ChatClientAgentSession(), [], null!)); + } + + /// + /// Verify that the constructor populates the properties and that LastResponse is never null. + /// + [Fact] + public void Constructor_ValidArguments_SetsProperties() + { + // Arrange + var agent = new Mock().Object; + var session = new ChatClientAgentSession(); + ChatMessage[] initialMessages = [new ChatMessage(ChatRole.User, "go")]; + var response = CreateResponse("done"); + + // Act + var context = new LoopContext(agent, session, initialMessages, response); + + // Assert + Assert.Same(agent, context.Agent); + Assert.Same(session, context.Session); + Assert.Same(initialMessages, context.InitialMessages); + Assert.Same(response, context.LastResponse); + Assert.Null(context.RunOptions); + Assert.NotNull(context.AdditionalProperties); + Assert.Equal(0, context.Iteration); + Assert.Empty(context.Feedback); + } + + /// + /// Verify that the session can be replaced through the internal setter (used by the loop for fresh contexts). + /// + [Fact] + public void Session_IsInternallySettable() + { + // Arrange + var context = new LoopContext( + new Mock().Object, new ChatClientAgentSession(), [], CreateResponse()); + var newSession = new ChatClientAgentSession(); + + // Act + context.Session = newSession; + + // Assert + Assert.Same(newSession, context.Session); + } + + /// + /// Verify that can be assigned through its internal setter. + /// + [Fact] + public void Feedback_IsInternallySettable() + { + // Arrange + var context = new LoopContext( + new Mock().Object, new ChatClientAgentSession(), [], CreateResponse()); + + // Act + context.Feedback = ["first", null]; + + // Assert + Assert.Equal(["first", null], context.Feedback); + } + + /// + /// Verify that an evaluator can be evaluated against a publicly-constructed context (the scenario the public + /// constructor exists to support). + /// + [Fact] + public async Task PubliclyConstructedContext_CanEvaluateEvaluatorAsync() + { + // Arrange + var context = new LoopContext( + new Mock().Object, + new ChatClientAgentSession(), + [new ChatMessage(ChatRole.User, "go")], + CreateResponse("done")); + var evaluator = new DelegateLoopEvaluator((ctx, _) => + new ValueTask( + ctx.LastResponse.Text == "done" ? LoopEvaluation.Stop() : LoopEvaluation.Continue())); + + // Act + LoopEvaluation evaluation = await evaluator.EvaluateAsync(context); + + // Assert + Assert.False(evaluation.ShouldReinvoke); + } + + private static AgentResponse CreateResponse(string text = "response") => + new([new ChatMessage(ChatRole.Assistant, text)]); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopEvaluationTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopEvaluationTests.cs new file mode 100644 index 0000000000..c6545deeba --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopEvaluationTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class LoopEvaluationTests +{ + /// + /// Verify that Stop produces an evaluation that does not re-invoke and carries no feedback. + /// + [Fact] + public void Stop_DoesNotReinvoke_AndHasNoFeedback() + { + // Act + var evaluation = LoopEvaluation.Stop(); + + // Assert + Assert.False(evaluation.ShouldReinvoke); + Assert.Null(evaluation.Feedback); + } + + /// + /// Verify that Continue with no argument re-invokes and carries no feedback. + /// + [Fact] + public void Continue_NoFeedback_ReinvokesWithNullFeedback() + { + // Act + var evaluation = LoopEvaluation.Continue(); + + // Assert + Assert.True(evaluation.ShouldReinvoke); + Assert.Null(evaluation.Feedback); + } + + /// + /// Verify that Continue with whitespace-only feedback normalizes the feedback to null, matching the documented + /// "null, empty, or whitespace is treated as no feedback" semantics. + /// + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\n")] + public void Continue_WhitespaceFeedback_NormalizesToNull(string feedback) + { + // Act + var evaluation = LoopEvaluation.Continue(feedback); + + // Assert + Assert.True(evaluation.ShouldReinvoke); + Assert.Null(evaluation.Feedback); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopTestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopTestHelpers.cs new file mode 100644 index 0000000000..98c9dd023f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopTestHelpers.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Shared helpers used by the LoopAgent and LoopEvaluator unit tests. +/// +internal static class LoopTestHelpers +{ + /// + /// Creates a that re-invokes the agent (without feedback) while the + /// supplied predicate returns . + /// + public static DelegateLoopEvaluator While(Func shouldReinvoke) => + new((context, _) => + new ValueTask( + shouldReinvoke(context) ? LoopEvaluation.Continue() : LoopEvaluation.Stop())); + + /// + /// Creates a mocked judge that always returns the supplied response text. + /// + public static IChatClient CreateJudgeClient(string responseText) + { + var mock = new Mock(); + mock.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, responseText))); + return mock.Object; + } + + /// + /// Creates a mocked judge that always returns the supplied response text and captures the + /// messages it was invoked with via . + /// + public static IChatClient CreateCapturingJudgeClient(string responseText, out List capturedMessages) + { + var captured = new List(); + capturedMessages = captured; + var mock = new Mock(); + mock.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((messages, _, _) => + { + captured.Clear(); + captured.AddRange(messages); + }) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, responseText))); + return mock.Object; + } + + public static async IAsyncEnumerable ToAsyncEnumerableAsync( + IEnumerable items, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + await Task.Yield(); + } + } +} + +/// +/// Captures the messages sent to a mocked non-streaming inner agent and produces responses by call index. +/// +internal sealed class InnerAgentCapture +{ + public InnerAgentCapture(Func responseFactory) + { + this.Mock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, session, _, _) => + { + this.CallCount++; + this.MessagesPerCall.Add(msgs.ToList()); + this.SessionsPerCall.Add(session); + }) + .ReturnsAsync(() => responseFactory(this.CallCount)); + } + + public Mock Mock { get; } = new(); + + public AIAgent Agent => this.Mock.Object; + + public int CallCount { get; private set; } + + public List> MessagesPerCall { get; } = []; + + public List SessionsPerCall { get; } = []; +} + +/// +/// Captures the messages sent to a mocked streaming inner agent and produces updates by call index. +/// +internal sealed class InnerStreamingCapture +{ + public InnerStreamingCapture(Func updatesFactory) + { + this.Mock + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, ct) => + { + this.CallCount++; + this.MessagesPerCall.Add(msgs.ToList()); + return LoopTestHelpers.ToAsyncEnumerableAsync(updatesFactory(this.CallCount), ct); + }); + } + + public Mock Mock { get; } = new(); + + public AIAgent Agent => this.Mock.Object; + + public int CallCount { get; private set; } + + public List> MessagesPerCall { get; } = []; +} 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 0000000000..4dd4ff1f6c --- /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 0000000000..1f320ec34f --- /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 0000000000..d624b58fa1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/ValkeyChatHistoryProviderTests.cs @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; +using Valkey.Glide; + +namespace Microsoft.Agents.AI.Valkey.UnitTests; + +/// +/// Unit tests for . +/// +public sealed class ValkeyChatHistoryProviderTests +{ + private static Mock CreateMockConnection(Mock? dbMock = null) + { + var mockConnection = new Mock(); + dbMock ??= new Mock(); + mockConnection.Setup(c => c.GetDatabase()).Returns(dbMock.Object); + return mockConnection; + } + + // --- Constructor tests --- + + [Fact] + public void Constructor_WithConnection_SetsProperties() + { + // Arrange & Act + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection().Object, + static (_) => new ValkeyChatHistoryProvider.State("conv-1"), + new ValkeyChatHistoryProviderOptions { KeyPrefix = "test_prefix" }); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void Constructor_WithConnection_NullConnection_Throws() + { + // Act & Assert + Assert.Throws(() => + new ValkeyChatHistoryProvider( + null!, + static (_) => new ValkeyChatHistoryProvider.State("conv-1"))); + } + + [Fact] + public void Constructor_WithConnection_NullStateInitializer_Throws() + { + // Act & Assert + Assert.Throws(() => + new ValkeyChatHistoryProvider( + CreateMockConnection().Object, + null!)); + } + + // --- State tests --- + + [Fact] + public void State_NullConversationId_Throws() + { + Assert.Throws(() => new ValkeyChatHistoryProvider.State(null!)); + } + + [Fact] + public void State_EmptyConversationId_Throws() + { + Assert.Throws(() => new ValkeyChatHistoryProvider.State("")); + } + + [Fact] + public void State_ValidConversationId_SetsProperty() + { + var state = new ValkeyChatHistoryProvider.State("my-conversation"); + Assert.Equal("my-conversation", state.ConversationId); + } + + [Fact] + public void State_JsonConstructor_RoundTrips() + { + // Arrange + var original = new ValkeyChatHistoryProvider.State("test-conv"); + + // Act + var json = JsonSerializer.Serialize(original); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal("test-conv", deserialized.ConversationId); + } + + // --- StateKeys tests --- + + [Fact] + public void StateKeys_ReturnsProviderTypeName() + { + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection().Object, + _ => new ValkeyChatHistoryProvider.State("conv-1")); + + var keys = provider.StateKeys; + Assert.Single(keys); + Assert.Equal(nameof(ValkeyChatHistoryProvider), keys[0]); + } + + [Fact] + public void StateKeys_WithCustomKey_ReturnsCustomKey() + { + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection().Object, + _ => new ValkeyChatHistoryProvider.State("conv-1"), + new ValkeyChatHistoryProviderOptions { StateKey = "custom_key" }); + + var keys = provider.StateKeys; + Assert.Single(keys); + Assert.Equal("custom_key", keys[0]); + } + + // --- ProvideChatHistoryAsync tests --- + + [Fact] + public async Task ProvideChatHistoryAsync_ReturnsDeserializedMessagesAsync() + { + // Arrange + var dbMock = new Mock(); + var msg1 = new ChatMessage(ChatRole.User, "hello"); + var msg2 = new ChatMessage(ChatRole.Assistant, "hi there"); + var values = new ValkeyValue[] + { + JsonSerializer.Serialize(msg1), + JsonSerializer.Serialize(msg2) + }; + dbMock.Setup(d => d.ListRangeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(values); + + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection(dbMock).Object, + _ => new ValkeyChatHistoryProvider.State("conv-1")); + + var context = TestHelpers.CreateChatHistoryInvokingContext(); + + // Act — should not throw + var result = await provider.InvokingAsync(context); + var messages = result.ToList(); + + // Assert — only the valid message + request message + Assert.True(messages.Count >= 1); + } + + [Fact] + public async Task ProvideChatHistoryAsync_WithMaxMessagesToRetrieve_UsesRangeQueryAsync() + { + // Arrange + var dbMock = new Mock(); + dbMock.Setup(d => d.ListRangeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection(dbMock).Object, + _ => new ValkeyChatHistoryProvider.State("conv-1"), + new ValkeyChatHistoryProviderOptions { MaxMessagesToRetrieve = 5 }); + + var context = TestHelpers.CreateChatHistoryInvokingContext(); + + // Act + await provider.InvokingAsync(context); + + // Assert — should use -5, -1 range + dbMock.Verify(d => d.ListRangeAsync( + It.IsAny(), -5, -1), Times.Once); + } + + [Fact] + public async Task ProvideChatHistoryAsync_CancellationToken_ThrowsAsync() + { + // Arrange + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection().Object, + _ => new ValkeyChatHistoryProvider.State("conv-1")); + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var context = TestHelpers.CreateChatHistoryInvokingContext(); + + // Act & Assert + await Assert.ThrowsAsync(() => + provider.InvokingAsync(context, cts.Token).AsTask()); + } + + // --- StoreChatHistoryAsync tests --- + + [Fact] + public async Task StoreChatHistoryAsync_BatchPushesMessagesAsync() + { + // Arrange + var dbMock = new Mock(); + dbMock.Setup(d => d.ListRightPushAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(2); + + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection(dbMock).Object, + _ => new ValkeyChatHistoryProvider.State("conv-1")); + + var context = TestHelpers.CreateChatHistoryInvokedContext( + [new ChatMessage(ChatRole.User, "hello")], + [new ChatMessage(ChatRole.Assistant, "hi")]); + + // Act + await provider.InvokedAsync(context); + + // Assert — batch push called once with array + dbMock.Verify(d => d.ListRightPushAsync( + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task StoreChatHistoryAsync_WithMaxMessages_TrimsAsync() + { + // Arrange + var dbMock = new Mock(); + dbMock.Setup(d => d.ListRightPushAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(1); + dbMock.Setup(d => d.ListTrimAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var provider = new ValkeyChatHistoryProvider( + CreateMockConnection(dbMock).Object, + _ => new ValkeyChatHistoryProvider.State("conv-1"), + new ValkeyChatHistoryProviderOptions { MaxMessages = 10 }); + + var context = TestHelpers.CreateChatHistoryInvokedContext( + [new ChatMessage(ChatRole.User, "hello")], + [new ChatMessage(ChatRole.Assistant, "hi")]); + + // Act + await provider.InvokedAsync(context); + + // Assert — trim called unconditionally when MaxMessages is set + dbMock.Verify(d => d.ListTrimAsync( + It.IsAny(), -10, -1), Times.Once); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs index 1327c3df48..1470e4f0f5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs @@ -321,6 +321,189 @@ public sealed class DefaultMcpToolHandlerTests #endregion + #region ComputeHeadersHash Tests + + [Fact] + public void ComputeHeadersHash_WithNullHeaders_ReturnsEmptyString() + { + // Act + string result = DefaultMcpToolHandler.ComputeHeadersHash(null); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void ComputeHeadersHash_WithEmptyHeaders_ReturnsEmptyString() + { + // Act + string result = DefaultMcpToolHandler.ComputeHeadersHash(new Dictionary()); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void ComputeHeadersHash_SameHeadersDifferentOrder_ReturnsSameHash() + { + // Arrange + Dictionary headers1 = new() + { + ["Authorization"] = "Bearer token123", + ["X-Custom"] = "value1" + }; + Dictionary headers2 = new() + { + ["X-Custom"] = "value1", + ["Authorization"] = "Bearer token123" + }; + + // Act + string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1); + string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2); + + // Assert + hash1.Should().Be(hash2); + } + + [Fact] + public void ComputeHeadersHash_SameKeysDifferentCaseKeys_ReturnsSameHash() + { + // Arrange — RFC 7230: header names are case-insensitive + Dictionary headers1 = new() { ["Authorization"] = "Bearer token" }; + Dictionary headers2 = new() { ["authorization"] = "Bearer token" }; + + // Act + string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1); + string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2); + + // Assert + hash1.Should().Be(hash2); + } + + [Fact] + public void ComputeHeadersHash_SameKeysDifferentCaseValues_ReturnsDifferentHash() + { + // Arrange — RFC 7235: credentials are case-sensitive + Dictionary headers1 = new() { ["Authorization"] = "Bearer ABC" }; + Dictionary headers2 = new() { ["Authorization"] = "Bearer abc" }; + + // Act + string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1); + string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2); + + // Assert + hash1.Should().NotBe(hash2); + } + + [Fact] + public void ComputeHeadersHash_DifferentHeaders_ReturnsDifferentHash() + { + // Arrange + Dictionary headers1 = new() { ["Authorization"] = "Bearer token1" }; + Dictionary headers2 = new() { ["Authorization"] = "Bearer token2" }; + + // Act + string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1); + string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2); + + // Assert + hash1.Should().NotBe(hash2); + } + + #endregion + + #region Cache Key Discrimination Tests + + // These tests exercise BuildCacheKey directly because the integration path + // (InvokeToolAsync against a fake server) doesn't surface cache-hit behavior + // without standing up a real MCP server — McpClient.CreateAsync fails before + // _clients[key] = newClient runs, so nothing ever gets cached. + // Tuple equality on the returned 4-tuple verifies that the dimensions + // collectively discriminate cache entries. + + [Fact] + public void BuildCacheKey_SameInputs_ReturnsEqualKeys() + { + // Arrange + Dictionary headers = new() { ["Authorization"] = "Bearer token" }; + + // Act + var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers); + var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers); + + // Assert + key1.Should().Be(key2); + } + + [Fact] + public void BuildCacheKey_DifferentConnectionName_ReturnsDifferentKeys() + { + // Act + var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-a", null); + var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-b", null); + + // Assert + key1.Should().NotBe(key2); + key1.Connection.Should().Be("connection-a"); + key2.Connection.Should().Be("connection-b"); + } + + [Fact] + public void BuildCacheKey_DifferentServerLabel_ReturnsDifferentKeys() + { + // Act + var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-a", null, null); + var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-b", null, null); + + // Assert + key1.Should().NotBe(key2); + key1.Label.Should().Be("label-a"); + key2.Label.Should().Be("label-b"); + } + + [Fact] + public void BuildCacheKey_CaseSensitiveUrlPath_ReturnsDifferentKeys() + { + // Arrange — RFC 3986: URL path is case-sensitive + // Act + var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/Tools", null, null, null); + var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/tools", null, null, null); + + // Assert + key1.Should().NotBe(key2); + } + + [Fact] + public void BuildCacheKey_HeaderValuesCaseSensitive_ReturnsDifferentKeys() + { + // Arrange — RFC 7235: credentials are case-sensitive + Dictionary headers1 = new() { ["Authorization"] = "Bearer ABC" }; + Dictionary headers2 = new() { ["Authorization"] = "Bearer abc" }; + + // Act + var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers1); + var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers2); + + // Assert — header value case must propagate into the cache key + key1.Should().NotBe(key2); + key1.HeadersHash.Should().NotBe(key2.HeadersHash); + } + + [Fact] + public void BuildCacheKey_NullLabelAndConnection_NormalizesToEmptyString() + { + // Act + var key = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, null); + + // Assert — verifies null-safety contract callers rely on + key.Label.Should().BeEmpty(); + key.Connection.Should().BeEmpty(); + key.HeadersHash.Should().BeEmpty(); + } + + #endregion + #region Reserved Tools/List Tests [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs index b00339ea3b..845f2a1871 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeFunctionToolExecutorTest.cs @@ -1,11 +1,21 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; +using Moq; +using ApprovalSnapshot = Microsoft.Agents.AI.Workflows.Declarative.ObjectModel.InvokeFunctionToolExecutor.ApprovalSnapshot; namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; @@ -261,6 +271,323 @@ public sealed class InvokeFunctionToolExecutorTest(ITestOutputHelper output) : W #endregion + #region Approval Snapshot Security Tests + + /// + /// Verifies that mutating the function-name variable after approval does not change + /// which function is actually invoked. The originally-approved name must be used. + /// + [Fact] + public async Task InvokeFunctionToolCaptureResponseUsesApprovedFunctionNameNotMutatedAsync() + { + // Arrange + const string ApprovedFunctionName = "safe_readonly_query"; + const string MutatedFunctionName = "dangerous_admin_tool"; + + this.State.Set("TargetFunction", FormulaValue.New(ApprovedFunctionName)); + this.State.InitializeSystem(); + this.State.Bind(); + + InvokeFunctionTool model = this.CreateModelWithVariableFunctionName( + displayName: nameof(InvokeFunctionToolCaptureResponseUsesApprovedFunctionNameNotMutatedAsync), + variableName: "TargetFunction"); + + string? capturedFunctionName = null; + TestFunctionAgentProvider testAgentProvider = new( + [ + AIFunctionFactory.Create(() => "safe-result", name: ApprovedFunctionName), + AIFunctionFactory.Create(() => "dangerous-result", name: MutatedFunctionName), + ], + onInvoke: name => capturedFunctionName = name); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + // Act - trigger ExecuteAsync to store the approval snapshot + Mock mockContext = CreateMockWorkflowContext(); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + // Simulate parallel branch mutating state during the approval window + this.State.Set("TargetFunction", FormulaValue.New(MutatedFunctionName)); + this.State.Bind(); + + // User clicks approve (they saw "safe_readonly_query" in the approval UI) + ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true); + + // Resume after approval + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the originally-approved function must be invoked, not the mutated one + Assert.NotNull(capturedFunctionName); + Assert.Equal(ApprovedFunctionName, capturedFunctionName); + } + + /// + /// Verifies that mutating an argument variable after approval does not change + /// the arguments actually passed to the invoked function. + /// + [Fact] + public async Task InvokeFunctionToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync() + { + // Arrange + const string FunctionName = "process_query"; + const string ArgumentKey = "query"; + const string ApprovedQuery = "SELECT * FROM users LIMIT 10"; + const string MutatedQuery = "DROP TABLE users CASCADE; --"; + + this.State.Set("SqlQuery", FormulaValue.New(ApprovedQuery)); + this.State.InitializeSystem(); + this.State.Bind(); + + InvokeFunctionTool model = this.CreateModelWithVariableArgument( + displayName: nameof(InvokeFunctionToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync), + functionName: FunctionName, + argumentKey: ArgumentKey, + variableName: "SqlQuery"); + + AIFunctionArguments? capturedArguments = null; + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create((string query) => $"executed:{query}", name: FunctionName)], + onInvokeArguments: args => capturedArguments = args); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + // Act - trigger ExecuteAsync to store the approval snapshot + Mock mockContext = CreateMockWorkflowContext(); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + // Simulate parallel branch mutating state during the approval window + this.State.Set("SqlQuery", FormulaValue.New(MutatedQuery)); + this.State.Bind(); + + // User clicks approve + ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true); + + // Resume after approval + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the originally-approved argument must be used, not the mutated one + Assert.NotNull(capturedArguments); + Assert.Equal(ApprovedQuery, capturedArguments[ArgumentKey]?.ToString()); + } + + /// + /// Verifies that the approval snapshot survives a checkpoint/restore cycle. + /// After restore, the originally-approved function must still be used even if state was mutated. + /// + [Fact] + public async Task InvokeFunctionToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync() + { + // Arrange + const string ApprovedFunctionName = "safe_readonly_query"; + const string MutatedFunctionName = "dangerous_admin_tool"; + + this.State.Set("TargetFunction", FormulaValue.New(ApprovedFunctionName)); + this.State.InitializeSystem(); + this.State.Bind(); + + InvokeFunctionTool model = this.CreateModelWithVariableFunctionName( + displayName: nameof(InvokeFunctionToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync), + variableName: "TargetFunction"); + + string? capturedFunctionName = null; + TestFunctionAgentProvider testAgentProvider = new( + [ + AIFunctionFactory.Create(() => "safe-result", name: ApprovedFunctionName), + AIFunctionFactory.Create(() => "dangerous-result", name: MutatedFunctionName), + ], + onInvoke: name => capturedFunctionName = name); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + // Act - trigger ExecuteAsync to store the approval snapshot + Mock mockContext = CreateMockWorkflowContextWithStateStore(); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + // Simulate checkpoint: persist to state store + await InvokeProtectedMethodAsync(action, "OnCheckpointingAsync", mockContext.Object, CancellationToken.None); + + // Simulate restore on a "new" executor instance by clearing the in-memory field via reflection + // (In production, a new executor instance would be created with _approvalSnapshot == null) + typeof(InvokeFunctionToolExecutor) + .GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(action, null); + + // Restore from state store + await InvokeProtectedMethodAsync(action, "OnCheckpointRestoredAsync", mockContext.Object, CancellationToken.None); + + // Mutate state after restore (simulating parallel branch) + this.State.Set("TargetFunction", FormulaValue.New(MutatedFunctionName)); + this.State.Bind(); + + // User clicks approve + ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true); + + // Resume after approval + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - the originally-approved function must be invoked, not the mutated one + Assert.NotNull(capturedFunctionName); + Assert.Equal(ApprovedFunctionName, capturedFunctionName); + } + + /// + /// Verifies that the approval snapshot is cleared after a completed approval cycle, + /// both in-memory and in the persisted state store. This prevents stale data from + /// influencing a subsequent execution of the same executor instance. + /// + [Fact] + public async Task InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletionAsync() + { + // Arrange + const string FunctionName = "any_function"; + + this.State.InitializeSystem(); + this.State.Bind(); + + InvokeFunctionTool model = this.CreateModel( + displayName: nameof(InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletionAsync), + functionName: FunctionName, + requireApproval: true); + + TestFunctionAgentProvider testAgentProvider = new( + [AIFunctionFactory.Create(() => "result", name: FunctionName)]); + InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State); + + // Act - run the full approval cycle + Dictionary stateStore = []; + Mock mockContext = CreateMockWorkflowContextWithStateStore(stateStore); + await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None); + + // Sanity: snapshot was captured + FieldInfo snapshotField = typeof(InvokeFunctionToolExecutor) + .GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)!; + Assert.NotNull(snapshotField.GetValue(action)); + + ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true); + await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None); + + // Assert - both in-memory field and persisted state are cleared + Assert.Null(snapshotField.GetValue(action)); + Assert.True(stateStore.ContainsKey("_approvalSnapshot")); + Assert.Null(stateStore["_approvalSnapshot"]); + } + + private static ExternalInputResponse CreateApprovalResponse(string actionId, bool approved) + { + FunctionCallContent functionCall = new(callId: actionId, name: "ignored"); + ToolApprovalRequestContent approvalRequest = new(actionId, functionCall); + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved); + return new ExternalInputResponse(new ChatMessage(ChatRole.User, [approvalResponse])); + } + + private static Mock CreateMockWorkflowContext() + { + Mock mockContext = new(); + mockContext.Setup(c => c.AddEventAsync(It.IsAny(), It.IsAny())) + .Returns(default(ValueTask)); + mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(default(ValueTask)); + mockContext.Setup(c => c.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(default(ValueTask)); + return mockContext; + } + + /// + /// Creates a mock workflow context that actually stores state values (for checkpoint/restore tests). + /// Optionally accepts an externally-owned dictionary so callers can inspect the persisted state. + /// + private static Mock CreateMockWorkflowContextWithStateStore(Dictionary? stateStore = null) + { + stateStore ??= []; + Mock mockContext = new(); + mockContext.Setup(c => c.AddEventAsync(It.IsAny(), It.IsAny())) + .Returns(default(ValueTask)); + mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((key, value, _, _) => stateStore[key] = value) + .Returns(default(ValueTask)); + mockContext.Setup(c => c.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(default(ValueTask)); + mockContext.Setup(c => c.ReadStateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((key, _, _) => + new ValueTask(stateStore.TryGetValue(key, out object? val) ? val as ApprovalSnapshot : null)); + mockContext.Setup(c => c.ReadStateKeysAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new HashSet()); + return mockContext; + } + + /// + /// Invokes a protected method on the executor via reflection (for testing checkpoint hooks). + /// + private static async ValueTask InvokeProtectedMethodAsync(InvokeFunctionToolExecutor action, string methodName, IWorkflowContext context, CancellationToken cancellationToken) + { + MethodInfo method = typeof(InvokeFunctionToolExecutor) + .GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance)!; + ValueTask result = (ValueTask)method.Invoke(action, [context, cancellationToken])!; + await result.ConfigureAwait(false); + } + + /// + /// Minimal concrete that exposes an injected + /// registry and records which function got invoked. + /// Used by the framework-invoke approval branch (InvokeRegisteredFunctionAsync). + /// + private sealed class TestFunctionAgentProvider : ResponseAgentProvider + { + private readonly Action? _onInvoke; + private readonly Action? _onInvokeArguments; + + public TestFunctionAgentProvider( + IEnumerable functions, + Action? onInvoke = null, + Action? onInvokeArguments = null) + { + this._onInvoke = onInvoke; + this._onInvokeArguments = onInvokeArguments; + this.Functions = functions.Select(f => (AIFunction)new RecordingAIFunction(f, this)).ToList(); + } + + internal void RecordInvocation(string name, AIFunctionArguments? arguments) + { + this._onInvoke?.Invoke(name); + if (arguments is not null) + { + this._onInvokeArguments?.Invoke(arguments); + } + } + + public override Task CreateConversationAsync(CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public override Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public override Task GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public override IAsyncEnumerable InvokeAgentAsync( + string agentId, string? agentVersion, string? conversationId, + IEnumerable? messages, IDictionary? inputArguments, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public override IAsyncEnumerable GetMessagesAsync( + string conversationId, int? limit = null, string? after = null, string? before = null, + bool newestFirst = false, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + private sealed class RecordingAIFunction(AIFunction inner, TestFunctionAgentProvider owner) : AIFunction + { + public override string Name => inner.Name; + public override string Description => inner.Description; + public override JsonElement JsonSchema => inner.JsonSchema; + + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + owner.RecordInvocation(inner.Name, arguments); + return inner.InvokeAsync(arguments, cancellationToken); + } + } + } + + #endregion + #region Helper Methods private async Task ExecuteTestAsync(InvokeFunctionTool model) @@ -318,5 +645,33 @@ public sealed class InvokeFunctionToolExecutorTest(ITestOutputHelper output) : W return AssignParent(builder); } + private InvokeFunctionTool CreateModelWithVariableFunctionName(string displayName, string variableName) + { + InvokeFunctionTool.Builder builder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + FunctionName = new StringExpression.Builder( + StringExpression.Variable(PropertyPath.TopicVariable(variableName))), + RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)), + }; + return AssignParent(builder); + } + + private InvokeFunctionTool CreateModelWithVariableArgument( + string displayName, string functionName, string argumentKey, string variableName) + { + InvokeFunctionTool.Builder builder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + FunctionName = new StringExpression.Builder(StringExpression.Literal(functionName)), + RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)), + }; + builder.Arguments.Add(argumentKey, + ValueExpression.Variable(PropertyPath.TopicVariable(variableName))); + return AssignParent(builder); + } + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestrationTests.cs index 30be5bd873..7c5260f507 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestrationTests.cs @@ -419,6 +419,82 @@ public class MagenticOrchestrationTests "final-answer synthesis must see what participants actually said"); } + [Fact] + public async Task Participant_Receives_Prior_Participant_Response_Not_InstructionAsync() + { + // Regression: each participant must see prior participants' *responses* (the running conversation), + // not their *instructions*. Previously the orchestrator broadcast the per-round instruction to every + // participant (untargeted fan-out) and never broadcast replies, so a later speaker received the earlier + // speaker's instruction and never its answer. + const string HealthInstruction = "HEALTH_CHECKER_INSTRUCTION_check_framework"; + const string DatabaseInstruction = "DATABASE_CHECKER_INSTRUCTION_check_database"; + const string HealthEchoPrefix = "HC_RESPONSE::"; + const string DatabaseEchoPrefix = "DB_RESPONSE::"; + + List facts = CreatePlanResponse("Facts"); + List plan = CreatePlanResponse("Plan"); + List round1Ledger = CreateProgressLedgerResponse( + isRequestSatisfied: false, + isInLoop: false, + isProgressBeingMade: true, + nextSpeaker: "HealthChecker", + instructionOrQuestion: HealthInstruction); + List round2Ledger = CreateProgressLedgerResponse( + isRequestSatisfied: false, + isInLoop: false, + isProgressBeingMade: true, + nextSpeaker: "DatabaseChecker", + instructionOrQuestion: DatabaseInstruction); + List round3Ledger = CreateProgressLedgerResponse( + isRequestSatisfied: true, + isInLoop: false, + isProgressBeingMade: true, + nextSpeaker: "DatabaseChecker", + instructionOrQuestion: "Done"); + List finalAnswer = CreateFinalAnswerResponse("All systems checked"); + + TestReplayAgent manager = new( + [facts, plan, round1Ledger, round2Ledger, round3Ledger, finalAnswer], + name: "Manager"); + RecordingEchoAgent healthChecker = new(name: "HealthChecker", prefix: HealthEchoPrefix); + RecordingEchoAgent databaseChecker = new(name: "DatabaseChecker", prefix: DatabaseEchoPrefix); + + Workflow workflow = new MagenticWorkflowBuilder(manager) + .AddParticipants(healthChecker, databaseChecker) + .RequirePlanSignoff(false) + .Build(); + + WorkflowRunResult runResult = await RunMagenticWorkflowAsync( + workflow, + [new ChatMessage(ChatRole.User, "Check system health")]); + + runResult.Result.Should().NotBeNull(); + runResult.Result![0].Text.Should().Contain("All systems checked"); + + // Each participant takes exactly one turn. + healthChecker.RecordedInputs.Should().ContainSingle(); + databaseChecker.RecordedInputs.Should().ContainSingle(); + + // The first speaker receives its own instruction. + List healthInput = healthChecker.RecordedInputs[0]; + healthInput.Should().Contain(m => m.Text.Contains(HealthInstruction), "the first speaker receives its own instruction"); + + // The second speaker must see the first speaker's RESPONSE (authored by HealthChecker, carrying the echo + // prefix that only the response — not the raw instruction — has), plus its own instruction. + List databaseInput = databaseChecker.RecordedInputs[0]; + databaseInput.Should().Contain( + m => m.AuthorName == "HealthChecker" && m.Text.Contains(HealthEchoPrefix), + "the next speaker must receive the prior participant's response (the running conversation)"); + databaseInput.Should().Contain(m => m.Text.Contains(DatabaseInstruction), + "the next speaker must receive its own instruction"); + + // The leaked-instruction bug: the second speaker must not receive HealthChecker's instruction as a + // bare message (it should only appear, if at all, embedded in HealthChecker's prefixed response). + databaseInput.Should().NotContain( + m => m.AuthorName != "HealthChecker" && m.Text.Trim() == HealthInstruction, + "the prior speaker's instruction must not leak into the next speaker's context as a standalone message"); + } + [Fact] public async Task PlanReview_Revised_Triggers_ReplanAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RecordingEchoAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RecordingEchoAgent.cs new file mode 100644 index 0000000000..83a0e994a1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RecordingEchoAgent.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// A that records the input messages it receives on each call. +/// Used by tests that need to assert what context a participant was actually handed - for example, +/// that a later speaker sees prior participants' responses (the running conversation) rather +/// than their instructions. +/// +internal sealed class RecordingEchoAgent(string? id = null, string? name = null, string? prefix = null) + : TestEchoAgent(id, name, prefix) +{ + public List> RecordedInputs { get; } = []; + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Materialize once so the deferred input is recorded and replayed identically. + List recorded = messages.ToList(); + this.RecordedInputs.Add(recorded); + + await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync(recorded, session, options, cancellationToken)) + { + yield return update; + } + } +} diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 85da8aef5f..fa63760751 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.8.1] - 2026-06-09 + +### Added +- **agent-framework-core**: Add MCP client OTel spans per GenAI semantic conventions ([#6349](https://github.com/microsoft/agent-framework/pull/6349)) +- **agent-framework-core**: Add MCP long-running task support ([#6319](https://github.com/microsoft/agent-framework/pull/6319)) + +### Changed +- **agent-framework-claude**: Bump `claude-agent-sdk` to 0.2.87 ([#6248](https://github.com/microsoft/agent-framework/pull/6248)) +- **agent-framework-core**: Document checkpoint storage security model and deserialization trust boundaries ([#6295](https://github.com/microsoft/agent-framework/pull/6295)) +- **agent-framework-azurefunctions**: Document checkpoint storage security model and deserialization trust boundaries ([#6295](https://github.com/microsoft/agent-framework/pull/6295)) + +### Fixed +- **agent-framework-core**: Filter MCP tool kwargs to declared params via allowlist ([#6399](https://github.com/microsoft/agent-framework/pull/6399)) +- **agent-framework-core**: Fix per-service-call history persistence with server-storing clients ([#6310](https://github.com/microsoft/agent-framework/pull/6310)) +- **agent-framework-openai**: Use `getattr` for non-OpenAI provider response compatibility ([#6270](https://github.com/microsoft/agent-framework/pull/6270)) +- **agent-framework-foundry-hosting**: Refactor workflow-as-agent pending request handling ([#6259](https://github.com/microsoft/agent-framework/pull/6259)) +- **agent-framework-gemini**: Make Gemini honor declarative `outputSchema`, not just JSON mode ([#5893](https://github.com/microsoft/agent-framework/pull/5893)) +- **agent-framework-mem0**: Isolate entity retrieval and correct `app_id` payload ([#6242](https://github.com/microsoft/agent-framework/pull/6242)) +- **agent-framework-ag-ui**: Match AG-UI approval responses to requested arguments ([#6376](https://github.com/microsoft/agent-framework/pull/6376)) + ## [1.8.0] - 2026-06-04 ### Added @@ -1169,7 +1189,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai** For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.8.0...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.8.1...HEAD +[1.8.1]: https://github.com/microsoft/agent-framework/compare/python-1.8.0...python-1.8.1 [1.8.0]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...python-1.8.0 [1.7.0]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...python-1.7.0 [1.6.0]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...python-1.6.0 diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 4a0f8a94f5..4f0ff83aa7 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0rc3" +version = "1.0.0rc4" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.6.0,<2", + "agent-framework-core>=1.8.1,<2", "ag-ui-protocol>=0.1.16,<0.2", "fastapi>=0.115.0,<0.133.1", "uvicorn[standard]>=0.30.0,<1" diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_serialization.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_serialization.py index 4ed080eceb..27730fb441 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_serialization.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_serialization.py @@ -14,6 +14,24 @@ This module adds: - reconstruct_to_type: for HITL responses where external data (without type markers) needs to be reconstructed to a known type - resolve_type: resolves 'module:class' type keys to Python types + +Security Model +-------------- +The underlying Azure Durable Functions storage (Azure Storage account) is the +trusted persistence layer for serialized checkpoint data. The +``RestrictedUnpickler`` in the core encoding module provides defense-in-depth +type filtering, but checkpoint storage itself must be properly access-controlled: + +- Ensure the Azure Storage account used by Durable Functions is not publicly + writable and uses appropriate RBAC / shared-access policies. +- Never route untrusted user input directly into ``deserialize_value`` without + first calling :func:`strip_pickle_markers` to neutralize injection of + pickle markers into the data path. +- Configure your checkpoint storage with ``allowed_checkpoint_types`` (or call + ``decode_checkpoint_value(..., allowed_types=...)`` directly) to restrict the set of types that can be deserialized. + +See :mod:`agent_framework._workflows._checkpoint_encoding` for the full +security model documentation. """ from __future__ import annotations diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index 77cfdd0c40..a68ada4c25 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260604" +version = "1.0.0b260609" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.8.0,<2", + "agent-framework-core>=1.8.1,<2", "agent-framework-durabletask>=1.0.0b260604,<2", "azure-functions>=1.24.0,<2", "azure-functions-durable>=1.3.1,<2", diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index c258a113f9..252dbb261b 100644 --- a/python/packages/claude/pyproject.toml +++ b/python/packages/claude/pyproject.toml @@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260521" +version = "1.0.0b260609" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.6.0,<2", + "agent-framework-core>=1.8.1,<2", "claude-agent-sdk>=0.1.36,<0.3", ] diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index ca0c5843a3..d12f127a25 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -82,6 +82,7 @@ agent_framework/ - **`MCPStdioTool`** / **`MCPStreamableHTTPTool`** / **`MCPWebsocketTool`** - Transport-specific subclasses. - **Argument allowlist (`_prepare_call_kwargs`)** - Before each `tools/call`, kwargs are filtered to an **allowlist** built from the tool's declared parameters (`inputSchema.properties`) plus any user-configured extras. Framework runtime kwargs injected through the function-invocation pipeline (e.g. `thread`, `conversation_id`, `chat_options`, `options`, `response_format`) are stripped by default rather than forwarded. A tool that declares no usable `properties` (including schemas with `additionalProperties: true`) forwards only the configured extras. The `_MCP_FRAMEWORK_DENYLIST` is a safety net for framework-named params a server *declares* in its schema (those are dropped); names explicitly opted in via `additional_tool_argument_names` always win. The reserved `_meta` key is extracted as MCP request metadata, never forwarded as an argument. - **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins. +- **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`. - **`MCPTaskOptions`** (experimental, `MCP_LONG_RUNNING_TASKS` feature, **frozen**) - Per-tool-instance options controlling the SEP-2663 long-running task lifecycle. When the server advertises a tool with `execution.taskSupport == "required"`, `MCPTool.call_tool` transparently routes through `call_tool_as_task`, which sends an augmented `tools/call`, polls `tasks/get` until terminal, and reinterprets `tasks/result` as a normal `CallToolResult`. Instances are immutable; replace via `MCPTool.task_options = MCPTaskOptions(...)`. Fields: - `default_ttl: timedelta | None` — forwarded to the server as `params.task.ttl` (milliseconds). When `None`, the server's default applies. - `cancel_remote_task_on_local_cancellation: bool = True` — only gates the `CancelledError` path. Abandonment paths (see below) always cancel. @@ -99,6 +100,23 @@ agent_framework/ - **`FileSearchResult`** / **`FileSearchMatch`** - `SerializationMixin` DTOs returned by `search_files`, carrying the matching file name, a context snippet, and the matching lines with 1-based line numbers. - **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_save_file`, `file_access_read_file`, `file_access_delete_file`, `file_access_list_files`, `file_access_search_files`) plus default usage instructions to each invocation. Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents. +### Tool Approval Harness (`_harness/_tool_approval.py`) + +- **`ToolApprovalMiddleware`** - Experimental opt-in agent middleware that coordinates session-backed approval + rules, heuristic `auto_approval_rules`, queued approval requests, collected approval responses, and + streaming/non-streaming approval prompts. Heuristic callbacks receive the underlying `function_call` content. +- **`ToolApprovalRule`** / **`ToolApprovalState`** - Serializable state models for standing approvals and queued + approval flow. `ToolApprovalRule.arguments is None` means a tool-wide rule; an empty dict `{}` means an exact + no-argument call for `create_always_approve_tool_with_arguments_response`. +- **`create_always_approve_tool_response`** / **`create_always_approve_tool_with_arguments_response`** - Helpers + that return normal `function_approval_response` content with `additional_properties` metadata consumed by + `ToolApprovalMiddleware`. Standing rules for hosted tools include the `server_label` boundary, so same-named tools + on different hosted servers do not share approvals. +- Mixed tool-call batches use a default .NET-style bypass in the function invocation loop: when a session is + available, approval requests for known non-approval-required tools are treated as already approved, hidden, stored + in session state keyed to the visible approval request ids from that batch, and reinjected only when that visible + approval flow resumes. + ### Workflows (`_workflows/`) - **`Workflow`** - Graph-based workflow definition diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 1c1a78a9f0..3a001cdb02 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -27,6 +27,7 @@ from ._clients import ( SupportsGetEmbeddings, SupportsImageGenerationTool, SupportsMCPTool, + SupportsShellTool, SupportsWebSearchTool, ) from ._compaction import ( @@ -124,7 +125,16 @@ from ._harness._todo import ( TodoSessionStore, TodoStore, ) -from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPTaskOptions, MCPWebsocketTool +from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPTaskOptions, MCPWebsocketTool, SamplingApprovalCallback +from ._harness._tool_approval import ( + DEFAULT_TOOL_APPROVAL_SOURCE_ID, + ToolApprovalMiddleware, + ToolApprovalRule, + ToolApprovalRuleCallback, + ToolApprovalState, + create_always_approve_tool_response, + create_always_approve_tool_with_arguments_response, +) from ._middleware import ( AgentContext, AgentMiddleware, @@ -330,6 +340,7 @@ __all__ = [ "DEFAULT_MEMORY_SOURCE_ID", "DEFAULT_MODE_SOURCE_ID", "DEFAULT_TODO_SOURCE_ID", + "DEFAULT_TOOL_APPROVAL_SOURCE_ID", "EXCLUDED_KEY", "EXCLUDE_REASON_KEY", "GROUP_ANNOTATION_KEY", @@ -472,6 +483,7 @@ __all__ = [ "RubricScore", "RunContext", "RunnerContext", + "SamplingApprovalCallback", "SecretString", "SelectiveToolCallCompactionStrategy", "SessionContext", @@ -495,6 +507,7 @@ __all__ = [ "SupportsGetEmbeddings", "SupportsImageGenerationTool", "SupportsMCPTool", + "SupportsShellTool", "SupportsWebSearchTool", "SwitchCaseEdgeGroup", "SwitchCaseEdgeGroupCase", @@ -508,6 +521,10 @@ __all__ = [ "TodoStore", "TokenBudgetComposedStrategy", "TokenizerProtocol", + "ToolApprovalMiddleware", + "ToolApprovalRule", + "ToolApprovalRuleCallback", + "ToolApprovalState", "ToolMode", "ToolResultCompactionStrategy", "ToolTypes", @@ -542,6 +559,8 @@ __all__ = [ "annotate_message_groups", "apply_compaction", "chat_middleware", + "create_always_approve_tool_response", + "create_always_approve_tool_with_arguments_response", "create_edge_runner", "create_harness_agent", "detect_media_type_from_base64", diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 746427bffd..f0bd051980 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -819,6 +819,36 @@ class SupportsFileSearchTool(Protocol): ... +@runtime_checkable +class SupportsShellTool(Protocol): + """Protocol for clients that support shell tools. + + This protocol enables runtime checking to determine if a client + supports executing shell commands. + + Examples: + .. code-block:: python + + from agent_framework import SupportsShellTool + + if isinstance(client, SupportsShellTool): + tool = client.get_shell_tool(func=shell.as_function()) + agent = ChatAgent(client, tools=[tool]) + """ + + @staticmethod + def get_shell_tool(**kwargs: Any) -> Any: + """Create a shell tool configuration. + + Keyword Args: + **kwargs: Provider-specific configuration options. + + Returns: + A tool configuration ready to pass to ChatAgent. + """ + ... + + # endregion diff --git a/python/packages/core/agent_framework/_harness/_agent.py b/python/packages/core/agent_framework/_harness/_agent.py index 5896f72141..1a6178b54d 100644 --- a/python/packages/core/agent_framework/_harness/_agent.py +++ b/python/packages/core/agent_framework/_harness/_agent.py @@ -15,7 +15,7 @@ from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Any from .._agents import Agent, SupportsAgentRun -from .._clients import SupportsWebSearchTool +from .._clients import SupportsShellTool, SupportsWebSearchTool from .._compaction import CompactionProvider, ContextWindowCompactionStrategy, ToolResultCompactionStrategy from .._feature_stage import ExperimentalFeature, experimental from .._sessions import ContextProvider, HistoryProvider, InMemoryHistoryProvider @@ -28,6 +28,8 @@ from ._todo import TodoProvider if TYPE_CHECKING: from collections.abc import Mapping + from agent_framework_tools.shell import ShellEnvironmentProviderOptions, ShellExecutor + from .._clients import SupportsChatGetResponse from .._compaction import CompactionStrategy, TokenizerProtocol from .._middleware import MiddlewareTypes @@ -66,23 +68,45 @@ def _assemble_instructions( def _assemble_compaction_provider( *, disable_compaction: bool, - max_context_window_tokens: int, - max_output_tokens: int, + max_context_window_tokens: int | None, + max_output_tokens: int | None, history_source_id: str, before_compaction_strategy: CompactionStrategy | None, after_compaction_strategy: CompactionStrategy | None, tokenizer: TokenizerProtocol | None, ) -> CompactionProvider | None: - """Build the compaction provider from parameters or defaults.""" + """Build the compaction provider from parameters or defaults. + + The token-budget defaults (``ContextWindowCompactionStrategy`` for the before phase and + ``ToolResultCompactionStrategy`` for the after phase) are only applied when the token + params are provided. Caller-supplied strategies are always honored. Either phase may end + up ``None``, which ``CompactionProvider`` interprets as "skip that phase". + + Returns None when compaction is explicitly disabled, or when neither phase has a strategy + (no custom strategies and no token budget to build the defaults). + """ if disable_compaction: return None - before_strategy = before_compaction_strategy or ContextWindowCompactionStrategy( - max_context_window_tokens=max_context_window_tokens, - max_output_tokens=max_output_tokens, - tokenizer=tokenizer, - ) - after_strategy = after_compaction_strategy or ToolResultCompactionStrategy(keep_last_tool_call_groups=2) + # Resolve the before-strategy: custom strategy wins; otherwise fall back to the + # token-budget-aware default when token params are available. + before_strategy = before_compaction_strategy + if before_strategy is None and max_context_window_tokens is not None and max_output_tokens is not None: + before_strategy = ContextWindowCompactionStrategy( + max_context_window_tokens=max_context_window_tokens, + max_output_tokens=max_output_tokens, + tokenizer=tokenizer, + ) + + # Resolve the after-strategy: custom strategy wins; otherwise fall back to the default + # when token params are available. + after_strategy = after_compaction_strategy + if after_strategy is None and max_context_window_tokens is not None and max_output_tokens is not None: + after_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=2) + + # Nothing to compact in either phase: skip the provider entirely. + if before_strategy is None and after_strategy is None: + return None return CompactionProvider( before_strategy=before_strategy, @@ -106,6 +130,7 @@ def _assemble_context_providers( skills_paths: Sequence[str] | None, background_agents: Sequence[SupportsAgentRun] | None, background_agents_instructions: str | None, + shell_context_provider: ContextProvider | None, extra_context_providers: Sequence[ContextProvider] | None, ) -> list[ContextProvider]: """Assemble the ordered list of context providers.""" @@ -137,6 +162,10 @@ def _assemble_context_providers( if background_agents: providers.append(BackgroundAgentsProvider(background_agents, instructions=background_agents_instructions)) + # Shell environment provider is opt-in: only added when a shell tool was wired. + if shell_context_provider is not None: + providers.append(shell_context_provider) + # Append any user-supplied additional providers. if extra_context_providers: providers.extend(extra_context_providers) @@ -144,6 +173,50 @@ def _assemble_context_providers( return providers +def _assemble_shell( + client: SupportsChatGetResponse[Any], + shell_executor: ShellExecutor | None, + shell_environment_provider_options: ShellEnvironmentProviderOptions | None, +) -> tuple[ToolTypes | None, ContextProvider | None]: + """Build the shell tool and environment provider when a shell executor is supplied. + + Returns a ``(tool, provider)`` tuple. Both are ``None`` when no shell executor is + provided, or when the client does not support shell tools (a warning is logged in the + latter case, since the environment provider is not useful without an execution path). + + Raises: + TypeError: If ``shell_executor`` does not expose a callable ``as_function()`` method. + """ + if shell_executor is None: + return None, None + + # ShellExecutor is a protocol without ``as_function()``, so the + # contract is validated at runtime: a shell tool such as LocalShellTool/DockerShellTool exposes it. + as_function = getattr(shell_executor, "as_function", None) + if not callable(as_function): + raise TypeError( + f"shell_executor must expose a callable 'as_function()' method " + f"(e.g. a LocalShellTool or DockerShellTool from agent-framework-tools), " + f"but got {type(shell_executor).__name__}." + ) + + if not isinstance(client, SupportsShellTool): + logger.warning( + "Shell tool not available: client %r does not implement SupportsShellTool. " + "Skipping the shell tool and environment provider.", + type(client).__name__, + ) + return None, None + + # Imported lazily: the shell types live in the separate agent-framework-tools package, + # which depends on core, so core cannot import them at module load time. + from agent_framework_tools.shell import ShellEnvironmentProvider + + shell_tool = client.get_shell_tool(func=as_function()) + shell_provider = ShellEnvironmentProvider(shell_executor, shell_environment_provider_options) + return shell_tool, shell_provider + + HARNESS_AGENT_PROVIDER_NAME = "microsoft.agent_framework.harness" @@ -157,8 +230,8 @@ def create_harness_agent( harness_instructions: str | None = None, agent_instructions: str | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - max_context_window_tokens: int, - max_output_tokens: int, + max_context_window_tokens: int | None = None, + max_output_tokens: int | None = None, history_provider: HistoryProvider | None = None, disable_compaction: bool = False, before_compaction_strategy: CompactionStrategy | None = None, @@ -174,6 +247,8 @@ def create_harness_agent( skills_paths: Sequence[str] | None = None, background_agents: Sequence[SupportsAgentRun] | None = None, background_agents_instructions: str | None = None, + shell_executor: ShellExecutor | None = None, + shell_environment_provider_options: ShellEnvironmentProviderOptions | None = None, disable_web_search: bool = False, otel_provider_name: str | None = None, context_providers: Sequence[ContextProvider] | None = None, @@ -206,8 +281,6 @@ def create_harness_agent( agent = create_harness_agent( OpenAIChatClient(model="gpt-4o"), - max_context_window_tokens=128_000, - max_output_tokens=16_384, ) session = agent.create_session() response = await agent.run("Plan a weekend trip to Seattle", session=session) @@ -243,13 +316,21 @@ def create_harness_agent( (e.g., "You are a research assistant focused on academic sources."). tools: Additional tools to include in the agent's toolset. max_context_window_tokens: Maximum tokens the model's context window supports. + Used to construct the default token-budget-aware compaction strategies. When None + (default) and no custom ``before_compaction_strategy`` / ``after_compaction_strategy`` + is provided, compaction is automatically disabled. max_output_tokens: Maximum output tokens per response. + Used to construct the default compaction strategies and sets a default max_tokens + chat option. When None (default), no default max_tokens option is set, and unless a + custom compaction strategy is provided, compaction is automatically disabled. history_provider: Custom history provider. When None, an InMemoryHistoryProvider is used. disable_compaction: When True, skip compaction provider setup. - before_compaction_strategy: Custom before-run compaction strategy. - Defaults to ContextWindowCompactionStrategy (token-budget aware). - after_compaction_strategy: Custom after-run compaction strategy. - Defaults to ToolResultCompactionStrategy. + before_compaction_strategy: Custom before-run compaction strategy. When provided, + compaction runs even if token params are omitted. Defaults to + ContextWindowCompactionStrategy (token-budget aware) when token params are provided. + after_compaction_strategy: Custom after-run compaction strategy. When provided, + compaction runs even if token params are omitted. Defaults to + ToolResultCompactionStrategy when token params are provided. tokenizer: Custom tokenizer for compaction strategies. disable_todo: When True, skip the TodoProvider. todo_provider: Custom TodoProvider instance. Ignored when disable_todo is True. @@ -270,6 +351,15 @@ def create_harness_agent( background_agents_instructions: Optional instruction override for the ``BackgroundAgentsProvider``. May include ``{background_agents}`` placeholder which will be replaced with the agent listing. + shell_executor: Optional shell tool that enables shell command execution. When + provided, the shell tool and a ``ShellEnvironmentProvider`` are automatically + added (provided the client supports shell tools; otherwise a warning is logged + and both are skipped). The object must expose ``as_function()`` and satisfy the + ``ShellExecutor`` protocol -- e.g. a ``LocalShellTool`` or ``DockerShellTool`` from + the ``agent-framework-tools`` package. The caller owns the executor's lifecycle. + shell_environment_provider_options: Optional ``ShellEnvironmentProviderOptions`` + (from ``agent-framework-tools``) used to customize the ``ShellEnvironmentProvider`` + environment probing and instructions. Only used when ``shell_executor`` is provided. disable_web_search: When True, skip automatic web search tool inclusion. When False (default), the web search tool is automatically added if the client implements SupportsWebSearchTool. A warning is logged if the client @@ -283,14 +373,19 @@ def create_harness_agent( A fully configured :class:`~agent_framework.Agent` instance. Raises: - ValueError: If max_context_window_tokens <= 0 or max_output_tokens < 0 - or max_output_tokens >= max_context_window_tokens. + ValueError: If max_context_window_tokens is provided and <= 0, or + max_output_tokens is provided and <= 0, or max_output_tokens >= + max_context_window_tokens when both are provided. """ - if max_context_window_tokens <= 0: + if max_context_window_tokens is not None and max_context_window_tokens <= 0: raise ValueError("max_context_window_tokens must be positive.") - if max_output_tokens < 0: - raise ValueError("max_output_tokens must be non-negative.") - if max_output_tokens >= max_context_window_tokens: + if max_output_tokens is not None and max_output_tokens <= 0: + raise ValueError("max_output_tokens must be positive.") + if ( + max_context_window_tokens is not None + and max_output_tokens is not None + and max_output_tokens >= max_context_window_tokens + ): raise ValueError("max_output_tokens must be less than max_context_window_tokens.") # Build history provider. @@ -307,6 +402,13 @@ def create_harness_agent( tokenizer=tokenizer, ) + # Build the shell tool and environment provider (opt-in via shell_executor). + shell_tool, shell_provider = _assemble_shell( + client, + shell_executor, + shell_environment_provider_options, + ) + # Build context providers. assembled_providers = _assemble_context_providers( history_provider=resolved_history, @@ -321,6 +423,7 @@ def create_harness_agent( skills_paths=skills_paths, background_agents=background_agents, background_agents_instructions=background_agents_instructions, + shell_context_provider=shell_provider, extra_context_providers=context_providers, ) @@ -338,6 +441,8 @@ def create_harness_agent( "Set disable_web_search=True to suppress this warning.", type(client).__name__, ) + if shell_tool is not None: + assembled_tools.append(shell_tool) if tools is not None: if isinstance(tools, Sequence): assembled_tools.extend(tools) # pyright: ignore[reportUnknownArgumentType] @@ -347,7 +452,8 @@ def create_harness_agent( # Build default options dict. default_opts: dict[str, Any] = dict(default_options) if default_options else {} - default_opts.setdefault("max_tokens", max_output_tokens) + if max_output_tokens is not None: + default_opts.setdefault("max_tokens", max_output_tokens) agent = Agent( client, diff --git a/python/packages/core/agent_framework/_harness/_tool_approval.py b/python/packages/core/agent_framework/_harness/_tool_approval.py new file mode 100644 index 0000000000..56595278d0 --- /dev/null +++ b/python/packages/core/agent_framework/_harness/_tool_approval.py @@ -0,0 +1,632 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import copy +import inspect +import json +from asyncio import sleep +from collections.abc import AsyncIterable, Awaitable, Callable, Iterable, Mapping, MutableMapping, Sequence +from typing import Any, Literal, cast + +from .._feature_stage import ExperimentalFeature, experimental +from .._middleware import AgentContext, AgentMiddleware +from .._serialization import SerializationMixin +from .._sessions import AgentSession +from .._types import ( + AgentResponse, + AgentResponseUpdate, + Content, + FinishReason, + FinishReasonLiteral, + Message, + ResponseStream, +) + +DEFAULT_TOOL_APPROVAL_SOURCE_ID = "tool_approval" +_FUNCTION_INVOCATION_BUDGET_STATE_KEY = "_function_invocation_budget_state" +ALWAYS_APPROVE_PROPERTY = "tool_approval" +ALWAYS_APPROVE_SCOPE_PROPERTY = "always_approve" +ALWAYS_APPROVE_TOOL: Literal["tool"] = "tool" +ALWAYS_APPROVE_TOOL_WITH_ARGUMENTS: Literal["tool_with_arguments"] = "tool_with_arguments" + +_RULES_KEY = "rules" +_QUEUED_APPROVAL_REQUESTS_KEY = "queued_approval_requests" +_COLLECTED_APPROVAL_RESPONSES_KEY = "collected_approval_responses" + +ToolApprovalScope = Literal["tool", "tool_with_arguments"] +ToolApprovalRuleCallback = Callable[[Content], bool | Awaitable[bool]] + + +def _parse_function_arguments(function_call: Content) -> dict[str, Any]: + arguments = function_call.parse_arguments() + return dict(arguments or {}) + + +def _serialize_argument_value(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) + + +def _serialize_arguments(function_call: Content) -> dict[str, str]: + """Serialize arguments for exact matching. + + ``None`` is reserved on :class:`ToolApprovalRule` for tool-wide rules. + An argument-scoped rule for a no-argument call stores ``{}``, so it only + matches future no-argument calls and never becomes a wildcard. + """ + arguments = _parse_function_arguments(function_call) + return {key: _serialize_argument_value(value) for key, value in arguments.items()} + + +def _server_label(function_call: Content) -> str | None: + """Return the hosted-tool server boundary for a function call, if present.""" + value = function_call.additional_properties.get("server_label") + return value if isinstance(value, str) else None + + +def _content_from_state(value: Any) -> Content: + if isinstance(value, Content): + return value + if isinstance(value, Mapping): + return Content.from_dict(cast(Mapping[str, Any], value)) + raise TypeError(f"Expected Content or mapping state item, got {type(value).__name__}.") + + +def _contents_from_state(values: Any) -> list[Content]: + if not isinstance(values, list): + return [] + state_items = list(cast(Iterable[Any], values)) + return [_content_from_state(value) for value in state_items] + + +def _content_to_state(content: Content) -> dict[str, Any]: + return content.to_dict() + + +@experimental(feature_id=ExperimentalFeature.HARNESS) +class ToolApprovalRule(SerializationMixin): + """A standing rule for approving future matching tool calls.""" + + tool_name: str + arguments: dict[str, str] | None + server_label: str | None + + def __init__( + self, + tool_name: str, + arguments: Mapping[str, str] | None = None, + *, + server_label: str | None = None, + ) -> None: + """Initialize a tool approval rule. + + Args: + tool_name: The function tool name this rule applies to. + arguments: Optional canonicalized argument values. When omitted, the + rule applies to every call to the tool. Use an empty mapping to + match only no-argument calls. + + Keyword Args: + server_label: Optional hosted-tool server boundary. Hosted approvals + only match future approvals from the same server label. + """ + normalized_name = tool_name.strip() + if not normalized_name: + raise ValueError("Tool approval rule tool_name must be a non-empty string.") + self.tool_name = normalized_name + self.arguments = dict(arguments) if arguments is not None else None + self.server_label = server_label + + @classmethod + def from_dict( + cls, + value: MutableMapping[str, Any], + /, + *, + dependencies: MutableMapping[str, Any] | None = None, + ) -> ToolApprovalRule: + """Create a rule from serialized state.""" + del dependencies + tool_name = value.get("tool_name") + if not isinstance(tool_name, str): + raise ValueError("Tool approval rule tool_name must be a string.") + raw_arguments = value.get("arguments") + if raw_arguments is not None and not isinstance(raw_arguments, Mapping): + raise ValueError("Tool approval rule arguments must be a mapping or None.") + server_label = value.get("server_label") + if server_label is not None and not isinstance(server_label, str): + raise ValueError("Tool approval rule server_label must be a string or None.") + arguments = ( + {str(key): str(argument_value) for key, argument_value in cast(Mapping[str, Any], raw_arguments).items()} + if isinstance(raw_arguments, Mapping) + else None + ) + return cls(tool_name=tool_name, arguments=arguments, server_label=server_label) + + def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]: + """Serialize the rule.""" + exclude = exclude or set() + payload: dict[str, Any] = {"tool_name": self.tool_name} + if "type" not in exclude: + payload["type"] = self._get_type_identifier() + if self.arguments is not None or not exclude_none: + payload["arguments"] = self.arguments + if self.server_label is not None or not exclude_none: + payload["server_label"] = self.server_label + return payload + + +@experimental(feature_id=ExperimentalFeature.HARNESS) +class ToolApprovalState(SerializationMixin): + """Session-backed state used by :class:`ToolApprovalMiddleware`.""" + + rules: list[ToolApprovalRule] + queued_approval_requests: list[Content] + collected_approval_responses: list[Content] + + def __init__( + self, + *, + rules: Sequence[ToolApprovalRule | Mapping[str, Any]] | None = None, + queued_approval_requests: Sequence[Content | Mapping[str, Any]] | None = None, + collected_approval_responses: Sequence[Content | Mapping[str, Any]] | None = None, + ) -> None: + """Initialize approval state.""" + self.rules = [ + rule if isinstance(rule, ToolApprovalRule) else ToolApprovalRule.from_dict(dict(rule)) + for rule in (rules or []) + ] + self.queued_approval_requests = [ + item if isinstance(item, Content) else Content.from_dict(item) for item in (queued_approval_requests or []) + ] + self.collected_approval_responses = [ + item if isinstance(item, Content) else Content.from_dict(item) + for item in (collected_approval_responses or []) + ] + + @classmethod + def from_dict( + cls, + value: MutableMapping[str, Any], + /, + *, + dependencies: MutableMapping[str, Any] | None = None, + ) -> ToolApprovalState: + """Create state from serialized state.""" + del dependencies + return cls( + rules=cast(Sequence[Mapping[str, Any]], value.get("rules", [])), + queued_approval_requests=cast(Sequence[Mapping[str, Any]], value.get("queued_approval_requests", [])), + collected_approval_responses=cast( + Sequence[Mapping[str, Any]], + value.get("collected_approval_responses", []), + ), + ) + + def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]: + """Serialize state.""" + del exclude_none + exclude = exclude or set() + payload: dict[str, Any] = { + "rules": [rule.to_dict() for rule in self.rules], + "queued_approval_requests": [_content_to_state(item) for item in self.queued_approval_requests], + "collected_approval_responses": [_content_to_state(item) for item in self.collected_approval_responses], + } + if "type" not in exclude: + payload["type"] = self._get_type_identifier() + return payload + + +def create_always_approve_tool_response(request: Content, *, reason: str | None = None) -> Content: + """Create an approval response that records a standing rule for the whole tool. + + Args: + request: The ``function_approval_request`` content to approve. + + Keyword Args: + reason: Optional approval reason stored in ``additional_properties``. + + Returns: + A ``function_approval_response`` with metadata consumed by + :class:`ToolApprovalMiddleware`. + """ + return _create_always_approve_response(request, ALWAYS_APPROVE_TOOL, reason=reason) + + +def create_always_approve_tool_with_arguments_response(request: Content, *, reason: str | None = None) -> Content: + """Create an approval response that records a standing rule for the tool and exact arguments.""" + return _create_always_approve_response(request, ALWAYS_APPROVE_TOOL_WITH_ARGUMENTS, reason=reason) + + +def _create_always_approve_response(request: Content, scope: ToolApprovalScope, *, reason: str | None) -> Content: + response = request.to_function_approval_response(approved=True) + metadata: dict[str, Any] = {ALWAYS_APPROVE_SCOPE_PROPERTY: scope} + if reason is not None: + metadata["reason"] = reason + response.additional_properties[ALWAYS_APPROVE_PROPERTY] = metadata + return response + + +def _get_state(session: AgentSession, *, source_id: str) -> ToolApprovalState: + raw_state = session.state.get(source_id) + if isinstance(raw_state, ToolApprovalState): + return raw_state + if isinstance(raw_state, MutableMapping): + raw_state_mapping = cast(MutableMapping[str, Any], raw_state) + return ToolApprovalState( + rules=cast(Sequence[Mapping[str, Any]], raw_state_mapping.get(_RULES_KEY, [])), + queued_approval_requests=_contents_from_state(raw_state_mapping.get(_QUEUED_APPROVAL_REQUESTS_KEY, [])), + collected_approval_responses=_contents_from_state( + raw_state_mapping.get(_COLLECTED_APPROVAL_RESPONSES_KEY, []), + ), + ) + if raw_state is not None: + raise TypeError(f"Session state for {source_id!r} must be a mapping, got {type(raw_state).__name__}.") + state = ToolApprovalState() + session.state[source_id] = state.to_dict(exclude={"type"}) + return state + + +def _save_state(session: AgentSession, state: ToolApprovalState, *, source_id: str) -> None: + serialized = state.to_dict(exclude={"type"}) + existing = session.state.get(source_id) + if isinstance(existing, MutableMapping): + for key, value in cast(MutableMapping[str, Any], existing).items(): + if key not in serialized and key != "type": + serialized[key] = value + session.state[source_id] = serialized + + +def _rule_exists(rules: Sequence[ToolApprovalRule], new_rule: ToolApprovalRule) -> bool: + for rule in rules: + if rule.tool_name != new_rule.tool_name: + continue + if rule.server_label != new_rule.server_label: + continue + if rule.arguments == new_rule.arguments: + return True + return False + + +def _add_rule_if_missing(state: ToolApprovalState, rule: ToolApprovalRule) -> None: + if not _rule_exists(state.rules, rule): + state.rules.append(rule) + + +def _function_call_from_request(request: Content) -> Content | None: + function_call = request.function_call + if function_call is None or function_call.type != "function_call" or function_call.name is None: + return None + return function_call + + +def _arguments_match(rule_arguments: Mapping[str, str], function_call: Content) -> bool: + call_arguments = _serialize_arguments(function_call) or {} + if len(rule_arguments) != len(call_arguments): + return False + return all(call_arguments.get(key) == value for key, value in rule_arguments.items()) + + +def _matches_rule(request: Content, rules: Sequence[ToolApprovalRule]) -> bool: + function_call = _function_call_from_request(request) + if function_call is None: + return False + for rule in rules: + if rule.tool_name != function_call.name: + continue + if rule.server_label != _server_label(function_call): + continue + if rule.arguments is None: + return True + if _arguments_match(rule.arguments, function_call): + return True + return False + + +def _get_always_approve_scope(response: Content) -> ToolApprovalScope | None: + metadata = response.additional_properties.get(ALWAYS_APPROVE_PROPERTY) + if not isinstance(metadata, Mapping): + return None + metadata_mapping = cast(Mapping[str, Any], metadata) + scope = metadata_mapping.get(ALWAYS_APPROVE_SCOPE_PROPERTY) + if scope == ALWAYS_APPROVE_TOOL: + return ALWAYS_APPROVE_TOOL + if scope == ALWAYS_APPROVE_TOOL_WITH_ARGUMENTS: + return ALWAYS_APPROVE_TOOL_WITH_ARGUMENTS + return None + + +def _clone_without_always_approve_metadata(response: Content) -> Content: + cloned = copy.deepcopy(response) + cloned.additional_properties.pop(ALWAYS_APPROVE_PROPERTY, None) + return cloned + + +@experimental(feature_id=ExperimentalFeature.HARNESS) +class ToolApprovalMiddleware(AgentMiddleware): + """Coordinate standing tool approvals and queued approval prompts for an agent. + + This middleware is opt-in and requires callers to run the agent with an + :class:`AgentSession`, because approval rules and queued requests are stored + in session state. + """ + + def __init__( + self, + *, + source_id: str = DEFAULT_TOOL_APPROVAL_SOURCE_ID, + auto_approval_rules: Sequence[ToolApprovalRuleCallback] | None = None, + ) -> None: + """Initialize the middleware. + + Keyword Args: + source_id: Session-state key used by this middleware. + auto_approval_rules: Optional callbacks that can auto-approve a + ``function_call``. Each callback receives the function-call + content and returns ``True`` to approve it. + """ + self.source_id = source_id + self.auto_approval_rules = tuple(auto_approval_rules or ()) + + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + """Process one agent invocation.""" + if context.session is None: + raise RuntimeError("ToolApprovalMiddleware requires an AgentSession.") + + state = _get_state(context.session, source_id=self.source_id) + context.client_kwargs.setdefault(_FUNCTION_INVOCATION_BUDGET_STATE_KEY, {}) + context.messages = self._prepare_inbound_messages(context.messages, state) + await self._drain_auto_approvable_queue(state) + if next_queued := self._pop_next_queued_request(state): + _save_state(context.session, state, source_id=self.source_id) + context.result = self._response_for_queued_request(next_queued, stream=context.stream) + return + if context.stream: + context.result = self._process_stream(context, call_next, state) + return + + while True: + context.messages = self._inject_collected_responses(context.messages, state) + state_changed = bool(state.collected_approval_responses) + state.collected_approval_responses.clear() + if state_changed: + _save_state(context.session, state, source_id=self.source_id) + + await call_next() + if isinstance(context.result, ResponseStream): + return + if context.result is None: + _save_state(context.session, state, source_id=self.source_id) + return + + all_auto_approved = await self._process_outbound_messages(context.result.messages, state) + _save_state(context.session, state, source_id=self.source_id) + if not all_auto_approved: + return + context.messages = [] + context.result = None + + def _response_for_queued_request( + self, + request: Content, + *, + stream: bool, + ) -> AgentResponse | ResponseStream[AgentResponseUpdate, AgentResponse]: + if not stream: + return AgentResponse(messages=[Message(role="assistant", contents=[request])]) + + async def _stream() -> AsyncIterable[AgentResponseUpdate]: + await sleep(0) + yield AgentResponseUpdate(role="assistant", contents=[request]) + + return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) + + def _process_stream( + self, + context: AgentContext, + call_next: Callable[[], Awaitable[None]], + state: ToolApprovalState, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: + async def _stream() -> AsyncIterable[AgentResponseUpdate]: + if context.session is None: + raise RuntimeError("ToolApprovalMiddleware requires an AgentSession.") + while True: + context.messages = self._inject_collected_responses(context.messages, state) + state_changed = bool(state.collected_approval_responses) + state.collected_approval_responses.clear() + if state_changed: + _save_state(context.session, state, source_id=self.source_id) + + await call_next() + if not isinstance(context.result, ResponseStream): + raise ValueError("Streaming ToolApprovalMiddleware requires a ResponseStream result.") + + approval_requests: list[Content] = [] + async for update in context.result: + approval_contents = [ + content for content in update.contents if content.type == "function_approval_request" + ] + if not approval_contents: + yield update + continue + approval_requests.extend(approval_contents) + remaining_contents = [ + content for content in update.contents if content.type != "function_approval_request" + ] + if remaining_contents: + raw_finish_reason = update.finish_reason + finish_reason: FinishReasonLiteral | FinishReason | None + if isinstance(raw_finish_reason, str): + finish_reason = FinishReason(raw_finish_reason) + else: + finish_reason = cast(FinishReasonLiteral | FinishReason | None, raw_finish_reason) + yield AgentResponseUpdate( + contents=remaining_contents, + role=update.role, + author_name=update.author_name, + agent_id=update.agent_id, + response_id=update.response_id, + message_id=update.message_id, + created_at=update.created_at, + finish_reason=finish_reason, + continuation_token=update.continuation_token, + additional_properties=update.additional_properties, + raw_representation=update.raw_representation, + ) + await context.result.get_final_response() + if not approval_requests: + return + + response_messages = [Message(role="assistant", contents=approval_requests)] + all_auto_approved = await self._process_outbound_messages(response_messages, state) + _save_state(context.session, state, source_id=self.source_id) + if not all_auto_approved: + for message in response_messages: + if message.contents: + yield AgentResponseUpdate(role=message.role, contents=message.contents) + return + context.messages = [] + context.result = None + + return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) + + def _prepare_inbound_messages(self, messages: Sequence[Message], state: ToolApprovalState) -> list[Message]: + prepared: list[Message] = [] + for message in messages: + replacement_contents: list[Content] = [] + changed = False + for content in message.contents: + if content.type == "function_approval_response": + replacement = self._handle_inbound_approval_response(content, state) + state.collected_approval_responses.append(replacement) + changed = True + continue + replacement_contents.append(content) + + if not changed: + prepared.append(message) + continue + if replacement_contents: + cloned = copy.copy(message) + cloned.contents = replacement_contents + prepared.append(cloned) + return prepared + + def _handle_inbound_approval_response(self, response: Content, state: ToolApprovalState) -> Content: + scope = _get_always_approve_scope(response) + if scope is None or not response.approved: + return response + + function_call = response.function_call + if function_call is not None and function_call.type == "function_call" and function_call.name is not None: + if scope == ALWAYS_APPROVE_TOOL: + _add_rule_if_missing( + state, + ToolApprovalRule( + tool_name=function_call.name, + server_label=_server_label(function_call), + ), + ) + else: + _add_rule_if_missing( + state, + ToolApprovalRule( + tool_name=function_call.name, + arguments=_serialize_arguments(function_call), + server_label=_server_label(function_call), + ), + ) + return _clone_without_always_approve_metadata(response) + + def _inject_collected_responses(self, messages: Sequence[Message], state: ToolApprovalState) -> list[Message]: + if not state.collected_approval_responses: + return list(messages) + return [Message(role="user", contents=list(state.collected_approval_responses)), *messages] + + async def _drain_auto_approvable_queue(self, state: ToolApprovalState) -> None: + remaining: list[Content] = [] + for request in state.queued_approval_requests: + if _matches_rule(request, state.rules) or await self._matches_auto_rule(request): + state.collected_approval_responses.append(request.to_function_approval_response(approved=True)) + continue + remaining.append(request) + state.queued_approval_requests = remaining + + def _pop_next_queued_request(self, state: ToolApprovalState) -> Content | None: + if not state.queued_approval_requests: + return None + return state.queued_approval_requests.pop(0) + + async def _process_outbound_messages(self, messages: list[Message], state: ToolApprovalState) -> bool: + approval_requests = [ + content + for message in messages + for content in message.contents + if content.type == "function_approval_request" + ] + if not approval_requests: + return False + + auto_approved: set[int] = set() + unresolved: list[Content] = [] + for request in approval_requests: + if _matches_rule(request, state.rules) or await self._matches_auto_rule(request): + state.collected_approval_responses.append(request.to_function_approval_response(approved=True)) + auto_approved.add(id(request)) + else: + unresolved.append(request) + + if not auto_approved and len(unresolved) <= 1: + return False + + queued_ids: set[int] = set() + for request in unresolved[1:]: + queued_ids.add(id(request)) + state.queued_approval_requests.append(request) + + remove_ids = auto_approved | queued_ids + self._remove_approval_requests(messages, remove_ids) + return not unresolved + + @staticmethod + def _remove_approval_requests(messages: list[Message], remove_ids: set[int]) -> None: + for message_index in range(len(messages) - 1, -1, -1): + message = messages[message_index] + filtered = [ + content + for content in message.contents + if content.type != "function_approval_request" or id(content) not in remove_ids + ] + if len(filtered) == len(message.contents): + continue + if filtered: + message.contents = filtered + else: + messages.pop(message_index) + + async def _matches_auto_rule(self, request: Content) -> bool: + function_call = _function_call_from_request(request) + if function_call is None: + return False + for rule in self.auto_approval_rules: + result = rule(function_call) + if inspect.isawaitable(result): + result = await result + if result: + return True + return False + + +__all__ = [ + "ALWAYS_APPROVE_PROPERTY", + "ALWAYS_APPROVE_SCOPE_PROPERTY", + "ALWAYS_APPROVE_TOOL", + "ALWAYS_APPROVE_TOOL_WITH_ARGUMENTS", + "DEFAULT_TOOL_APPROVAL_SOURCE_ID", + "ToolApprovalMiddleware", + "ToolApprovalRule", + "ToolApprovalRuleCallback", + "ToolApprovalState", + "create_always_approve_tool_response", + "create_always_approve_tool_with_arguments_response", +] diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 784c618302..f25a44c38c 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -16,6 +16,7 @@ from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ig from dataclasses import dataclass from datetime import timedelta from functools import partial +from inspect import isawaitable from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast from opentelemetry import propagate @@ -99,6 +100,22 @@ _mcp_call_headers: contextvars.ContextVar[dict[str, str]] = contextvars.ContextV MCP_DEFAULT_TIMEOUT = 30 MCP_DEFAULT_SSE_READ_TIMEOUT = 60 * 5 +# Default safety limits applied to server-initiated MCP sampling requests +# (``sampling/createMessage``). MCP servers are untrusted third parties, so the +# default ``sampling_callback`` denies requests unless an approval callback is +# supplied, and bounds the cost of any approved request. +# - ``_DEFAULT_SAMPLING_MAX_TOKENS`` clamps the server-requested ``maxTokens``. +# - ``_DEFAULT_SAMPLING_MAX_REQUESTS`` caps the number of sampling requests per +# session connection (the counter resets on reconnect). +_DEFAULT_SAMPLING_MAX_TOKENS = 4096 +_DEFAULT_SAMPLING_MAX_REQUESTS = 25 + +# A user-supplied gate invoked before each server-initiated sampling request is +# forwarded to the chat client. It receives the raw ``CreateMessageRequestParams`` +# and returns (or awaits to) a truthy value to approve the request or a falsy +# value to deny it. Both synchronous and asynchronous callables are supported. +SamplingApprovalCallback = Callable[["types.CreateMessageRequestParams"], "bool | Coroutine[Any, Any, bool]"] + # region: Helpers LOG_LEVEL_MAPPING: dict[str, int] = { @@ -345,6 +362,9 @@ class MCPTool: session: ClientSession | None = None, request_timeout: int | None = None, client: SupportsChatGetResponse | None = None, + sampling_approval_callback: SamplingApprovalCallback | None = None, + sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS, + sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS, additional_properties: dict[str, Any] | None = None, task_options: MCPTaskOptions | None = None, additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, @@ -359,7 +379,13 @@ class MCPTool: name: The name of the MCP tool. description: A description of the MCP tool. approval_mode: Whether approval is required to run tools. - allowed_tools: A collection of tool names to allow. + allowed_tools: Optional allow-list of MCP tool names to expose as functions. + ``None`` (the default) exposes every tool advertised by the MCP server. + A non-empty collection exposes only the tools whose names appear in it. + An empty collection (``[]``) exposes no tools — if you simply want to + disable tool execution, prefer ``load_tools=False`` instead. ``[]`` is + useful as a runtime guard or when you want to load tool metadata for + inspection without exposing the tools for invocation. tool_name_prefix: Optional prefix to prepend to exposed MCP function names. load_tools: Whether to load tools from the MCP server. parse_tool_results: An optional callable with signature @@ -378,6 +404,20 @@ class MCPTool: session: An existing MCP client session to use. request_timeout: Timeout in seconds for MCP requests. client: A chat client for sampling callbacks. + sampling_approval_callback: Optional gate invoked before each server-initiated + ``sampling/createMessage`` request is forwarded to ``client``. It receives the + raw ``CreateMessageRequestParams`` and may be synchronous or asynchronous; + returning a truthy value approves the request and a falsy value denies it. When + ``None`` (the default), every sampling request is **denied** because MCP servers + are untrusted third parties (confused-deputy risk). To restore the legacy + auto-approve behavior, pass ``lambda params: True`` as an explicit, conscious + opt-in. + sampling_max_tokens: Upper bound applied to the server-requested ``maxTokens`` for an + approved sampling request. The effective value is ``min(requested, cap)``. Set to + ``None`` to disable the cap. Defaults to ``_DEFAULT_SAMPLING_MAX_TOKENS``. + sampling_max_requests: Maximum number of sampling requests allowed per session + connection; further requests are rejected. The counter resets on reconnect. Set + to ``None`` to disable the limit. Defaults to ``_DEFAULT_SAMPLING_MAX_REQUESTS``. additional_properties: Additional properties for the tool. task_options: Options controlling how long-running MCP tasks are driven for tools that advertise ``execution.taskSupport == "required"``. When ``None``, @@ -410,6 +450,10 @@ class MCPTool: self.session = session self.request_timeout = request_timeout self.client = client + self.sampling_approval_callback = sampling_approval_callback + self.sampling_max_tokens = sampling_max_tokens + self.sampling_max_requests = sampling_max_requests + self._sampling_request_count = 0 self._functions: list[FunctionTool] = [] self._tool_call_meta_by_name: dict[str, dict[str, Any]] = {} self._tool_task_support_by_name: dict[str, str] = {} @@ -539,6 +583,9 @@ class MCPTool: case _: result.append(Content.from_text(str(item))) + if mcp_type.structuredContent is not None: + result.append(Content.from_text(json.dumps(mcp_type.structuredContent, default=str))) + if not result: result.append(Content.from_text("null")) return result @@ -698,7 +745,7 @@ class MCPTool: @property def functions(self) -> list[FunctionTool]: """Get the list of functions that are allowed.""" - if not self.allowed_tools: + if self.allowed_tools is None: return self._functions allowed_names = set(self.allowed_tools) filtered_functions: list[FunctionTool] = [] @@ -840,6 +887,7 @@ class MCPTool: self._supports_prompts = True self._supports_logging = None self._ping_available = True + self._sampling_request_count = 0 def _set_server_capabilities(self, capabilities: types.ServerCapabilities | None) -> None: self._server_capabilities = capabilities @@ -994,6 +1042,49 @@ class MCPTool: except Exception as exc: logger.warning("Failed to set log level to %s", logger.level, exc_info=exc) + async def _sampling_request_approved(self, params: types.CreateMessageRequestParams) -> bool: + """Run the configured sampling approval gate. + + Returns ``True`` only when an approval callback is configured and approves the request. + When no callback is set, the request is denied (safe default for untrusted servers). + """ + callback = self.sampling_approval_callback + if callback is None: + logger.warning( + "Denying MCP sampling request from '%s': no 'sampling_approval_callback' configured.", + self.name, + ) + return False + try: + outcome = callback(params) + if isawaitable(outcome): + outcome = await outcome + except Exception as ex: + logger.warning( + "Denying MCP sampling request from '%s': approval callback raised %s.", + self.name, + ex, + exc_info=True, + ) + return False + approved = bool(outcome) + if not approved: + logger.warning("MCP sampling request from '%s' was denied by the approval callback.", self.name) + return approved + + def _capped_sampling_max_tokens(self, requested: int) -> int: + """Clamp the server-requested ``maxTokens`` to ``sampling_max_tokens`` when configured.""" + cap = self.sampling_max_tokens + if cap is not None and requested > cap: + logger.warning( + "Capping MCP sampling maxTokens for '%s' from %d to %d.", + self.name, + requested, + cap, + ) + return cap + return requested + async def sampling_callback( self, context: RequestContext[ClientSession, Any], @@ -1001,20 +1092,32 @@ class MCPTool: ) -> types.CreateMessageResult | types.ErrorData: """Callback function for sampling. - This function is called when the MCP server needs to get a message completed. - It uses the configured chat client to generate responses. + This function is called when the MCP server sends a ``sampling/createMessage`` + request. It enforces safety guardrails and, if the request is approved, uses the + configured chat client to generate a response. + + Safety: + MCP servers are untrusted third parties, so forwarding server-controlled prompts + to the chat client without review is a confused-deputy risk. This callback + therefore applies, in order: a per-session rate limit + (``sampling_max_requests``), an approval gate (``sampling_approval_callback``, + which **denies by default** when not configured), and a ``maxTokens`` cap + (``sampling_max_tokens``). To allow sampling, pass a ``sampling_approval_callback`` + that returns a truthy value (use ``lambda params: True`` to auto-approve as an + explicit opt-in). Note: - This is a simple version of this function. It can be overridden to allow - more complex sampling. It gets added to the session at initialization time, - so overriding it is the best way to customize this behavior. + This is the default implementation. It can be overridden to allow more complex + sampling. It gets added to the session at initialization time, so overriding it is + the best way to customize this behavior. Args: context: The request context from the MCP server. params: The message creation request parameters. Returns: - Either a CreateMessageResult with the generated message or ErrorData if generation fails. + Either a CreateMessageResult with the generated message or ErrorData if the request + is denied, rate limited, or generation fails. """ from mcp import types @@ -1023,7 +1126,38 @@ class MCPTool: code=types.INTERNAL_ERROR, message="No chat client available. Please set a chat client.", ) - logger.debug("Sampling callback called with params: %s", params) + + logger.warning( + "MCP server '%s' sent a sampling/createMessage request (%d message(s), maxTokens=%s).", + self.name, + len(params.messages), + params.maxTokens, + ) + + if self.sampling_max_requests is not None: + if self._sampling_request_count >= self.sampling_max_requests: + logger.warning( + "Denying MCP sampling request from '%s': per-session limit of %d reached.", + self.name, + self.sampling_max_requests, + ) + return types.ErrorData( + code=types.INVALID_REQUEST, + message="Sampling rate limit exceeded for this MCP session.", + ) + self._sampling_request_count += 1 + + if not await self._sampling_request_approved(params): + if self.sampling_approval_callback is None: + message = ( + "Sampling request denied. MCP sampling is disabled by default for untrusted " + "servers; provide a 'sampling_approval_callback' that approves the request to " + "enable it." + ) + else: + message = "Sampling request denied by the 'sampling_approval_callback'." + return types.ErrorData(code=types.INVALID_REQUEST, message=message) + messages: list[Message] = [] for msg in params.messages: messages.append(self._parse_message_from_mcp(msg)) @@ -1045,7 +1179,7 @@ class MCPTool: if params.temperature is not None: options["temperature"] = params.temperature - options["max_tokens"] = params.maxTokens + options["max_tokens"] = self._capped_sampling_max_tokens(params.maxTokens) if params.stopSequences is not None: options["stop"] = params.stopSequences @@ -2219,6 +2353,9 @@ class MCPStdioTool(MCPTool): env: dict[str, str] | None = None, encoding: str | None = None, client: SupportsChatGetResponse | None = None, + sampling_approval_callback: SamplingApprovalCallback | None = None, + sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS, + sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS, additional_properties: dict[str, Any] | None = None, task_options: MCPTaskOptions | None = None, additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, @@ -2260,12 +2397,28 @@ class MCPStdioTool(MCPTool): - A dict with keys `always_require_approval` or `never_require_approval`, followed by a sequence of strings with the names of the relevant tools. A tool should not be listed in both, if so, it will require approval. - allowed_tools: A list of tools that are allowed to use this tool. + allowed_tools: Optional allow-list of MCP tool names to expose as functions. + ``None`` (the default) exposes every tool advertised by the MCP server. + A non-empty collection exposes only the tools whose names appear in it. + An empty collection (``[]``) exposes no tools — if you simply want to + disable tool execution, prefer ``load_tools=False`` instead. ``[]`` is + useful as a runtime guard or when you want to load tool metadata for + inspection without exposing the tools for invocation. additional_properties: Additional properties. args: The arguments to pass to the command. env: The environment variables to set for the command. encoding: The encoding to use for the command output. client: The chat client to use for sampling. + sampling_approval_callback: Optional gate run before each server-initiated + ``sampling/createMessage`` request reaches ``client``. Receives the raw + ``CreateMessageRequestParams`` (sync or async); a truthy return approves the + request, a falsy return denies it. When ``None`` (the default) every sampling + request is **denied**, since MCP servers are untrusted (confused-deputy risk). + Pass ``lambda params: True`` to auto-approve as an explicit opt-in. + sampling_max_tokens: Cap applied to an approved request's ``maxTokens`` + (``min(requested, cap)``); ``None`` disables it. + sampling_max_requests: Per-session cap on the number of sampling requests; further + requests are rejected. Resets on reconnect. ``None`` disables it. task_options: Options for tools that advertise ``execution.taskSupport == "required"``. See :class:`MCPTaskOptions`. additional_tool_argument_names: Extra argument names to forward to the MCP server in @@ -2300,6 +2453,9 @@ class MCPStdioTool(MCPTool): request_timeout=request_timeout, task_options=task_options, additional_tool_argument_names=additional_tool_argument_names, + sampling_approval_callback=sampling_approval_callback, + sampling_max_tokens=sampling_max_tokens, + sampling_max_requests=sampling_max_requests, ) self.command = command self.args = args or [] @@ -2375,6 +2531,9 @@ class MCPStreamableHTTPTool(MCPTool): allowed_tools: Collection[str] | None = None, terminate_on_close: bool | None = None, client: SupportsChatGetResponse | None = None, + sampling_approval_callback: SamplingApprovalCallback | None = None, + sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS, + sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS, additional_properties: dict[str, Any] | None = None, http_client: AsyncClient | None = None, header_provider: Callable[[dict[str, Any]], dict[str, str]] | None = None, @@ -2419,10 +2578,26 @@ class MCPStreamableHTTPTool(MCPTool): - A dict with keys `always_require_approval` or `never_require_approval`, followed by a sequence of strings with the names of the relevant tools. A tool should not be listed in both, if so, it will require approval. - allowed_tools: A list of tools that are allowed to use this tool. + allowed_tools: Optional allow-list of MCP tool names to expose as functions. + ``None`` (the default) exposes every tool advertised by the MCP server. + A non-empty collection exposes only the tools whose names appear in it. + An empty collection (``[]``) exposes no tools — if you simply want to + disable tool execution, prefer ``load_tools=False`` instead. ``[]`` is + useful as a runtime guard or when you want to load tool metadata for + inspection without exposing the tools for invocation. additional_properties: Additional properties. terminate_on_close: Close the transport when the MCP client is terminated. client: The chat client to use for sampling. + sampling_approval_callback: Optional gate run before each server-initiated + ``sampling/createMessage`` request reaches ``client``. Receives the raw + ``CreateMessageRequestParams`` (sync or async); a truthy return approves the + request, a falsy return denies it. When ``None`` (the default) every sampling + request is **denied**, since MCP servers are untrusted (confused-deputy risk). + Pass ``lambda params: True`` to auto-approve as an explicit opt-in. + sampling_max_tokens: Cap applied to an approved request's ``maxTokens`` + (``min(requested, cap)``); ``None`` disables it. + sampling_max_requests: Per-session cap on the number of sampling requests; further + requests are rejected. Resets on reconnect. ``None`` disables it. http_client: Optional asyncClient to use. If not provided, the ``streamable_http_client`` API will create and manage a default client. To configure headers, timeouts, or other HTTP client settings, create @@ -2466,6 +2641,9 @@ class MCPStreamableHTTPTool(MCPTool): request_timeout=request_timeout, task_options=task_options, additional_tool_argument_names=additional_tool_argument_names, + sampling_approval_callback=sampling_approval_callback, + sampling_max_tokens=sampling_max_tokens, + sampling_max_requests=sampling_max_requests, ) self.url = url self.terminate_on_close = terminate_on_close @@ -2590,6 +2768,9 @@ class MCPWebsocketTool(MCPTool): approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None, allowed_tools: Collection[str] | None = None, client: SupportsChatGetResponse | None = None, + sampling_approval_callback: SamplingApprovalCallback | None = None, + sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS, + sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS, additional_properties: dict[str, Any] | None = None, task_options: MCPTaskOptions | None = None, additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, @@ -2632,9 +2813,25 @@ class MCPWebsocketTool(MCPTool): - A dict with keys `always_require_approval` or `never_require_approval`, followed by a sequence of strings with the names of the relevant tools. A tool should not be listed in both, if so, it will require approval. - allowed_tools: A list of tools that are allowed to use this tool. + allowed_tools: Optional allow-list of MCP tool names to expose as functions. + ``None`` (the default) exposes every tool advertised by the MCP server. + A non-empty collection exposes only the tools whose names appear in it. + An empty collection (``[]``) exposes no tools — if you simply want to + disable tool execution, prefer ``load_tools=False`` instead. ``[]`` is + useful as a runtime guard or when you want to load tool metadata for + inspection without exposing the tools for invocation. additional_properties: Additional properties. client: The chat client to use for sampling. + sampling_approval_callback: Optional gate run before each server-initiated + ``sampling/createMessage`` request reaches ``client``. Receives the raw + ``CreateMessageRequestParams`` (sync or async); a truthy return approves the + request, a falsy return denies it. When ``None`` (the default) every sampling + request is **denied**, since MCP servers are untrusted (confused-deputy risk). + Pass ``lambda params: True`` to auto-approve as an explicit opt-in. + sampling_max_tokens: Cap applied to an approved request's ``maxTokens`` + (``min(requested, cap)``); ``None`` disables it. + sampling_max_requests: Per-session cap on the number of sampling requests; further + requests are rejected. Resets on reconnect. ``None`` disables it. task_options: Options for tools that advertise ``execution.taskSupport == "required"``. See :class:`MCPTaskOptions`. additional_tool_argument_names: Extra argument names to forward to the MCP server in @@ -2669,6 +2866,9 @@ class MCPWebsocketTool(MCPTool): request_timeout=request_timeout, task_options=task_options, additional_tool_argument_names=additional_tool_argument_names, + sampling_approval_callback=sampling_approval_callback, + sampling_max_tokens=sampling_max_tokens, + sampling_max_requests=sampling_max_requests, ) self.url = url self._client_kwargs = kwargs diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 7bb54ee2c9..ad232ffeb4 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -90,6 +90,9 @@ logger = logging.getLogger("agent_framework") DEFAULT_MAX_ITERATIONS: Final[int] = 40 DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST: Final[int] = 3 SHELL_TOOL_KIND_VALUE: Final[str] = "shell" +_TOOL_APPROVAL_STATE_KEY: Final[str] = "tool_approval" +_ALREADY_APPROVED_APPROVAL_REQUEST_GROUPS_KEY: Final[str] = "already_approved_approval_request_groups" +_FUNCTION_INVOCATION_BUDGET_STATE_KEY: Final[str] = "_function_invocation_budget_state" ApprovalMode: TypeAlias = Literal["always_require", "never_require"] ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]") ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) @@ -1685,15 +1688,15 @@ async def _try_execute_function_calls( # The live tools list (when tools is the run-local list) is exposed on the # FunctionInvocationContext so tools can add/remove tools during the run. live_tools: list[ToolTypes] | None = cast("list[ToolTypes]", tools) if isinstance(tools, list) else None - approval_tools = [tool_name for tool_name, tool in tool_map.items() if tool.approval_mode == "always_require"] + approval_tools = {tool_name for tool_name, tool in tool_map.items() if tool.approval_mode == "always_require"} logger.debug( "_try_execute_function_calls: tool_map keys=%s, approval_tools=%s", list(tool_map.keys()), approval_tools, ) - declaration_only = [tool_name for tool_name, tool in tool_map.items() if tool.declaration_only] + declaration_only = {tool_name for tool_name, tool in tool_map.items() if tool.declaration_only} configured_additional_tools = config.get("additional_tools") or [] - additional_tool_names = [tool.name for tool in configured_additional_tools] + additional_tool_names = {tool.name for tool in configured_additional_tools} # check if any are calling functions that need approval # if so, we return approval request for all approval_needed = False @@ -1719,15 +1722,39 @@ async def _try_execute_function_calls( raise KeyError(f'Error: Requested function "{fcc.name}" not found.') # type: ignore[attr-defined] if approval_needed: # approval can only be needed for Function Call Content, not Approval Responses. - logger.debug("Returning function_approval_request contents") - return ( - [ - Content.from_function_approval_request(id=fcc.call_id, function_call=fcc) # type: ignore[attr-defined, arg-type] - for fcc in function_calls - if fcc.type == "function_call" - ], - False, + logger.debug("Returning visible function_approval_request contents and storing already-approved requests") + visible_requests: list[Content] = [] + already_approved_requests: list[Content] = [] + for fcc in function_calls: + if fcc.type != "function_call": + continue + approval_request = Content.from_function_approval_request( + id=fcc.call_id, # type: ignore[arg-type] + function_call=fcc, + ) + tool_name = fcc.name # type: ignore[attr-defined] + if tool_name is None: + visible_requests.append(approval_request) + continue + tool = tool_map.get(tool_name) + if ( + tool_name in approval_tools + or tool is None + or tool_name in declaration_only + or tool_name in additional_tool_names + ): + visible_requests.append(approval_request) + continue + if invocation_session is None: + visible_requests.append(approval_request) + continue + already_approved_requests.append(approval_request) + _store_already_approved_approval_requests( + invocation_session, + visible_requests, + already_approved_requests, ) + return (visible_requests, False) if declaration_only_flag: # return the declaration only tools to the user, since we cannot execute them. # Mark as user_input_request so AgentExecutor emits request_info events and pauses the workflow. @@ -1912,6 +1939,108 @@ def _is_hosted_tool_approval(content: Any) -> bool: return bool(ap and ap.get("server_label")) +def _get_tool_approval_state(invocation_session: AgentSession | None) -> dict[str, Any] | None: + """Return the shared tool-approval state bag for the invocation session.""" + if invocation_session is None: + return None + raw_state = invocation_session.state.get(_TOOL_APPROVAL_STATE_KEY) + if isinstance(raw_state, dict): + return cast(dict[str, Any], raw_state) + from ._harness._tool_approval import ToolApprovalState + + if isinstance(raw_state, ToolApprovalState): + serialized_state = raw_state.to_dict(exclude={"type"}) + invocation_session.state[_TOOL_APPROVAL_STATE_KEY] = serialized_state + return serialized_state + if raw_state is not None: + raise TypeError( + f"Session state for {_TOOL_APPROVAL_STATE_KEY!r} must be a dict or ToolApprovalState, " + f"got {type(raw_state).__name__}." + ) + new_state: dict[str, Any] = {} + invocation_session.state[_TOOL_APPROVAL_STATE_KEY] = new_state + return new_state + + +def _content_from_state(value: Any) -> Content | None: + """Restore a Content item stored in session state.""" + from ._types import Content + + if isinstance(value, Content): + return value + if isinstance(value, Mapping): + return Content.from_dict(cast(Mapping[str, Any], value)) + return None + + +def _store_already_approved_approval_requests( + invocation_session: AgentSession | None, + visible_approval_requests: Sequence[Content], + already_approved_requests: Sequence[Content], +) -> None: + """Store hidden already-approved requests keyed by the visible approvals that resume the batch.""" + if not already_approved_requests: + return + state = _get_tool_approval_state(invocation_session) + if state is None: + return + visible_ids = [request.id for request in visible_approval_requests if request.id] + if not visible_ids: + return + + existing_groups = state.get(_ALREADY_APPROVED_APPROVAL_REQUEST_GROUPS_KEY) + pending_groups: list[Any] = ( + list(cast(Iterable[Any], existing_groups)) if isinstance(existing_groups, list) else [] + ) + pending_groups.append({ + "approval_request_ids": visible_ids, + "approval_requests": [request.to_dict() for request in already_approved_requests], + }) + state[_ALREADY_APPROVED_APPROVAL_REQUEST_GROUPS_KEY] = pending_groups + + +def _pop_already_approved_approval_responses( + invocation_session: AgentSession | None, + approval_response_ids: set[str], +) -> list[Content]: + """Pop already-approved requests for the visible approval ids being answered.""" + if not approval_response_ids: + return [] + state = _get_tool_approval_state(invocation_session) + if state is None: + return [] + raw_groups = state.get(_ALREADY_APPROVED_APPROVAL_REQUEST_GROUPS_KEY, []) + if not isinstance(raw_groups, list): + return [] + + responses: list[Content] = [] + remaining_groups: list[Any] = [] + raw_group_items = list(cast(Iterable[Any], raw_groups)) + for raw_group in raw_group_items: + if not isinstance(raw_group, Mapping): + continue + group = cast(Mapping[str, Any], raw_group) + raw_ids = group.get("approval_request_ids") + raw_group_ids: Iterable[Any] = cast(Iterable[Any], raw_ids) if isinstance(raw_ids, list) else () + group_ids = {str(item) for item in raw_group_ids} + if group_ids.isdisjoint(approval_response_ids): + remaining_groups.append(raw_group) + continue + raw_requests = group.get("approval_requests") + if not isinstance(raw_requests, list): + continue + for raw_request in list(cast(Iterable[Any], raw_requests)): + request = _content_from_state(raw_request) + if request is None or request.type != "function_approval_request": + continue + responses.append(request.to_function_approval_response(approved=True)) + if remaining_groups: + state[_ALREADY_APPROVED_APPROVAL_REQUEST_GROUPS_KEY] = remaining_groups + else: + state.pop(_ALREADY_APPROVED_APPROVAL_REQUEST_GROUPS_KEY, None) + return responses + + def _collect_approval_responses( messages: list[Message], ) -> dict[str, Content]: @@ -2157,8 +2286,24 @@ async def _process_function_requests( errors_in_a_row: int, max_errors: int, execute_function_calls: Callable[..., Awaitable[tuple[list[Content], bool, bool]]], + invocation_session: AgentSession | None = None, ) -> FunctionRequestResult: + from ._types import Message + if prepped_messages is not None: + explicit_approval_response_ids = { + content.id + for message in prepped_messages + if isinstance(message, Message) + for content in message.contents + if content.type == "function_approval_response" and content.id + } + already_approved_responses = _pop_already_approved_approval_responses( + invocation_session, + explicit_approval_response_ids, + ) + if already_approved_responses: + prepped_messages.append(Message(role="user", contents=already_approved_responses)) fcc_todo = _collect_approval_responses(prepped_messages) if not fcc_todo: fcc_todo = {} @@ -2362,6 +2507,10 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): function_middleware_pipeline = self._get_function_middleware_pipeline(runtime_middleware["function"]) if runtime_middleware["chat"]: effective_client_kwargs["middleware"] = runtime_middleware["chat"] + raw_budget_state = effective_client_kwargs.pop(_FUNCTION_INVOCATION_BUDGET_STATE_KEY, None) + budget_state: dict[str, Any] = ( + cast(dict[str, Any], raw_budget_state) if isinstance(raw_budget_state, dict) else {} + ) max_errors = self.function_invocation_configuration.get( "max_consecutive_errors_per_request", DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST ) @@ -2411,7 +2560,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): nonlocal mutable_options nonlocal filtered_kwargs errors_in_a_row: int = 0 - total_function_calls: int = 0 + total_function_calls = int(budget_state.get("total_function_calls", 0) or 0) max_function_calls: int | None = self.function_invocation_configuration.get("max_function_calls") prepped_messages = list(messages) fcc_messages: list[Message] = [] @@ -2420,7 +2569,9 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): loop_enabled = self.function_invocation_configuration.get("enabled", True) max_iterations = self.function_invocation_configuration.get("max_iterations", DEFAULT_MAX_ITERATIONS) - for attempt_idx in range(max_iterations if loop_enabled else 0): + attempt_start = int(budget_state.get("attempt_count", 0) or 0) + for attempt_idx in range(attempt_start, max_iterations if loop_enabled else 0): + budget_state["attempt_count"] = attempt_idx + 1 approval_result = await _process_function_requests( response=None, prepped_messages=prepped_messages, @@ -2430,12 +2581,21 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): errors_in_a_row=errors_in_a_row, max_errors=max_errors, execute_function_calls=execute_function_calls, + invocation_session=invocation_session, ) if approval_result.get("action") == "stop": response = ChatResponse(messages=prepped_messages) break errors_in_a_row = approval_result.get("errors_in_a_row", errors_in_a_row) total_function_calls += approval_result.get("function_call_count", 0) + budget_state["total_function_calls"] = total_function_calls + if max_function_calls is not None and total_function_calls >= max_function_calls: + logger.info( + "Maximum function calls reached (%d/%d). Stopping further function calls for this request.", + total_function_calls, + max_function_calls, + ) + mutable_options["tool_choice"] = "none" response = cast( ChatResponse[Any], @@ -2468,11 +2628,13 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): errors_in_a_row=errors_in_a_row, max_errors=max_errors, execute_function_calls=execute_function_calls, + invocation_session=invocation_session, ) if result.get("action") == "return": response.usage_details = aggregated_usage return _clear_internal_conversation_id(response) total_function_calls += result.get("function_call_count", 0) + budget_state["total_function_calls"] = total_function_calls if result.get("action") == "stop": # Error threshold reached: force a final non-tool turn so # function_call_output items are submitted before exit. @@ -2549,7 +2711,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): nonlocal mutable_options nonlocal stream_result_hooks errors_in_a_row: int = 0 - total_function_calls: int = 0 + total_function_calls = int(budget_state.get("total_function_calls", 0) or 0) max_function_calls: int | None = self.function_invocation_configuration.get("max_function_calls") prepped_messages = list(messages) fcc_messages: list[Message] = [] @@ -2557,7 +2719,9 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): loop_enabled = self.function_invocation_configuration.get("enabled", True) max_iterations = self.function_invocation_configuration.get("max_iterations", DEFAULT_MAX_ITERATIONS) - for attempt_idx in range(max_iterations if loop_enabled else 0): + attempt_start = int(budget_state.get("attempt_count", 0) or 0) + for attempt_idx in range(attempt_start, max_iterations if loop_enabled else 0): + budget_state["attempt_count"] = attempt_idx + 1 approval_result = await _process_function_requests( response=None, prepped_messages=prepped_messages, @@ -2567,9 +2731,18 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): errors_in_a_row=errors_in_a_row, max_errors=max_errors, execute_function_calls=execute_function_calls, + invocation_session=invocation_session, ) errors_in_a_row = approval_result.get("errors_in_a_row", errors_in_a_row) total_function_calls += approval_result.get("function_call_count", 0) + budget_state["total_function_calls"] = total_function_calls + if max_function_calls is not None and total_function_calls >= max_function_calls: + logger.info( + "Maximum function calls reached (%d/%d). Stopping further function calls for this request.", + total_function_calls, + max_function_calls, + ) + mutable_options["tool_choice"] = "none" if approval_result.get("action") == "stop": mutable_options["tool_choice"] = "none" return @@ -2622,9 +2795,11 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): errors_in_a_row=errors_in_a_row, max_errors=max_errors, execute_function_calls=execute_function_calls, + invocation_session=invocation_session, ) errors_in_a_row = result.get("errors_in_a_row", errors_in_a_row) total_function_calls += result.get("function_call_count", 0) + budget_state["total_function_calls"] = total_function_calls if role := result.get("update_role"): yield ChatResponseUpdate( contents=result.get("function_call_results") or [], diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py index dd1fb3d704..c66faae75e 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py @@ -13,6 +13,35 @@ during deserialization. The default built-in safe set covers common Python value types (primitives, datetime, uuid, ...), all ``agent_framework`` internal types, and all ``openai.types`` types. Callers can extend the set by passing additional ``"module:qualname"`` strings. + +Security Model +-------------- +Checkpoint storage is treated as a **trusted data source**. The serialization +format uses Python's ``pickle`` module which can execute arbitrary code during +deserialization. The ``RestrictedUnpickler`` provides a defense-in-depth +allowlist that limits instantiable classes, but it is **not** a security +boundary — certain allowlisted builtins (e.g. ``getattr``) are required for +legitimate object reconstruction (enums, named tuples) and cannot be removed +without breaking compatibility. + +Developers **must** ensure that: + +1. The checkpoint storage backend (file system, Cosmos DB, Azure Blob, Durable + Functions storage) is access-controlled and not writable by untrusted + parties. +2. Data flowing into ``decode_checkpoint_value`` originates exclusively from + the application's own checkpoint storage — never from user-supplied HTTP + requests, message payloads, or other untrusted sources. +3. The ``allowed_types`` parameter is specified whenever possible to restrict + the set of reconstructible types to the minimum required by the application. +4. Never pass untrusted external input to ``decode_checkpoint_value``. If you + must accept external JSON that might contain checkpoint markers, sanitize it + first (for example, :func:`agent_framework_azurefunctions._serialization.strip_pickle_markers`). + +The allowlist is a mitigation that reduces attack surface but does not +eliminate the inherent risks of deserializing untrusted pickle data. Treat +your checkpoint storage with the same access controls you would apply to +application secrets or database credentials. """ from __future__ import annotations diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 39b180a23e..bc03bb02dd 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.8.0" +version = "1.8.1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/core/tests/core/test_harness_agent.py b/python/packages/core/tests/core/test_harness_agent.py index 58ef3f5f2d..0a280e87ed 100644 --- a/python/packages/core/tests/core/test_harness_agent.py +++ b/python/packages/core/tests/core/test_harness_agent.py @@ -194,6 +194,63 @@ def test_create_harness_agent_returns_full_agent() -> None: assert isinstance(agent, FullAgent) +def test_create_harness_agent_no_token_params_disables_compaction() -> None: + """When token params are omitted, compaction is automatically disabled.""" + agent = create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + ) + provider_types = [type(p) for p in agent.context_providers] + assert CompactionProvider not in provider_types + + +def test_create_harness_agent_no_token_params_skips_max_tokens_option() -> None: + """When max_output_tokens is omitted, max_tokens should not be set in default options.""" + agent = create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + ) + assert agent.default_options.get("max_tokens") is None + + +def test_create_harness_agent_custom_before_strategy_enables_compaction_without_tokens() -> None: + """A custom before_compaction_strategy enables compaction even when token params are omitted.""" + from agent_framework import ToolResultCompactionStrategy + + agent = create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + before_compaction_strategy=ToolResultCompactionStrategy(), + ) + provider_types = [type(p) for p in agent.context_providers] + assert CompactionProvider in provider_types + + +def test_create_harness_agent_disable_compaction_overrides_custom_before_strategy() -> None: + """disable_compaction=True wins even when a custom before strategy is provided.""" + from agent_framework import ToolResultCompactionStrategy + + agent = create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + before_compaction_strategy=ToolResultCompactionStrategy(), + disable_compaction=True, + ) + provider_types = [type(p) for p in agent.context_providers] + assert CompactionProvider not in provider_types + + +def test_create_harness_agent_custom_after_strategy_enables_compaction_without_tokens() -> None: + """A custom after_compaction_strategy enables compaction even when token params are omitted.""" + from agent_framework import ToolResultCompactionStrategy + + agent = create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + after_compaction_strategy=ToolResultCompactionStrategy(), + ) + compaction_providers = [p for p in agent.context_providers if isinstance(p, CompactionProvider)] + assert len(compaction_providers) == 1 + # Before phase is skipped (no token budget, no custom before strategy), after phase is set. + assert compaction_providers[0].before_strategy is None + assert compaction_providers[0].after_strategy is not None + + # --- Validation Tests --- @@ -207,14 +264,15 @@ def test_create_harness_agent_rejects_invalid_context_tokens() -> None: ) -def test_create_harness_agent_rejects_negative_output_tokens() -> None: - """max_output_tokens must be non-negative.""" - with pytest.raises(ValueError, match="max_output_tokens must be non-negative"): - create_harness_agent( - client=_FakeChatClient(), # type: ignore[arg-type] - max_context_window_tokens=1000, - max_output_tokens=-1, - ) +def test_create_harness_agent_rejects_non_positive_output_tokens() -> None: + """max_output_tokens must be positive when provided.""" + for invalid_value in (0, -1): + with pytest.raises(ValueError, match="max_output_tokens must be positive"): + create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=1000, + max_output_tokens=invalid_value, + ) def test_create_harness_agent_rejects_output_gte_context() -> None: @@ -485,3 +543,127 @@ def test_create_harness_agent_empty_background_agents_list() -> None: ) providers = agent.context_providers or [] assert not any(isinstance(p, BackgroundAgentsProvider) for p in providers) + + +# --- Shell Tool Tests --- + + +class _FakeShellTool: + """Fake shell executor/tool exposing as_function().""" + + def as_function(self) -> str: + return "shell_fn" + + +class _FakeShellClient(_FakeChatClient): + """Fake client that supports the shell tool.""" + + def __init__(self) -> None: + self.shell_func: Any = None + + def get_shell_tool(self, *, func: Any = None, **kwargs: Any) -> str: + self.shell_func = func + return "shell_tool_instance" + + +def test_create_harness_agent_adds_shell_tool_and_provider() -> None: + """Shell tool and ShellEnvironmentProvider should be added when a shell executor is supplied.""" + from agent_framework_tools.shell import ShellEnvironmentProvider + + client = _FakeShellClient() + agent = create_harness_agent( + client=client, # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_web_search=True, + shell_executor=_FakeShellTool(), + ) + tools = agent.default_options.get("tools", []) + assert "shell_tool_instance" in tools + assert client.shell_func == "shell_fn" + providers = agent.context_providers or [] + assert any(isinstance(p, ShellEnvironmentProvider) for p in providers) + + +def test_create_harness_agent_shell_passes_custom_options() -> None: + """Custom ShellEnvironmentProviderOptions should be forwarded to the provider.""" + from agent_framework_tools.shell import ShellEnvironmentProvider, ShellEnvironmentProviderOptions + + options = ShellEnvironmentProviderOptions(probe_tools=("git",)) + agent = create_harness_agent( + client=_FakeShellClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_web_search=True, + shell_executor=_FakeShellTool(), + shell_environment_provider_options=options, + ) + providers = agent.context_providers or [] + provider = next(p for p in providers if isinstance(p, ShellEnvironmentProvider)) + assert provider._options is options + + +def test_create_harness_agent_shell_skipped_when_unsupported(caplog: pytest.LogCaptureFixture) -> None: + """When the client lacks get_shell_tool, both the tool and provider are skipped with a warning.""" + import logging + + from agent_framework_tools.shell import ShellEnvironmentProvider + + with caplog.at_level(logging.WARNING, logger="agent_framework._harness._agent"): + agent = create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_web_search=True, + shell_executor=_FakeShellTool(), + ) + assert any("SupportsShellTool" in msg for msg in caplog.messages) + providers = agent.context_providers or [] + assert not any(isinstance(p, ShellEnvironmentProvider) for p in providers) + assert "tools" not in agent.default_options or not agent.default_options.get("tools") + + +def test_create_harness_agent_no_shell_by_default() -> None: + """No shell tool or provider should be added when shell_executor is not provided.""" + from agent_framework_tools.shell import ShellEnvironmentProvider + + agent = create_harness_agent( + client=_FakeShellClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_web_search=True, + ) + providers = agent.context_providers or [] + assert not any(isinstance(p, ShellEnvironmentProvider) for p in providers) + + +def test_create_harness_agent_shell_executor_without_as_function_raises() -> None: + """A shell_executor lacking a callable as_function() should raise a clear TypeError.""" + + class _BadExecutor: + pass + + with pytest.raises(TypeError, match="as_function"): + create_harness_agent( + client=_FakeShellClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_web_search=True, + shell_executor=_BadExecutor(), + ) + + +def test_create_harness_agent_shell_executor_validated_before_client_check() -> None: + """The as_function() contract is validated upfront, even when the client lacks shell support.""" + + class _BadExecutor: + pass + + with pytest.raises(TypeError, match="as_function"): + create_harness_agent( + client=_FakeChatClient(), # type: ignore[arg-type] + max_context_window_tokens=128_000, + max_output_tokens=16_384, + disable_web_search=True, + shell_executor=_BadExecutor(), + ) diff --git a/python/packages/core/tests/core/test_harness_tool_approval.py b/python/packages/core/tests/core/test_harness_tool_approval.py new file mode 100644 index 0000000000..9bc03c839e --- /dev/null +++ b/python/packages/core/tests/core/test_harness_tool_approval.py @@ -0,0 +1,817 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from agent_framework import ( + DEFAULT_TOOL_APPROVAL_SOURCE_ID, + Agent, + AgentSession, + ChatResponse, + ChatResponseUpdate, + Content, + Message, + SupportsChatGetResponse, + ToolApprovalMiddleware, + ToolApprovalState, + create_always_approve_tool_response, + create_always_approve_tool_with_arguments_response, + tool, +) + + +def _approval_requests(messages: list[Message]) -> list[Content]: + return [ + content for message in messages for content in message.contents if content.type == "function_approval_request" + ] + + +async def test_mixed_batch_hides_already_approved_request_until_approval_replay( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Mixed batches should only show real approval requests when a session can store hidden requests.""" + no_approval_calls = 0 + approval_calls = 0 + + @tool(name="lookup_work_items", approval_mode="never_require") + def lookup_work_items(query: str) -> str: + nonlocal no_approval_calls + no_approval_calls += 1 + return f"found {query}" + + @tool(name="add_comment", approval_mode="always_require") + def add_comment(comment: str) -> str: + nonlocal approval_calls + approval_calls += 1 + return f"added {comment}" + + agent = Agent(client=chat_client_base, tools=[lookup_work_items, add_comment]) + session = AgentSession(session_id="approval-session") + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_lookup", + name="lookup_work_items", + arguments='{"query": "mine"}', + ), + Content.from_function_call( + call_id="call_comment", + name="add_comment", + arguments='{"comment": "done"}', + ), + ], + ) + ) + ] + + first_response = await agent.run("update work item", session=session) + + requests = _approval_requests(first_response.messages) + assert [request.function_call.name for request in requests] == ["add_comment"] + assert no_approval_calls == 0 + assert approval_calls == 0 + + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["complete"]))] + second_response = await agent.run(requests[0].to_function_approval_response(approved=True), session=session) + + assert second_response.text == "complete" + assert no_approval_calls == 1 + assert approval_calls == 1 + + +async def test_mixed_batch_accepts_restored_tool_approval_state( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Mixed-batch bypass should work when session state contains ToolApprovalState.""" + safe_calls = 0 + risky_calls = 0 + + @tool(name="safe_read", approval_mode="never_require") + def safe_read() -> str: + nonlocal safe_calls + safe_calls += 1 + return "safe" + + @tool(name="risky_write", approval_mode="always_require") + def risky_write() -> str: + nonlocal risky_calls + risky_calls += 1 + return "risky" + + agent = Agent(client=chat_client_base, tools=[safe_read, risky_write]) + session = AgentSession(session_id="restored-state-session") + session.state[DEFAULT_TOOL_APPROVAL_SOURCE_ID] = ToolApprovalState() + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_safe", name="safe_read", arguments="{}"), + Content.from_function_call(call_id="call_risky", name="risky_write", arguments="{}"), + ], + ) + ) + ] + + first_response = await agent.run("read and write", session=session) + requests = _approval_requests(first_response.messages) + + assert [request.function_call.name for request in requests] == ["risky_write"] + assert safe_calls == 0 + assert risky_calls == 0 + + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["done"]))] + final_response = await agent.run(requests[0].to_function_approval_response(approved=True), session=session) + + assert final_response.text == "done" + assert safe_calls == 1 + assert risky_calls == 1 + + +async def test_hidden_mixed_batch_requests_do_not_replay_on_unrelated_turn( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Stored hidden approvals should only replay when an approval response resumes the flow.""" + safe_calls = 0 + risky_calls = 0 + + @tool(name="safe_lookup", approval_mode="never_require") + def safe_lookup() -> str: + nonlocal safe_calls + safe_calls += 1 + return "safe" + + @tool(name="risky_update", approval_mode="always_require") + def risky_update() -> str: + nonlocal risky_calls + risky_calls += 1 + return "risky" + + agent = Agent(client=chat_client_base, tools=[safe_lookup, risky_update]) + session = AgentSession(session_id="stale-hidden-session") + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_safe", name="safe_lookup", arguments="{}"), + Content.from_function_call(call_id="call_risky", name="risky_update", arguments="{}"), + ], + ) + ) + ] + + first_response = await agent.run("lookup and update", session=session) + request = _approval_requests(first_response.messages)[0] + + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["unrelated"]))] + unrelated_response = await agent.run("never mind, answer something else", session=session) + + assert unrelated_response.text == "unrelated" + assert safe_calls == 0 + assert risky_calls == 0 + + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["done"]))] + final_response = await agent.run(request.to_function_approval_response(approved=True), session=session) + + assert final_response.text == "done" + assert safe_calls == 1 + assert risky_calls == 1 + + +async def test_hidden_mixed_batch_requests_replay_only_for_matching_visible_approval( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Approving one mixed batch must not replay hidden calls from another abandoned batch.""" + safe_a_calls = 0 + safe_b_calls = 0 + risky_a_calls = 0 + risky_b_calls = 0 + + @tool(name="safe_a", approval_mode="never_require") + def safe_a() -> str: + nonlocal safe_a_calls + safe_a_calls += 1 + return "safe-a" + + @tool(name="safe_b", approval_mode="never_require") + def safe_b() -> str: + nonlocal safe_b_calls + safe_b_calls += 1 + return "safe-b" + + @tool(name="risky_a", approval_mode="always_require") + def risky_a() -> str: + nonlocal risky_a_calls + risky_a_calls += 1 + return "risky-a" + + @tool(name="risky_b", approval_mode="always_require") + def risky_b() -> str: + nonlocal risky_b_calls + risky_b_calls += 1 + return "risky-b" + + agent = Agent(client=chat_client_base, tools=[safe_a, safe_b, risky_a, risky_b]) + session = AgentSession(session_id="grouped-hidden-session") + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_safe_a", name="safe_a", arguments="{}"), + Content.from_function_call(call_id="call_risky_a", name="risky_a", arguments="{}"), + ], + ) + ) + ] + + first_response = await agent.run("batch a", session=session) + assert [request.function_call.name for request in _approval_requests(first_response.messages)] == ["risky_a"] + + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_safe_b", name="safe_b", arguments="{}"), + Content.from_function_call(call_id="call_risky_b", name="risky_b", arguments="{}"), + ], + ) + ) + ] + + second_response = await agent.run("batch b", session=session) + second_request = _approval_requests(second_response.messages)[0] + + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["done"]))] + final_response = await agent.run(second_request.to_function_approval_response(approved=True), session=session) + + assert final_response.text == "done" + assert safe_a_calls == 0 + assert risky_a_calls == 0 + assert safe_b_calls == 1 + assert risky_b_calls == 1 + + +async def test_tool_approval_middleware_queues_multiple_approval_requests( + chat_client_base: SupportsChatGetResponse, +) -> None: + """The opt-in middleware should present multiple unresolved approvals one at a time.""" + first_calls = 0 + second_calls = 0 + + @tool(name="first_tool", approval_mode="always_require") + def first_tool() -> str: + nonlocal first_calls + first_calls += 1 + return "first" + + @tool(name="second_tool", approval_mode="always_require") + def second_tool() -> str: + nonlocal second_calls + second_calls += 1 + return "second" + + agent = Agent( + client=chat_client_base, + tools=[first_tool, second_tool], + middleware=[ToolApprovalMiddleware()], + ) + session = AgentSession(session_id="queue-session") + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_first", name="first_tool", arguments="{}"), + Content.from_function_call(call_id="call_second", name="second_tool", arguments="{}"), + ], + ) + ) + ] + + first_response = await agent.run("call both", session=session) + + first_requests = _approval_requests(first_response.messages) + assert [request.function_call.name for request in first_requests] == ["first_tool"] + assert first_calls == 0 + assert second_calls == 0 + + second_response = await agent.run(first_requests[0].to_function_approval_response(approved=True), session=session) + + second_requests = _approval_requests(second_response.messages) + assert [request.function_call.name for request in second_requests] == ["second_tool"] + assert first_calls == 0 + assert second_calls == 0 + + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["done"]))] + final_response = await agent.run(second_requests[0].to_function_approval_response(approved=True), session=session) + + assert final_response.text == "done" + assert first_calls == 1 + assert second_calls == 1 + + +async def test_tool_approval_middleware_preserves_hidden_mixed_batch_requests( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Middleware state saves should not discard core hidden already-approved requests.""" + lookup_calls = 0 + write_calls = 0 + + @tool(name="lookup_records", approval_mode="never_require") + def lookup_records() -> str: + nonlocal lookup_calls + lookup_calls += 1 + return "records" + + @tool(name="write_record", approval_mode="always_require") + def write_record() -> str: + nonlocal write_calls + write_calls += 1 + return "written" + + agent = Agent( + client=chat_client_base, + tools=[lookup_records, write_record], + middleware=[ToolApprovalMiddleware()], + ) + session = AgentSession(session_id="mixed-middleware-session") + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_lookup", name="lookup_records", arguments="{}"), + Content.from_function_call(call_id="call_write", name="write_record", arguments="{}"), + ], + ) + ) + ] + + first_response = await agent.run("lookup and write", session=session) + request = _approval_requests(first_response.messages)[0] + + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["done"]))] + second_response = await agent.run(request.to_function_approval_response(approved=True), session=session) + + assert second_response.text == "done" + assert lookup_calls == 1 + assert write_calls == 1 + + +async def test_tool_approval_middleware_auto_approval_rule_receives_function_call( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Heuristic auto-approval callbacks should receive function-call content and approve matching calls.""" + auto_calls = 0 + manual_calls = 0 + seen_calls: list[tuple[str, str | None]] = [] + + @tool(name="auto_write", approval_mode="always_require") + def auto_write() -> str: + nonlocal auto_calls + auto_calls += 1 + return "auto" + + @tool(name="manual_write", approval_mode="always_require") + def manual_write() -> str: + nonlocal manual_calls + manual_calls += 1 + return "manual" + + async def auto_approve_auto_write(function_call: Content) -> bool: + seen_calls.append((function_call.type, function_call.name)) + return function_call.name == "auto_write" + + agent = Agent( + client=chat_client_base, + tools=[auto_write, manual_write], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[auto_approve_auto_write])], + ) + session = AgentSession(session_id="heuristic-session") + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_auto", name="auto_write", arguments="{}"), + Content.from_function_call(call_id="call_manual", name="manual_write", arguments="{}"), + ], + ) + ) + ] + + first_response = await agent.run("write both", session=session) + + requests = _approval_requests(first_response.messages) + assert [request.function_call.name for request in requests] == ["manual_write"] + assert seen_calls == [("function_call", "auto_write"), ("function_call", "manual_write")] + assert auto_calls == 0 + assert manual_calls == 0 + + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["done"]))] + final_response = await agent.run(requests[0].to_function_approval_response(approved=True), session=session) + + assert final_response.text == "done" + assert auto_calls == 1 + assert manual_calls == 1 + + +async def test_tool_approval_middleware_auto_approved_loops_share_function_call_budget( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Auto-approved re-entry should not reset max_function_calls.""" + calls = 0 + + @tool(name="budgeted_tool", approval_mode="always_require") + def budgeted_tool(value: str) -> str: + nonlocal calls + calls += 1 + return value + + def auto_approve_budgeted_tool(function_call: Content) -> bool: + return function_call.name == "budgeted_tool" + + chat_client_base.function_invocation_configuration["max_function_calls"] = 1 # type: ignore[attr-defined] + agent = Agent( + client=chat_client_base, + tools=[budgeted_tool], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[auto_approve_budgeted_tool])], + ) + session = AgentSession(session_id="shared-budget-session") + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_first", + name="budgeted_tool", + arguments='{"value": "first"}', + ) + ], + ) + ), + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_second", + name="budgeted_tool", + arguments='{"value": "second"}', + ) + ], + ) + ), + ] + + response = await agent.run("call repeatedly", session=session) + + assert response.text == "I broke out of the function invocation loop..." + assert calls == 1 + + +async def test_tool_approval_middleware_queues_streamed_approval_requests( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Streaming approval requests should also be queued one at a time.""" + calls = 0 + + @tool(name="first_streamed_tool", approval_mode="always_require") + def first_streamed_tool() -> str: + nonlocal calls + calls += 1 + return "first" + + @tool(name="second_streamed_tool", approval_mode="always_require") + def second_streamed_tool() -> str: + nonlocal calls + calls += 1 + return "second" + + agent = Agent( + client=chat_client_base, + tools=[first_streamed_tool, second_streamed_tool], + middleware=[ToolApprovalMiddleware()], + ) + session = AgentSession(session_id="stream-queue-session") + chat_client_base.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[Content.from_function_call(call_id="call_first", name="first_streamed_tool", arguments="{}")], + role="assistant", + ), + ChatResponseUpdate( + contents=[ + Content.from_function_call(call_id="call_second", name="second_streamed_tool", arguments="{}") + ], + role="assistant", + ), + ] + ] + + first_stream = agent.run("call both", stream=True, session=session) + first_updates = [update async for update in first_stream] + first_requests = [content for update in first_updates for content in update.user_input_requests] + assert [request.function_call.name for request in first_requests] == ["first_streamed_tool"] + assert calls == 0 + + second_stream = agent.run( + first_requests[0].to_function_approval_response(approved=True), + stream=True, + session=session, + ) + second_updates = [update async for update in second_stream] + second_requests = [content for update in second_updates for content in update.user_input_requests] + assert [request.function_call.name for request in second_requests] == ["second_streamed_tool"] + assert calls == 0 + + chat_client_base.streaming_responses = [ + [ChatResponseUpdate(contents=[Content.from_text("done")], role="assistant")] + ] + final_stream = agent.run( + second_requests[0].to_function_approval_response(approved=True), + stream=True, + session=session, + ) + final_updates = [update async for update in final_stream] + final_response = await final_stream.get_final_response() + + assert final_updates[-1].text == "done" + assert final_response.text == "done" + assert calls == 2 + + +async def test_tool_approval_middleware_always_approve_tool_rule( + chat_client_base: SupportsChatGetResponse, +) -> None: + """An always-approve response should add a standing tool-level approval rule.""" + calls = 0 + + @tool(name="dangerous_tool", approval_mode="always_require") + def dangerous_tool(value: str) -> str: + nonlocal calls + calls += 1 + return value + + agent = Agent( + client=chat_client_base, + tools=[dangerous_tool], + middleware=[ToolApprovalMiddleware()], + ) + session = AgentSession(session_id="standing-rule-session") + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_initial", + name="dangerous_tool", + arguments='{"value": "one"}', + ) + ], + ) + ) + ] + + first_response = await agent.run("call once", session=session) + first_request = _approval_requests(first_response.messages)[0] + + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["first done"]))] + await agent.run(create_always_approve_tool_response(first_request), session=session) + + assert calls == 1 + + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_auto", + name="dangerous_tool", + arguments='{"value": "two"}', + ) + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["second done"])), + ] + + second_response = await agent.run("call again", session=session) + + assert second_response.text == "second done" + assert calls == 2 + + +async def test_tool_approval_middleware_standing_rules_include_hosted_server_boundary( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A standing hosted-tool rule should only match the same server_label.""" + calls = 0 + + @tool(name="hosted_tool", approval_mode="always_require") + def hosted_tool() -> str: + nonlocal calls + calls += 1 + return "hosted" + + def hosted_call(call_id: str, server_label: str) -> Content: + return Content.from_function_call( + call_id=call_id, + name="hosted_tool", + arguments="{}", + additional_properties={"server_label": server_label}, + ) + + agent = Agent( + client=chat_client_base, + tools=[hosted_tool], + middleware=[ToolApprovalMiddleware()], + ) + session = AgentSession(session_id="hosted-boundary-session") + chat_client_base.run_responses = [ + ChatResponse(messages=Message(role="assistant", contents=[hosted_call("call_initial", "server-a")])) + ] + + first_response = await agent.run("call hosted a", session=session) + first_request = _approval_requests(first_response.messages)[0] + + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["server a done"]))] + await agent.run(create_always_approve_tool_response(first_request), session=session) + + assert calls == 0 + + chat_client_base.run_responses = [ + ChatResponse(messages=Message(role="assistant", contents=[hosted_call("call_same_server", "server-a")])), + ChatResponse(messages=Message(role="assistant", contents=["same server done"])), + ] + + same_server_response = await agent.run("call hosted a again", session=session) + + assert same_server_response.text == "same server done" + assert _approval_requests(same_server_response.messages) == [] + assert calls == 0 + + chat_client_base.run_responses = [ + ChatResponse(messages=Message(role="assistant", contents=[hosted_call("call_other_server", "server-b")])) + ] + + other_server_response = await agent.run("call hosted b", session=session) + + requests = _approval_requests(other_server_response.messages) + assert [request.function_call.additional_properties["server_label"] for request in requests] == ["server-b"] + assert calls == 0 + + +async def test_tool_approval_middleware_always_approve_tool_with_arguments_rule( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Argument-scoped always-approve rules should require exact argument matches.""" + calls = 0 + + @tool(name="argument_scoped_tool", approval_mode="always_require") + def argument_scoped_tool(value: str) -> str: + nonlocal calls + calls += 1 + return value + + agent = Agent( + client=chat_client_base, + tools=[argument_scoped_tool], + middleware=[ToolApprovalMiddleware()], + ) + session = AgentSession(session_id="argument-rule-session") + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_initial", + name="argument_scoped_tool", + arguments='{"value": "same"}', + ) + ], + ) + ) + ] + + first_response = await agent.run("call with same", session=session) + first_request = _approval_requests(first_response.messages)[0] + + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["first done"]))] + await agent.run(create_always_approve_tool_with_arguments_response(first_request), session=session) + + assert calls == 1 + + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_same", + name="argument_scoped_tool", + arguments='{"value": "same"}', + ) + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["same done"])), + ] + + second_response = await agent.run("call with same again", session=session) + + assert second_response.text == "same done" + assert calls == 2 + + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_different", + name="argument_scoped_tool", + arguments='{"value": "different"}', + ) + ], + ) + ) + ] + + third_response = await agent.run("call with different args", session=session) + + requests = _approval_requests(third_response.messages) + assert [request.function_call.arguments for request in requests] == ['{"value": "different"}'] + assert calls == 2 + + +async def test_tool_approval_middleware_empty_arguments_rule_is_not_tool_wide( + chat_client_base: SupportsChatGetResponse, +) -> None: + """An argument-scoped no-argument approval should not become a wildcard.""" + calls = 0 + + @tool(name="optional_args_tool", approval_mode="always_require") + def optional_args_tool(value: str = "default") -> str: + nonlocal calls + calls += 1 + return value + + agent = Agent( + client=chat_client_base, + tools=[optional_args_tool], + middleware=[ToolApprovalMiddleware()], + ) + session = AgentSession(session_id="empty-arguments-rule-session") + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_empty", + name="optional_args_tool", + arguments="{}", + ) + ], + ) + ) + ] + + first_response = await agent.run("call without args", session=session) + first_request = _approval_requests(first_response.messages)[0] + + chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["empty done"]))] + await agent.run(create_always_approve_tool_with_arguments_response(first_request), session=session) + + assert calls == 1 + + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_non_empty", + name="optional_args_tool", + arguments='{"value": "custom"}', + ) + ], + ) + ) + ] + + second_response = await agent.run("call with args", session=session) + + requests = _approval_requests(second_response.messages) + assert [request.function_call.arguments for request in requests] == ['{"value": "custom"}'] + assert calls == 1 diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 7c45296cbb..3eaf60785d 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -342,6 +342,69 @@ def test_parse_tool_result_from_mcp_resource_link_text_resource_and_unknown(): assert result[1].text == "Embedded result" +def test_parse_tool_result_from_mcp_structured_content_only(): + """Test that structuredContent is parsed when content list is empty.""" + mcp_result = types.CallToolResult( + content=[], + structuredContent={"Tables": [{"Name": "Sales", "Columns": ["Amount", "Date"]}]}, + ) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + parsed = json.loads(result[0].text) + assert parsed == {"Tables": [{"Name": "Sales", "Columns": ["Amount", "Date"]}]} + + +def test_parse_tool_result_from_mcp_structured_content_with_text(): + """Test that structuredContent is appended alongside regular content items.""" + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="Summary")], + structuredContent={"data": [1, 2, 3]}, + ) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) + + assert isinstance(result, list) + assert len(result) == 2 + assert result[0].type == "text" + assert result[0].text == "Summary" + assert result[1].type == "text" + parsed = json.loads(result[1].text) + assert parsed == {"data": [1, 2, 3]} + + +def test_parse_tool_result_from_mcp_structured_content_none(): + """Test that None structuredContent does not affect results.""" + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="Hello")], + structuredContent=None, + ) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert result[0].text == "Hello" + + +def test_parse_tool_result_from_mcp_structured_content_non_serializable(): + """Test that non-JSON-serializable values in structuredContent degrade gracefully.""" + mcp_result = types.CallToolResult( + content=[], + structuredContent={"data": b"raw bytes", "count": 42}, + ) + result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + parsed = json.loads(result[0].text) + assert parsed["count"] == 42 + # bytes should be converted to string representation via default=str + assert "raw bytes" in parsed["data"] + + def test_mcp_content_types_to_ai_content_text(): """Test conversion of MCP text content to AI content.""" mcp_content = types.TextContent(type="text", text="Sample text") @@ -1467,6 +1530,7 @@ def test_mcp_tool_approval_mode_returns_none_for_unmatched_names() -> None: 3, ["tool_one", "tool_two", "tool_three"], ), # None means all tools are allowed + ([], 0, []), # Empty list means no tools are allowed (["tool_one"], 1, ["tool_one"]), # Only tool_one is allowed ( ["tool_one", "tool_three"], @@ -1813,6 +1877,18 @@ async def test_mcp_tool_message_handler_cancel_and_replace(): assert len(tool._pending_reload_tasks) == 0 +def _approve(_params: object) -> bool: + """Approving sampling gate used by tests that exercise forwarding behavior.""" + return True + + +def _make_sampling_response(text: str = "response", model: str = "test-model") -> Mock: + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text(text)])] + mock_response.model = model + return mock_response + + async def test_mcp_tool_sampling_callback_no_client(): """Test sampling callback error path when no chat client is available.""" tool = MCPStdioTool(name="test_tool", command="python") @@ -1828,9 +1904,190 @@ async def test_mcp_tool_sampling_callback_no_client(): assert "No chat client available" in result.message +async def test_mcp_tool_sampling_callback_denies_by_default(): + """Sampling is denied when no approval callback is configured (safe default).""" + tool = MCPStdioTool(name="test_tool", command="python") + mock_chat_client = AsyncMock() + tool.client = mock_chat_client + + params = Mock() + params.messages = [] + params.maxTokens = 128 + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.ErrorData) + assert result.code == types.INVALID_REQUEST + assert "denied" in result.message + assert "sampling_approval_callback" in result.message + mock_chat_client.get_response.assert_not_called() + + +async def test_mcp_tool_sampling_callback_denied_by_callback(): + """Sampling is denied when the approval callback returns a falsy value.""" + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=lambda params: False) + mock_chat_client = AsyncMock() + tool.client = mock_chat_client + + params = Mock() + params.messages = [] + params.maxTokens = 128 + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.ErrorData) + assert result.code == types.INVALID_REQUEST + assert "denied by the 'sampling_approval_callback'" in result.message + mock_chat_client.get_response.assert_not_called() + + +async def test_mcp_tool_sampling_callback_callback_exception_denies(): + """An approval callback that raises results in denial, not an LLM call.""" + + def boom(_params: object) -> bool: + raise RuntimeError("approval error") + + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=boom) + mock_chat_client = AsyncMock() + tool.client = mock_chat_client + + params = Mock() + params.messages = [] + params.maxTokens = 128 + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.ErrorData) + assert result.code == types.INVALID_REQUEST + mock_chat_client.get_response.assert_not_called() + + +async def test_mcp_tool_sampling_callback_async_approval(): + """An async approval callback that approves allows the request through.""" + + async def approve(_params: object) -> bool: + return True + + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=approve) + mock_chat_client = AsyncMock() + mock_chat_client.get_response.return_value = _make_sampling_response("ok") + tool.client = mock_chat_client + + params = Mock() + params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))] + params.temperature = None + params.maxTokens = 100 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + assert result.content.text == "ok" + mock_chat_client.get_response.assert_awaited_once() + + +async def test_mcp_tool_sampling_callback_clamps_max_tokens(): + """An approved request's maxTokens is clamped to sampling_max_tokens.""" + tool = MCPStdioTool( + name="test_tool", + command="python", + sampling_approval_callback=_approve, + sampling_max_tokens=512, + ) + mock_chat_client = AsyncMock() + mock_chat_client.get_response.return_value = _make_sampling_response() + tool.client = mock_chat_client + + params = Mock() + params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))] + params.temperature = None + params.maxTokens = 1_000_000 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + options = mock_chat_client.get_response.call_args.kwargs.get("options") or {} + assert options["max_tokens"] == 512 + + +async def test_mcp_tool_sampling_callback_does_not_clamp_under_cap(): + """A request below the cap keeps its requested maxTokens.""" + tool = MCPStdioTool( + name="test_tool", + command="python", + sampling_approval_callback=_approve, + sampling_max_tokens=512, + ) + mock_chat_client = AsyncMock() + mock_chat_client.get_response.return_value = _make_sampling_response() + tool.client = mock_chat_client + + params = Mock() + params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))] + params.temperature = None + params.maxTokens = 100 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + options = mock_chat_client.get_response.call_args.kwargs.get("options") or {} + assert options["max_tokens"] == 100 + + +async def test_mcp_tool_sampling_callback_rate_limited(): + """Sampling requests beyond sampling_max_requests are rejected per session.""" + tool = MCPStdioTool( + name="test_tool", + command="python", + sampling_approval_callback=_approve, + sampling_max_requests=2, + ) + mock_chat_client = AsyncMock() + mock_chat_client.get_response.return_value = _make_sampling_response() + tool.client = mock_chat_client + + def make_params() -> Mock: + params = Mock() + params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))] + params.temperature = None + params.maxTokens = 100 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + return params + + first = await tool.sampling_callback(Mock(), make_params()) + second = await tool.sampling_callback(Mock(), make_params()) + third = await tool.sampling_callback(Mock(), make_params()) + + assert isinstance(first, types.CreateMessageResult) + assert isinstance(second, types.CreateMessageResult) + assert isinstance(third, types.ErrorData) + assert third.code == types.INVALID_REQUEST + assert "rate limit" in third.message.lower() + assert mock_chat_client.get_response.await_count == 2 + + # The counter resets on a session reset. + tool._reset_session_state() + fourth = await tool.sampling_callback(Mock(), make_params()) + assert isinstance(fourth, types.CreateMessageResult) + + async def test_mcp_tool_sampling_callback_chat_client_exception(): """Test sampling callback when chat client raises exception.""" - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) # Mock chat client that raises exception mock_chat_client = AsyncMock() @@ -1846,7 +2103,7 @@ async def test_mcp_tool_sampling_callback_chat_client_exception(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = None params.tools = None @@ -1863,7 +2120,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): """Test sampling callback when response has no valid content types.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) # Mock chat client with response containing only invalid content types mock_chat_client = AsyncMock() @@ -1892,7 +2149,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = None params.tools = None @@ -1905,18 +2162,18 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): assert "Failed to get right content types from the response." in result.message mock_chat_client.get_response.assert_awaited_once() _, kwargs = mock_chat_client.get_response.await_args - assert kwargs["options"] == {"max_tokens": None} + assert kwargs["options"] == {"max_tokens": 100} async def test_mcp_tool_sampling_callback_no_response_and_successful_message_creation(): """Test sampling callback when the chat client returns no response and then valid content.""" - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) tool.client = AsyncMock() params = Mock() params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = None params.tools = None @@ -1955,7 +2212,7 @@ async def test_mcp_tool_sampling_callback_forwards_system_prompt(): """Test sampling callback passes systemPrompt as instructions in options.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -1972,7 +2229,7 @@ async def test_mcp_tool_sampling_callback_forwards_system_prompt(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = "You are a helpful assistant" params.tools = None @@ -1990,7 +2247,7 @@ async def test_mcp_tool_sampling_callback_forwards_tools(): """Test sampling callback converts MCP tools to FunctionTools and passes them in options.""" from agent_framework import FunctionTool, Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -2013,7 +2270,7 @@ async def test_mcp_tool_sampling_callback_forwards_tools(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = None params.tools = [mcp_tool] @@ -2036,7 +2293,7 @@ async def test_mcp_tool_sampling_callback_forwards_tool_choice(): """Test sampling callback passes toolChoice mode in options.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -2053,7 +2310,7 @@ async def test_mcp_tool_sampling_callback_forwards_tool_choice(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = None params.tools = None @@ -2071,7 +2328,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt(): """Test sampling callback forwards empty string systemPrompt as instructions.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -2088,7 +2345,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = "" params.tools = None @@ -2106,7 +2363,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_tools_list(): """Test sampling callback forwards empty tools list in options.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -2123,7 +2380,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_tools_list(): mock_message.content.text = "Test question" params.messages = [mock_message] params.temperature = None - params.maxTokens = None + params.maxTokens = 100 params.stopSequences = None params.systemPrompt = None params.tools = [] @@ -2141,7 +2398,7 @@ async def test_mcp_tool_sampling_callback_forwards_generation_params_in_options( """Test sampling callback passes temperature, max_tokens, and stop in options.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -2182,7 +2439,7 @@ async def test_mcp_tool_sampling_callback_omits_temperature_when_none(): """Test sampling callback does not set temperature in options when it is None.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() @@ -2219,7 +2476,7 @@ async def test_mcp_tool_sampling_callback_always_passes_max_tokens(): """Test sampling callback always sets max_tokens in options since maxTokens is a required int field.""" from agent_framework import Message - tool = MCPStdioTool(name="test_tool", command="python") + tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve) mock_chat_client = AsyncMock() mock_response = Mock() diff --git a/python/packages/core/tests/core/test_mcp_observability.py b/python/packages/core/tests/core/test_mcp_observability.py index 8b17f3dc88..e32bce1b02 100644 --- a/python/packages/core/tests/core/test_mcp_observability.py +++ b/python/packages/core/tests/core/test_mcp_observability.py @@ -76,6 +76,7 @@ def _make_call_tool_result(text: str = "result", is_error: bool = False) -> Mock result = Mock() result.isError = is_error result.content = [types.TextContent(type="text", text=text)] + result.structuredContent = None return result diff --git a/python/packages/foundry/pyproject.toml b/python/packages/foundry/pyproject.toml index ab7b4d1dd6..f9a0788273 100644 --- a/python/packages/foundry/pyproject.toml +++ b/python/packages/foundry/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.8.0" +version = "1.8.1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,8 +23,8 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.8.0,<2", - "agent-framework-openai>=1.8.0,<2", + "agent-framework-core>=1.8.1,<2", + "agent-framework-openai>=1.8.1,<2", "azure-ai-inference>=1.0.0b9,<1.0.0b10", "azure-ai-projects>=2.2.0,<3.0", ] diff --git a/python/packages/foundry_hosting/pyproject.toml b/python/packages/foundry_hosting/pyproject.toml index 82246af024..59cc347d13 100644 --- a/python/packages/foundry_hosting/pyproject.toml +++ b/python/packages/foundry_hosting/pyproject.toml @@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0a260604" +version = "1.0.0a260609" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.8.0,<2", + "agent-framework-core>=1.8.1,<2", "azure-ai-agentserver-core>=2.0.0b3,<3", "azure-ai-agentserver-responses>=1.0.0b7,<2", "azure-ai-agentserver-invocations>=1.0.0b3,<2", diff --git a/python/packages/gemini/pyproject.toml b/python/packages/gemini/pyproject.toml index 9a8ad94723..5262170575 100644 --- a/python/packages/gemini/pyproject.toml +++ b/python/packages/gemini/pyproject.toml @@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0a260521" +version = "1.0.0a260609" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.6.0,<2.0", + "agent-framework-core>=1.8.1,<2.0", "google-genai>=1.65.0,<2.0.0", ] diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py index 03e3c2269c..484d056a0d 100644 --- a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py @@ -978,6 +978,10 @@ async def test_sandbox_code_failure_returns_nonzero_exit(restored_sandbox) -> No @skip_if_hyperlight_integration_tests_disabled +@pytest.mark.skipif( + sys.platform == "win32" and sys.version_info < (3, 11), + reason="Hyperlight sandbox snapshot/restore crashes on Windows Python 3.10.", +) async def test_sandbox_snapshot_restore_keeps_sandbox_functional(restored_sandbox) -> None: """Verify snapshot/restore cycle leaves the sandbox in a working state.""" # Mutate the sandbox diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index d7db0a2b95..77f934c892 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260521" +version = "1.0.0b260609" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.6.0,<2", + "agent-framework-core>=1.8.1,<2", "mem0ai>=1.0.0,<2", ] diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index ce44efeabd..46d7cc1649 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.8.0" +version = "1.8.1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.8.0,<2", + "agent-framework-core>=1.8.1,<2", "openai>=1.99.0,<3", ] diff --git a/python/packages/purview/README.md b/python/packages/purview/README.md index a802cd9615..0a78e07605 100644 --- a/python/packages/purview/README.md +++ b/python/packages/purview/README.md @@ -320,4 +320,5 @@ except (PurviewAuthenticationError, PurviewRateLimitError, PurviewRequestError, - **Streaming Responses**: Post-response policy evaluation presently applies only to non-streaming chat responses. - **Error Handling**: Use `ignore_exceptions` and `ignore_payment_required` settings for graceful degradation. When enabled, errors are logged but don't fail the request. - **Caching**: Protection scopes responses and 402 errors are cached by default with a 4-hour TTL. Cache is automatically invalidated when protection scope state changes. +- **Cold-cache parallelization**: On a `ProtectionScopes` cache miss, scopes are refreshed in the background while `ProcessContent` runs in the foreground. - **Background Processing**: Content Activities and offline Process Content requests are handled asynchronously using background tasks to avoid blocking the main execution flow. diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index 241de80d61..eb949287fd 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -231,18 +231,19 @@ class ScopedContentProcessor: cached_ps_resp = await self._cache.get(cache_key) if cached_ps_resp is not None and isinstance(cached_ps_resp, ProtectionScopesResponse): - ps_resp = cached_ps_resp - else: - ttl = self._settings.get("cache_ttl_seconds") - ttl_seconds = ttl if ttl is not None else 14400 - try: - ps_resp = await self._client.get_protection_scopes(ps_req) - await self._cache.set(cache_key, ps_resp, ttl_seconds=ttl_seconds) - except PurviewPaymentRequiredError as ex: - # Cache the exception at tenant level so all subsequent requests for this tenant fail fast - await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=ttl_seconds) - raise + return await self._process_with_cached_scopes(pc_request, cached_ps_resp, cache_key) + task = asyncio.create_task(self._refresh_protection_scopes_background(ps_req, cache_key, pc_request)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + return await self._call_process_content(pc_request, cache_key, dlp_actions=[]) + + async def _process_with_cached_scopes( + self, + pc_request: ProcessContentRequest, + ps_resp: ProtectionScopesResponse, + cache_key: str, + ) -> ProcessContentResponse: if ps_resp.scope_identifier: pc_request.scope_identifier = ps_resp.scope_identifier @@ -259,13 +260,7 @@ class ScopedContentProcessor: task.add_done_callback(self._background_tasks.discard) return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id) - pc_resp = await self._client.process_content(pc_request) - - if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED: - await self._cache.remove(cache_key) - - pc_resp.policy_actions = self._combine_policy_actions(pc_resp.policy_actions, dlp_actions) - return pc_resp + return await self._call_process_content(pc_request, cache_key, dlp_actions=dlp_actions) # No applicable scopes - send content activities in background ca_req = ContentActivitiesRequest( @@ -281,12 +276,52 @@ class ScopedContentProcessor: # Respond with HttpStatusCode 204(No Content) return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id) + async def _call_process_content( + self, + pc_request: ProcessContentRequest, + cache_key: str, + dlp_actions: list[DlpActionInfo], + ) -> ProcessContentResponse: + pc_resp = await self._client.process_content(pc_request) + + if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED: + await self._cache.remove(cache_key) + + if dlp_actions: + pc_resp.policy_actions = self._combine_policy_actions(pc_resp.policy_actions, dlp_actions) + return pc_resp + + async def _refresh_protection_scopes_background( + self, ps_req: ProtectionScopesRequest, cache_key: str, pc_request: ProcessContentRequest + ) -> None: + """Fetch protection scopes and warm the cache without blocking the foreground call.""" + ttl = self._settings.get("cache_ttl_seconds") + ttl_seconds = ttl if ttl is not None else 14400 + try: + ps_resp = await self._client.get_protection_scopes(ps_req) + await self._cache.set(cache_key, ps_resp, ttl_seconds=ttl_seconds) + should_process, _, _ = self._check_applicable_scopes(pc_request, ps_resp) + if not should_process: + ca_req = ContentActivitiesRequest( + user_id=pc_request.user_id, + tenant_id=pc_request.tenant_id, + content_to_process=pc_request.content_to_process, + correlation_id=pc_request.correlation_id, + ) + await self._send_content_activities_background(ca_req) + except PurviewPaymentRequiredError as ex: + tenant_payment_cache_key = f"purview:payment_required:{ps_req.tenant_id}" + await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=ttl_seconds) + logger.warning("Background protection scopes refresh failed with payment required: %s", ex) + except Exception as ex: + logger.warning("Background protection scopes refresh failed: %s", ex) + async def _process_content_background(self, pc_request: ProcessContentRequest, cache_key: str) -> None: """Process content in background for offline execution mode.""" try: pc_resp = await self._client.process_content(pc_request) - # If protection scope state is modified, make another PC request and invalidate cache + # If protection scopes changed, invalidate cache and retry once. if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED: await self._cache.remove(cache_key) await self._client.process_content(pc_request) @@ -306,14 +341,10 @@ class ScopedContentProcessor: def _combine_policy_actions( existing: list[DlpActionInfo] | None, new_actions: list[DlpActionInfo] ) -> list[DlpActionInfo]: - by_key: dict[str, DlpActionInfo] = {} - for a in existing or []: - if a.action: - by_key[a.action] = a - for a in new_actions: - if a.action: - by_key[a.action] = a - return list(by_key.values()) + combined: dict[tuple[DlpAction | None, RestrictionAction | None], DlpActionInfo] = {} + for action_info in (existing or []) + new_actions: + combined.setdefault((action_info.action, action_info.restriction_action), action_info) + return list(combined.values()) @staticmethod def _check_applicable_scopes( diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index 285fb338d8..0cc9d7a8a9 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -2,6 +2,7 @@ """Tests for Purview processor.""" +import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -217,10 +218,38 @@ class TestScopedContentProcessor: assert action1 in combined assert action2 in combined + async def test_combine_policy_actions_preserves_restriction_only_actions( + self, processor: ScopedContentProcessor + ) -> None: + """Test _combine_policy_actions keeps actions that only set restrictionAction.""" + existing_action = DlpActionInfo(action=DlpAction.OTHER, restrictionAction=RestrictionAction.OTHER) + restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK) + + combined = processor._combine_policy_actions([existing_action], [restriction_only_action]) + + assert combined == [existing_action, restriction_only_action] + + async def test_combine_policy_actions_deduplicates_by_action_and_restriction( + self, processor: ScopedContentProcessor + ) -> None: + """Test _combine_policy_actions removes exact duplicate actions.""" + block_action = DlpActionInfo(action=DlpAction.BLOCK_ACCESS, restriction_action=RestrictionAction.BLOCK) + duplicate_block_action = DlpActionInfo( + action=DlpAction.BLOCK_ACCESS, restriction_action=RestrictionAction.BLOCK + ) + restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK) + + combined = processor._combine_policy_actions( + [block_action], + [duplicate_block_action, restriction_only_action], + ) + + assert combined == [block_action, restriction_only_action] + async def test_process_with_scopes_calls_client_methods( self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory ) -> None: - """Test _process_with_scopes calls get_protection_scopes when scopes response is empty.""" + """Test _process_with_scopes calls process_content immediately and warms scopes in background on cache miss.""" from agent_framework_purview._models import ( ContentActivitiesResponse, ProtectionScopesResponse, @@ -236,38 +265,91 @@ class TestScopedContentProcessor: response = await processor._process_with_scopes(request) - mock_client.get_protection_scopes.assert_called_once() - # When no scopes apply, process_content is not called (activities are sent in background) - mock_client.process_content.assert_not_called() - # The response should have id=204 (No Content) when no scopes apply - assert response.id == "204" + # On cache miss, ProcessContent runs in the foreground and the response is returned. + assert response.id == "response-123" + mock_client.process_content.assert_called_once() - async def test_process_with_scopes_ignores_unexpected_cached_value_type( + # Protection scopes are refreshed in a background task. + await asyncio.gather(*list(processor._background_tasks)) + mock_client.get_protection_scopes.assert_called_once() + mock_client.send_content_activities.assert_called_once() + + async def test_process_with_scopes_preserves_restriction_only_policy_actions( self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory ) -> None: - """Test that a corrupted cache entry does not crash processing.""" + """Test cold-cache ProcessContent actions are not dropped when they only contain restrictionAction.""" + from agent_framework_purview._models import ProtectionScopesResponse + + request = process_content_request_factory() + restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK) + + mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": []})) + mock_client.process_content = AsyncMock( + return_value=ProcessContentResponse( + id="response-123", + protection_scope_state="notModified", + policy_actions=[restriction_only_action], + ) + ) + + response = await processor._process_with_scopes(request) + + assert response.policy_actions == [restriction_only_action] + await asyncio.gather(*list(processor._background_tasks)) + + async def test_process_with_cached_scopes_preserves_restriction_only_policy_actions( + self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory + ) -> None: + """Test cached ProtectionScopes actions are not dropped when they only contain restrictionAction.""" from agent_framework_purview._models import ( ExecutionMode, PolicyLocation, PolicyScope, - ProcessContentResponse, ProtectionScopeActivities, ProtectionScopesResponse, ) request = process_content_request_factory() + restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK) + process_content_action = DlpActionInfo(action=DlpAction.OTHER, restriction_action=RestrictionAction.OTHER) + scope_location = PolicyLocation( + data_type="microsoft.graph.policyLocationApplication", + value="app-id", + ) + scope = PolicyScope( + activities=ProtectionScopeActivities.UPLOAD_TEXT, + locations=[scope_location], + policy_actions=[restriction_only_action], + execution_mode=ExecutionMode.EVALUATE_INLINE, + ) - # Return a valid, inline scope so we stay on the normal (non-background) path. - scope_location = PolicyLocation(**{ - "@odata.type": "microsoft.graph.policyLocationApplication", - "value": "app-id", - }) - scope = PolicyScope(**{ - "activities": ProtectionScopeActivities.UPLOAD_TEXT, - "locations": [scope_location], - "execution_mode": ExecutionMode.EVALUATE_INLINE, - }) - mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": [scope]})) + processor._cache.get = AsyncMock( + side_effect=[ + None, + ProtectionScopesResponse(scope_identifier="scope-123", scopes=[scope]), + ] + ) # type: ignore[method-assign] + mock_client.process_content = AsyncMock( + return_value=ProcessContentResponse( + id="response-123", + protection_scope_state="notModified", + policy_actions=[process_content_action], + ) + ) + + response = await processor._process_with_scopes(request) + + assert response.policy_actions == [process_content_action, restriction_only_action] + + async def test_process_with_scopes_ignores_unexpected_cached_value_type( + self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory + ) -> None: + """Test that a corrupted cache entry does not crash processing.""" + from agent_framework_purview._models import ProtectionScopesResponse + + request = process_content_request_factory() + + mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": []})) mock_client.process_content = AsyncMock( return_value=ProcessContentResponse(**{"id": "ok", "protectionScopeState": "notModified"}) ) @@ -279,8 +361,9 @@ class TestScopedContentProcessor: response = await processor._process_with_scopes(request) assert response.id == "ok" - mock_client.get_protection_scopes.assert_called_once() mock_client.process_content.assert_called_once() + await asyncio.gather(*list(processor._background_tasks)) + mock_client.get_protection_scopes.assert_called_once() async def test_process_with_scopes_uses_tenant_payment_exception_cache( self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory @@ -301,8 +384,6 @@ class TestScopedContentProcessor: self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory ) -> None: """Test offline background processing invalidates cache and retries when scope state changes.""" - from agent_framework_purview._models import ProcessContentResponse - request = process_content_request_factory() request.scope_identifier = "etag-1" @@ -319,6 +400,36 @@ class TestScopedContentProcessor: processor._cache.remove.assert_called_once_with("purview:protection_scopes:abc") assert mock_client.process_content.call_count == 2 + async def test_background_scope_refresh_caches_payment_required( + self, mock_client: AsyncMock, process_content_request_factory + ) -> None: + """402 raised during background scope refresh is cached at the tenant level.""" + from agent_framework_purview._cache import InMemoryCacheProvider + from agent_framework_purview._exceptions import PurviewPaymentRequiredError + + settings = PurviewSettings( + app_name="Test App", + tenant_id="12345678-1234-1234-1234-123456789012", + purview_app_location=PurviewAppLocation( + location_type=PurviewLocationType.APPLICATION, location_value="app-id" + ), + ) + + cache = InMemoryCacheProvider() + processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache) + + mock_client.get_protection_scopes = AsyncMock(side_effect=PurviewPaymentRequiredError("nope")) + mock_client.process_content = AsyncMock( + return_value=ProcessContentResponse(**{"id": "pc-1", "protectionScopeState": "notModified"}) + ) + + request = process_content_request_factory() + await processor._process_with_scopes(request) + await asyncio.gather(*list(processor._background_tasks)) + + cached = await cache.get(f"purview:payment_required:{request.tenant_id}") + assert isinstance(cached, PurviewPaymentRequiredError) + async def test_map_messages_with_user_id_in_additional_properties(self, mock_client: AsyncMock) -> None: """Test user_id extraction from message additional_properties.""" settings = PurviewSettings( @@ -387,6 +498,8 @@ class TestScopedContentProcessor: self, mock_client: AsyncMock, process_content_request_factory ) -> None: """Test that response is returned when scopes don't apply (activities sent in background).""" + from agent_framework_purview._models import ProtectionScopesResponse + settings = PurviewSettings( app_name="Test App", tenant_id="12345678-1234-1234-1234-123456789012", @@ -398,10 +511,8 @@ class TestScopedContentProcessor: pc_request = process_content_request_factory() - # Mock get_protection_scopes to return no applicable scopes - mock_ps_response = MagicMock() - mock_ps_response.scopes = [] - mock_client.get_protection_scopes.return_value = mock_ps_response + mock_ps_response = ProtectionScopesResponse(scopes=[]) + processor._cache.get = AsyncMock(side_effect=[None, mock_ps_response]) # type: ignore[method-assign] # Mock send_content_activities to return success (called in background) mock_ca_response = MagicMock() @@ -410,8 +521,10 @@ class TestScopedContentProcessor: response = await processor._process_with_scopes(pc_request) - mock_client.get_protection_scopes.assert_called_once() + mock_client.get_protection_scopes.assert_not_called() mock_client.process_content.assert_not_called() + await asyncio.gather(*list(processor._background_tasks)) + mock_client.send_content_activities.assert_called_once() # Response should have id=204 when no scopes apply assert response.id == "204" @@ -419,6 +532,8 @@ class TestScopedContentProcessor: self, mock_client: AsyncMock, process_content_request_factory ) -> None: """Test that errors in background activities don't affect the response.""" + from agent_framework_purview._models import ProtectionScopesResponse + settings = PurviewSettings( app_name="Test App", tenant_id="12345678-1234-1234-1234-123456789012", @@ -430,10 +545,8 @@ class TestScopedContentProcessor: pc_request = process_content_request_factory() - # Mock get_protection_scopes to return no applicable scopes - mock_ps_response = MagicMock() - mock_ps_response.scopes = [] - mock_client.get_protection_scopes.return_value = mock_ps_response + mock_ps_response = ProtectionScopesResponse(scopes=[]) + processor._cache.get = AsyncMock(side_effect=[None, mock_ps_response]) # type: ignore[method-assign] # Mock send_content_activities to return error (called in background task) mock_ca_response = MagicMock() @@ -445,6 +558,8 @@ class TestScopedContentProcessor: # Since activities are sent in background, errors don't affect the response # Response should have id=204 when no scopes apply assert response.id == "204" + await asyncio.gather(*list(processor._background_tasks)) + mock_client.send_content_activities.assert_called_once() class TestUserIdResolution: @@ -656,10 +771,12 @@ class TestScopedContentProcessorCaching: mock_client.get_protection_scopes.return_value = ProtectionScopesResponse( scope_identifier="scope-123", scopes=[] ) + mock_client.process_content.return_value = ProcessContentResponse(id="ok", protection_scope_state="notModified") messages = [Message(role="user", contents=["Test"])] await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012") + await asyncio.gather(*list(processor._background_tasks)) mock_client.get_protection_scopes.assert_called_once() @@ -670,7 +787,7 @@ class TestScopedContentProcessorCaching: async def test_payment_required_exception_cached_at_tenant_level( self, mock_client: AsyncMock, settings: PurviewSettings ) -> None: - """Test that 402 payment required exceptions are cached at tenant level.""" + """Test that background scope 402 returns once, then throws from the tenant-level cache.""" from agent_framework_purview._cache import InMemoryCacheProvider from agent_framework_purview._exceptions import PurviewPaymentRequiredError @@ -678,13 +795,12 @@ class TestScopedContentProcessorCaching: processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache_provider) mock_client.get_protection_scopes.side_effect = PurviewPaymentRequiredError("Payment required") + mock_client.process_content.return_value = ProcessContentResponse(id="ok", protection_scope_state="notModified") messages = [Message(role="user", contents=["Test"])] - with pytest.raises(PurviewPaymentRequiredError): - await processor.process_messages( - messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012" - ) + await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012") + await asyncio.gather(*list(processor._background_tasks)) mock_client.get_protection_scopes.assert_called_once() diff --git a/python/pyproject.toml b/python/pyproject.toml index 1cdeec2282..0a4e6f34a9 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.8.0" +version = "1.8.1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core[all]==1.8.0", + "agent-framework-core[all]==1.8.1", ] [dependency-groups] diff --git a/python/samples/02-agents/harness/README.md b/python/samples/02-agents/harness/README.md index 3bf0f09110..e162c1ff45 100644 --- a/python/samples/02-agents/harness/README.md +++ b/python/samples/02-agents/harness/README.md @@ -17,6 +17,7 @@ from a chat client. | AgentModeProvider | Plan/execute mode tracking | | MemoryContextProvider | File-based durable memory (when `memory_store` provided) | | SkillsProvider | File-based skill discovery and progressive loading | +| Shell tool | Shell command execution + environment probing (when `shell_executor` provided) | | OpenTelemetry | Built-in observability | Each feature can be disabled or customized via keyword arguments. @@ -45,13 +46,23 @@ python samples/02-agents/harness/harness_research.py ### Minimal Setup -`create_harness_agent` requires only a chat client and token budget parameters: +`create_harness_agent` requires only a chat client: ```python from agent_framework import create_harness_agent from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential +agent = create_harness_agent( + client=FoundryChatClient(credential=AzureCliCredential()), +) +``` + +### With Compaction + +Provide token budget parameters to enable automatic context-window compaction: + +```python agent = create_harness_agent( client=FoundryChatClient(credential=AzureCliCredential()), max_context_window_tokens=128_000, @@ -59,7 +70,7 @@ agent = create_harness_agent( ) ``` -### Customization +### Further Customization Disable or customize any feature: @@ -81,3 +92,25 @@ agent = create_harness_agent( The `AgentModeProvider` enables a two-phase workflow: 1. **Plan mode** — Interactive: the agent asks questions, creates todos, gets approval 2. **Execute mode** — Autonomous: the agent works through todos independently + +### Shell Tool + +Pass a shell executor (e.g. `LocalShellTool` from `agent-framework-tools`) to enable shell +command execution plus automatic environment probing via a `ShellEnvironmentProvider`. The +tool is only wired when the chat client supports shell tools; otherwise a warning is logged +and the shell tool/provider are skipped. The caller owns the executor's lifecycle. + +```python +from agent_framework_tools.shell import LocalShellTool, ShellEnvironmentProviderOptions + +async with LocalShellTool(acknowledge_unsafe=True) as shell: + agent = create_harness_agent( + client=client, + max_context_window_tokens=128_000, + max_output_tokens=16_384, + shell_executor=shell, + # Optional: customize environment probing. + shell_environment_provider_options=ShellEnvironmentProviderOptions(probe_tools=("git", "python")), + ) +``` + diff --git a/python/samples/02-agents/harness/console/agent_runner.py b/python/samples/02-agents/harness/console/agent_runner.py index 3b7c685dbd..743ad1e132 100644 --- a/python/samples/02-agents/harness/console/agent_runner.py +++ b/python/samples/02-agents/harness/console/agent_runner.py @@ -313,9 +313,7 @@ class HarnessAgentRunner: """ actions: list[FollowUpAction] = [] for observer in self._observers: - observer_actions = await observer.on_stream_complete( - self._ux, self._agent, session - ) + observer_actions = await observer.on_stream_complete(self._ux, self._agent, session) if observer_actions: actions.extend(observer_actions) return actions diff --git a/python/samples/02-agents/harness/console/app.py b/python/samples/02-agents/harness/console/app.py index c56360c661..e2260eb200 100644 --- a/python/samples/02-agents/harness/console/app.py +++ b/python/samples/02-agents/harness/console/app.py @@ -182,18 +182,12 @@ class HarnessApp(App[None]): if command_handlers is None: from .commands import build_default_command_handlers - self._command_handlers = build_default_command_handlers( - agent, mode_colors=mode_colors - ) + self._command_handlers = build_default_command_handlers(agent, mode_colors=mode_colors) else: self._command_handlers = command_handlers # Compute help text from command handlers - help_parts = [ - h.get_help_text() - for h in self._command_handlers - if h.get_help_text() is not None - ] + help_parts = [h.get_help_text() for h in self._command_handlers if h.get_help_text() is not None] help_text = ", ".join(help_parts) if help_parts else None # State and driver diff --git a/python/samples/02-agents/harness/console/commands/todo_handler.py b/python/samples/02-agents/harness/console/commands/todo_handler.py index 73703e6db3..e32ffd3f6a 100644 --- a/python/samples/02-agents/harness/console/commands/todo_handler.py +++ b/python/samples/02-agents/harness/console/commands/todo_handler.py @@ -45,9 +45,7 @@ class TodoCommandHandler(CommandHandler): ux.append_info_line("TodoProvider is not available.") return True - todos = await self._todo_provider.store.load_items( - session, source_id=self._todo_provider.source_id - ) + todos = await self._todo_provider.store.load_items(session, source_id=self._todo_provider.source_id) if not todos: ux.append_info_line("No todos yet.") diff --git a/python/samples/02-agents/harness/console/components/scroll_panel.py b/python/samples/02-agents/harness/console/components/scroll_panel.py index a9cf15a774..35b478b54b 100644 --- a/python/samples/02-agents/harness/console/components/scroll_panel.py +++ b/python/samples/02-agents/harness/console/components/scroll_panel.py @@ -72,7 +72,7 @@ class HarnessScrollPanel(RichLog): # Truncate lines back to where streaming started if len(self.lines) > self._streaming_line_start: - del self.lines[self._streaming_line_start:] + del self.lines[self._streaming_line_start :] from textual.geometry import Size self.virtual_size = Size(self._widest_line_width, len(self.lines)) diff --git a/python/samples/02-agents/harness/console/observers/planning_models.py b/python/samples/02-agents/harness/console/observers/planning_models.py index 9b4a92e575..d4c425b078 100644 --- a/python/samples/02-agents/harness/console/observers/planning_models.py +++ b/python/samples/02-agents/harness/console/observers/planning_models.py @@ -41,8 +41,7 @@ class PlanningQuestion(BaseModel): choices: list[str] | None = Field( default=None, description=( - "For clarifications, this has a list of options that the user can " - "choose from. null for approvals." + "For clarifications, this has a list of options that the user can choose from. null for approvals." ), ) diff --git a/python/samples/02-agents/mcp/README.md b/python/samples/02-agents/mcp/README.md index de57286320..53af7d31a8 100644 --- a/python/samples/02-agents/mcp/README.md +++ b/python/samples/02-agents/mcp/README.md @@ -14,6 +14,7 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to | **API Key Authentication** | [`mcp_api_key_auth.py`](mcp_api_key_auth.py) | Demonstrates API key authentication with MCP servers using `header_provider`, runtime invocation kwargs, and a command-line API key argument | | **GitHub Integration with PAT** | [`mcp_github_pat.py`](mcp_github_pat.py) | Demonstrates connecting to GitHub's MCP server using Personal Access Token (PAT) authentication | | **Long-Running Task** | [`mcp_long_running_task.py`](mcp_long_running_task.py) | Demonstrates transparent SEP-2663 long-running task handling for MCP tools that advertise `taskSupport=required`. Self-spawns a stdio MCP child server | +| **Sampling Approval** | [`mcp_sampling_approval.py`](mcp_sampling_approval.py) | Demonstrates gating server-initiated `sampling/createMessage` requests with a `sampling_approval_callback`, plus the `sampling_max_tokens` and `sampling_max_requests` guardrails. MCP sampling is denied by default | ## Prerequisites diff --git a/python/samples/02-agents/mcp/mcp_sampling_approval.py b/python/samples/02-agents/mcp/mcp_sampling_approval.py new file mode 100644 index 0000000000..0d359b7aec --- /dev/null +++ b/python/samples/02-agents/mcp/mcp_sampling_approval.py @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent, MCPStreamableHTTPTool +from agent_framework.openai import OpenAIChatClient +from dotenv import load_dotenv +from mcp import types + +# Load environment variables from .env file +load_dotenv() + +""" +MCP Sampling Approval Example + +MCP servers can send the client a ``sampling/createMessage`` request, asking the +client to run an LLM completion on the server's behalf. Because remote MCP +servers are untrusted third parties, forwarding these server-controlled prompts +to your chat client without review is a confused-deputy risk: a malicious server +could exfiltrate context, force tool calls, or burn through your token budget. + +For that reason Agent Framework **denies MCP sampling by default**. To allow it, +pass a ``sampling_approval_callback`` to the MCP tool. The callback receives the +raw ``CreateMessageRequestParams`` and returns ``True`` to approve or ``False`` +to deny. It may be synchronous or asynchronous, so you can implement a +human-in-the-loop prompt, a policy check, or an audit log. + +Two further guardrails apply to approved requests: +- ``sampling_max_tokens`` caps the server-requested ``maxTokens``. +- ``sampling_max_requests`` limits how many sampling requests a single session + may make. + +To restore the legacy "always approve" behavior (only do this for servers you +trust), pass ``sampling_approval_callback=lambda params: True``. +""" + + +async def approve_sampling(params: types.CreateMessageRequestParams) -> bool: + """Human-in-the-loop approval gate for server-initiated sampling. + + Shows the server-supplied system prompt and messages, then asks the user to + approve or deny. Returning ``False`` rejects the request. + """ + print("\n--- MCP server requested a sampling/createMessage ---") + if params.systemPrompt: + print(f"System prompt: {params.systemPrompt}") + for message in params.messages: + text = getattr(message.content, "text", message.content) + print(f"{message.role}: {text}") + answer = await asyncio.to_thread(input, "Approve this sampling request? [y/N]: ") + return answer.strip().lower() in {"y", "yes"} + + +async def main() -> None: + """Run an agent against an MCP server with a sampling approval gate.""" + async with Agent( + client=OpenAIChatClient(), + name="Agent", + instructions="You are a helpful assistant. Use your MCP tool when answering the user's question.", + tools=MCPStreamableHTTPTool( + name="MCP tool", + description="MCP tool description.", + url="", + # Passing ``client`` enables sampling; the approval callback gates it. + client=OpenAIChatClient(), + sampling_approval_callback=approve_sampling, + sampling_max_tokens=2048, + sampling_max_requests=5, + ), + ) as agent: + query = "Use your MCP tool to help answer this question." + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result.text}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/tools/README.md b/python/samples/02-agents/tools/README.md index ad07d86ecd..b24fe2223b 100644 --- a/python/samples/02-agents/tools/README.md +++ b/python/samples/02-agents/tools/README.md @@ -22,6 +22,7 @@ injection, and dynamic (progressive) tool exposure. |------|--------------| | [`function_tool_with_approval.py`](function_tool_with_approval.py) | Requiring human approval before a tool runs. | | [`function_tool_with_approval_and_sessions.py`](function_tool_with_approval_and_sessions.py) | Tool approvals combined with sessions. | +| [`tool_approval_middleware.py`](tool_approval_middleware.py) | Session-backed approval coordination, mixed-batch approvals, and "always approve" rules. | | [`function_invocation_configuration.py`](function_invocation_configuration.py) | Configuring function-invocation settings (e.g. max iterations). | | [`control_total_tool_executions.py`](control_total_tool_executions.py) | All the ways to cap how many times tools run. | | [`function_tool_with_max_invocations.py`](function_tool_with_max_invocations.py) | Limiting the number of invocations per tool. | diff --git a/python/samples/02-agents/tools/tool_approval_middleware.py b/python/samples/02-agents/tools/tool_approval_middleware.py new file mode 100644 index 0000000000..5b8dc4fb42 --- /dev/null +++ b/python/samples/02-agents/tools/tool_approval_middleware.py @@ -0,0 +1,191 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Annotated + +from agent_framework import ( + Agent, + AgentResponse, + AgentSession, + Content, + Message, + ToolApprovalMiddleware, + create_always_approve_tool_response, + create_always_approve_tool_with_arguments_response, + tool, +) +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +""" +This sample demonstrates how a host application can decide which approval +requests may run now, which must be rejected, and which can be remembered for +future runs. + +The model may not request every tool on every run. The important part is the +approval mechanism: + +1. Tools that are safe to run immediately use ``approval_mode="never_require"``. +2. Sensitive tools use ``approval_mode="always_require"``. +3. ``ToolApprovalMiddleware`` coordinates approval prompts and standing rules. +4. The host turns user policy into ``function_approval_response`` content: + - approve for this request only; + - reject for this request; + - approve and remember the tool for future requests; + - approve and remember the tool only when called again with the same arguments. +5. Heuristic auto-approval rules can approve low-risk function calls before + the user is prompted. +""" + +# Load environment variables from .env file +load_dotenv() + + +@tool(approval_mode="never_require") +def lookup_ticket(ticket_id: Annotated[str, "Support ticket id, for example T-123"]) -> str: + """Look up a support ticket. This read-only tool runs without approval.""" + return f"Ticket {ticket_id}: customer confirmed the issue can be closed." + + +@tool(approval_mode="always_require") +def close_ticket( + ticket_id: Annotated[str, "Support ticket id, for example T-123"], + resolution: Annotated[str, "Short resolution text"], +) -> str: + """Close a support ticket.""" + return f"Ticket {ticket_id} closed with resolution: {resolution}" + + +@tool(approval_mode="always_require") +def notify_customer( + ticket_id: Annotated[str, "Support ticket id, for example T-123"], + message: Annotated[str, "Message to send to the customer"], +) -> str: + """Notify the customer about a ticket update.""" + return f"Customer notified for {ticket_id}: {message}" + + +@tool(approval_mode="always_require") +def add_internal_note( + ticket_id: Annotated[str, "Support ticket id, for example T-123"], + note: Annotated[str, "Internal note text"], +) -> str: + """Add an internal note to a support ticket.""" + return f"Internal note added to {ticket_id}: {note}" + + +@tool(approval_mode="always_require") +def delete_attachment( + ticket_id: Annotated[str, "Support ticket id, for example T-123"], + attachment_name: Annotated[str, "Attachment file name"], +) -> str: + """Delete an attachment from a support ticket.""" + return f"Deleted {attachment_name} from ticket {ticket_id}." + + +def auto_approve_low_risk_notes(function_call: Content) -> bool: + """Heuristic rule: auto-approve short internal notes for the target ticket.""" + if function_call.name != "add_internal_note": + return False + + arguments = function_call.parse_arguments() or {} + note = str(arguments.get("note", "")) + return arguments.get("ticket_id") == "T-123" and len(note) <= 120 + + +def approval_response_for_user_policy(request: Content) -> Content: + """Convert user/host policy into an approval response for one tool request.""" + function_call = request.function_call + if function_call is None or function_call.name is None: + return request.to_function_approval_response(approved=False) + + tool_name = function_call.name + print(f"Approval requested: {tool_name}({function_call.arguments})") + + if tool_name in {"close_ticket"}: + print(f"Decision: approve and remember future {tool_name} calls with these exact arguments") + return create_always_approve_tool_with_arguments_response(request) + + if tool_name in {"notify_customer"}: + print(f"Decision: approve and remember all future {tool_name} calls") + return create_always_approve_tool_response(request) + + if tool_name in {"delete_attachment"}: + print(f"Decision: reject {tool_name} for this run") + return request.to_function_approval_response(approved=False) + + print(f"Decision: reject {tool_name}; no policy allowed it") + return request.to_function_approval_response(approved=False) + + +async def resolve_approval_requests(agent: Agent, response: AgentResponse, session: AgentSession) -> AgentResponse: + """Resolve approval prompts until the agent returns a regular answer.""" + result = response + while result.user_input_requests: + approval_responses = [approval_response_for_user_policy(request) for request in result.user_input_requests] + result = await agent.run(Message(role="user", contents=approval_responses), session=session) + return result + + +async def main() -> None: + """Run the tool approval middleware sample.""" + # 1. Create a regular chat client. + client = FoundryChatClient(credential=AzureCliCredential()) + + # 2. Create an agent with sensitive tools and opt-in ToolApprovalMiddleware. + agent = Agent( + client=client, + name="SupportAgent", + instructions=( + "You are a support agent. Use tools when useful. " + "Look up ticket T-123, close it if the customer confirmed, notify the customer, " + "add a short internal note, and do not delete attachments unless the tool is approved." + ), + tools=[lookup_ticket, close_ticket, notify_customer, add_internal_note, delete_attachment], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[auto_approve_low_risk_notes])], + ) + session = agent.create_session() + + # 3. Ask for work that may trigger a mixed batch of safe and sensitive tool calls. + query = ( + "Please process ticket T-123: check the ticket, close it as resolved, " + "notify the customer, add a short internal note, and remove debug.log if it is attached." + ) + print(f"User: {query}") + result = await agent.run(query, session=session) + + # 4. Convert approval requests into approve/reject/always-approve responses. + result = await resolve_approval_requests(agent, result, session) + print(f"Agent: {result.text}") + + # 5. Later runs can use remembered approval rules: + # - notify_customer: all future calls to the tool. + # - close_ticket: only future calls with the same arguments. + # - add_internal_note: low-risk matching calls are auto-approved by the heuristic callback. + follow_up = "Send the customer a short follow-up for ticket T-123." + print(f"\nUser: {follow_up}") + result = await agent.run(follow_up, session=session) + result = await resolve_approval_requests(agent, result, session) + print(f"Agent: {result.text}") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +User: Please process ticket T-123: check the ticket, close it as resolved, +notify the customer, add a short internal note, and remove debug.log if it is attached. +Approval requested: close_ticket({"ticket_id": "T-123", "resolution": "resolved"}) +Decision: approve and remember future close_ticket calls with these exact arguments +Approval requested: notify_customer({"ticket_id": "T-123", "message": "Your ticket has been resolved."}) +Decision: approve and remember all future notify_customer calls +Approval requested: delete_attachment({"ticket_id": "T-123", "attachment_name": "debug.log"}) +Decision: reject delete_attachment for this run +Agent: Ticket T-123 was closed, the customer was notified, and a short internal note was added. +I did not delete debug.log. + +User: Send the customer a short follow-up for ticket T-123. +Agent: The customer was sent a short follow-up for ticket T-123. +""" diff --git a/python/samples/05-end-to-end/purview_agent/README.md b/python/samples/05-end-to-end/purview_agent/README.md index 1cdb7e3ef4..12293ec306 100644 --- a/python/samples/05-end-to-end/purview_agent/README.md +++ b/python/samples/05-end-to-end/purview_agent/README.md @@ -3,7 +3,7 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluation to an Agent Framework `Agent` using the **middleware** approach. **What this sample demonstrates:** -1. Configure an Azure OpenAI chat client +1. Configure a Foundry chat client 2. Add Purview policy enforcement middleware (`PurviewPolicyMiddleware`) 3. Add Purview policy enforcement at the chat client level (`PurviewChatPolicyMiddleware`) 4. Implement a custom cache provider for advanced caching scenarios @@ -17,8 +17,8 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat | Variable | Required | Purpose | |----------|----------|---------| -| `AZURE_OPENAI_ENDPOINT` | Yes | Azure OpenAI endpoint (https://.openai.azure.com) | -| `AZURE_OPENAI_MODEL` | Optional | Model deployment name (defaults inside SDK if omitted) | +| `FOUNDRY_PROJECT_ENDPOINT` | Yes | Azure AI Foundry project endpoint, for example `https://.services.ai.azure.com/api/projects/` | +| `FOUNDRY_MODEL` | Optional | Model deployment name (defaults to `gpt-4o-mini`) | | `PURVIEW_CLIENT_APP_ID` | Yes* | Client (application) ID used for Purview authentication | | `PURVIEW_USE_CERT_AUTH` | Optional (`true`/`false`) | Switch between certificate and interactive auth | | `PURVIEW_TENANT_ID` | Yes (when cert auth on) | Tenant ID for certificate authentication | @@ -31,7 +31,8 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat Opens a browser on first run to sign in. ```powershell -$env:AZURE_OPENAI_ENDPOINT = "https://your-openai-instance.openai.azure.com" +$env:FOUNDRY_PROJECT_ENDPOINT = "https://.services.ai.azure.com/api/projects/" +$env:FOUNDRY_MODEL = "gpt-4o-mini" $env:PURVIEW_CLIENT_APP_ID = "00000000-0000-0000-0000-000000000000" ``` @@ -64,22 +65,27 @@ If interactive auth is used, a browser window will appear the first time. ## 4. How It Works -The sample demonstrates three different scenarios: +The sample demonstrates four integration scenarios. Each scenario runs the same three-message sequence via `run_policy_flow(...)`: + +1. **good (cold cache)** - a benign prompt that exercises the cold-cache parallel ProtectionScopes warmup + foreground ProcessContent path. +2. **expected block** - a sensitive prompt containing the Visa test credit card number `4111 1111 1111 1111`. If the tenant has a DLP policy for `Microsoft 365 Copilot and AI apps` targeting the Credit Card sensitive info type with a Block action, this prompt returns the configured `blocked_prompt_message` (default: `Prompt blocked by policy`). If no DLP policy applies, the prompt is allowed (the LLM may still decline on its own, but that is a model-level response, not a Purview block). +3. **good (warm cache)** - a second benign prompt that exercises the warm-cache path. The custom cache provider scenario prints `Cache HIT` for the same protection-scopes key, confirming the cache and middleware state survive a prior block. ### A. Agent Middleware (`run_with_agent_middleware`) -1. Builds an Azure OpenAI chat client (using the environment endpoint / deployment) +1. Builds a Foundry chat client (using the environment project endpoint / deployment) 2. Chooses credential mode (certificate vs interactive) 3. Creates `PurviewPolicyMiddleware` with `PurviewSettings` 4. Injects middleware into the agent at construction -5. Sends two user messages sequentially -6. Prints results (or policy block messages) +5. Runs the three-message `good -> block -> good` orchestration +6. Prints `ALLOWED` or `BLOCKED` per message, plus the model response 7. Uses default caching automatically ### B. Chat Client Middleware (`run_with_chat_middleware`) 1. Creates a chat client with `PurviewChatPolicyMiddleware` attached directly 2. Policy evaluation happens at the chat client level rather than agent level 3. Demonstrates an alternative integration point for Purview policies -4. Uses default caching automatically +4. Runs the same `good -> block -> good` orchestration +5. Uses default caching automatically ### C. Custom Cache Provider (`run_with_custom_cache_provider`) 1. Implements the `CacheProvider` protocol with a custom class (`SimpleDictCacheProvider`) @@ -88,9 +94,27 @@ The sample demonstrates three different scenarios: - `async def get(self, key: str) -> Any | None` - `async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None` - `async def remove(self, key: str) -> None` +4. Runs the `good -> block -> good` orchestration and prints `Cache MISS`/`Cache HIT` traces alongside policy outcomes, showing the cold-cache warmup populating the cache and warm-cache requests skipping ProtectionScopes. + +### D. Default Cache (`run_with_default_cache`) +1. Same as the agent middleware path but with explicit cache TTL and size limits in `PurviewSettings` +2. Uses the default in-memory `CacheProvider` +3. Runs the `good -> block -> good` orchestration **Policy Behavior:** -Prompt blocks set a system-level message: `Prompt blocked by policy` and terminate the run early. Response blocks rewrite the output to `Response blocked by policy`. +Prompt blocks substitute the configured `blocked_prompt_message` (default `Prompt blocked by policy`) and terminate the agent run early. Response blocks substitute `blocked_response_message`. The LLM is never called for a blocked prompt. + +**Seeing a real `BLOCKED` outcome:** +The middle prompt only returns `BLOCKED` if the tenant actually has a Purview DLP policy that matches the request. Specifically, all of the following must be true: + +1. The Entra app id used by `PURVIEW_CLIENT_APP_ID` (the same id Agent Framework sends as `policyLocationApplication.value`) is registered as an integrated AI app in Purview (Settings -> AI app and agent locations). +2. A DLP policy in the tenant targets the location `Microsoft 365 Copilot and AI apps`, scoped to that app id (or `All apps`). +3. The policy has a rule with the condition `Content contains -> Sensitive info types -> Credit Card Number` and an action of `Restrict access to Microsoft 365 Copilot and AI apps -> Block`. +4. The policy is `On` (not `Test mode without notifications`). +5. The signed-in user is in the policy's user scope. +6. Required Graph delegated permissions are admin-consented: `ProtectionScopes.Compute.All`, `Content.Process.All`, `ContentActivity.Write`. + +If any of those are missing, the credit card prompt is allowed at the Purview layer. The model itself may still decline on its own; that response is a model-level refusal, not a Purview block. The cold/warm cache orchestration is still demonstrated either way - the `Cache MISS -> Cache HIT` trace from the custom cache scenario does not depend on a block firing. --- diff --git a/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py b/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py index 5eb2845886..7305ea12e8 100644 --- a/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py +++ b/python/samples/05-end-to-end/purview_agent/sample_purview_agent.py @@ -11,8 +11,8 @@ Shows: Note: Caching is automatic and enabled by default. Environment variables: -- AZURE_OPENAI_ENDPOINT (required) -- AZURE_OPENAI_MODEL (optional, defaults to gpt-4o-mini) +- FOUNDRY_PROJECT_ENDPOINT (required) - Azure AI Foundry project endpoint URL +- FOUNDRY_MODEL (optional, defaults to gpt-4o-mini) - PURVIEW_CLIENT_APP_ID (required) - PURVIEW_USE_CERT_AUTH (optional, set to "true" for certificate auth) - PURVIEW_TENANT_ID (required if certificate auth) @@ -45,6 +45,37 @@ load_dotenv() JOKER_NAME = "Joker" JOKER_INSTRUCTIONS = "You are good at telling jokes. Keep responses concise." +# Sequential prompts to demonstrate good -> block -> good orchestration. +# The sensitive prompt contains a Visa test credit card number that matches Purview's +# built-in Credit Card sensitive information type. If the tenant has a DLP policy that +# blocks credit card content for Microsoft 365 Copilot and AI apps, the second message +# will be blocked and the third will verify that subsequent calls still flow normally +# after a block. +GOOD_PROMPT_PRIMARY = "Tell me a joke about a pirate." +SENSITIVE_PROMPT = "My corporate credit card is 4111 1111 1111 1111. Please confirm receipt." +GOOD_PROMPT_FOLLOWUP = "Another light joke please." + + +async def run_policy_flow( + label: str, + agent: Agent, + user_id: str | None, + blocked_text: str, +) -> None: + """Run a good -> block candidate -> good sequence and report each outcome.""" + blocked_marker = blocked_text.lower() + prompts = [ + ("good (cold cache)", GOOD_PROMPT_PRIMARY), + ("expected block", SENSITIVE_PROMPT), + ("good (warm cache)", GOOD_PROMPT_FOLLOWUP), + ] + for tag, text in prompts: + response: AgentResponse = await agent.run( + Message("user", [text], additional_properties={"user_id": user_id}) + ) + outcome = "BLOCKED" if blocked_marker in str(response).lower() else "ALLOWED" + print(f"[{label}] {tag}: {outcome}\n{response}\n") + # Custom Cache Provider Implementation class SimpleDictCacheProvider: @@ -138,21 +169,17 @@ def build_credential() -> Any: async def run_with_agent_middleware() -> None: - endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") + endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") if not endpoint: - print("Skipping run: AZURE_OPENAI_ENDPOINT not set") + print("Skipping run: FOUNDRY_PROJECT_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini") + deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) + client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential()) - purview_agent_middleware = PurviewPolicyMiddleware( - build_credential(), - PurviewSettings( - app_name="Agent Framework Sample App", - ), - ) + settings = PurviewSettings(app_name="Agent Framework Sample App") + purview_agent_middleware = PurviewPolicyMiddleware(build_credential(), settings) agent = Agent( client=client, @@ -162,39 +189,26 @@ async def run_with_agent_middleware() -> None: ) print("-- Agent MiddlewareTypes Path --") - first: AgentResponse = await agent.run( - Message("user", ["Tell me a joke about a pirate."], additional_properties={"user_id": user_id}) - ) - print("First response (agent middleware):\n", first) - - second: AgentResponse = await agent.run( - Message( - role="user", contents=["That was funny. Tell me another one."], additional_properties={"user_id": user_id} - ) - ) - print("Second response (agent middleware):\n", second) + blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy" + await run_policy_flow("agent middleware", agent, user_id, blocked_text) async def run_with_chat_middleware() -> None: - endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") + endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") if not endpoint: - print("Skipping chat middleware run: AZURE_OPENAI_ENDPOINT not set") + print("Skipping chat middleware run: FOUNDRY_PROJECT_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_MODEL", default="gpt-4o-mini") + deployment = os.environ.get("FOUNDRY_MODEL", default="gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") + settings = PurviewSettings(app_name="Agent Framework Sample App (Chat)") client = FoundryChatClient( model=deployment, - endpoint=endpoint, + project_endpoint=endpoint, credential=AzureCliCredential(), middleware=[ - PurviewChatPolicyMiddleware( - build_credential(), - PurviewSettings( - app_name="Agent Framework Sample App (Chat)", - ), - ) + PurviewChatPolicyMiddleware(build_credential(), settings) ], ) @@ -205,43 +219,27 @@ async def run_with_chat_middleware() -> None: ) print("-- Chat MiddlewareTypes Path --") - first: AgentResponse = await agent.run( - Message( - role="user", - contents=["Give me a short clean joke."], - additional_properties={"user_id": user_id}, - ) - ) - print("First response (chat middleware):\n", first) - - second: AgentResponse = await agent.run( - Message( - role="user", - contents=["One more please."], - additional_properties={"user_id": user_id}, - ) - ) - print("Second response (chat middleware):\n", second) + blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy" + await run_policy_flow("chat middleware", agent, user_id, blocked_text) async def run_with_custom_cache_provider() -> None: """Demonstrate implementing and using a custom cache provider.""" - endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") + endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") if not endpoint: - print("Skipping custom cache provider run: AZURE_OPENAI_ENDPOINT not set") + print("Skipping custom cache provider run: FOUNDRY_PROJECT_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini") + deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) + client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential()) custom_cache = SimpleDictCacheProvider() + settings = PurviewSettings(app_name="Agent Framework Sample App (Custom Provider)") purview_agent_middleware = PurviewPolicyMiddleware( build_credential(), - PurviewSettings( - app_name="Agent Framework Sample App (Custom Provider)", - ), + settings, cache_provider=custom_cache, ) @@ -254,38 +252,28 @@ async def run_with_custom_cache_provider() -> None: print("-- Custom Cache Provider Path --") print("Using SimpleDictCacheProvider") + blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy" + await run_policy_flow("custom cache", agent, user_id, blocked_text) - first: AgentResponse = await agent.run( - Message( - role="user", contents=["Tell me a joke about a programmer."], additional_properties={"user_id": user_id} - ) - ) - print("First response (custom provider):\n", first) - - second: AgentResponse = await agent.run( - Message("user", ["That's hilarious! One more?"], additional_properties={"user_id": user_id}) - ) - print("Second response (custom provider):\n", second) +async def run_with_default_cache() -> None: """Demonstrate using the default built-in cache.""" - endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") + endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") if not endpoint: - print("Skipping default cache run: AZURE_OPENAI_ENDPOINT not set") + print("Skipping default cache run: FOUNDRY_PROJECT_ENDPOINT not set") return - deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini") + deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini") user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID") - client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential()) + client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential()) # No cache_provider specified - uses default InMemoryCacheProvider - purview_agent_middleware = PurviewPolicyMiddleware( - build_credential(), - PurviewSettings( - app_name="Agent Framework Sample App (Default Cache)", - cache_ttl_seconds=3600, - max_cache_size_bytes=100 * 1024 * 1024, # 100MB - ), + settings = PurviewSettings( + app_name="Agent Framework Sample App (Default Cache)", + cache_ttl_seconds=3600, + max_cache_size_bytes=100 * 1024 * 1024, # 100MB ) + purview_agent_middleware = PurviewPolicyMiddleware(build_credential(), settings) agent = Agent( client=client, @@ -296,16 +284,8 @@ async def run_with_custom_cache_provider() -> None: print("-- Default Cache Path --") print("Using default InMemoryCacheProvider with settings-based configuration") - - first: AgentResponse = await agent.run( - Message("user", ["Tell me a joke about AI."], additional_properties={"user_id": user_id}) - ) - print("First response (default cache):\n", first) - - second: AgentResponse = await agent.run( - Message("user", ["Nice! Another AI joke please."], additional_properties={"user_id": user_id}) - ) - print("Second response (default cache):\n", second) + blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy" + await run_policy_flow("default cache", agent, user_id, blocked_text) async def main() -> None: @@ -326,6 +306,11 @@ async def main() -> None: except Exception as ex: # pragma: no cover - demo resilience print(f"Custom cache provider path failed: {ex}") + try: + await run_with_default_cache() + except Exception as ex: # pragma: no cover - demo resilience + print(f"Default cache path failed: {ex}") + if __name__ == "__main__": asyncio.run(main()) diff --git a/python/uv.lock b/python/uv.lock index 35007ebace..72aa2d01ad 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -115,7 +115,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.8.0" +version = "1.8.1" source = { virtual = "." } dependencies = [ { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -185,7 +185,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0rc3" +version = "1.0.0rc4" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -279,7 +279,7 @@ requires-dist = [ [[package]] name = "agent-framework-azurefunctions" -version = "1.0.0b260604" +version = "1.0.0b260609" source = { editable = "packages/azurefunctions" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -333,7 +333,7 @@ requires-dist = [ [[package]] name = "agent-framework-claude" -version = "1.0.0b260521" +version = "1.0.0b260609" source = { editable = "packages/claude" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -363,7 +363,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.8.0" +version = "1.8.1" source = { editable = "packages/core" } dependencies = [ { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -529,7 +529,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260518" }] [[package]] name = "agent-framework-foundry" -version = "1.8.0" +version = "1.8.1" source = { editable = "packages/foundry" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -548,7 +548,7 @@ requires-dist = [ [[package]] name = "agent-framework-foundry-hosting" -version = "1.0.0a260604" +version = "1.0.0a260609" source = { editable = "packages/foundry_hosting" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -584,7 +584,7 @@ requires-dist = [ [[package]] name = "agent-framework-gemini" -version = "1.0.0a260521" +version = "1.0.0a260609" source = { editable = "packages/gemini" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -714,7 +714,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b260521" +version = "1.0.0b260609" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -774,7 +774,7 @@ requires-dist = [ [[package]] name = "agent-framework-openai" -version = "1.8.0" +version = "1.8.1" source = { editable = "packages/openai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },