Bound the loop
- Lesson 13
- 45 min
- Module 4: Tool calling safely
Lesson 13 of 19 · Building Reliable Applications with LLM APIsBound the loop
1. Start with one model request
2. Prompting as an engineering task
3. Structured outputs and validation
4. Tool calling safely
5. Conversations and streaming
6. Reliability and cost controls
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 ready
What you need to know
run_tool, the checks and the tool message, from Execute in application code.- The
Outcomeofanalyse_ticket(valid,rejected,failed), from Validate meaning.
What you need on your computer
- The project after Execute in application code:
assistant/tools.pycomplete andtests/test_tools.py.python -m pytestshows49 passed, 1 deselected. - If you skipped it, copy the snapshot
end-of-m03with the steps of Reset the project, and do the steps of the two earlier lessons of this module. Or copyend-of-m04and read this lesson’s steps against it.
A new colleague on the phone
Section titled “A new colleague on the phone”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: shippedcall 2 -> get_order({"order_id":"LK-581106"}) the same call againcall 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.
The idea: a bounded loop
Section titled “The idea: a bounded loop”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.
Text description of the diagram
The loop starts with a request that has the get_order tool.
- Before each model call: have 4 model calls already been made? If yes, stop: max_steps.
- Call the model. Are there tool calls in the response? If no, this is the final answer: it goes to the two gates.
- If yes, for each call: is it the same tool with the same arguments as an earlier call? If yes, stop: repeated_call.
- If no: run_tool runs the checks, then the read. The result is cut to 2,000 characters.
- 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.
The loop in code
Section titled “The loop in code”Here is the project’s loop. Read it next to the picture: each return is one box.
"""The tool loop: the model proposes calls, the application decides, and the loop always ends."""
import jsonimport timefrom dataclasses import dataclass, fieldfrom typing import Callable
from .orders import OrderBookfrom .providers import Completionfrom .tools import run_tool
@dataclassclass 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
@dataclassclass Step: name: str arguments: str outcome: str # "ok", "refused: <code>" or "stopped: repeated call"
@dataclassclass 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 resultFour details are worth a second look:
for _ in range(limits.max_steps)is the step limit. Awhile Trueloop 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_upkeeps only the orders thatrun_toolreturned. The business rulestatus_not_from_lookupuses it: the model cannot claim a status that the code did not see.completeandclockare parameters. The project passes the provider’scompleteand 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.
Watch the limit stop a loop
Section titled “Watch the limit stop a loop”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
Predict first: In the constructed loop, the model asks for LK-581106 twice. At which step does the loop stop, and does any answer reach the two gates?
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 Application → model Model call 1: 2 messages (system, user) and the get_order description
Constructed: no model call was made.
2 Model Proposes a call
get_order({"order_id":"LK-581106"})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 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 Application → model Model call 2: 4 messages (system, user, assistant, tool) and the get_order description
Constructed: no model call was made.
6 Model Proposes a call
get_order({"order_id":"LK-581106"})7 Application Stops the loop: repeated call
The same call with the same arguments was already made.
8 Application No answer for the two gates: failed
The tool loop was stopped: repeated_call.
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.
Connect the loop to the assistant
Section titled “Connect the loop to the assistant”-
Make
assistant/loop.pywith the code above. -
Open
assistant/analyse.py. Add these two imports, in alphabetical order with the other imports:from .loop import Limits, LoopResult, Step, run_tool_loopfrom .tools import TOOLS -
In
Outcome, add the fieldstepsafterproblems. Into_dict, add the keytool_stepsafter the keyproblems: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], -
Replace
analyse_ticketwith 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) -
Open
assistant/__main__.py. Add--no-toolsbelow the line that adds--save, and--toolsbelow the linemany = …: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)") -
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
analysebranch, the second in the loop ofeval. Update the docstring at the top too:analysenow uses tools, andevaluses none unless you add--tools. -
Copy
tests/test_loop.pyandtests/test_analyse.pyfrom the snapshotend-of-m04into yourtestsfolder, as you copied the recordings in Describe a tool. They test the recorded loops, the constructed repeat, the step limit and the time limit. -
Run the tests:
Terminal window python -m pytest -
Run the demo ticket, then the constructed loop:
Terminal window python -m assistant analyse T-80008python -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 outThe 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 outTry 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.
Approval before any write
Section titled “Approval before any write”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.
Text description of the diagram
A design only: the assistant of this course has no write tool.
- The model proposes request_refund with the order LK-428830 and the amount 129.00.
- The assistant runs the same checks as for a read: the form, the customer’s own order, an amount not above the order total.
- It saves a pending request with the status “waiting”. No money moves.
- 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.
- 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.
- 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_refundwrites 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.
Module practice
Section titled “Module practice”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.
- Make
assistant/ticket_tools.pywith a descriptionGET_TICKET(the four parts), a functionget_ticket(ticket_id, customer_id, records)and a functionrun_ticket_tool(name, arguments, customer_id, records). - 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. - Write
tests/test_ticket_tools.pywith one test for an own ticket, one for another customer’s ticket, and one for bad arguments. - Do not add
GET_TICKETtoTOOLS. The mock has no recordings for a request with two tools, so test the functions directly.
Show an example answer
"""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 jsonimport 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}"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.
Module check
Section titled “Module check”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
"""Module check: block an unauthorized identifier and stop a repeated-call loop."""import json
from assistant.loop import run_tool_loopfrom assistant.orders import OrderBookfrom 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 pytestpasses, 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:
mv ticket-assistant ticket-assistant-oldcp -R nextia-learning-labs/C08/snapshots/end-of-m04 ticket-assistantOn 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.
Knowledge check
Section titled “Knowledge check”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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.
analysenow uses the lookup, and--no-toolsturns it off.eval --toolsscores the 69 tickets with it: team 67 of 68,needs_human66 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.