Aller au contenu principal
Nicolas Cousin Tech SolutionsNicolas Cousin Tech Solutions
Module 7 of 10

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 its CallId (the string) and the result of your module 6 boundary: a valid request Ok (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:

  • runAgent receives the client as a parameter instead of using Llm.client directly. This choice will come in handy in module 8.
  • execute only 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.
  • decodeRequest reuses your boundary: JsonSerializer.Serialize request.Arguments puts the arguments back into JSON text form, the input decode expects.

Your CallTools branch must, in this order:

  1. print [turn N] CallTools followed by the list of decoded requests (hint: snd is the counterpart of fst and returns the second element; List.map snd requests therefore extracts the Result part of each pair, and %A prints it);
  2. add response.Messages to the history, as in module 5;
  3. for each pair (callId, request): call execute, print -> <result>, and add to the history a ChatRole.Tool message containing a FunctionResultContent(callId, result);
  4. 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] FinalAnswer directly, with no CallTools: 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 FS0025 warning on match classify response with: the CallTools branch is missing. An FS0058 error ("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.