In the last lesson, the Kladde got an agent that can talk but knows nothing. Ask it who your contact at the utilities company is, and it guesses or honestly says it doesn't know. In this lesson, it gets tools: small methods of the Kladde that it may call itself. With them it finds contacts, lists open tenders and reads the office's day rates. All three only read; the agent isn't allowed to write until the next lesson, and then only with approval.

Two packages are new in the tests:

Package Version
Microsoft.Extensions.TimeProvider.Testing 10.11.0
Microsoft.Extensions.AI.Evaluation.Quality (in Kladde.Ai.Evaluation since T9.2) 10.11.0

How a model calls a tool

A language model can't run code. But in its answer, it can write: "I'd like to call the tool GetDayRate with the role Consulting." This ability is called function calling or tool calling. The flow has four steps:

  1. Along with the question, your code sends a list of tools. Each tool has a name, a description and a JSON schema of its parameters.
  2. The model answers not with text but with a call: name and arguments. In Microsoft.Extensions.AI, that is a FunctionCallContent.
  3. Your code runs the method and sends the result back as a new message, with the role tool, as a FunctionResultContent.
  4. The model gets everything again, sees the result and answers, or it calls the next tool.

In the ChatClientAgent, step 3 is handled by a FunctionInvokingChatClient from Microsoft.Extensions.AI. The agent builds it into its pipeline by itself. Important for the costs: every round is a model call of its own. A question with one tool costs two calls, one with three tools in a row costs four, and every call sends the whole history so far along.

The following file-based app shows the flow without a model. A small client plays the model: on the first call, it asks for the tool; on the second, it answers with the result.

C#
// tool.cs
#:package Microsoft.Agents.AI@1.28.0

using System.ComponentModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

// A local function gets another name from the compiler, so the tool gets its name here.
AIFunction rate = AIFunctionFactory.Create(GetDayRate, name: "GetDayRate");
Console.WriteLine($"{rate.Name}: {rate.Description}");
Console.WriteLine(rate.JsonSchema);

AIAgent agent = new TwoStepModel().AsAIAgent(instructions: "You answer briefly.", tools: [rate]);
AgentResponse response = await agent.RunAsync("What does a day of consulting cost with us?");

foreach (ChatMessage message in response.Messages)
{
    Console.WriteLine($"{message.Role}: {string.Join(", ", message.Contents.Select(Describe))}");
}

[Description("Returns the net day rate of a role.")]
static decimal GetDayRate([Description("The role, for example 'Consulting' or 'Development'.")] string role) =>
    role == "Consulting" ? 950m : 820m;

static string Describe(AIContent content) => content switch
{
    FunctionCallContent call => $"Call {call.Name}({string.Join(", ", call.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})",
    FunctionResultContent result => $"Result {result.Result}",
    TextContent text => $"Text “{text.Text}”",
    _ => content.GetType().Name,
};

// Plays the model: first it asks for the tool, then it answers with what the tool returned.
sealed class TwoStepModel : IChatClient
{
    public Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
    {
        Console.WriteLine($"Model gets: {string.Join(", ", messages.Select(m => m.Role))} | Tools: {string.Join(", ", options?.Tools?.Select(t => t.Name) ?? [])}");
        FunctionResultContent? result = messages.Last().Contents.OfType<FunctionResultContent>().FirstOrDefault();
        ChatMessage answer = result is null
            ? new(ChatRole.Assistant, [new FunctionCallContent("call-1", "GetDayRate", new Dictionary<string, object?> { ["role"] = "Consulting" })])
            : new(ChatRole.Assistant, $"A day of consulting costs {result.Result} net.");
        return Task.FromResult(new ChatResponse(answer));
    }

    public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
        throw new NotSupportedException();

    public object? GetService(Type serviceType, object? serviceKey = null) => null;

    public void Dispose()
    {
    }
}
PowerShell
dotnet run tool.cs
GetDayRate: Returns the net day rate of a role.
{"type":"object","properties":{"role":{"description":"The role, for example 'Consulting' or 'Development'.","type":"string"}},"required":["role"]}
Model gets: user | Tools: GetDayRate
Model gets: user, assistant, tool | Tools: GetDayRate
assistant: Call GetDayRate(role: Consulting)
tool: Result 950
assistant: Text “A day of consulting costs 950 net.”

Read the output from top to bottom:

  • What the model knows about the tool is in the first two lines: name, description and the schema. ' is the apostrophe; that's how the JSON serializer escapes it. The model gets nothing more, not even the method's code.
  • Two requests for one question. The second contains the question, the call as a message from the assistant and the result as a message with the role tool.
  • The agent's response contains all three messages. Later, you read from it which tools it used.

And the comment above AIFunctionFactory.Create is meant seriously. Without name:, the tool here is called _Main_g_GetDayRate_0_0, because that's what the compiler calls a local function. The model then calls "GetDayRate", and the loop finds no tool of that name. With normal methods of a class, as in the Kladde in a moment, the name is right by itself.