Giving the Model a Tool
Estimated time: about 18 min; it mostly depends on the model's responses on your CPU.
The problem this module solves
In module 1, you saw an agent's cycle in the form of simplified JSON: the model requests a tool, your code runs it, the model answers. Before automating this cycle in a loop (module 7), you need to see it work once, step by step, with the real .NET types — and pinpoint exactly what the model does and what your code does.
From module 1's JSON to .NET types
Microsoft.Extensions.AI represents the exchange with objects rather than JSON. The mapping is direct:
| In module 1's JSON | In Microsoft.Extensions.AI |
|---|---|
a { "role": "user", "content": ... } message |
ChatMessage(ChatRole.User, "...") |
the tools list |
ChatOptions.Tools, a list of AITool |
the tool_call response with name and arguments |
a FunctionCallContent (Name, Arguments, CallId properties) |
| the tool's result sent back to the model | a FunctionResultContent(callId, result) in a ChatRole.Tool-role message |
The CallId is an identifier assigned to each request by the provider or by the client library: your result must carry it back, so the model knows which request it answers.
To declare the tool, AIFunctionFactory.Create takes a .NET function, a name, and a description, and infers the JSON schema of the parameters from them. An F# function becomes a .NET delegate with Func<int, string>(getOrder) — the equivalent of passing a method where C# expects a Func<int, string>.
The tool.fsx script
Create tool.fsx. It loads the two files you already have; everything else is the round trip, in four numbered steps:
// tool.fsx — module 05: a tool round trip, done by hand.
#load "llm.fsx" "orders.fsx"
open System
open System.Text.Json
open Microsoft.Extensions.AI
open Llm
open Orders
// 1. Describe the tool to the model: name, description, schema (inferred from getOrder).
let orderTool =
AIFunctionFactory.Create(
Func<int, string>(getOrder),
"getOrder",
"Returns the status of an order from its number."
)
printfn "[schema sent to the model] %s" (orderTool.JsonSchema.GetRawText())
let options = createOptions ()
options.Tools <- ResizeArray<AITool> [ orderTool :> AITool ]
let instructions =
"You are the support assistant of an online shop. "
+ "Use the tools to answer. Answer in English, briefly."
let history =
ResizeArray<ChatMessage>
[ ChatMessage(ChatRole.System, instructions)
ChatMessage(ChatRole.User, "Where is my order 4512?") ]
// 2. First turn: the model answers… with a tool request, or with text.
let response1 = client.GetResponseAsync(history, options).GetAwaiter().GetResult()
let requests =
response1.Messages
|> Seq.collect (fun message -> message.Contents)
|> Seq.choose (fun content ->
match content with
| :? FunctionCallContent as request -> Some request
| _ -> None)
|> List.ofSeq
printfn "[turn 1] tool requests: %d" requests.Length
if requests.IsEmpty then
printfn "[turn 1] model text: %s" response1.Text
else
// 3. Your code runs the tool, then adds the result to the history.
history.AddRange(response1.Messages)
for request in requests do
let argsJson = JsonSerializer.Serialize request.Arguments
printfn "[the model requests] %s %s" request.Name argsJson
// Naive reading: module 06 will replace this cast with a real boundary.
let number = (request.Arguments["orderId"] :?> JsonElement).GetInt32()
let result = getOrder number
printfn "[your code runs] getOrder %d -> %s" number result
let content = FunctionResultContent(request.CallId, result) :> AIContent
history.Add(ChatMessage(ChatRole.Tool, ResizeArray<AIContent> [ content ]))
// 4. Second turn: the model reads the result and writes the final answer.
let response2 = client.GetResponseAsync(history, options).GetAwaiter().GetResult()
printfn "[turn 2] final answer: %s" response2.Text
A few new pieces of syntax, all bridges to .NET:
ResizeArray<T>is F#'s name forList<T>, .NET's mutable list;Seq.collectflattens a sequence of sequences, likeSelectManyin LINQ.:? FunctionCallContent as requestis a type test inside amatch, the equivalent of C#'sis FunctionCallContent request. The response contains a list of contents of various types (text, tool request…), andSeq.choosekeeps only the requests.:> AITooland:> AIContentare conversions to the parent type (upcast). C# does them implicitly; F# requires them when it builds a list of a parent type. If you forget them, you'll get anFS0041error ("No overloads match for method…") on theResizeArrayline.:?> JsonElementis the reverse conversion (downcast), like(JsonElement)xin C#. It throws an exception if the value isn't of the expected type — keep this line in mind, it's the subject of module 6.history.AddRange(response1.Messages)adds the model's request to the history before the result: the model has to reread its own request to understand the tool's answer.
Exercise — run the round trip
dotnet fsi tool.fsx
Expect two calls to the model, so anywhere from a few seconds to about thirty seconds on a CPU alone.
Expected output — the lines produced by your code are exact; only the final answer varies:
[schema sent to the model] {"type":"object","properties":{"orderId":{"type":"integer"}},"required":["orderId"]}
[turn 1] tool requests: 1
[the model requests] getOrder {"orderId":4512}
[your code runs] getOrder 4512 -> Order 4512: shipped, tracking TRK-88
[turn 2] final answer: <variable text>
The schema kept the F# parameter's name, orderId, and its type, integer: that, along with its description, is all the model knows about your function.
Observed on 2026-10-30 with qwen3.5:4b on a CPU alone, three successive runs of the same script, with the temperature at 0, gave this final answer:
[turn 2] final answer: Your order 4512 has been shipped. You can track it using the number TRK-88.
Identical three times here, but don't count on it: with the French version of this script, two successive runs gave two differently worded answers. The tool request, on the other hand, was identical every time. That's why, throughout the course, only the lines produced by your code serve as reference output.
If your output differs
[turn 1] tool requests: 0, followed by some text: the model answered without requesting the tool. During the course's preparation runs,qwen3.5:4brequestedgetOrderwith the right number in every run for this kind of question (16 out of 16 with the French version of the script, 3 out of 3 in English), but nothing guarantees it. Run it once more; if it persists, keep the output above as a reference. In module 7, this case will become an expected case of your loop, not a surprise.- An
FS0041error on aResizeArrayline: a:> AIToolor a:> AIContentis missing. [the model requests]shows two requests: the model can request several tools in a single response; the script runs them all. This case was observed with a question about two orders (see the next exercise).
Exercise — who does what?
Here's the real trace of another run of the same script, where only the question changed: "Give me the status of my orders 4512 and 7781." (observed on 2026-10-30 with qwen3.5:4b on a CPU alone; the schema line is omitted):
[turn 1] tool requests: 2
[the model requests] getOrder {"orderId":4512}
[your code runs] getOrder 4512 -> Order 4512: shipped, tracking TRK-88
[the model requests] getOrder {"orderId":7781}
[your code runs] getOrder 7781 -> Order 7781: processing
[turn 2] final answer: Order 4512 is shipped with tracking number TRK-88. Order 7781 is currently being processed.
Without rereading the module, answer:
- Where was the
getOrderfunction run? - What did the model return on turn 1?
- What did the message sent to the model just before turn 2 contain?
Answers
- In your
dotnet fsiprocess, by your code, in thefor request in requestsloop. The model never has access to the function. - Two tool requests (
FunctionCallContent), each with a name, arguments, and aCallId— no text meant for the customer. - The full history: the previous messages, the model's two requests, then two
Tool-role messages, each containing aFunctionResultContentwith the matchingCallIdand the text returned bygetOrder.
What you just did
You declared an F# function as a tool, saw the schema the model receives, read the model's call request in the form of a FunctionCallContent, ran the tool in your code, and sent its result back with the right CallId. You also saw that a temperature of 0 doesn't make the model's text perfectly reproducible — unlike what your code produces.
One line of this script should bother you: :?> JsonElement followed by GetInt32(). If the model sends "ABC-12" instead of a number, or forgets the argument, the script stops on an exception. In the next module, you replace this cast with a boundary that turns every possible defect into a named case, with Result.
Check your understanding
Trace from another agent, with a `getStock` tool: `[turn 1] tool requests: 1`, then `[the model requests] getStock {"sku":"A-17"}`, then `[your code runs] getStock A-17 -> 3 in stock`. What does the request sent to the model for turn 2 contain?
A colleague claims: "With `AIFunctionFactory.Create`, the model receives my F# function and runs it on its side." What does the model actually receive?
Want to hear about the next modules?
The Academy stays free and open-access, no sign-up required. If you'd just like to be notified by email when a new module ships, here you go — no obligation, unsubscribe anytime with one click.