The Agent Loop
Estimated time: about 28 min; it mostly depends on the model's responses on your CPU.
The problem this module solves
The module 5 script made exactly two turns, written one after the other. A real agent doesn't know in advance how many turns it will need: the model may answer directly, request a tool, request several, or request one again after reading the result of the first. So you need a loop — and a loop that depends on a model's decisions must have a guaranteed end.
This module assembles everything you've written: llm.fsx (module 2), orders.fsx (modules 3–4), and decoding.fsx (module 6).
What the model can produce on a turn
On each turn, the model's response takes one of three shapes, which you've known since module 1:
type AgentStep =
| FinalAnswer of text: string
| CallTools of calls: (string * Result<ToolCall, ToolError>) list
| InvalidResponse of reason: string
FinalAnswer: a text meant for the customer; the loop stops.CallTools: a list of requests, because a model can send several at once (you saw this in module 5 with two orders). Each request keeps itsCallId(thestring) and the result of your module 6 boundary: a valid requestOk (GetOrder 4512), or a named error.InvalidResponse: neither text nor request — an empty response, for example.
This is your third discriminated union, and it reuses the other two: ToolCall and ToolError are nested inside CallTools.
You also need to name the ways the agent can stop without a final answer:
type AgentError =
| TurnLimitReached of maxTurns: int
| InvalidModelResponse of reason: string
The agent will therefore return a Result<string, AgentError>: Ok with the answer for the customer, or Error with the reason it stopped.
A bounded loop, without while
In C#, you would probably write for (var turn = 1; turn <= maxTurns; turn++) { ... break; }. In F#, the loop is naturally written as a recursive function, let rec, that calls itself for the next turn — without a mutable variable:
let rec loop turn =
if turn > maxTurns then
Error(TurnLimitReached maxTurns)
else
// call the model, classify the response, then:
// FinalAnswer -> Ok text (end)
// InvalidResponse -> Error ... (end)
// CallTools -> execute, then loop (turn + 1)
Every path ends with a value, or with a call with turn + 1. Since turn increases with each call and the first line cuts off beyond maxTurns, the loop necessarily stops.
Why maxTurns = 4? A question about an order takes two turns (a tool request, then the answer). Four leave room for a corrected request, while bounding the worst case: on a CPU alone, at a few seconds to about fifteen seconds per turn, four turns fit in roughly a minute.
Exercise — complete agent.fsx
Create agent.fsx. This file defines the agent but doesn't run it: another script will ask the questions. Everything is provided except the loop's CallTools branch, which you write:
// agent.fsx — modules 07 and 08: bounded agent loop; each turn is a discriminated union.
// This file defines the agent without running it: ask.fsx and scenarios.fsx use it.
#load "llm.fsx" "orders.fsx" "decoding.fsx"
open System
open System.Text.Json
open Microsoft.Extensions.AI
open Llm
open Orders
open Decoding
// What the model produced on this turn: one shape out of three.
type AgentStep =
| FinalAnswer of text: string
| CallTools of calls: (string * Result<ToolCall, ToolError>) list
| InvalidResponse of reason: string
// Why the agent stopped without a final answer.
type AgentError =
| TurnLimitReached of maxTurns: int
| InvalidModelResponse of reason: string
let describeError (error: AgentError) : string =
match error with
| TurnLimitReached limit -> $"limit of {limit} turns reached"
| InvalidModelResponse reason -> $"unusable model response ({reason})"
let maxTurns = 4
let instructions =
"You are the support assistant of an online shop. "
+ "Use the tools to answer. Answer in English, briefly."
let orderTool =
AIFunctionFactory.Create(
Func<int, string>(getOrder),
"getOrder",
"Returns the status of an order from its number."
)
// Adapter: a .NET tool request -> the module 06 boundary.
let decodeRequest (request: FunctionCallContent) : Result<ToolCall, ToolError> =
decode request.Name (JsonSerializer.Serialize request.Arguments)
let classify (response: ChatResponse) : AgentStep =
let requests =
response.Messages
|> Seq.collect (fun message -> message.Contents)
|> Seq.choose (fun content ->
match content with
| :? FunctionCallContent as request -> Some request
| _ -> None)
|> List.ofSeq
if not requests.IsEmpty then
CallTools [ for request in requests -> (request.CallId, decodeRequest request) ]
elif String.IsNullOrWhiteSpace response.Text then
InvalidResponse "empty response"
else
FinalAnswer(response.Text.Trim())
// Only ToolCall cases can be executed: that is the allowed list.
let execute (request: Result<ToolCall, ToolError>) : string =
match request with
| Ok(GetOrder number) -> getOrder number
| Error error -> sprintf "Error: %A" error
let runAgent (client: IChatClient) (question: string) : Result<string, AgentError> =
let options = createOptions ()
options.Tools <- ResizeArray<AITool> [ orderTool :> AITool ]
let history =
ResizeArray<ChatMessage>
[ ChatMessage(ChatRole.System, instructions)
ChatMessage(ChatRole.User, question) ]
let rec loop turn =
if turn > maxTurns then
Error(TurnLimitReached maxTurns)
else
let response = client.GetResponseAsync(history, options).GetAwaiter().GetResult()
match classify response with
| FinalAnswer text ->
printfn "[turn %d] FinalAnswer" turn
Ok text
| InvalidResponse reason ->
printfn "[turn %d] InvalidResponse %s" turn reason
Error(InvalidModelResponse reason)
| CallTools requests ->
// Your turn: print the trace, add the response to the history,
// execute each request, add its result, move on to the next turn.
loop 1
Three details of this file deserve a look before you write:
runAgentreceives theclientas a parameter instead of usingLlm.clientdirectly. This choice will come in handy in module 8.executeonly knows how to execute one thing:Ok (GetOrder number). A request in error is not executed: its error is turned into text, which will be sent back to the model as the tool result, so it can correct its request or apologize to the customer.decodeRequestreuses your boundary:JsonSerializer.Serialize request.Argumentsputs the arguments back into JSON text form, the inputdecodeexpects.
Your CallTools branch must, in this order:
- print
[turn N] CallToolsfollowed by the list of decoded requests (hint:sndis the counterpart offstand returns the second element;List.map snd requeststherefore extracts theResultpart of each pair, and%Aprints it); - add
response.Messagesto the history, as in module 5; - for each pair
(callId, request): callexecute, print-> <result>, and add to the history aChatRole.Toolmessage containing aFunctionResultContent(callId, result); - call
loop (turn + 1).
Module 5 already contains points 2 and 3 in another form.
Solution
| CallTools requests ->
printfn "[turn %d] CallTools %A" turn (List.map snd requests)
history.AddRange(response.Messages)
for (callId, request) in requests do
let result = execute request
printfn " -> %s" result
let content = FunctionResultContent(callId, result) :> AIContent
history.Add(ChatMessage(ChatRole.Tool, ResizeArray<AIContent> [ content ]))
loop (turn + 1)
Exercise — ask real questions
Create ask.fsx, which loads the agent and asks it two questions, with the real model:
// ask.fsx — module 07: ask the agent real questions (local model).
#load "agent.fsx"
open Agent
let questions =
[ "Where is my order 4512?"
"Where is my order 9999?" ]
for question in questions do
printfn "=== %s" question
match runAgent Llm.client question with
| Ok text -> printfn "Answer: %s" text
| Error error -> printfn "Stopped: %s" (describeError error)
ask.fsx only loads agent.fsx, which itself loads the other three files: that way, each one is loaded only once. Llm.client remains accessible by its full name.
dotnet fsi ask.fsx
Expect four model calls, so anywhere from a few tens of seconds to a minute on a CPU alone.
Expected output — the turn markers and tool results are produced by your code; only the final answers vary:
=== Where is my order 4512?
[turn 1] CallTools [Ok (GetOrder 4512)]
-> Order 4512: shipped, tracking TRK-88
[turn 2] FinalAnswer
Answer: <variable text>
=== Where is my order 9999?
[turn 1] CallTools [Ok (GetOrder 9999)]
-> Order 9999 not found
[turn 2] FinalAnswer
Answer: <variable text>
Example of real answers, observed on 2026-10-30 with qwen3.5:4b on a CPU alone (30 to 40 seconds for the whole run, with the agent as it stands at the end of this module): "Your order 4512 has been shipped. You can track it using the number TRK-88." then "I'm sorry, but order 9999 could not be found. Please check your order number or try again later."
What you're checking: at least one sequence of tool request → execution by your code → final answer, with your tool's data, and a clean ending even for an order that doesn't exist.
If your output differs
[turn 1] FinalAnswerdirectly, with noCallTools: the model answered without consulting the tool. Your loop handled it as an anticipated case and stopped cleanly — that's exactly its job. Run it again; if it persists, keep the output above as your reference.[turn 1] CallTools [Error (...)]: the model sent a request that your boundary rejects. Look at the next turn: the error was sent back to it as the tool result. During the course's preparation runs, this didn't happen for these two questions; module 8 replays this case reproducibly.- An
FS0025warning onmatch classify response with: theCallToolsbranch is missing. AnFS0058error ("possible incorrect indentation"): the branch exists but is empty or badly indented — its content must be further to the right than the|.
The automatic version exists, and not using it here is deliberate
Microsoft.Extensions.AI can run this loop for you: ChatClientBuilder(client).UseFunctionInvocation().Build() produces a client that executes the requested tools itself and only hands you the final answer. During the preparation runs, it called getOrder 4512 and returned an answer in what looked like a single call. It's convenient — but the AgentStep union, the decoding, and the "execute or refuse" decision disappear from your code. The goal of this module was to see them; once they're understood, choosing the automatic version becomes an informed decision, not a mystery.
What you just did
You wrote an agent loop bounded by an explicit limit, where each turn is classified as FinalAnswer, CallTools, or InvalidResponse, and you handled each case in a match. Your code executes the allowed tools, sends their results — or their errors — back to the model, and always stops: on a final answer, on an unusable response, or at the turn limit.
In the next module, you'll trigger what almost never happens in testing but will happen one day in production: a forbidden tool, an absurd argument, a model that loops, a stopped server, an exceeded timeout. Each failure will become a named case — and the compiler will help you not forget any of them.
Check your understanding
In your loop, `maxTurns = 4`, and the test `if turn > maxTurns then Error(TurnLimitReached maxTurns)` comes before the model call. The model requests a tool on every turn without ever concluding. How many model calls are made, and what does `runAgent` return?
On the first turn, the model returns a response with no tool request at all and empty text. What does your module 7 agent do?
You add a fourth case to `AgentStep`, `NeedsHumanReview of reason: string`, without changing anything else in `agent.fsx`. What does the compiler report?
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.