From eab761337d2bdc0a13f9764945d40952ea778695 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:09:40 +0000 Subject: [PATCH 1/3] Add dynamic tool expansion sample --- dotnet/agent-framework-dotnet.slnx | 1 + .../Agent_Step20_DynamicFunctionTools.csproj | 15 + .../Program.cs | 275 ++++++++++++++++++ .../README.md | 36 +++ dotnet/samples/02-agents/Agents/README.md | 1 + 5 files changed, 328 insertions(+) create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index b3a95ea1f6..c1a79de0c3 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -67,6 +67,7 @@ + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj new file mode 100644 index 0000000000..4ea7a45b8a --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs new file mode 100644 index 0000000000..3dc351c752 --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to dynamically expand the set of function tools available to an +// agent during a function-calling loop. The agent starts with a single "RequestTools" function. +// When the model calls RequestTools with a description of the capabilities needed, the function +// uses the ambient FunctionInvocationContext to add new tools to ChatOptions.Tools. The agent +// can then use the newly added tools in subsequent iterations of the same function-calling loop. + +using System.ComponentModel; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; + +var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); +var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-5.4-mini"; + +// Pre-defined tool implementations that can be loaded on demand. +[Description("Get the current weather for a city.")] +static string GetWeather([Description("The city name.")] string city) => + city.ToUpperInvariant() switch + { + "SEATTLE" => "Seattle: 55°F, cloudy with light rain.", + "NEW YORK" => "New York: 72°F, sunny and warm.", + "LONDON" => "London: 48°F, overcast with fog.", + _ => $"{city}: weather data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'." + }; + +[Description("Get the current local time for a city.")] +static string GetTime([Description("The city name.")] string city) => + city.ToUpperInvariant() switch + { + "SEATTLE" => "Seattle: 9:00 AM PST", + "NEW YORK" => "New York: 12:00 PM EST", + "LONDON" => "London: 5:00 PM GMT", + _ => $"{city}: time data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'." + }; + +[Description("Convert a temperature from Fahrenheit to Celsius.")] +static string ConvertFahrenheitToCelsius([Description("The temperature in Fahrenheit.")] double fahrenheit) => + $"{fahrenheit}°F = {(fahrenheit - 32) * 5 / 9:F1}°C"; + +// A registry of tool sets that can be loaded by description keyword. +Dictionary> toolCatalog = new(StringComparer.OrdinalIgnoreCase) +{ + ["weather"] = [AIFunctionFactory.Create(GetWeather, name: "GetWeather")], + ["time"] = [AIFunctionFactory.Create(GetTime, name: "GetTime")], + ["temperature"] = [AIFunctionFactory.Create(ConvertFahrenheitToCelsius, name: "ConvertFahrenheitToCelsius")], +}; + +// The RequestTools function uses the ambient FunctionInvocationContext to add tools dynamically. +AIFunction requestToolsFunction = AIFunctionFactory.Create( + [Description("Request additional tools to be loaded based on a description of the functionality needed. " + + "Call this when you need capabilities that are not yet available in your current tool set.")] ( + [Description("A description of the functionality required, e.g. 'weather', 'time', or 'temperature conversion'.")] string description + ) => + { + // Access the ambient FunctionInvocationContext provided by FunctionInvokingChatClient. + var context = FunctionInvokingChatClient.CurrentContext + ?? throw new InvalidOperationException("No ambient FunctionInvocationContext available."); + + var tools = context.Options?.Tools; + if (tools is null) + { + return "Unable to register new tools: ChatOptions.Tools is not available."; + } + + // Find matching tool sets from the catalog. + List addedToolNames = []; + foreach (var kvp in toolCatalog) + { + var keyword = kvp.Key; + var catalogTools = kvp.Value; + if (description.Contains(keyword, StringComparison.OrdinalIgnoreCase)) + { + foreach (var tool in catalogTools) + { + // Avoid adding duplicates. + if (tool is AIFunction fn && !tools.Any(t => t is AIFunction existing && existing.Name == fn.Name)) + { + tools.Add(tool); + addedToolNames.Add(fn.Name); + } + } + } + } + + return addedToolNames.Count > 0 + ? $"Successfully loaded tools: {string.Join(", ", addedToolNames)}. You can now call these tools." + : $"No tools matched the description '{description}'. Available categories: {string.Join(", ", toolCatalog.Keys)}."; + }, + name: "RequestTools"); + +// Create the agent with only the RequestTools function initially. +// Insert chat client middleware that logs the tools available on each LLM call, +// making the dynamic expansion visible in the console output. +AIAgent agent = new OpenAIClient(apiKey) + .GetChatClient(model) + .AsIChatClient() + .AsBuilder() + .Use(getResponseFunc: ToolLoggingMiddleware, getStreamingResponseFunc: ToolLoggingStreamingMiddleware) + .BuildAIAgent( + instructions: """ + You are a helpful assistant. You start with limited tools. + When you need functionality that you don't currently have, call RequestTools with a description + of what you need. After new tools are loaded, use them to answer the user's question. + """, + tools: [requestToolsFunction]); + +// Run a conversation that triggers dynamic tool expansion. +Console.WriteLine("=== Dynamic Function Tools Sample ===\n"); + +string[] prompts = +[ + "What's the weather like in Seattle and London?", + "What time is it in New York?", + "Can you convert those temperatures to Celsius?" +]; + +// --- Non-Streaming Mode --- +Console.ForegroundColor = ConsoleColor.Yellow; +Console.WriteLine("=== Non-Streaming Mode ==="); +Console.ResetColor(); +Console.WriteLine(); + +AgentSession session = await agent.CreateSessionAsync(); + +foreach (var prompt in prompts) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("[User] "); + Console.ResetColor(); + Console.WriteLine(prompt); + + var response = await agent.RunAsync(prompt, session); + + // Print all message contents including tool calls, tool results, and text. + foreach (var message in response.Messages) + { + foreach (var content in message.Contents) + { + switch (content) + { + case FunctionCallContent functionCall: + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})"); + Console.ResetColor(); + break; + + case FunctionResultContent functionResult: + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}"); + Console.ResetColor(); + break; + + case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text): + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("[Agent] "); + Console.ResetColor(); + Console.WriteLine(textContent.Text); + break; + } + } + } + + Console.WriteLine(); +} + +// --- Streaming Mode --- +Console.ForegroundColor = ConsoleColor.Yellow; +Console.WriteLine("=== Streaming Mode ==="); +Console.ResetColor(); +Console.WriteLine(); + +AgentSession streamingSession = await agent.CreateSessionAsync(); + +foreach (var prompt in prompts) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("[User] "); + Console.ResetColor(); + Console.WriteLine(prompt); + + bool inAgentText = false; + + await foreach (var update in agent.RunStreamingAsync(prompt, streamingSession)) + { + foreach (var content in update.Contents) + { + switch (content) + { + case FunctionCallContent functionCall: + if (inAgentText) + { + Console.WriteLine(); + inAgentText = false; + } + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})"); + Console.ResetColor(); + break; + + case FunctionResultContent functionResult: + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}"); + Console.ResetColor(); + break; + + case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text): + if (!inAgentText) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("[Agent] "); + Console.ResetColor(); + inAgentText = true; + } + + Console.Write(textContent.Text); + break; + } + } + } + + if (inAgentText) + { + Console.WriteLine(); + } + + Console.WriteLine(); +} + +// Chat client middleware that logs the number and names of tools on each LLM request. +async Task ToolLoggingMiddleware( + IEnumerable messages, + ChatOptions? options, + IChatClient innerChatClient, + CancellationToken cancellationToken) +{ + LogTools(options); + + return await innerChatClient.GetResponseAsync(messages, options, cancellationToken); +} + +// Streaming version of the tool logging middleware. +async IAsyncEnumerable ToolLoggingStreamingMiddleware( + IEnumerable messages, + ChatOptions? options, + IChatClient innerChatClient, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) +{ + LogTools(options); + + await foreach (var update in innerChatClient.GetStreamingResponseAsync(messages, options, cancellationToken)) + { + yield return update; + } +} + +// Shared helper to log the current tool set. +void LogTools(ChatOptions? options) +{ + if (options?.Tools is { Count: > 0 } tools) + { + var toolNames = tools.OfType().Select(t => t.Name); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" [Middleware] LLM call with {tools.Count} tool(s): {string.Join(", ", toolNames)}"); + Console.ResetColor(); + } + else + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine(" [Middleware] LLM call with 0 tools"); + Console.ResetColor(); + } +} diff --git a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/README.md b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/README.md new file mode 100644 index 0000000000..15460a621d --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/README.md @@ -0,0 +1,36 @@ +# Dynamic Function Tools + +This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop. + +## What it demonstrates + +- The agent starts with only a single `RequestTools` function +- When the model needs capabilities it doesn't have, it calls `RequestTools` with a description of the functionality needed +- The `RequestTools` function uses the ambient `FunctionInvokingChatClient.CurrentContext` to access `ChatOptions.Tools` and add new tools at runtime +- The agent then uses the newly added tools in subsequent iterations of the same function-calling loop + +## How it works + +1. A tool catalog maps keywords (e.g. "weather", "time", "temperature") to pre-built `AIFunction` instances +2. The `RequestTools` function matches the description against catalog keywords and adds matching tools to `ChatOptions.Tools` +3. `FunctionInvokingChatClient` automatically picks up the new tools on the next iteration of its loop + +## Prerequisites + +- .NET 10 SDK or later +- OpenAI API key + +## Running the sample + +Set the required environment variables: + +```bash +export OPENAI_API_KEY="your-api-key" +export OPENAI_CHAT_MODEL_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini +``` + +Run the sample: + +```bash +dotnet run +``` diff --git a/dotnet/samples/02-agents/Agents/README.md b/dotnet/samples/02-agents/Agents/README.md index f429d8156c..af946b5fac 100644 --- a/dotnet/samples/02-agents/Agents/README.md +++ b/dotnet/samples/02-agents/Agents/README.md @@ -46,6 +46,7 @@ Before you begin, ensure you have the following prerequisites: |[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.| |[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.| |[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.| +|[Dynamic function tools](./Agent_Step20_DynamicFunctionTools/)|This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop using the ambient FunctionInvocationContext.| ## Running the samples from the console From 7eda913f84ec5a79f076ad3d82c9f48f68f0709d Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:32:00 +0000 Subject: [PATCH 2/3] Address PR comments --- .../Agent_Step20_DynamicFunctionTools.csproj | 7 ++++++- .../Agent_Step20_DynamicFunctionTools/Program.cs | 16 +++++++++++----- .../Agent_Step20_DynamicFunctionTools/README.md | 12 +++++++----- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj index 4ea7a45b8a..41aafe3437 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj +++ b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj @@ -2,12 +2,17 @@ Exe - net10.0 + net10.0 enable enable + + + + + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs index 3dc351c752..6d159e48e1 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs @@ -7,12 +7,13 @@ // can then use the newly added tools in subsequent iterations of the same function-calling loop. using System.ComponentModel; +using Azure.AI.OpenAI; +using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -using OpenAI; -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-5.4-mini"; +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"; // Pre-defined tool implementations that can be loaded on demand. [Description("Get the current weather for a city.")] @@ -93,8 +94,13 @@ static string ConvertFahrenheitToCelsius([Description("The temperature in Fahren // Create the agent with only the RequestTools function initially. // Insert chat client middleware that logs the tools available on each LLM call, // making the dynamic expansion visible in the console output. -AIAgent agent = new OpenAIClient(apiKey) - .GetChatClient(model) +// 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()) + .GetChatClient(deploymentName) .AsIChatClient() .AsBuilder() .Use(getResponseFunc: ToolLoggingMiddleware, getStreamingResponseFunc: ToolLoggingStreamingMiddleware) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/README.md b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/README.md index 15460a621d..fb0245b542 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/README.md @@ -18,19 +18,21 @@ This sample demonstrates how to dynamically expand the set of function tools ava ## Prerequisites - .NET 10 SDK or later -- OpenAI API key +- Azure OpenAI service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) +- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource ## Running the sample Set the required environment variables: -```bash -export OPENAI_API_KEY="your-api-key" -export OPENAI_CHAT_MODEL_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini +```powershell +$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini ``` Run the sample: -```bash +```powershell dotnet run ``` From 4a211b90e137b1bb43af8c13d1a76f8f9e31bf30 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:02:23 +0100 Subject: [PATCH 3/3] Remove tool names from tool call response to avoid confusing LLM --- .../Agents/Agent_Step20_DynamicFunctionTools/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs index 6d159e48e1..ac3dd4b491 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs @@ -86,7 +86,7 @@ static string ConvertFahrenheitToCelsius([Description("The temperature in Fahren } return addedToolNames.Count > 0 - ? $"Successfully loaded tools: {string.Join(", ", addedToolNames)}. You can now call these tools." + ? "Successfully loaded tools" : $"No tools matched the description '{description}'. Available categories: {string.Join(", ", toolCatalog.Keys)}."; }, name: "RequestTools");