Skip to content

Commit 6851a9c

Browse files
authored
.NET: Add dynamic tool expansion sample (#5425)
* Add dynamic tool expansion sample * Address PR comments * Remove tool names from tool call response to avoid confusing LLM
1 parent dfca81f commit 6851a9c

5 files changed

Lines changed: 341 additions & 0 deletions

File tree

dotnet/agent-framework-dotnet.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
6565
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
6666
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
67+
<Project Path="samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj" />
6768
</Folder>
6869
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
6970
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFrameworks>net10.0</TargetFrameworks>
6+
7+
<Nullable>enable</Nullable>
8+
<ImplicitUsings>enable</ImplicitUsings>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="Azure.AI.OpenAI" />
13+
<PackageReference Include="Azure.Identity" />
14+
</ItemGroup>
15+
16+
<ItemGroup>
17+
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
18+
</ItemGroup>
19+
20+
</Project>
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
// This sample demonstrates how to dynamically expand the set of function tools available to an
4+
// agent during a function-calling loop. The agent starts with a single "RequestTools" function.
5+
// When the model calls RequestTools with a description of the capabilities needed, the function
6+
// uses the ambient FunctionInvocationContext to add new tools to ChatOptions.Tools. The agent
7+
// can then use the newly added tools in subsequent iterations of the same function-calling loop.
8+
9+
using System.ComponentModel;
10+
using Azure.AI.OpenAI;
11+
using Azure.Identity;
12+
using Microsoft.Agents.AI;
13+
using Microsoft.Extensions.AI;
14+
15+
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
16+
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
17+
18+
// Pre-defined tool implementations that can be loaded on demand.
19+
[Description("Get the current weather for a city.")]
20+
static string GetWeather([Description("The city name.")] string city) =>
21+
city.ToUpperInvariant() switch
22+
{
23+
"SEATTLE" => "Seattle: 55°F, cloudy with light rain.",
24+
"NEW YORK" => "New York: 72°F, sunny and warm.",
25+
"LONDON" => "London: 48°F, overcast with fog.",
26+
_ => $"{city}: weather data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
27+
};
28+
29+
[Description("Get the current local time for a city.")]
30+
static string GetTime([Description("The city name.")] string city) =>
31+
city.ToUpperInvariant() switch
32+
{
33+
"SEATTLE" => "Seattle: 9:00 AM PST",
34+
"NEW YORK" => "New York: 12:00 PM EST",
35+
"LONDON" => "London: 5:00 PM GMT",
36+
_ => $"{city}: time data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
37+
};
38+
39+
[Description("Convert a temperature from Fahrenheit to Celsius.")]
40+
static string ConvertFahrenheitToCelsius([Description("The temperature in Fahrenheit.")] double fahrenheit) =>
41+
$"{fahrenheit}°F = {(fahrenheit - 32) * 5 / 9:F1}°C";
42+
43+
// A registry of tool sets that can be loaded by description keyword.
44+
Dictionary<string, List<AITool>> toolCatalog = new(StringComparer.OrdinalIgnoreCase)
45+
{
46+
["weather"] = [AIFunctionFactory.Create(GetWeather, name: "GetWeather")],
47+
["time"] = [AIFunctionFactory.Create(GetTime, name: "GetTime")],
48+
["temperature"] = [AIFunctionFactory.Create(ConvertFahrenheitToCelsius, name: "ConvertFahrenheitToCelsius")],
49+
};
50+
51+
// The RequestTools function uses the ambient FunctionInvocationContext to add tools dynamically.
52+
AIFunction requestToolsFunction = AIFunctionFactory.Create(
53+
[Description("Request additional tools to be loaded based on a description of the functionality needed. " +
54+
"Call this when you need capabilities that are not yet available in your current tool set.")] (
55+
[Description("A description of the functionality required, e.g. 'weather', 'time', or 'temperature conversion'.")] string description
56+
) =>
57+
{
58+
// Access the ambient FunctionInvocationContext provided by FunctionInvokingChatClient.
59+
var context = FunctionInvokingChatClient.CurrentContext
60+
?? throw new InvalidOperationException("No ambient FunctionInvocationContext available.");
61+
62+
var tools = context.Options?.Tools;
63+
if (tools is null)
64+
{
65+
return "Unable to register new tools: ChatOptions.Tools is not available.";
66+
}
67+
68+
// Find matching tool sets from the catalog.
69+
List<string> addedToolNames = [];
70+
foreach (var kvp in toolCatalog)
71+
{
72+
var keyword = kvp.Key;
73+
var catalogTools = kvp.Value;
74+
if (description.Contains(keyword, StringComparison.OrdinalIgnoreCase))
75+
{
76+
foreach (var tool in catalogTools)
77+
{
78+
// Avoid adding duplicates.
79+
if (tool is AIFunction fn && !tools.Any(t => t is AIFunction existing && existing.Name == fn.Name))
80+
{
81+
tools.Add(tool);
82+
addedToolNames.Add(fn.Name);
83+
}
84+
}
85+
}
86+
}
87+
88+
return addedToolNames.Count > 0
89+
? "Successfully loaded tools"
90+
: $"No tools matched the description '{description}'. Available categories: {string.Join(", ", toolCatalog.Keys)}.";
91+
},
92+
name: "RequestTools");
93+
94+
// Create the agent with only the RequestTools function initially.
95+
// Insert chat client middleware that logs the tools available on each LLM call,
96+
// making the dynamic expansion visible in the console output.
97+
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
98+
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
99+
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
100+
AIAgent agent = new AzureOpenAIClient(
101+
new Uri(endpoint),
102+
new DefaultAzureCredential())
103+
.GetChatClient(deploymentName)
104+
.AsIChatClient()
105+
.AsBuilder()
106+
.Use(getResponseFunc: ToolLoggingMiddleware, getStreamingResponseFunc: ToolLoggingStreamingMiddleware)
107+
.BuildAIAgent(
108+
instructions: """
109+
You are a helpful assistant. You start with limited tools.
110+
When you need functionality that you don't currently have, call RequestTools with a description
111+
of what you need. After new tools are loaded, use them to answer the user's question.
112+
""",
113+
tools: [requestToolsFunction]);
114+
115+
// Run a conversation that triggers dynamic tool expansion.
116+
Console.WriteLine("=== Dynamic Function Tools Sample ===\n");
117+
118+
string[] prompts =
119+
[
120+
"What's the weather like in Seattle and London?",
121+
"What time is it in New York?",
122+
"Can you convert those temperatures to Celsius?"
123+
];
124+
125+
// --- Non-Streaming Mode ---
126+
Console.ForegroundColor = ConsoleColor.Yellow;
127+
Console.WriteLine("=== Non-Streaming Mode ===");
128+
Console.ResetColor();
129+
Console.WriteLine();
130+
131+
AgentSession session = await agent.CreateSessionAsync();
132+
133+
foreach (var prompt in prompts)
134+
{
135+
Console.ForegroundColor = ConsoleColor.Green;
136+
Console.Write("[User] ");
137+
Console.ResetColor();
138+
Console.WriteLine(prompt);
139+
140+
var response = await agent.RunAsync(prompt, session);
141+
142+
// Print all message contents including tool calls, tool results, and text.
143+
foreach (var message in response.Messages)
144+
{
145+
foreach (var content in message.Contents)
146+
{
147+
switch (content)
148+
{
149+
case FunctionCallContent functionCall:
150+
Console.ForegroundColor = ConsoleColor.Yellow;
151+
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
152+
Console.ResetColor();
153+
break;
154+
155+
case FunctionResultContent functionResult:
156+
Console.ForegroundColor = ConsoleColor.DarkYellow;
157+
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
158+
Console.ResetColor();
159+
break;
160+
161+
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
162+
Console.ForegroundColor = ConsoleColor.Cyan;
163+
Console.Write("[Agent] ");
164+
Console.ResetColor();
165+
Console.WriteLine(textContent.Text);
166+
break;
167+
}
168+
}
169+
}
170+
171+
Console.WriteLine();
172+
}
173+
174+
// --- Streaming Mode ---
175+
Console.ForegroundColor = ConsoleColor.Yellow;
176+
Console.WriteLine("=== Streaming Mode ===");
177+
Console.ResetColor();
178+
Console.WriteLine();
179+
180+
AgentSession streamingSession = await agent.CreateSessionAsync();
181+
182+
foreach (var prompt in prompts)
183+
{
184+
Console.ForegroundColor = ConsoleColor.Green;
185+
Console.Write("[User] ");
186+
Console.ResetColor();
187+
Console.WriteLine(prompt);
188+
189+
bool inAgentText = false;
190+
191+
await foreach (var update in agent.RunStreamingAsync(prompt, streamingSession))
192+
{
193+
foreach (var content in update.Contents)
194+
{
195+
switch (content)
196+
{
197+
case FunctionCallContent functionCall:
198+
if (inAgentText)
199+
{
200+
Console.WriteLine();
201+
inAgentText = false;
202+
}
203+
204+
Console.ForegroundColor = ConsoleColor.Yellow;
205+
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
206+
Console.ResetColor();
207+
break;
208+
209+
case FunctionResultContent functionResult:
210+
Console.ForegroundColor = ConsoleColor.DarkYellow;
211+
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
212+
Console.ResetColor();
213+
break;
214+
215+
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
216+
if (!inAgentText)
217+
{
218+
Console.ForegroundColor = ConsoleColor.Cyan;
219+
Console.Write("[Agent] ");
220+
Console.ResetColor();
221+
inAgentText = true;
222+
}
223+
224+
Console.Write(textContent.Text);
225+
break;
226+
}
227+
}
228+
}
229+
230+
if (inAgentText)
231+
{
232+
Console.WriteLine();
233+
}
234+
235+
Console.WriteLine();
236+
}
237+
238+
// Chat client middleware that logs the number and names of tools on each LLM request.
239+
async Task<ChatResponse> ToolLoggingMiddleware(
240+
IEnumerable<ChatMessage> messages,
241+
ChatOptions? options,
242+
IChatClient innerChatClient,
243+
CancellationToken cancellationToken)
244+
{
245+
LogTools(options);
246+
247+
return await innerChatClient.GetResponseAsync(messages, options, cancellationToken);
248+
}
249+
250+
// Streaming version of the tool logging middleware.
251+
async IAsyncEnumerable<ChatResponseUpdate> ToolLoggingStreamingMiddleware(
252+
IEnumerable<ChatMessage> messages,
253+
ChatOptions? options,
254+
IChatClient innerChatClient,
255+
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
256+
{
257+
LogTools(options);
258+
259+
await foreach (var update in innerChatClient.GetStreamingResponseAsync(messages, options, cancellationToken))
260+
{
261+
yield return update;
262+
}
263+
}
264+
265+
// Shared helper to log the current tool set.
266+
void LogTools(ChatOptions? options)
267+
{
268+
if (options?.Tools is { Count: > 0 } tools)
269+
{
270+
var toolNames = tools.OfType<AIFunction>().Select(t => t.Name);
271+
Console.ForegroundColor = ConsoleColor.DarkGray;
272+
Console.WriteLine($" [Middleware] LLM call with {tools.Count} tool(s): {string.Join(", ", toolNames)}");
273+
Console.ResetColor();
274+
}
275+
else
276+
{
277+
Console.ForegroundColor = ConsoleColor.DarkGray;
278+
Console.WriteLine(" [Middleware] LLM call with 0 tools");
279+
Console.ResetColor();
280+
}
281+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Dynamic Function Tools
2+
3+
This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop.
4+
5+
## What it demonstrates
6+
7+
- The agent starts with only a single `RequestTools` function
8+
- When the model needs capabilities it doesn't have, it calls `RequestTools` with a description of the functionality needed
9+
- The `RequestTools` function uses the ambient `FunctionInvokingChatClient.CurrentContext` to access `ChatOptions.Tools` and add new tools at runtime
10+
- The agent then uses the newly added tools in subsequent iterations of the same function-calling loop
11+
12+
## How it works
13+
14+
1. A tool catalog maps keywords (e.g. "weather", "time", "temperature") to pre-built `AIFunction` instances
15+
2. The `RequestTools` function matches the description against catalog keywords and adds matching tools to `ChatOptions.Tools`
16+
3. `FunctionInvokingChatClient` automatically picks up the new tools on the next iteration of its loop
17+
18+
## Prerequisites
19+
20+
- .NET 10 SDK or later
21+
- Azure OpenAI service endpoint and deployment configured
22+
- Azure CLI installed and authenticated (for Azure credential authentication)
23+
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource
24+
25+
## Running the sample
26+
27+
Set the required environment variables:
28+
29+
```powershell
30+
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
31+
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
32+
```
33+
34+
Run the sample:
35+
36+
```powershell
37+
dotnet run
38+
```

dotnet/samples/02-agents/Agents/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Before you begin, ensure you have the following prerequisites:
4646
|[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.|
4747
|[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.|
4848
|[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.|
49+
|[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.|
4950

5051
## Running the samples from the console
5152

0 commit comments

Comments
 (0)