From 4149f24791ca0964622e196e8e4425b3cd69cffb Mon Sep 17 00:00:00 2001 From: chetantoshniwal Date: Wed, 10 Jun 2026 23:46:46 -0700 Subject: [PATCH 1/6] Python: [Generated by SRE Agent] Fix MCP allowed_tools empty list handling (#6296) * Fix MCP allowed_tools empty list handling When allowed_tools is set to an empty list [], the falsy check 'if not self.allowed_tools' incorrectly treats it as unconfigured (same as None), causing all tools to be exposed. Change to an explicit 'is None' check so that an empty list correctly results in no tools being allowed. Co-authored-by: Azure SRE Agent * Clarify allowed_tools docstring: None vs [] semantics Per Eduard's review on PR #6296: explicitly document that None exposes all tools and [] exposes none, across all four MCPTool / MCPStdioTool / MCPStreamableHTTPTool / MCPWebsocketTool docstrings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * allowed_tools docstring: recommend load_tools=False for full disable Per Eduard's follow-up on PR #6296: `load_tools=False` is the cleaner idiom when you don't want to expose any tools. Reframe `allowed_tools=[]` in the docstring as a runtime guard / inspection-only path and cross-reference `load_tools`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Azure SRE Agent Co-authored-by: Giles Odigwe <79032838+giles17@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/agent_framework/_mcp.py | 34 +++++++++++++++++--- python/packages/core/tests/core/test_mcp.py | 1 + 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 2fc79e85a5..f25a44c38c 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -379,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 @@ -739,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] = [] @@ -2391,7 +2397,13 @@ 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. @@ -2566,7 +2578,13 @@ 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. @@ -2795,7 +2813,13 @@ 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 diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index a40c1c9b54..3eaf60785d 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -1530,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"], From 8e1998ddcb46d75cf3cc277bc74d2ea87b0e035a Mon Sep 17 00:00:00 2001 From: Matthias Howell <106839070+MatthiasHowellYopp@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:18:00 -0400 Subject: [PATCH 2/6] .NET: Adds Valkey to chat message history - issue 5445 (#5542) * Adds Valkey to chat message history * Address review: switch to Valkey.Glide, add options class, remove context provider - Switch from StackExchange.Redis to Valkey.Glide 1.1.0 (official Valkey .NET client) - Extract optional params into ValkeyChatHistoryProviderOptions - Add JsonSerializerOptions support, remove [RequiresUnreferencedCode] - Make MaxMessages/MaxMessagesToRetrieve readonly via options - Remove ValkeyContextProvider (overlaps with ChatHistoryMemoryProvider + MEVD) - Remove ValkeyProviderScope (only used by context provider) - Remove connection string constructors (caller manages IConnectionMultiplexer) - Update samples to use new API and gpt-5.4-mini * Use type-safe JsonSerializer overloads, remove suppress attributes Use JsonSerializerOptions.GetTypeInfo() for Serialize/Deserialize calls to enable NativeAOT/trimming compatibility without suppress attributes. Default to AgentAbstractionsJsonUtilities.DefaultOptions when no options provided. Signed-off-by: Matthias Howell * Update READMEs: remove context provider references Remove ValkeyContextProvider and long-term memory references from sample READMEs since the context provider was removed from this PR. Simplify Valkey server requirements (no search module needed for chat history). Signed-off-by: Matthias Howell * Apply suggestion from @westey-m * Fix formatting (dotnet format) Signed-off-by: Matthias Howell * Update dotnet/src/Microsoft.Agents.AI.Valkey/Microsoft.Agents.AI.Valkey.csproj Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --------- Signed-off-by: Matthias Howell Co-authored-by: Matthias Howell Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- .gitignore | 1 + dotnet/Directory.Packages.props | 5 + dotnet/agent-framework-dotnet.slnx | 4 + ...WithMemory_Step03_MemoryUsingValkey.csproj | 22 ++ .../Program.cs | 55 ++++ .../README.md | 30 +++ ...ry_Step03_MemoryUsingValkey_Bedrock.csproj | 20 ++ .../Program.cs | 57 ++++ .../README.md | 41 +++ .../Microsoft.Agents.AI.Valkey.csproj | 40 +++ .../ValkeyChatHistoryProvider.cs | 225 ++++++++++++++++ .../ValkeyChatHistoryProviderOptions.cs | 56 ++++ ...icrosoft.Agents.AI.Valkey.UnitTests.csproj | 11 + .../TestHelpers.cs | 44 ++++ .../ValkeyChatHistoryProviderTests.cs | 249 ++++++++++++++++++ 15 files changed, 860 insertions(+) create mode 100644 dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/AgentWithMemory_Step03_MemoryUsingValkey.csproj create mode 100644 dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/Program.cs create mode 100644 dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey/README.md create mode 100644 dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj create mode 100644 dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/Program.cs create mode 100644 dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI.Valkey/Microsoft.Agents.AI.Valkey.csproj create mode 100644 dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Valkey/ValkeyChatHistoryProviderOptions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/Microsoft.Agents.AI.Valkey.UnitTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/TestHelpers.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Valkey.UnitTests/ValkeyChatHistoryProviderTests.cs diff --git a/.gitignore b/.gitignore index e6b9efb40e..258a8c0704 100644 --- a/.gitignore +++ b/.gitignore @@ -214,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/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index fc57fdea08..76a4444bc1 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -138,10 +138,15 @@ + + + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 98c364cd53..6afa318012 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -194,6 +194,8 @@ + + @@ -625,6 +627,7 @@ + @@ -678,5 +681,6 @@ + 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/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/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); + } +} From 12ce09916512f457c8c4aa84e18d1001010420f3 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:00:01 +0100 Subject: [PATCH 3/6] .NET: Add LoopAgent capability for Harnesses (#6384) * Add LoopAgent capability for Harnesses * Address PR comments. * Add support for returning user messages and response aggregation * Support fresh context per iteration with input sessions via cloning * Add ability to receive newly created sessions via callback * Address PR comments * Add judge criteria * Address PR comments --- dotnet/agent-framework-dotnet.slnx | 1 + .../Harness_Step05_Loop.csproj | 21 + .../Harness/Harness_Step05_Loop/Program.cs | 272 ++++ .../Harness/Harness_Step05_Loop/README.md | 59 + dotnet/samples/02-agents/Harness/README.md | 1 + .../Harness/Loop/AIJudgeLoopEvaluator.cs | 201 +++ .../Loop/AIJudgeLoopEvaluatorOptions.cs | 48 + .../Loop/CompletionMarkerLoopEvaluator.cs | 78 ++ .../CompletionMarkerLoopEvaluatorOptions.cs | 26 + .../Harness/Loop/DelegateLoopEvaluator.cs | 40 + .../Harness/Loop/JudgeVerdict.cs | 26 + .../Harness/Loop/LoopAgent.cs | 548 ++++++++ .../Harness/Loop/LoopAgentOptions.cs | 117 ++ .../Harness/Loop/LoopContext.cs | 97 ++ .../Harness/Loop/LoopEvaluation.cs | 86 ++ .../Harness/Loop/LoopEvaluator.cs | 41 + .../Harness/Loop/LoopJsonContext.cs | 16 + .../Harness/Loop/AIJudgeLoopEvaluatorTests.cs | 314 +++++ .../CompletionMarkerLoopEvaluatorTests.cs | 145 ++ .../Loop/DelegateLoopEvaluatorTests.cs | 113 ++ .../Harness/Loop/LoopAgentTests.cs | 1231 +++++++++++++++++ .../Harness/Loop/LoopContextTests.cs | 146 ++ .../Harness/Loop/LoopEvaluationTests.cs | 55 + .../Harness/Loop/LoopTestHelpers.cs | 141 ++ 24 files changed, 3823 insertions(+) create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step05_Loop/Harness_Step05_Loop.csproj create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step05_Loop/Program.cs create mode 100644 dotnet/samples/02-agents/Harness/Harness_Step05_Loop/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Loop/AIJudgeLoopEvaluator.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Loop/AIJudgeLoopEvaluatorOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Loop/CompletionMarkerLoopEvaluator.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Loop/CompletionMarkerLoopEvaluatorOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Loop/DelegateLoopEvaluator.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Loop/JudgeVerdict.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgentOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopEvaluation.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopEvaluator.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopJsonContext.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/AIJudgeLoopEvaluatorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/CompletionMarkerLoopEvaluatorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/DelegateLoopEvaluatorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopAgentTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopContextTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopEvaluationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/Loop/LoopTestHelpers.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 6afa318012..5c846f0def 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -129,6 +129,7 @@ + diff --git a/dotnet/samples/02-agents/Harness/Harness_Step05_Loop/Harness_Step05_Loop.csproj b/dotnet/samples/02-agents/Harness/Harness_Step05_Loop/Harness_Step05_Loop.csproj new file mode 100644 index 0000000000..f5d6f368b6 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/Harness_Step05_Loop/Harness_Step05_Loop.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + 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/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/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; } = []; +} From c9e2a490be5cf5f1d44482d54ca826dc6f50d7fd Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:09:55 -0700 Subject: [PATCH 4/6] =?UTF-8?q?Fix=20AzureFunctions=20integration=20tests?= =?UTF-8?q?=20=E2=80=94=20set=20FUNCTIONS=5FWORKER=5FRUNTIME=20(#6425)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure Functions Core Tools v4 can no longer auto-detect the worker runtime when local.settings.json is absent. Add the required FUNCTIONS_WORKER_RUNTIME=dotnet-isolated environment variable to both StartFunctionApp helpers and re-enable the skipped tests. Fixes: https://github.com/microsoft/agent-framework/issues/6402 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SamplesValidation.cs | 17 +++++++++-------- .../WorkflowSamplesValidation.cs | 11 ++++++----- 2 files changed, 15 insertions(+), 13 deletions(-) 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"; From c79f886dc3f48ae8b3a2c139e704c8389e8a2c15 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 11 Jun 2026 10:26:00 -0700 Subject: [PATCH 5/6] .NET: Align Foundry sample environment variables and credentials. (#6422) * dotnet: refresh Foundry sample guidance Carry forward the still-relevant sample guidance and Foundry-specific documentation fixes from the old stacked sample migration work, adapted to the current repo layout and policy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * dotnet: rename Foundry sample env vars Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * dotnet: remove persistent provider sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * dotnet: drop SAMPLE_GUIDELINES.md from this PR Defer the guidelines doc and its cross-link to a follow-on PR to avoid broken-link failures in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * dotnet: add DefaultAzureCredential warning to remaining samples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * dotnet: address PR review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/agent-framework-dotnet.slnx | 1 - dotnet/eng/verify-samples/AgentsSamples.cs | 13 ------ .../Agent_With_AzureAIAgentsPersistent.csproj | 20 --------- .../Program.cs | 44 ------------------- .../README.md | 26 ----------- .../Agent_With_AzureAIProject/Program.cs | 4 +- .../Agent_With_AzureAIProject/README.md | 6 +-- .../Agent_With_AzureFoundryModel/Program.cs | 2 +- .../Agent_With_AzureFoundryModel/README.md | 6 +-- .../02-agents/AgentProviders/README.md | 1 - .../Agent_Step01_FileBasedSkills/Program.cs | 3 ++ .../Agent_Step02_CodeDefinedSkills/Program.cs | 3 ++ .../Agent_Step03_ClassBasedSkills/Program.cs | 3 ++ .../Agent_Step04_MixedSkills/Program.cs | 3 ++ .../Agent_Step05_SkillsWithDI/Program.cs | 3 ++ .../Program.cs | 3 ++ .../Program.cs | 3 ++ .../Program.cs | 3 ++ .../Program.cs | 4 +- .../README.md | 6 +-- .../Program.cs | 4 +- .../Agents/Agent_Step07_AsMcpTool/Program.cs | 4 +- .../Agents/Agent_Step07_AsMcpTool/README.md | 8 ++-- .../Agent_Step15_DeepResearch/Program.cs | 4 +- .../Agent_Step15_DeepResearch/README.md | 6 +-- .../Program.cs | 3 ++ .../Program.cs | 9 ++-- .../README.md | 8 ++-- .../Agent_Step01_Basics/Program.cs | 4 +- .../Agent_Step01_Basics/README.md | 8 ++-- .../Program.cs | 4 +- .../README.md | 8 ++-- .../Program.cs | 4 +- .../README.md | 8 ++-- .../Program.cs | 4 +- .../Agent_Step03_UsingFunctionTools/README.md | 8 ++-- .../Program.cs | 4 +- .../README.md | 6 +-- .../Agent_Step05_StructuredOutput/Program.cs | 4 +- .../Agent_Step05_StructuredOutput/README.md | 6 +-- .../Program.cs | 4 +- .../README.md | 6 +-- .../Agent_Step07_Observability/Program.cs | 4 +- .../Agent_Step07_Observability/README.md | 6 +-- .../Program.cs | 4 +- .../README.md | 6 +-- .../Program.cs | 4 +- .../README.md | 6 +-- .../Agent_Step10_UsingImages/Program.cs | 4 +- .../Agent_Step10_UsingImages/README.md | 6 +-- .../Agent_Step11_AsFunctionTool/Program.cs | 4 +- .../Agent_Step11_AsFunctionTool/README.md | 6 +-- .../Agent_Step12_Middleware/Program.cs | 4 +- .../Agent_Step12_Middleware/README.md | 6 +-- .../Agent_Step13_Plugins/Program.cs | 4 +- .../Agent_Step13_Plugins/README.md | 6 +-- .../Agent_Step14_CodeInterpreter/Program.cs | 4 +- .../Agent_Step14_CodeInterpreter/README.md | 6 +-- .../Agent_Step15_ComputerUse/Program.cs | 5 ++- .../Agent_Step15_ComputerUse/README.md | 6 +-- .../Agent_Step16_FileSearch/Program.cs | 4 +- .../Agent_Step16_FileSearch/README.md | 6 +-- .../Agent_Step17_OpenAPITools/Program.cs | 4 +- .../Agent_Step17_OpenAPITools/README.md | 6 +-- .../Agent_Step18_BingCustomSearch/Program.cs | 4 +- .../Agent_Step18_BingCustomSearch/README.md | 6 +-- .../Agent_Step19_SharePoint/Program.cs | 4 +- .../Agent_Step19_SharePoint/README.md | 6 +-- .../Agent_Step20_MicrosoftFabric/Program.cs | 4 +- .../Agent_Step20_MicrosoftFabric/README.md | 6 +-- .../Agent_Step21_WebSearch/Program.cs | 4 +- .../Agent_Step21_WebSearch/README.md | 6 +-- .../Agent_Step22_MemorySearch/Program.cs | 4 +- .../Agent_Step22_MemorySearch/README.md | 6 +-- .../Agent_Step23_LocalMCP/Program.cs | 4 +- .../Agent_Step23_LocalMCP/README.md | 6 +-- .../Program.cs | 4 +- .../README.md | 6 +-- .../Agent_Step25_FoundryToolboxMcp/Program.cs | 6 +-- .../Agent_Step25_FoundryToolboxMcp/README.md | 8 ++-- .../Program.cs | 6 +-- .../README.md | 6 +-- .../02-agents/AgentsWithFoundry/README.md | 18 +++++--- .../Evaluation_CustomEvals/Program.cs | 4 +- .../Evaluation_CustomEvals/README.md | 8 ++-- .../Evaluation_ExpectedOutputs/Program.cs | 7 ++- .../Evaluation_ExpectedOutputs/README.md | 6 +-- .../Evaluation_SimpleEval/Program.cs | 4 +- .../Evaluation_SimpleEval/README.md | 8 ++-- .../Harness_Step01_Research/Program.cs | 4 +- .../Harness/Harness_Step01_Research/README.md | 4 +- .../Program.cs | 7 ++- .../README.md | 4 +- .../Harness_Step03_DataProcessing/Program.cs | 7 ++- .../Harness_Step03_DataProcessing/README.md | 4 +- .../Harness_Step04_CodeExecution/Program.cs | 7 ++- .../Harness_Step04_CodeExecution/README.md | 6 +-- .../FoundryAgent_Hosted_MCP/Program.cs | 4 +- .../FoundryAgent_Hosted_MCP/README.md | 6 +-- .../Agents/FoundryAgent/Program.cs | 11 +++-- .../Concurrent/Concurrent/Program.cs | 11 +++-- .../Declarative/ExecuteCode/Program.cs | 2 +- .../Declarative/ExecuteWorkflow/Program.cs | 2 +- .../Declarative/HostedWorkflow/Program.cs | 2 +- .../03-workflows/Declarative/README.md | 14 +++--- .../Evaluation_WorkflowEval/Program.cs | 9 ++-- .../Evaluation_WorkflowEval/README.md | 8 ++-- .../Program.cs | 6 +-- .../README.md | 8 ++-- .../Orchestration/Handoff/Program.cs | 6 +-- .../Orchestration/Magentic/Program.cs | 6 +-- .../Orchestration/Magentic/README.md | 4 +- .../05_WorkflowAndAgents/Program.cs | 3 ++ .../responses/Hosted-AgentSkills/.env.example | 4 +- .../responses/Hosted-AgentSkills/Program.cs | 9 ++-- .../responses/Hosted-AgentSkills/README.md | 6 +-- .../Hosted-AgentSkills/agent.manifest.yaml | 6 +-- .../responses/Hosted-AgentSkills/agent.yaml | 4 +- .../Hosted-AgentSkills/scripts/smoke.ps1 | 4 +- .../Hosted-AzureSearchRag/.env.example | 4 +- .../Hosted-AzureSearchRag/Program.cs | 9 ++-- .../responses/Hosted-AzureSearchRag/README.md | 6 +-- .../Hosted-ChatClientAgent/.env.example | 4 +- .../Hosted-ChatClientAgent/Program.cs | 9 ++-- .../Hosted-ChatClientAgent/README.md | 6 +-- .../responses/Hosted-Files/.env.example | 4 +- .../responses/Hosted-Files/Program.cs | 13 +++--- .../responses/Hosted-Files/README.md | 8 ++-- .../Hosted-FoundryAgent/.env.example | 2 +- .../responses/Hosted-FoundryAgent/Program.cs | 7 ++- .../responses/Hosted-FoundryAgent/README.md | 4 +- .../responses/Hosted-LocalTools/.env.example | 4 +- .../responses/Hosted-LocalTools/Program.cs | 9 ++-- .../responses/Hosted-LocalTools/README.md | 6 +-- .../responses/Hosted-McpTools/.env.example | 4 +- .../responses/Hosted-McpTools/Program.cs | 9 ++-- .../responses/Hosted-McpTools/README.md | 6 +-- .../responses/Hosted-MemoryAgent/.env.example | 4 +- .../responses/Hosted-MemoryAgent/Program.cs | 9 ++-- .../responses/Hosted-MemoryAgent/README.md | 6 +-- .../Hosted-MemoryAgent/scripts/smoke.ps1 | 4 +- .../Hosted-Observability/.env.example | 4 +- .../responses/Hosted-Observability/Program.cs | 9 ++-- .../responses/Hosted-Observability/README.md | 6 +-- .../responses/Hosted-TextRag/.env.example | 4 +- .../responses/Hosted-TextRag/Program.cs | 9 ++-- .../responses/Hosted-TextRag/README.md | 6 +-- .../responses/Hosted-Toolbox/Program.cs | 17 ++++--- .../Hosted-ToolboxMcpSkills/.env.example | 4 +- .../Hosted-ToolboxMcpSkills/Program.cs | 13 +++--- .../Hosted-ToolboxMcpSkills/README.md | 6 +-- .../agent.manifest.yaml | 6 +-- .../Hosted-ToolboxMcpSkills/agent.yaml | 4 +- .../Hosted-Workflow-Handoff/Program.cs | 3 ++ .../Hosted-Workflow-Simple/.env.example | 4 +- .../Hosted-Workflow-Simple/Program.cs | 9 ++-- .../Hosted-Workflow-Simple/README.md | 8 ++-- .../SessionFilesClient/Program.cs | 6 +-- .../SessionFilesClient/README.md | 8 ++-- .../Using-Samples/SimpleAgent/Program.cs | 6 +-- .../A2AClientServer/A2AServer/Program.cs | 2 +- .../05-end-to-end/A2AClientServer/README.md | 4 +- .../EditorAgent/Program.cs | 3 ++ .../WriterAgent/Program.cs | 3 ++ .../Evaluation_ConversationSplits/Program.cs | 4 +- .../Evaluation_ConversationSplits/README.md | 10 ++--- .../Evaluation_FoundryQuality/Program.cs | 4 +- .../Evaluation_FoundryQuality/README.md | 8 ++-- .../Evaluation_MixedProviders/Program.cs | 4 +- .../Evaluation_MixedProviders/README.md | 10 ++--- 170 files changed, 525 insertions(+), 517 deletions(-) delete mode 100644 dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj delete mode 100644 dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs delete mode 100644 dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 5c846f0def..3160eb6de7 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -24,7 +24,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/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj deleted file mode 100644 index d40e93232b..0000000000 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Agent_With_AzureAIAgentsPersistent.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - - - - - - - - - - - - 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_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/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/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/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 01239efef5..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 ``` 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 f82ee30e5a..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 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 4e11ed8023..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. 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 80b68abe2e..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. @@ -153,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 3aa80756ee..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 ``` 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 8ad876e21e..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. 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 db0a232412..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) 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 d8820096a0..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 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 e51959c6f5..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 ``` 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 c45b3f9101..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= ``` 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 b06b8f5688..3adae07004 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs @@ -6,14 +6,16 @@ // call tools provided by the Foundry platform's managed MCP proxy. // // Required environment variables: -// AZURE_AI_PROJECT_ENDPOINT (local-dev) OR FOUNDRY_PROJECT_ENDPOINT (hosted 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. -// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o) +// FOUNDRY_MODEL - Model deployment name (default: gpt-4o) // // Optional: -// TOOLBOX_NAME - Name of the toolbox to load (default: my-toolbox) +// 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 @@ -39,9 +41,14 @@ string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_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") ?? "my-toolbox"; +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( 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 707f37fb58..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 ``` 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-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 12aa97a8d7..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,4 +1,4 @@ -# 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`. @@ -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=hosted-workflow-simple +FOUNDRY_MODEL=hosted-workflow-simple ``` > **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. @@ -125,7 +125,7 @@ If you need to override defaults, set deployment-time environment variables in t ```bash azd env set AGENT_NAME hosted-workflow-simple -azd env set AZURE_AI_MODEL_DEPLOYMENT_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). 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 +``` From df29af611c5a2c7306f0dea6e68973783ce07cfe Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 11 Jun 2026 19:35:44 +0200 Subject: [PATCH 6/6] Python: Add tool approval middleware (#6414) * Add Python tool approval middleware Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix tool approval restored state handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Gate hidden approvals on explicit approval responses Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle string inputs in approval replay scan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Cover argument-scoped approval rules Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refine tool approval state and budgets Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix tool approval PR CI failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert DevUI Aspire README link change Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/AGENTS.md | 17 + .../packages/core/agent_framework/__init__.py | 16 + .../_harness/_tool_approval.py | 632 ++++++++++++++ .../packages/core/agent_framework/_tools.py | 205 ++++- .../tests/core/test_harness_tool_approval.py | 817 ++++++++++++++++++ .../hyperlight/test_hyperlight_codeact.py | 4 + python/samples/02-agents/tools/README.md | 1 + .../tools/tool_approval_middleware.py | 191 ++++ 8 files changed, 1868 insertions(+), 15 deletions(-) create mode 100644 python/packages/core/agent_framework/_harness/_tool_approval.py create mode 100644 python/packages/core/tests/core/test_harness_tool_approval.py create mode 100644 python/samples/02-agents/tools/tool_approval_middleware.py diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 92e83c5df4..d12f127a25 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -100,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 b287b4be57..478adb2dc7 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -125,6 +125,15 @@ from ._harness._todo import ( TodoStore, ) 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 +339,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", @@ -509,6 +519,10 @@ __all__ = [ "TodoStore", "TokenBudgetComposedStrategy", "TokenizerProtocol", + "ToolApprovalMiddleware", + "ToolApprovalRule", + "ToolApprovalRuleCallback", + "ToolApprovalState", "ToolMode", "ToolResultCompactionStrategy", "ToolTypes", @@ -543,6 +557,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/_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/_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/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/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/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. +"""