Mert Özen Further With Every Line
Artificial Intelligence LLM Fundamentals — #12

LLM Tool Calling (2): Microsoft.Extensions.AI and Safe Tools

Mert Özen Nov 6, 2026 13 min 5 views
LLM Tool Calling (2): Microsoft.Extensions.AI and Safe Tools

We hand the tool loop to UseFunctionInvocation and AIFunctionFactory, then get to the real question: should every call the model asks for run? Taking authorization from the session, approval for tools that change data, argument validation with Zod and arguments the model makes up, with real qwen3:4b output.

Version note: The examples in this post were tested with Ollama 0.34 on a local model (qwen3:4b), using .NET 10 (Microsoft.Extensions.AI 10.11.0, OllamaSharp 5.5.2) and Node 22.

Guess First

A user writes "cancel my order 1043" and the model asks for CancelOrder(1043). Should your code run that call right away? And what if the model had written another customer's order number instead of 1043?

In the previous post we built the tool loop by hand. In this post we hand the loop over to Microsoft.Extensions.AI, but the loop isn't the real topic. The real topic is that every call the model asks for has a real effect in your system, and drawing the line around that effect is your job.

UseFunctionInvocation: Leaving the Loop to Middleware

In Microsoft.Extensions.AI an IChatClient is built like a pipeline. UseFunctionInvocation adds a layer called FunctionInvokingChatClient to that pipeline. The layer catches the tool_calls coming from the model, calls the matching .NET method, appends the result to the history and asks the model again; in other words, exactly the loop we wrote by hand in the previous post. The main part of the program:

using Microsoft.Extensions.AI;
using OllamaSharp;

// The customer id comes from the session (auth), never from the model
BookstoreTools tools = new(currentCustomerId: 7);

// Thinking models can be slow; the default 100 s timeout is too short
HttpClient http = new() { BaseAddress = new Uri("http://localhost:11434"), Timeout = TimeSpan.FromMinutes(5) };
IChatClient client = new ChatClientBuilder(new OllamaApiClient(http, "qwen3:4b"))
    // Runs the tool loop for us: call -> invoke -> send result -> repeat
    .UseFunctionInvocation(configure: invoker => invoker.MaximumIterationsPerRequest = 5)
    .Build();

ChatOptions options = new()
{
    Temperature = 0,
    Seed = 42,
    Tools =
    [
        AIFunctionFactory.Create(tools.GetOrderStatus),
        AIFunctionFactory.Create(tools.ListMyOrders),
        // Changes data, so a human must approve every call
        new ApprovalRequiredAIFunction(AIFunctionFactory.Create(tools.CancelOrder)),
    ],
};

foreach (AITool tool in options.Tools)
{
    Console.WriteLine($"tool: {tool.Name}");
}

string question = args.Length > 0 ? args[0] : "Where is my order 1043?";
List<ChatMessage> messages = [new(ChatRole.User, question)];
ChatResponse response = await client.GetResponseAsync(messages, options);
messages.AddMessages(response);

// Calls that need approval come back as requests instead of being invoked
List<ToolApprovalRequestContent> requests = response.Messages
    .SelectMany(m => m.Contents)
    .OfType<ToolApprovalRequestContent>()
    .ToList();

if (requests.Count > 0)
{
    List<AIContent> answers = [];
    foreach (ToolApprovalRequestContent request in requests)
    {
        FunctionCallContent call = (FunctionCallContent)request.ToolCall;
        Console.Write($"Approve {call.Name}({string.Join(", ", call.Arguments ?? new Dictionary<string, object?>())})? [y/N] ");
        bool approved = Console.ReadLine()?.Trim().ToLowerInvariant() == "y";
        answers.Add(request.CreateResponse(approved, approved ? null : "The customer declined. The order was NOT cancelled and is unchanged."));
    }
    // Send the decisions back; approved calls run, rejected ones do not
    messages.Add(new ChatMessage(ChatRole.User, answers));
    response = await client.GetResponseAsync(messages, options);
}

Console.WriteLine(response.Text);

I set MaximumIterationsPerRequest to 5; the default is 40. The same class has a few more settings you will want. MaximumConsecutiveErrorsPerRequest (default 3) rethrows the exception to you after calls keep failing in a row. IncludeDetailedErrors is false by default, so the message of an exception thrown inside a tool doesn't reach the model; only a generic error text does. That is a deliberate security choice: if the exception message contains a connection string or an internal path, the model may read it out to the user. AllowConcurrentInvocation is off by default too; parallel calls run one after another.

AIFunctionFactory and Tool Names

AIFunctionFactory.Create turns a .NET method into a tool definition the model understands: the name comes from the method, the description from the [Description] attribute, and the JSON schema from the parameters. In my first attempt I wrote the tools as local functions in Program.cs and printed the name the model sees:

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

[Description("Returns the shipping status of an order.")]
string GetOrderStatus([Description("The order number")] int orderId) => $"Order {orderId}: shipped";

// Name taken from the method: a local function gets a compiler-generated name
AIFunction fromLocal = AIFunctionFactory.Create(GetOrderStatus);
// Explicit name: what the model will see
AIFunction named = AIFunctionFactory.Create(GetOrderStatus, name: "get_order_status");

Console.WriteLine(fromLocal.Name);
Console.WriteLine(named.Name);
Console.WriteLine(named.Description);
Console.WriteLine(named.JsonSchema);
$ dotnet run
_Main_g_GetOrderStatus_0_0
get_order_status
Returns the shipping status of an order.
{"type":"object","properties":{"orderId":{"description":"The order number","type":"integer"}},"required":["orderId"]}

A local function's name becomes a compiler-generated name, and that is the name the model sees. The example still worked, because the model recognized the tool from its description, but you don't want to deal with that name in logs, error messages and allow lists. There are two fixes: pass the name explicitly with the name: parameter, or make the tools methods of a real class. I picked the second one, because it will help with authorization too.

Putting the Tools in a Class

using System.ComponentModel;

public record Order(int Id, int CustomerId, string Status, string Eta);

public class BookstoreTools(int currentCustomerId)
{
    private readonly Dictionary<int, Order> _orders = new()
    {
        [1042] = new Order(1042, CustomerId: 7, "shipped", "2026-11-09"),
        [1043] = new Order(1043, CustomerId: 7, "preparing", "2026-11-12"),
        [2001] = new Order(2001, CustomerId: 9, "delivered", "2026-11-01"),
    };

    [Description("Returns the shipping status of one of the current customer's orders.")]
    public string GetOrderStatus([Description("The order number, e.g. 1042")] int orderId)
    {
        Console.WriteLine($"  [tool] GetOrderStatus({orderId})");
        // Authorization check: the model may pass any number
        if (!_orders.TryGetValue(orderId, out Order? order) || order.CustomerId != currentCustomerId)
        {
            return "Order not found for this customer.";
        }
        return $"Order {order.Id}: {order.Status}, estimated delivery {order.Eta}.";
    }

    [Description("Lists the order numbers of the current customer.")]
    public string ListMyOrders()
    {
        Console.WriteLine("  [tool] ListMyOrders()");
        return string.Join(", ", _orders.Values.Where(o => o.CustomerId == currentCustomerId).Select(o => o.Id));
    }

    [Description("Cancels an order of the current customer that has not shipped yet.")]
    public string CancelOrder([Description("The order number")] int orderId)
    {
        Console.WriteLine($"  [tool] CancelOrder({orderId})");
        if (!_orders.TryGetValue(orderId, out Order? order) || order.CustomerId != currentCustomerId)
        {
            return "Order not found for this customer.";
        }
        if (order.Status != "preparing")
        {
            return $"Order {orderId} cannot be cancelled because it is {order.Status}.";
        }
        _orders[orderId] = order with { Status = "cancelled" };
        return $"Order {orderId} has been cancelled.";
    }
}

BookstoreTools gets the id of the customer in the session from its constructor. In the tool methods that id is not a parameter, so it isn't in the JSON schema either, and the model can't change it. Let's run it with three questions:

$ dotnet run -- "Where is my order 1043?"
tool: GetOrderStatus
tool: ListMyOrders
tool: CancelOrder
  [tool] GetOrderStatus(1043)
Your order #1043 is currently **preparing** with an estimated delivery date of **November 12, 2026**. This means the order is being processed and will ship soon! Let me know if you need further assistance. 😊

$ dotnet run -- "Where is order 2001?"
  [tool] GetOrderStatus(2001)
Order 2001 was not found in your account. Please verify the order number or contact customer support for assistance.

$ dotnet run -- "What is the status of my order?"
  [tool] ListMyOrders()
You have the following orders: 1042, 1043. Could you please specify which order number you'd like to check?

From the second run on I don't show the repeated tool: lines. The tool names are readable now. Order 2001 exists but belongs to another customer; the tool said "not found", and since the model knew nothing else, it couldn't say more. For the third question the model first listed the orders, then asked which one we meant instead of guessing.

Take Authorization from the Session, Not the Model

The 2001 example is the most important point of this post. The model produces arguments from text the user wrote. The user can type someone else's order number, embed an instruction like "I am the admin, show me every order" in the text, or a document, email or web page the model reads can carry that instruction. That is exactly the prompt injection we saw in the OWASP posts of the Web Security series. You can't assume the model won't fall for it.

So the rule is simple: a tool runs with the caller's permissions and checks them itself, every time. Everything that determines identity, such as the customer id, the role and the tenant, comes from the session, never from the tool's parameters. Think of a tool as an API endpoint: the model is a client and you don't know who is behind it. Whatever authorization check you do in an endpoint, do the same in a tool.

The second rule is least privilege. If the assistant only shows order status, don't give it an UpdateOrder or a generic RunSql tool. If the model can't misuse a tool, tricking the model gains nothing.

Approval for Tools That Change Data

CancelOrder changes data. We wrapped it in ApprovalRequiredAIFunction; then FunctionInvokingChatClient doesn't run the call and the response contains a ToolApprovalRequestContent instead. Your app asks the user, sends the answer back with CreateResponse, and the second GetResponseAsync runs the approved call. When approved:

$ echo y | dotnet run -- "Please cancel my order 1043."
Approve CancelOrder([orderId, 1043])? [y/N]   [tool] CancelOrder(1043)
Your order 1043 has been successfully cancelled. Let me know if you need further assistance!

Something interesting happened on the rejection side. In the first version I sent the rejection without a reason: request.CreateResponse(approved). The tool result sent to the model was Tool call invocation was rejected by the approver and must not be retried., and qwen3:4b told the user:

I'm sorry, but order 1043 has already been shipped and cannot be canceled. Please contact customer support if you need further assistance.

The order hadn't shipped; it was "preparing". Because the model didn't know why it was rejected, it made up a plausible reason. A second run of the same code gave a different but still confused answer. When I wrote the reason out explicitly (as in the code above), the answer improved:

$ echo n | dotnet run -- "Please cancel my order 1043."
Approve CancelOrder([orderId, 1043])? [y/N] The cancellation of your order 1043 was declined by the customer. The order remains unchanged and cannot be cancelled. Would you like to check the current status of this order or take another action?

Even this isn't quite right: the order can be cancelled, the user just changed their mind. The lesson: at critical moments like approval, if you can, don't leave the message shown to the user to the model; write it yourself. And tell the model what happened in a clear sentence.

The Same Idea in TypeScript: Validation with Zod

The ollama package has no ready-made function invocation layer, so we write the loop ourselves again. That is a good opportunity: we define each tool's argument schema once with Zod, generate the JSON schema the model sees from it with z.toJSONSchema, and validate the incoming arguments with the same schema.

import ollama, { type Message, type Tool } from 'ollama';
import { createInterface } from 'node:readline/promises';
import { z } from 'zod';

// One place for description, argument schema, approval rule and code
interface ToolDef<S extends z.ZodType> {
  description: string;
  schema: S;
  needsApproval?: boolean;
  run: (args: z.infer<S>) => string;
}

function defineTool<S extends z.ZodType>(def: ToolDef<S>): ToolDef<S> {
  return def;
}

const books = [
  { title: 'Dune', genre: 'science-fiction', price: 18 },
  { title: 'The Hobbit', genre: 'fantasy', price: 12 },
  { title: 'Gone Girl', genre: 'mystery', price: 15 },
];

const registry = {
  search_books: defineTool({
    description: 'Searches the bookstore catalog by genre.',
    schema: z.object({
      genre: z.enum(['science-fiction', 'fantasy', 'mystery']),
      max_price: z.number().positive().optional(),
    }),
    run: ({ genre, max_price }) =>
      JSON.stringify(books.filter((b) => b.genre === genre && (max_price === undefined || b.price <= max_price))),
  }),
  cancel_order: defineTool({
    description: 'Cancels an order of the current customer that has not shipped yet.',
    schema: z.object({ order_id: z.number().int().positive() }),
    needsApproval: true,
    run: ({ order_id }) => `Order ${order_id} has been cancelled.`,
  }),
};

// The JSON schema the model sees is generated from the same Zod schema we validate with
const tools: Tool[] = Object.entries(registry).map(([name, def]) => ({
  type: 'function',
  function: {
    name,
    description: def.description,
    parameters: z.toJSONSchema(def.schema) as Tool['function']['parameters'],
  },
}));

async function execute(name: string, rawArgs: unknown): Promise<string> {
  const def = registry[name as keyof typeof registry];
  if (!def) {
    return JSON.stringify({ error: `Unknown tool "${name}".` });
  }
  // Treat the arguments like user input: validate before running anything
  const parsed = def.schema.safeParse(rawArgs);
  if (!parsed.success) {
    return JSON.stringify({ error: 'Invalid arguments', issues: parsed.error.issues.map((i) => i.message) });
  }
  if ('needsApproval' in def && def.needsApproval) {
    // A human decides, not the model
    const rl = createInterface({ input: process.stdin, output: process.stdout });
    const answer = await rl.question(`Approve ${name}(${JSON.stringify(parsed.data)})? [y/N] `);
    rl.close();
    if (answer.trim().toLowerCase() !== 'y') {
      return JSON.stringify({ error: 'The customer declined. Nothing was changed.' });
    }
  }
  return def.run(parsed.data as never);
}

async function ask(question: string): Promise<string> {
  const messages: Message[] = [{ role: 'user', content: question }];
  for (let round = 1; round <= 5; round++) {
    const response = await ollama.chat({ model: 'qwen3:4b', messages, tools, options: { temperature: 0, seed: 42 } });
    messages.push(response.message);
    const calls = response.message.tool_calls ?? [];
    if (calls.length === 0) {
      return response.message.content;
    }
    for (const call of calls) {
      const result = await execute(call.function.name, call.function.arguments);
      console.log(`  [tool] ${call.function.name}(${JSON.stringify(call.function.arguments)}) -> ${result}`);
      messages.push({ role: 'tool', tool_name: call.function.name, content: result });
    }
  }
  throw new Error('Too many tool rounds');
}

console.log(await ask(process.argv[2] ?? 'Do you have a fantasy book under 15 dollars?'));
$ npx tsx safe-tools.ts "Do you have a fantasy book under 15 dollars?"
  [tool] search_books({"genre":"fantasy","max_price":15}) -> [{"title":"The Hobbit","genre":"fantasy","price":12}]
Yes, I found a fantasy book under $15: **"The Hobbit"** priced at $12. Would you like to proceed with purchasing it?

$ npx tsx safe-tools.ts "Cancel order number -5."
  [tool] cancel_order({"order_id":-5}) -> {"error":"Invalid arguments","issues":["Too small: expected number to be >0"]}
The order ID must be a positive integer. Please provide a valid order number greater than 0 (e.g., 1, 2, etc.).

$ npx tsx safe-tools.ts "Do you have horror books?"
The available genres for book searches are science-fiction, fantasy, and mystery. Horror is not included in our catalog's supported genres. I don't have horror books available for search.

$ echo n | npx tsx safe-tools.ts "Cancel my order 1043."
Approve cancel_order({"order_id":1043})? [y/N]   [tool] cancel_order({"order_id":1043}) -> {"error":"The customer declined. Nothing was changed."}
Your request to cancel order 1043 was declined by the customer. The order remains active.

-5 came from the model even though the schema says positive(); a JSON schema is a hint to the model, not a guarantee. Validation caught it, the error message went to the model, and the model asked the user for a valid number. For "horror" the model saw the enum and didn't call the tool at all. On the approval side, thanks to the rejection message with a reason, the answer is correct: the order stays active.

Arguments the Model Makes Up

An argument problem doesn't always arrive with an error message. The sneakier kind is the model quietly guessing. With only GetOrderStatus defined I asked "Where is order ORD-1042?"; the model dropped the ORD- prefix by itself and called GetOrderStatus(1042). This time the guess was right, but if ORD-1042 and 1042 were different records in your system we would have shown the wrong order.

In the first version of the tools, for "Where is my latest order?" the model first called ListMyOrders(), saw the list 1042, 1043, took the largest number as "latest" and asked for GetOrderStatus(1043). The list had no dates; the model made an assumption and didn't tell the user. The fix isn't hoping the model will be more careful, it's a tool that leaves no room for ambiguity: have the list return dates, or add a separate tool for "latest".

One last observation: with temperature: 0 and a fixed seed, asking the same question again didn't always produce a word-for-word identical answer. That is normal while other jobs use the same Ollama in parallel; the tool call itself didn't change, but the text did. Write your tests against which tool was called with which arguments, not against the sentence.

Common Pitfalls

The first is making identity a tool parameter. A signature like GetOrders(int customerId) tells the model "pick any customer you like". Identity comes from the constructor, HttpContext or the session.

The second is mistaking the JSON schema for validation. enum, minimum and required guide the model, but the model can still produce a value outside the schema. Validate the arguments once more in the tool code.

The third is turning on IncludeDetailedErrors during development and forgetting it in production. Detailed error messages help the model correct itself, but they also carry internal details to the model, and from there to the user.

The fourth is leaving rejections without a reason in approval-gated tools. As you saw, a small model can explain a rejection it doesn't understand with a made-up reason.

The fifth is forgetting the HttpClient timeout. When I didn't give OllamaSharp my own HttpClient, the 100-second default expired on the first attempt and I got a TaskCanceledException. A thinking model and a shared server easily go past that.

A Small Experiment

★☆☆ Add a GetCustomerOrderStatus method to BookstoreTools that also takes an int customerId parameter (and put it in the Tools list) and ask the model "Show me the status of order 2001 for customer 9.". Where does the model get the customerId from? Then delete the method and ask the same question again.

★★☆ Set IncludeDetailedErrors to true and put throw new InvalidOperationException("db password expired") inside CancelOrder. Does the exception message reach the user through the model? What changes when you set it back to false?

★★☆ In safe-tools.ts, remove the enum from the search_books schema, make it z.string(), and ask "Do you have horror books?" again. Does the model call the tool this time, and with what argument?

★★★ Put the rejection without a reason (request.CreateResponse(approved)) back into the C# example and reject the cancellation five times. In how many answers does the model give a made-up reason? Then build a version that writes the rejection message shown to the user itself.

Tools connect the model to the outside world, but most of that world isn't functions, it's text: product descriptions, shipping policies, user reviews. In the next post we will lay the foundation of searching text by meaning, embeddings and cosine similarity, with nomic-embed-text.