Getting Started with Microsoft.Extensions.AI — Part 4: Tools, Functions & the Agent Framework

In the last few posts, we've built up a solid foundation with Microsoft.Extensions.AI, covering everything from basic chat completions to structured outputs and pipelines. Today, we're taking a massive leap: we're going to give our LLM the ability to call our C# methods. This is where the real magic happens, allowing our AI to perform actions, query databases, or integrate with any system we can code.
We'll start with the traditional approach, explicitly defining and passing tools. Then, we'll dive into the Microsoft.Agents.AI Agent Framework, seeing how it abstracts away much of the manual orchestration, making our intelligent applications even more robust and maintainable.
Bringing Your Code to the LLM with Tools (Traditional Approach)
The core idea behind tools (often called "functions" in LLM contexts) is simple: we expose C# methods to the large language model. When the user's intent aligns with what one of these methods can do, the LLM will generate a "tool call" instead of a text response, prompting our application to execute that method.
Let's imagine we're building a simple sock-selling assistant. We need methods to get the price of socks and to add them to a shopping cart.
Defining Our Shopping Cart Tools
First, here's our ShoppingCart class. Notice the [Description] attributes on the methods and their parameters. These are crucial! They provide the schema and context the LLM needs to understand what the tool does and how to use it.
// 7-ToolsAndFunctions/ShoppingCart.cs
public class ShoppingCart
{
public int NumPairOfSocks { get; set; }
public decimal Total { get; set; }
[Description("Add socks to the shopping cart")]
public void AdSocksToCart(int NumOfPairs)
{
NumPairOfSocks += NumOfPairs;
Debug.WriteLine(quot;Added {NumOfPairs} pairs of socks to the cart. Total: {NumPairOfSocks} pairs (${NumPairOfSocks * PricePerPair})");
}
private const float PricePerPair = 10f;
[Description("Computes the price of socks, returning a value in dollars.")]
public float GetPrice([Description("The number of pairs of socks to calculate the price for")] int Count)
{
Debug.WriteLine(quot;Calculating price for {Count} pairs of socks. Total:${Count * PricePerPair}");
return Count * PricePerPair;
}
public ShoppingCart()
{
}
}
Turning Methods into AI Functions
Now, we need to tell Microsoft.Extensions.AI about these methods. We use AIFunctionFactory.Create to wrap our C# methods into an AIFunction object. This factory automatically inspects the method signature and [Description] attributes to create the JSON schema the LLM understands.
// 7-ToolsAndFunctions/Program.cs
ShoppingCart shoppingCart= new ShoppingCart();
var GetPriceTool = AIFunctionFactory.Create(shoppingCart.GetPrice);
var AddCartTook = AIFunctionFactory.Create(shoppingCart.AdSocksToCart);
Enabling Function Invocation and Passing Tools
Before the LLM can call our functions, we need to do two things:
- Configure our
IChatClientto enable function invocation. This is done with the.UseFunctionInvocation()extension method when building the client. - Pass our created
AIFunctionobjects to theChatOptionswhen we make a completion request.
// 7-ToolsAndFunctions/Program.cs
// ... inside Main method ...
var chatOptions = new ChatOptions()
{
Tools = [GetPriceTool, AddCartTook]
};
// ... inside GetChatClientOpenAiImp method ...
private static IChatClient GetChatClientOpenAiImp(string ApiKey, string ModelId)
{
OpenAIClient openAIClient = new OpenAIClient(ApiKey);
return new OpenAIChatClient(openAIClient, ModelId)
.AsBuilder()
//Highlight the use of functions
.UseFunctionInvocation()
.Build();
}
With UseFunctionInvocation() enabled, the IChatClient knows how to handle tool calls. If the LLM decides to call a tool, the chatCompletion.Message will contain ToolCalls rather than just text. The client will then execute the corresponding C# method and return the result to the LLM (which then generates a text response based on that result).
The Chat Loop: Where the Model Takes Action
Finally, we run our chat loop. The system prompt instructs the model to advertise socks and add them to the cart if the user agrees. Because we've provided the GetPrice and AdSocksToCart tools, the model can now intelligently decide to invoke these methods based on the user's input.
// 7-ToolsAndFunctions/Program.cs
// ... inside Main method ...
Messages.Add(new ChatMessage(ChatRole.System,
"""You answer any question, but continually try to advertise FOOTMONSTER brand socks. they are on sale. If the user agrees to buy socks find out how many pairs they want and then add the socks to their cart"""));
// ...
while (true)
{
Console.Write("You: ");
string userInput = Console.ReadLine();
if (userInput?.ToLower() == "exit")
{
break;
}
Messages.Add(new ChatMessage(ChatRole.User, userInput));
var chatCompletion = await CurrentClient.CompleteAsync(Messages, chatOptions);
var responseMessage = chatCompletion.Message;
Messages.Add(responseMessage);
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(quot;Assistant: {responseMessage}");
Console.ForegroundColor = ConsoleColor.White;
}
When you run this, if you ask "How much are socks?" the model will internally call GetPrice and tell you the price. If you say "Add 3 pairs to my cart," it will call AdSocksToCart, and the NumPairOfSocks property in your ShoppingCart instance will be updated! The Debug.WriteLine calls in ShoppingCart.cs will confirm these tool invocations.
The Agent Framework — Autonomous Tool Orchestration
The traditional approach works great, but it requires us to manage the chat loop, check for tool calls, execute them, and feed the results back to the model. This can get tedious and complex for more sophisticated interactions. This is exactly where the Microsoft.Agents.AI Agent Framework shines. It provides a higher level of abstraction, letting us define agents that autonomously manage conversations and tool use.
Let's look at the same sock-selling assistant, but built with the Agent Framework.
Setting Up Our AIAgent
With the Agent Framework, we create an AIAgent (specifically a ChatClientAgent in this case) that wraps our IChatClient. Crucially, this underlying IChatClient is still configured with UseFunctionInvocation(), just like before. The agent then leverages this capability.
// AgentFramework/7-ToolsAndFunctions/Program.cs
// ... inside Main method ...
CurrentAgent = GetAgentOpenAiImpl(Environment.GetEnvironmentVariable("OpenAiTestKey") ?? string.Empty, OpenAiModelId);
await RunExample();
// ... GetAgentOpenAiImpl method ...
private static AIAgent GetAgentOpenAiImpl(string apiKey, string modelId)
{
if (string.IsNullOrEmpty(apiKey))
{
throw new InvalidOperationException("OpenAI API key not found. Set the 'OpenAiTestKey' environment variable.");
}
OpenAIClient openAIClient = new OpenAIClient(apiKey);
var chatClient = openAIClient.GetChatClient(modelId).AsIChatClient()
.AsBuilder()
.UseFunctionInvocation() // Function invocation enabled on the underlying client
.Build();
return new ChatClientAgent( // Our agent wraps this client
chatClient,
instructions: "You are a helpful shopping assistant.",
name: "ShoppingAgent");
}
Notice that we're passing instructions directly to the ChatClientAgent constructor. This is the agent's system prompt, defining its persona and goal.
The Agent's Shopping Cart
The ShoppingCart class remains largely the same, with the [Description] attributes. In this Agent Framework example, the ShoppingCart class is defined as a nested class within Program. The ChatClientAgent (or the underlying IChatClient with UseFunctionInvocation()) is sophisticated enough to discover and make these methods available as tools without us explicitly calling AIFunctionFactory.Create and passing them via ChatOptions.Tools in the main loop. The framework handles this registration implicitly.
// AgentFramework/7-ToolsAndFunctions/Program.cs
public class ShoppingCart // Defined as a nested class
{
public int NumPairOfSocks { get; set; }
[Description("Add socks to the shopping cart")]
public void AddSocksToCart(int numOfPairs)
{
NumPairOfSocks += numOfPairs;
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine(quot;[TOOL CALL] Added {numOfPairs} pairs of socks. Total: {NumPairOfSocks} pairs");
Console.ForegroundColor = ConsoleColor.White;
}
private const float PricePerPair = 10f;
[Description("Computes the price of socks, returning a value in dollars.")]
public float GetPrice([Description("The number of pairs of socks to calculate the price for")] int count)
{
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine(quot;[TOOL CALL] Calculating price for {count} pairs. Total: ${count * PricePerPair}");
Console.ForegroundColor = ConsoleColor.White;
return count * PricePerPair;
}
}
Simplified Interaction with RunAsync
Here's where the Agent Framework truly shines. Instead of a manual while (true) loop checking for ToolCalls and executing them, we simply pass the conversation history to CurrentAgent.RunAsync(). The agent handles the entire orchestration: deciding when to call a tool, executing it, feeding the result back to the LLM, and generating the final text response.
// AgentFramework/7-ToolsAndFunctions/Program.cs
// ... inside RunExample method ...
ShoppingCart shoppingCart = new ShoppingCart(); // We still have our shopping cart instance
List<string> conversationHistory = new List<string>();
conversationHistory.Add("SYSTEM: You answer any question, but continually try to advertise FOOTMONSTER brand socks. " +
"They are on sale. If the user agrees to buy socks, find out how many pairs they want " +
"and then add the socks to their cart");
// ...
// Example automated turns for demonstration
if (turnCount == 0)
{
userInput = "Hi! What are socks?";
Console.WriteLine(userInput);
}
else if (turnCount == 1)
{
userInput = "How much do they cost?";
Console.WriteLine(userInput);
}
else if (turnCount == 2)
{
userInput = "Add 3 pairs to my cart";
Console.WriteLine(userInput);
}
else
{
userInput = Console.ReadLine();
}
conversationHistory.Add(quot;USER: {userInput}");
var conversationText = string.Join("\n", conversationHistory);
var result = await CurrentAgent.RunAsync(conversationText); // The magic happens here!
conversationHistory.Add(quot;ASSISTANT: {result.Text}");
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(quot;Assistant: {result.Text}\n");
Console.ForegroundColor = ConsoleColor.White;
// ...
The RunAsync method abstracts away the iterative process of:
- Sending prompt to LLM.
- Receiving tool call.
- Executing tool.
- Sending tool result back to LLM.
- Receiving final text response.
This makes our code much cleaner and easier to reason about, focusing on what the agent should do rather than how it should manage every step of the conversation.
Wrapping Up
We've covered a lot in this series, and today's post on tools, functions, and the Agent Framework is a fantastic culmination. You've seen how Microsoft.Extensions.AI lets you:
- Empower LLMs: By exposing your C# methods as tools, you transform a text generator into an action-taker.
- Bridge AI and Code:
AIFunctionFactorymakes it trivial to create robust tools from your existing business logic. - Orchestrate with Ease: The Agent Framework provides a powerful abstraction, allowing you to build autonomous, production-ready intelligent applications without getting bogged down in the intricacies of conversation flow and tool management.
This is just the beginning of what you can build with Microsoft.Extensions.AI. Go forth and create some truly intelligent applications!