Skip to content

Bound the loop

  • Lesson 13
  • 45 min
  • Module 4: Tool calling safely

What you will be able to do

Make sure that the tool loop always ends, whatever the model does. Make sure too that no tool can change an order or move money without a person’s approval.

Before you startWhat to know and to have readyAbout 45 minutes

What you need to know

What you need on your computer

  • The project after Execute in application code: assistant/tools.py complete and tests/test_tools.py. python -m pytest shows 49 passed, 1 deselected.
  • If you skipped it, copy the snapshot end-of-m03 with the steps of Reset the project, and do the steps of the two earlier lessons of this module. Or copy end-of-m04 and read this lesson’s steps against it.

A shop manager asks a new colleague: “Call the supplier and find out when the hammocks arrive.” The colleague calls. Nobody answers. The colleague calls again, and again, all afternoon. The question was clear; the stopping rule was missing.

A good manager adds three rules. Call at most three times. Stop after twenty minutes. If you get the same answer twice, stop and tell me. And one more rule, for a different kind of call: never agree to pay anything without my signature.

The problem: the model decides when the loop ends

Section titled “The problem: the model decides when the loop ends”

In the last lesson, the loop went round once: the model asked for a tool, got the result, and answered. But nothing forces the model to answer. After each tool result it can ask for another tool, and the application keeps going while it asks.

No recorded loop of the course went wrong. Over all 69 tickets, 46 tickets needed one model call and 23 needed two. No loop repeated a call. So the failure below is constructed. TermAmiraLarkfield’s developer. She writes the code of the help-desk tools in the courses. wrote a pair of responses by hand in which the model asks for the same order twice. The project keeps them as the model constructed-example.

call 1 -> get_order({"order_id":"LK-581106"}) ok: shipped
call 2 -> get_order({"order_id":"LK-581106"}) the same call again
call 3 -> ...

A model that repeats itself once will often repeat itself again, because it sees almost the same conversation each time. A loop with no limit then runs until something outside breaks it. Each call sends the whole conversation again: T-80008’s second call already had 2,181 input tokens. Fifty calls would send more than 100,000 tokens. That is the whole token limit of TermLarkfieldAn online shop for the home. It is not a real company. All Nextia courses use its help desk and data as the example.Learn more AI in everyday productsAI Career Foundations: Roles, Skills and Roadmaps’s TermDeploymentA model that an account sets up at a provider, with its own name and its own limits. Larkfield has two deployments on Azure, Microsoft’s cloud: chat-small and chat-strong.Learn more Handle provider limitsBuilding Reliable Applications with LLM APIs (x-ratelimit-limit-tokens: 100000 in the recorded response headers). One stuck ticket would block the other 44 of the day.

A bounded loop is a loop with limits that the code enforces, so it always ends. The model chooses what to ask for. The code chooses how long the conversation may last. The project uses four limits, one for each way a loop can run away:

Limit Value in the project What it stops
Steps at most 4 model calls a model that never gives a final answer
Time at most 60 s for the whole loop slow calls that add up
Result size at most 2,000 characters per tool result a huge result that fills the context and the bill
Repeated calls the same tool with the same arguments twice a model that is stuck

The values come from the recordings. Real loops needed at most 2 model calls, so 4 leaves room. The TermMedianThe middle value when you sort the values. Half are lower and half are higher. One very slow call does not change it much, so it is a good “usual” time.Learn more Measure practical performanceLLMs: How They Work and How to Choose One call took 1.55 s, so 60 s is generous. The longest real tool result was 350 characters. When a limit stops the loop, the result is failed: no answer is saved, and a person handles the ticket, as for any other failure.

Follow the loop in the picture from the top. Every path ends in a box: an answer or a stop.

A flow of the tool loop. Before each model call: 4 model calls already made? Yes: stopped max_steps. No: call the model. Tool calls in the response? No: final answer, the two gates. Yes: same call and arguments as before? Yes: stopped repeated_call. No: run_tool, the checks then the read; cut the result to 2,000 characters; more than 60 s since the start? Yes: stopped time. No: back to the first check. Every stop leads to: outcome failed, no answer is saved, a person takes the ticket.
The bounded tool loop of the project (assistant/loop.py). Three stops and one answer: every path ends.
Text description of the diagram

The loop starts with a request that has the get_order tool.

  1. Before each model call: have 4 model calls already been made? If yes, stop: max_steps.
  2. Call the model. Are there tool calls in the response? If no, this is the final answer: it goes to the two gates.
  3. If yes, for each call: is it the same tool with the same arguments as an earlier call? If yes, stop: repeated_call.
  4. If no: run_tool runs the checks, then the read. The result is cut to 2,000 characters.
  5. Have more than 60 seconds passed since the start? If yes, stop: time. If no, go back to step 1.

Every stop leads to the same outcome: failed. No answer is saved, and a person takes the ticket.

Here is the project’s loop. Read it next to the picture: each return is one box.

assistant/loop.py
"""The tool loop: the model proposes calls, the application decides, and the loop always ends."""
import json
import time
from dataclasses import dataclass, field
from typing import Callable
from .orders import OrderBook
from .providers import Completion
from .tools import run_tool
@dataclass
class Limits:
max_steps: int = 4 # model calls in one loop
max_seconds: float = 60.0 # wall-clock time for the whole loop
max_result_chars: int = 2000 # a tool result longer than this is cut before the model sees it
@dataclass
class Step:
name: str
arguments: str
outcome: str # "ok", "refused: <code>" or "stopped: repeated call"
@dataclass
class LoopResult:
completion: Completion | None # the final answer, or None when the loop was stopped
completions: list[Completion] = field(default_factory=list)
steps: list[Step] = field(default_factory=list)
looked_up: dict[str, dict] = field(default_factory=dict) # orders that the application really returned
stopped: str = "" # "", "max_steps", "repeated_call" or "time"
def run_tool_loop(complete: Callable[[dict], Completion], request: dict, customer_id: str, book: OrderBook,
limits: Limits = Limits(), clock: Callable[[], float] = time.monotonic) -> LoopResult:
result = LoopResult(None)
messages = list(request["messages"])
seen = set()
start = clock()
for _ in range(limits.max_steps):
completion = complete({**request, "messages": list(messages)}) # a copy: the list grows below
result.completions.append(completion)
if not completion.tool_calls:
result.completion = completion
return result
messages.append({"role": "assistant", "content": completion.text, "tool_calls": [
{"id": c.id, "type": "function", "function": {"name": c.name, "arguments": c.arguments}}
for c in completion.tool_calls]})
for call in completion.tool_calls:
signature = (call.name, call.arguments.replace(" ", ""))
if signature in seen:
result.steps.append(Step(call.name, call.arguments, "stopped: repeated call"))
result.stopped = "repeated_call"
return result
seen.add(signature)
output, outcome = run_tool(call.name, call.arguments, customer_id, book)
result.steps.append(Step(call.name, call.arguments, outcome))
if output["ok"]:
result.looked_up[output["order"]["order_id"]] = output["order"]
text = json.dumps(output, ensure_ascii=False)[:limits.max_result_chars]
messages.append({"role": "tool", "tool_call_id": call.id, "content": text})
if clock() - start > limits.max_seconds:
result.stopped = "time"
return result
result.stopped = "max_steps"
return result

Four details are worth a second look:

  • for _ in range(limits.max_steps) is the step limit. A while True loop would trust the model to stop.
  • The signature of a call is its name and its arguments without spaces. So {"order_id": "LK-581106"} and {"order_id":"LK-581106"} count as the same call.
  • looked_up keeps only the orders that run_tool returned. The business rule status_not_from_lookup uses it: the model cannot claim a status that the code did not see.
  • complete and clock are parameters. The project passes the provider’s complete and the real clock. A test can pass a fake model and a fake clock, so no test waits 60 seconds.

The repeated-call check catches a stuck model, not a failed one. Two different wrong IDs are not a repeat. The step limit stops those.

The explorer now has the constructed loop. Choose Repeated call (constructed), then step through it, and compare it with Own order.

Explore

A loop that ends with an answer, and a loop that the code stops

T-80008, customer C-81265: “Hello, where is my order LK-581106? The confirmation email said the garden shed shipped on Monday, but I have no delivery date yet.”

Constructed example. No model wrote these responses: no recorded loop repeated a call, so this one was written by hand to test the limit.

Model calls
2
Tokens so far
none (constructed)
Tool outcome
ok, stopped: repeated call
Result
failed

Step 8 of 8. No answer to check: The tool loop was stopped: repeated_call.

  1. 1 Application → model Model call 1: 2 messages (system, user) and the get_order description

    Constructed: no model call was made.

  2. 2 Model Proposes a call

    get_order({"order_id":"LK-581106"})
  3. 3 Application Checks the proposal, then reads the order

    • pass The tool name is get_order
    • pass The arguments are JSON with one key, order_id
    • pass order_id is LK- and 6 digits
    • pass The order exists
    • pass The order belongs to this ticket's customer
  4. 4 Application → model Sends the tool result back

    {
     "ok": true,
     "order": {
      "order_id": "LK-581106",
      "status": "shipped",
      "ordered_on": "2026-10-01",
      "shipped_on": "2026-10-05",
      "delivered_on": null,
      "shipping_fee": 0,
      "total": 899,
      "currency": "CAD",
      "items": [
       {
        "product": "garden shed",
        "quantity": 1,
        "unit_price": 899
       }
      ]
     }
    }
  5. 5 Application → model Model call 2: 4 messages (system, user, assistant, tool) and the get_order description

    Constructed: no model call was made.

  6. 6 Model Proposes a call

    get_order({"order_id":"LK-581106"})
  7. 7 Application Stops the loop: repeated call

    The same call with the same arguments was already made.

  8. 8 Application No answer for the two gates: failed

    The tool loop was stopped: repeated_call.

Own order: a real recording (gpt-6-luna as chat-small on Azure). Repeated call: constructed; no model wrote it.
Text description of the explorer

The same explorer as in the lesson Execute in application code, with two loops: own order (T-80008, real) and repeated call (T-80008, constructed).

Repeated call, constructed: model call 1 with 2 messages; no tokens, because no model was called. The model proposes get_order with LK-581106. All five checks pass and the application returns the order: shipped. Model call 2 with 4 messages. The model proposes get_order with LK-581106 again. The application stops the loop: repeated call; the same call with the same arguments was already made. There is no final answer. Result: failed, “The tool loop was stopped: repeated_call.”

Own order, real: two model calls, the order returned once, a final answer with the status shipped, both gates pass: valid. 4,224 tokens in, 123 out.

The constructed loop stops at step 7, before the second lookup runs. No answer reaches the gates, so nothing can be saved.

  1. Make assistant/loop.py with the code above.

  2. Open assistant/analyse.py. Add these two imports, in alphabetical order with the other imports:

    from .loop import Limits, LoopResult, Step, run_tool_loop
    from .tools import TOOLS
  3. In Outcome, add the field steps after problems. In to_dict, add the key tool_steps after the key problems:

    assistant/analyse.py (Outcome)
    problems: list[Problem] = field(default_factory=list)
    steps: list[Step] = field(default_factory=list)
    "tool_steps": [asdict(s) for s in self.steps],
  4. Replace analyse_ticket with this version:

    assistant/analyse.py (analyse_ticket)
    def analyse_ticket(ticket: Ticket, provider, model: str, prompt: str = "v2", structured: bool = True,
    tools: bool = True, book: OrderBook | None = None, limits: Limits = Limits(),
    reasoning_effort: str = "") -> Outcome:
    book = book or OrderBook()
    request = build_request(ticket, model, prompt, response_format() if structured else None,
    TOOLS if tools else None, reasoning_effort)
    try:
    if tools:
    result = run_tool_loop(provider.complete, request, ticket.customer_id, book, limits)
    else:
    completion = provider.complete(request)
    result = LoopResult(completion, [completion])
    except ProviderError as error:
    return Outcome(ticket.ticket_id, "failed", None, error=str(error))
    if result.stopped:
    return Outcome(ticket.ticket_id, "failed", None, steps=result.steps, completions=result.completions,
    error=f"The tool loop was stopped: {result.stopped}.")
    verdict = validate(result.completion, ticket, book, result.looked_up)
    return Outcome(ticket.ticket_id, "valid" if verdict.valid else "rejected", verdict.analysis, verdict.problems,
    result.steps, result.completions)
  5. Open assistant/__main__.py. Add --no-tools below the line that adds --save, and --tools below the line many = …:

    one.add_argument("--no-tools", action="store_true", help="do not offer the order lookup")
    many.add_argument("--tools", action="store_true", help="offer the order lookup (default: off)")
  6. In the same file, pass the setting and the flags on:

    assistant/__main__.py (changed lines)
    options = dict(model=model, prompt=args.prompt, structured=not args.no_schema,
    reasoning_effort=settings.reasoning_effort)
    outcome = analyse_ticket(ticket, provider, tools=not args.no_tools, **options)
    outcome = analyse_ticket(ticket, provider, tools=args.tools, **options)

    The first is in the analyse branch, the second in the loop of eval. Update the docstring at the top too: analyse now uses tools, and eval uses none unless you add --tools.

  7. Copy tests/test_loop.py and tests/test_analyse.py from the snapshot end-of-m04 into your tests folder, as you copied the recordings in Describe a tool. They test the recorded loops, the constructed repeat, the step limit and the time limit.

  8. Run the tests:

    Terminal window
    python -m pytest
  9. Run the demo ticket, then the constructed loop:

    Terminal window
    python -m assistant analyse T-80008
    python -m assistant analyse T-80008 --model constructed-example

Step 8 prints 59 passed, 1 deselected. The last line of the first command in step 9:

valid | chat-small | prompt v2 | 2 call(s) | 4224 tokens in, 123 out

The second command prints the loop’s steps and stops (constructed responses, so 0 tokens):

"tool_steps": [
{
"name": "get_order",
"arguments": "{\"order_id\":\"LK-581106\"}",
"outcome": "ok"
},
{
"name": "get_order",
"arguments": "{\"order_id\":\"LK-581106\"}",
"outcome": "stopped: repeated call"
}
],
"error": "The tool loop was stopped: repeated_call."
}
failed | constructed-example | prompt v2 | 2 call(s) | 0 tokens in, 0 out

Try Termchat-strongThe name that Larkfield’s code gives to a larger hosted model: gpt-6.1-sol on Azure. It is often more accurate than chat-small, and it costs about 14 to 18 times more per call.Learn more Request anatomyBuilding Reliable Applications with LLM APIs too: python -m assistant analyse T-80008 --model chat-strong. The real HTTP 400 from Describe a tool now ends as failed | chat-strong | prompt v2 | 0 call(s) | 0 tokens in, 0 out, not as a crash.

Every limit above protects a read. A read that goes wrong wastes time and tokens; you can repeat it. A write is different: a refund, a cancelled order, an email to a customer. A wrong write cannot be taken back by stopping a loop.

Larkfield’s assistant has no write tool, on purpose. Rule 8 of TermGraceThe lead of Larkfield’s help desk. She writes the support policy and decides what a good answer to a customer is.’s policy says that it only drafts and never refunds or changes an order. The safest write tool is the one that does not exist.

But tickets ask for writes. T-80003 says: “Please refund the 129.00 dollars to my card today. I’d rather not send it back, it’s heavy.” Suppose Larkfield one day wants the assistant to prepare refunds. The design must keep the manager’s signature from the phone story: a person approves before anything changes. The picture is a design for that, not code in the project.

A design, not in the project. The model proposes request_refund for LK-428830, 129.00. The assistant runs the same checks as for a read. It saves a pending request with status waiting; no money moves. The pending request shows to an agent with the order, the policy rule and the model's reason. If the agent approves, the agent's action sends the refund to the payment system with the request ID as the idempotency key, and it is done once. If the agent rejects, the request is closed with a reason.
A design for an approval step. The assistant of this course has no write tool; this shows how one would look.
Text description of the diagram

A design only: the assistant of this course has no write tool.

  1. The model proposes request_refund with the order LK-428830 and the amount 129.00.
  2. The assistant runs the same checks as for a read: the form, the customer’s own order, an amount not above the order total.
  3. It saves a pending request with the status “waiting”. No money moves.
  4. The pending request shows in the help-desk app to an agent, a person, with the order, the policy rule and the model’s reason.
  5. If the agent approves, the agent’s action sends the refund to the payment system, with the request ID as the idempotency key. The payment system does it once.
  6. If the agent rejects, the request is closed, with a reason.

Three ideas carry the design:

  • The model’s tool only creates a proposal. request_refund writes a row with the status “waiting”. It has no access to the payment system at all.
  • A person approves, in the application, not in the chat. The agent sees the order, the rule and the reason, and presses a button. The model cannot press it, and no ticket text can reach it.
  • The write runs once. The request ID goes to the payment system as an idempotency key: if the same approval arrives twice, the payment system refunds only once. Handle provider limits explains why repeated requests need this.

For T-80003, Grace’s policy gives the answer: no refund before the item comes back. An agent would reject the request in a few seconds. The approval step costs that agent’s time on every write, which is why you design it only for writes, never for reads.

Common mistake

'The model will flag a refund request for a person'

Symptom: TermTomásLarkfield’s AI engineer. He often suggests a quick shortcut, and the lesson shows why it fails. argues that an approval step is too much work: “The prompt says that a refund outside the policy needs a person. The model sets needs_human, and the agent sees it.” Amira runs the whole TermTest setA fixed set of examples with known correct answers. You run each version of a prompt or model on the same set, so the results compare fairly.Learn more Build a representative test setLLMs: How They Work and How to Choose One with the lookup on: python -m assistant eval --tools. The score shows "needs_human_true_caught": "8/9". The miss is T-80003, the refund request. With the lookup, Termchat-smallThe name that Larkfield’s code gives to a small, low-cost hosted model: gpt-6-luna on Azure. The code uses the name, so the model can change without a code change.Learn more What an LLM API isBuilding Reliable Applications with LLM APIs set needs_human to false. Without the lookup, the same model set it to true.

Cause: the reply was correct (“we can’t refund the parasol without receiving it back”). But the flag is a judgement of the model, and a small change in the conversation changed it. Here, the change was one extra tool result. A judgement that changes with the context cannot be the only guard in front of money.

Fix: put the approval in the structure, not in the model’s judgement. A write tool only creates a pending request, and only a person’s action in the application completes it. Then a missed flag costs an agent a minute, not 129 dollars. For flags that must not be missed, add a business rule in code, as Module 3 did for safety words.

What it means for a real AI service: a model can draft, sort and suggest. Anything that is hard to undo needs a control that does not depend on the model getting it right this time.

The whole evaluation with the lookup on (real recordings) shows a cost in quality too. Team stays at 67 of 68, but needs_human agreement falls from 67 to 66 of 69. With 69 tickets, one ticket is not proof of a trend. But T-80003 is the ticket that matters most to Grace, so the team keeps the approval design.

Add a second read-only tool: a lookup of the customer’s earlier tickets. Use synthetic records: three or four made-up tickets in a Python dictionary, with a customer_id, a date, a team, a status and a short summary. At least one record must belong to another customer.

  1. Make assistant/ticket_tools.py with a description GET_TICKET (the four parts), a function get_ticket(ticket_id, customer_id, records) and a function run_ticket_tool(name, arguments, customer_id, records).
  2. Use the same rules as get_order. Check the form of the ID: T- and 5 digits. Refuse extra keys. Refuse another customer’s ticket with the same words as a missing one.
  3. Write tests/test_ticket_tools.py with one test for an own ticket, one for another customer’s ticket, and one for bad arguments.
  4. Do not add GET_TICKET to TOOLS. The mock has no recordings for a request with two tools, so test the functions directly.
Show an example answer
assistant/ticket_tools.py
"""Module practice: a read-only lookup of the customer's earlier tickets.
The records are synthetic: made up for this practice, like every ticket of the course.
The rules are the same as for get_order: the model proposes, this code decides.
"""
import json
import re
from .tools import ToolRefused
TICKET_ID = re.compile(r"^T-\d{5}$")
PAST_TICKETS = { # synthetic records
"T-50001": {"customer_id": "C-81265", "opened_on": "2026-09-02", "team": "delivery", "status": "closed",
"summary": "A garden hose arrived two days late. The customer was told the new date."},
"T-50002": {"customer_id": "C-81265", "opened_on": "2026-09-20", "team": "payment", "status": "closed",
"summary": "The customer asked for a copy of the invoice. It was sent."},
"T-50003": {"customer_id": "C-38852", "opened_on": "2026-09-28", "team": "delivery", "status": "open",
"summary": "A question about the delivery address of a garden shed."},
}
GET_TICKET = {
"type": "function",
"function": {
"name": "get_ticket",
"description": ("Look up one of this customer's earlier Larkfield support tickets by its ticket ID. "
"Read-only. Returns the date, the team, the status and a short summary. "
"Tickets of other customers are not available."),
"strict": True,
"parameters": {
"type": "object",
"properties": {"ticket_id": {"type": "string", "description": "The ticket ID, T- and 5 digits."}},
"required": ["ticket_id"],
"additionalProperties": False,
},
},
}
def get_ticket(ticket_id: str, customer_id: str, records: dict = PAST_TICKETS) -> dict:
"""Return the ticket if it exists and belongs to customer_id; otherwise raise ToolRefused."""
if not isinstance(ticket_id, str) or not TICKET_ID.match(ticket_id):
raise ToolRefused("invalid_argument", "The ticket ID must be T- followed by 5 digits.")
record = records.get(ticket_id)
if record is None:
raise ToolRefused("not_found", f"No ticket {ticket_id} is available for this customer.")
if record["customer_id"] != customer_id:
raise ToolRefused("other_customer", f"No ticket {ticket_id} is available for this customer.")
return {"ticket_id": ticket_id, **{k: v for k, v in record.items() if k != "customer_id"}}
def run_ticket_tool(name: str, arguments: str, customer_id: str, records: dict = PAST_TICKETS) -> tuple[dict, str]:
"""Run one proposed call. Return (the result for the model, the outcome for the log)."""
try:
if name != "get_ticket":
raise ToolRefused("unknown_tool", f"There is no tool named {name}.")
try:
args = json.loads(arguments)
except json.JSONDecodeError:
raise ToolRefused("invalid_argument", "The arguments are not valid JSON.") from None
if not isinstance(args, dict) or set(args) != {"ticket_id"}:
raise ToolRefused("invalid_argument", "get_ticket takes exactly one argument: ticket_id.")
return {"ok": True, "ticket": get_ticket(args["ticket_id"], customer_id, records)}, "ok"
except ToolRefused as refused:
return {"ok": False, "error": str(refused)}, f"refused: {refused.code}"
tests/test_ticket_tools.py
import pytest
from assistant.ticket_tools import run_ticket_tool
def test_a_customer_gets_their_own_earlier_ticket():
result, outcome = run_ticket_tool("get_ticket", '{"ticket_id": "T-50001"}', "C-81265")
assert outcome == "ok" and result["ticket"]["status"] == "closed"
assert "customer_id" not in result["ticket"]
def test_another_customers_ticket_is_refused_like_a_missing_one():
other, other_outcome = run_ticket_tool("get_ticket", '{"ticket_id": "T-50003"}', "C-81265")
missing, missing_outcome = run_ticket_tool("get_ticket", '{"ticket_id": "T-59999"}', "C-81265")
assert other_outcome == "refused: other_customer" and missing_outcome == "refused: not_found"
assert other["error"].replace("T-50003", "X") == missing["error"].replace("T-59999", "X")
@pytest.mark.parametrize("arguments", ['{"ticket_id": "T-5001"}', '{"ticket_id": "T-50001", "customer_id": "C-38852"}',
"not json"])
def test_bad_arguments_are_refused_before_the_records(arguments):
result, outcome = run_ticket_tool("get_ticket", arguments, "C-81265")
assert result["ok"] is False and outcome == "refused: invalid_argument"

python -m pytest tests/test_ticket_tools.py prints 5 passed. The tool reuses ToolRefused, so the log has the same outcome codes for both tools. To offer it to a live model, the loop would also need to call run_ticket_tool for the name get_ticket.

Show two things with one test: the application blocks an unauthorized identifier, and the loop ends a repeated call. Write tests/test_module_check.py. Use a scripted model: a function that always returns the same constructed tool call. Let it ask for the neighbour’s order LK-615204, with Tom’s customer ID C-20417.

Show an example answer
tests/test_module_check.py
"""Module check: block an unauthorized identifier and stop a repeated-call loop."""
import json
from assistant.loop import run_tool_loop
from assistant.orders import OrderBook
from assistant.providers import Completion
def scripted_call(order_id):
"""A constructed model response that asks for get_order (no model wrote it)."""
data = {"choices": [{"finish_reason": "tool_calls", "message": {"content": None, "tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "get_order",
"arguments": json.dumps({"order_id": order_id})}}]}}]}
return Completion.from_response(data, 0.0)
def test_the_neighbours_order_is_blocked_and_the_repeat_stops_the_loop():
def model(request):
return scripted_call("LK-615204") # asks for the neighbour's order every time
result = run_tool_loop(model, {"messages": []}, "C-20417", OrderBook())
assert [s.outcome for s in result.steps] == ["refused: other_customer", "stopped: repeated call"]
assert result.stopped == "repeated_call" and result.completion is None
assert result.looked_up == {}

python -m pytest tests/test_module_check.py prints 1 passed. The first call is refused, so looked_up stays empty: the neighbour’s order never entered the conversation. The second call is the same, so the loop stops before it runs.

You have finished the module when:

  • python -m pytest passes, with your new tests.
  • You can explain, for T-80001, which check refused the call and why the model got the same words as for a missing order.
  • You can explain which limit stops a model that asks for four different wrong IDs, and why the repeated-call check does not.

Reset. If your project is broken, rename it and copy the snapshot end-of-m04 with the steps of Reset the project:

Terminal window
mv ticket-assistant ticket-assistant-old
cp -R nextia-learning-labs/C08/snapshots/end-of-m04 ticket-assistant

On Windows, in PowerShell: Rename-Item ticket-assistant ticket-assistant-old, then Copy-Item -Recurse nextia-learning-labs\C08\snapshots\end-of-m04 ticket-assistant. Make the TermVirtual environmentA folder with its own Python and its own packages, for one project. Packages that you install there do not change other projects.Learn more Isolate dependenciesPython for Practical AI Engineering again, as the snapshots README shows. python -m pytest then prints 59 passed, 1 deselected. The snapshot has the guided steps of this module, not the module practice. These steps were tested on macOS; the Windows commands are not tested.

Check your understanding

A model proposes get_order with LK-000001, then LK-000002, then LK-000003, then LK-000004. Which limit stops the loop, and after how many model calls?

Show the answer

The step limit. The four calls have different arguments, so the repeated-call check never matches. Each one is refused (not_found), and the loop goes on. After the fourth model call and its refusal, range(4) is used up, and the loop ends with stopped: max_steps. The outcome is failed, and a person takes the ticket. The test test_the_step_limit_stops_a_model_that_never_answers checks this case with a limit of 3.

Check your understanding

Why can a read-only tool run without a person's approval, while a refund tool cannot?

Show the answer

A wrong read costs time and tokens, and the checks make sure that it only reads what the customer may see. You can repeat it or ignore it. A wrong write, such as a refund, changes money or data and is hard or impossible to undo. So a write tool only creates a pending request. A person approves it in the application, where no model output and no ticket text can reach.

Check your understanding

Predict: you set Limits(max_result_chars=50) and run T-80008 with the mock. What happens to the tool message, and why may the mock then fail?

Show the answer

The tool result for LK-581106 is longer than 50 characters. So the loop cuts it after 50 characters, in the middle of the JSON. The model would see a broken result. With the mock, the second request is now different from the recorded one, because the tool message is part of the request. So the mock answers No recording for this request, and the outcome is failed. The real results were at most 350 characters, which is why the project uses 2,000.

Recap

  • The model decides whether to ask for another tool, so the code must decide when the loop ends. A bounded loop has limits that the code enforces.
  • The project’s limits: at most 4 model calls, 60 s, 2,000 characters per tool result, and no repeated identical call. A stop gives the outcome failed: nothing is saved, and a person takes the ticket.
  • No recorded loop repeated a call or needed more than 2 model calls. The repeated-call loop of this lesson is constructed, and the tests use it.
  • analyse now uses the lookup, and --no-tools turns it off. eval --tools scores the 69 tickets with it: team 67 of 68, needs_human 66 of 69. It missed T-80003, the refund request.
  • The assistant has no write tool. A write would need an approval step. The model’s tool creates a pending request, a person approves it in the application, and the write runs once with an idempotency key.

You have finished Module 4. Next, the assistant keeps a conversation with an agent, within a token budget.

Next: Manage conversation state