How AI Agents WorkCourse map

Chapter 2 · The core loop

Tools

Tools let a model change things in the world, but the model never touches them. It writes a request, and your code decides whether to run it.

What a tool really is

Patch has a tool for searching code. The spec describes it like this:

name: search_files
input:
    query: string
output:
    matching files

The model sees only a short entry like this: a name, a sentence about what the tool does, and the arguments with their types. Your code holds the rest: the function that searches the files, and rules such as "read-only, and only inside the repository".

The model picks tools from the visible part alone, so a tool's description works like a prompt. A vague description leads to wrong calls.

The life of one call

Say the model wants to find where Patch checks token expiry. It replies with a request instead of text:

// the model's reply
{"role": "assistant", "tool_calls": [
  {"id": "call_1", "name": "search_files",
   "arguments": {"query": "expires_at"}}]}

// what your runtime adds to the conversation after running it
{"role": "tool", "tool_call_id": "call_1",
 "content": "auth/session.py:5: return token.expires_at >= now - ..."}

Your runtime (the code that runs the agent loop) checks the request and runs the search. It adds the output to the conversation with the same id. The id tells the model which result answers which request. The model is not waiting meanwhile, because its call has already ended. Your runtime calls it again later, with the result in the history.

Vendors name these fields differently, but the flow is the same:

Runtime tells model: "These tools exist."
        ↓
Model outputs:
  "Call search_files(query='authentication')"
        ↓
Runtime validates the request
        ↓
Runtime executes code
  (the tool touches the world)
        ↓
Result returned to the model
  (it reads it on its next call)

Schemas: the contract for arguments

Patch sends this definition of search_files with every call:

{"name": "search_files",
 "description": "Search for literal text in files under the workspace. ...",
 "parameters": {
   "type": "object",
   "properties": {
     "query": {"type": "string", "description": "Text to find, not a regex"},
     "path": {"type": "string", "description": "File or folder. Default: all"},
     "max_results": {"type": "integer", "minimum": 1, "maximum": 50}},
   "required": ["query"],
   "additionalProperties": false}}

The parameters part is a JSON Schema, a standard format for describing JSON data. Here it says that query is a required string.

Schema choices prevent mistakes. A fixed list of allowed values, such as "open" or "closed", stops the model from inventing a third. Rejecting unknown fields catches arguments your code would otherwise ignore without a word. But a schema checks only form: it cannot tell that ../deploy/prod.env points outside the repository.

Validating arguments

Each request in this table fails a different check:

Four checks before a tool runs
CheckQuestionA request that fails it
ParseIs it complete JSON?A reply cut off mid-argument
SchemaRight fields and types?max_results: "all"
Current stateDoes it still make sense?An edit whose old text is gone
PolicyIs it allowed?Reading ../deploy/prod.env

Treat arguments like a request body from the internet, because they are input you did not write. A library can do the first two checks, but the last two need your own rules. When a check fails, your runtime skips the tool and returns an error result with the same call id, so the model can try again.

The third row is a stale object. The model read a file at step 3, and someone changed it before the edit at step 9. A good edit tool takes the old text and refuses if it no longer matches, like a compare-and-swap. So the edit fails instead of overwriting a teammate's change (chapter 3).

When a call fails

Suppose the model asks for auth/tokens.py, but the file is called auth/token_utils.py. Your runtime returns this result: "No such file: auth/tokens.py. Similar: auth/token_utils.py." On its next call, the model asks for the right file.

So return errors to the model as tool results, instead of crashing or hiding the failure, and say what to try next.

Who handles which failure
FailureWho handles it
Network timeout, rate limitYour runtime retries with growing waits (backoff)
Wrong arguments, denial, missing fileThe model, from an error result with a hint
A bug in the toolThe model gets an error result, and you get an alert
The same failure again and againYour runtime stops the run or asks a person

Every call needs exactly one result before the next model call, and some APIs reject a history with gaps. So after a crash or a cancel, your runtime still records a result, such as "cancelled".

Big outputs

Patch runs pytest -v, and pytest prints 1,227 lines. Your runtime saves the full output to a file and returns a short preview instead:

exit code 1
============================= test session starts =============================
collected 1200 items
[... 1,220 lines truncated. Full output saved to .patch/outputs/call_1.log ...]
FAILED tests/test_auth.py::test_login_expired_token - assert 200 == 401
FAILED tests/test_billing.py::test_refund_rounding - assert ...
================== 2 failed, 1198 passed in 48.31s ==================

The preview keeps the first lines, which show what ran, and the last lines, which hold the summary. Its marker says how much was cut and where the rest is, so the model knows the preview is incomplete.

Structured output needs more care, because cutting JSON at a character limit leaves invalid JSON. Drop whole items instead, and say how many you dropped. Chapter 4 covers what to do when the context still fills up.

Several calls at once

The model asks to read auth/tokens.py and auth/session.py in one reply. Your runtime runs both reads at once. The second finishes first, and then the first fails because the file does not exist.

So your runtime matches each result to its request by call id, not by finishing order. It returns every result, including the error.

Which calls can run together?

Calls can depend on each other in two ways, and each one is handled in a different place.

One call needs another's result. To read the file a search finds, you need the search result first. This kind sorts itself out, because the model writes every argument before any call in its reply runs. It cannot name the file before the search returns, so the read comes in the next step. Calls in one reply never need each other's output.

Two calls touch the same thing. Say the model asks to edit auth/session.py and run the tests, in one reply. Both calls have complete arguments, but the tests read the file the edit changes. Run them at once, and the tests might check the old code. The model can miss a clash like this, so your runtime decides from what each tool does, not from what the model meant:

One call at a time, in the listed order, is always correct. Running reads together only makes it faster. You can also tell the model in the system prompt to put only independent calls in one reply. That helps, but the rule in your runtime is what makes a wrong guess safe. The effect classes later in this chapter give each tool the label this rule needs.

Step through five calls

The widget follows five calls through a runtime. Watch the count of blocked calls: a blocked call never counts as run.

Interactive widget. Without JavaScript: a valid call runs and returns a result tied to call_1. A call with "all" where a number belongs fails the schema, so nothing runs. A read of ../deploy/prod.env passes the schema, but the rules deny it. Two parallel reads finish out of order, and each result keeps its id. A 1,227-line log is saved, and the model searches it, then reads only the failing part.

Structured outputs

Patch can end a run by calling a tool instead of writing a final message:

submit_result(
    summary="Removed the 10-minute grace period in auth/session.py",
    files_changed=["auth/session.py"],
    tests_passed=True,           # a claim, not evidence
)

Your runtime offers submit_result with a schema for the final answer, so the run ends with data your code can read. Some providers offer constrained decoding, which restricts what the model can generate so the output always matches the schema. Without it, your runtime checks the output and asks for a retry. Either way, the content can still be false, so your runtime should check the last test run itself.

How the model finds tools

Patch sends all four tool definitions with every call. An assistant with 300 tools cannot, because each definition costs tokens on every call, and similar tools make the choice harder.

Runtimes handle this in four ways. A static list sends the same tools every time. A filtered list hides tools that cannot work right now. A tool server lists its tools on request. Tool search lets the model search a large catalog, so the runtime loads only the definitions it needs.

MCP (Model Context Protocol) is a standard way for a server to offer tools. The server lists its tools and runs the calls your runtime sends. It is not an agent loop, because your runtime still decides what runs. It is not a security boundary either. The spec says the protocol cannot enforce its own security rules, so your runtime must. Treat a server's descriptions and results as untrusted unless you trust the server.

Effect classes and destructive tools

run_command("ls tests") only lists files, and run_command("rm -rf build") deletes them. run_command("pytest") runs project code, which can do whatever the sandbox and test setup allow. The tool name is the same in all three, so it says nothing about the risk.

So give each tool, or each call, an effect class:

Three effect classes
ClassExamplesTypical treatment
Read-onlysearch_files, read_fileRuns freely, within its scope
Reversible writeedit_file in a clean checkoutRuns, because the original can be restored
External or irreversibleSend an email, push to main, DROP TABLENeeds approval, or gets no tool

An edit is reversible only if you can restore the original. Git cannot bring back uncommitted work that an edit overwrote, so give the agent a clean checkout or save each original first. Read-only does not mean harmless either, because reading a secret and sending it somewhere is a leak (chapter 10).

Who reads the effect class?

Your code reads it, not the model. You set the class once when you define the tool, and several parts of your runtime act on it:

For run_command, the class depends on the command, so your runtime has to classify each call. When it cannot tell, it should pick the riskier class.

"Effect class" is this course's name. Real systems use the same idea under other names:

Permissions

Suppose the rule is "never delete files outside the repository". Written in the system prompt, it is only a request that the model can misread and injected text can argue against. Written in the code that dispatches tool calls, it runs on every call:

def dispatch(call):
    tool = TOOLS.get(call.name)
    if tool is None:
        return error(call.id, "unknown tool: " + call.name)
    problems = tool.schema.validate(call.arguments)
    if problems:
        return error(call.id, "invalid arguments: " + "; ".join(problems))
    verdict, reason = policy(tool, call.arguments)   # "allow" | "deny" | "ask"
    if verdict == "deny":
        return error(call.id, "denied by policy: " + reason)
    if verdict == "ask":
        return pause_for_approval(call)              # chapter 11
    output = tool.run(**call.arguments)
    return result(call.id, limit(output, artifact_for=call.id))

A permission check answers allow, deny, or ask a person (chapter 11). But it covers only the paths it intercepts: if a shell tool can delete the same files, the gate has a hole. It also cannot stop a process that finds another way, which takes a sandbox (chapter 10).

Code as the action

# one model call, many tool calls, one short result
hits = tools.search_files(query="expires_at")
for path in sorted({h.path for h in hits}):
    text = tools.read_file(path=path)
    if "timedelta(minutes=10)" in text:
        print(path, "has a 10-minute grace period")

Some agents let the model write a short script like this instead of one call per step. Your runtime runs it in a sandbox and returns only what it prints, so many steps cost one model call. But the model now writes programs, so the sandbox must be tight, and the script's tool calls should pass the same checks.

Why tools make models so much more useful

Ask a model with no tools whether Patch's auth test passes, and it can only guess from the code you pasted. Give it run_command, and it runs the test and reads the result.

Tools give the model fresh facts and a way to act. They also give it feedback, so it can try a fix, test it, and try again. Coding suits agents because tests give fast, honest feedback (chapter 9).

Running example: Patch gets real tools

Patch version 2 gets four tools, each with an effect class. Its runtime checks every call before it runs, returns blocked calls as errors, and saves long outputs to files.

# Patch v1: one tool, no checks
- tools = [run_command]
# Patch v2: four tools, each with an effect class
+ tools = [
+     Tool("search_files", effect="read-only"),
+     Tool("read_file",    effect="read-only"),
+     Tool("edit_file",    effect="reversible write"),   # clean checkout, originals saved
+     Tool("run_command",  effect="depends on the command",
+          allow=["pytest", "git status", "git diff"]),
+ ]

Patch still cannot tell you which files it changed. Chapter 3 gives it that record.

What goes wrong

Vague or overlapping tools

Why it happens
The model picks tools from their descriptions alone, so similar tools get mixed up.
How to spot it
Traces show the same wrong tool or argument mistake again and again.
What to do
Say when to use each tool and when not to, and merge tools that overlap.

Errors that end the run or vanish

Why it happens
The runtime crashes on a tool error or returns an empty string, so the model never learns what went wrong.
How to spot it
Runs end in exceptions, or the model repeats a failing call.
What to do
Return every error as a result with a hint, and retry network errors in code.

Rules that live only in the prompt

Why it happens
"Never touch files outside the repo" is only a request, which a confused model or injected text can ignore.
How to spot it
Ask the agent to break the rule. If clever wording gets it through, nothing enforces the rule.
What to do
Check paths and commands in the dispatcher, and run the agent in a sandbox.

You decide, AI builds

Decide yourself

  • Which tools exist, and which actions get no tool at all.
  • The effect class of each tool, what each class requires, and which calls may run at the same time.
  • Output limits, and where full outputs are kept.
  • Which failures code retries, and which go to the model.

Delegate to AI

  • JSON Schema generated from typed function signatures.
  • The validation and dispatch code.
  • Retries with backoff, truncation, and saved output files.
  • Tool wrappers around existing APIs.

Check yourself

How does a tool call actually work, from the model's output to its next call? (Q9)

Answer

The runtime lists the tools in the context, and the model replies with a request: a tool name, arguments, and an id. The runtime checks it, runs the function, and adds the output to the history with the same id.

The model calls read_file with the path ../.env. The schema says the path is a string. What should happen?

  1. It runs, because the schema check passed
  2. The policy check denies it, and an error result goes back for that call id
  3. The runtime drops the call without a result
Answer

The schema only checks that the path is a string. The policy finds the path outside the workspace and denies it, and the model still gets a result.

Two parallel calls finish in reverse order, and the first one fails. What does the model receive?

  1. Only the successful result
  2. Two results, each tied to its call id, one marked as an error
  3. An exception that ends the run
Answer

Results attach by id, not by finishing order, so the model gets the error for one call and the result of the other.

Takeaways

Going deeper