Loops¶
An "agent" in the sense we use here is the tool-use loop driven further: the model takes a goal, reasons about which tool to call, runs it, observes the result, and repeats until the goal is met. The page you're reading is about that loop — its shape, how to implement it robustly, and where it tends to go wrong.
The shape of the loop¶
flowchart TD
S[Start: user gives goal] --> R[Model reasons, picks next action]
R --> D{Model asked for a tool?}
D -->|yes| T[Run the tool]
T --> O[Append result to messages]
O --> R
D -->|no| F[Return final answer]
Compared to a one-shot API call, two things are new:
- The model is asked to decide what to do next, not just what to say.
- Your code is in the control loop — it executes tools and feeds observations back in.
The "agent" label is earned once the loop handles tasks that require multiple tool calls whose order the model must figure out on its own.
Minimal implementation¶
Same pattern as Tool Use, now wrapped in a function with an iteration cap, so a confused model can't loop forever.
Pick your provider¶
All three providers we cover use the openai SDK, so only two lines — the client construction and the model ID — change between them:
SJTU's OpenAI-compatible gateway at models.sjtu.edu.cn exposes deepseek-chat and other models (campus-network / VPN only). Same code shape as DeepSeek — just swap the key env var and the base URL.
Shared agent loop¶
Everything below is identical across providers — it reads client and model from the setup above.
import json
import os
from dotenv import load_dotenv
load_dotenv()
# client and model come from one of the tabs above
# 1. Tools
def add(a: float, b: float) -> float: return a + b
def subtract(a: float, b: float) -> float: return a - b
def multiply(a: float, b: float) -> float: return a * b
def divide(a: float, b: float) -> float: return a / b
TOOLS_BY_NAME = {
"add": add, "subtract": subtract, "multiply": multiply, "divide": divide,
}
# 2. Tool schemas (one per function)
def _two_num_schema(name: str, desc: str):
return {
"type": "function",
"function": {
"name": name,
"description": desc,
"parameters": {
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "number"},
},
"required": ["a", "b"],
},
},
}
TOOL_SCHEMAS = [
_two_num_schema("add", "Add two numbers."),
_two_num_schema("subtract", "Subtract b from a."),
_two_num_schema("multiply", "Multiply two numbers."),
_two_num_schema("divide", "Divide a by b."),
]
def dispatch(tool_call) -> str:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments or "{}")
return str(TOOLS_BY_NAME[name](**args))
# 3. The loop
def run_agent(goal: str, max_iterations: int = 10) -> str:
messages = [
{
"role": "system",
"content": (
"You are a math agent. Use the tools to compute the answer. "
"Think briefly before each tool call and give a short final answer."
),
},
{"role": "user", "content": goal},
]
for step in range(max_iterations):
resp = client.chat.completions.create(
model=model,
messages=messages,
tools=TOOL_SCHEMAS,
)
msg = resp.choices[0].message
messages.append(msg.model_dump(exclude_none=True))
if not msg.tool_calls:
return msg.content # model decided it is done
for tc in msg.tool_calls:
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"content": dispatch(tc),
}
)
raise RuntimeError(f"agent exceeded {max_iterations} iterations without finishing")
if __name__ == "__main__":
goal = (
"A system has a current state of 3.2 and a setpoint of 5.0. "
"What is the error as a percentage of the setpoint?"
)
print(run_agent(goal))
A worked example¶
The prompt asks for (5.0 − 3.2) / 5.0 × 100. A typical run walks the loop three times before finishing:
| Step | Model emits | Your code does |
|---|---|---|
| 1 | tool_calls: [subtract(a=5.0, b=3.2)] |
runs subtract, appends result 1.8 |
| 2 | tool_calls: [divide(a=1.8, b=5.0)] |
runs divide, appends result 0.36 |
| 3 | tool_calls: [multiply(a=0.36, b=100)] |
runs multiply, appends result 36.0 |
| 4 | content: "The error is 36% of the setpoint.", no tool_calls |
loop exits, returns |
The exact sequence varies per run — some models would call multiply and divide together in step 2 as parallel tool calls, finishing in one fewer iteration.
Design decisions¶
- Iteration cap. The single most important guardrail. Without it, a misbehaving model or a faulty tool can burn budget indefinitely. Pick a number that's 2-3× your expected step count and raise a clear error on overflow.
- System prompt. State the agent's role, its tools, its stopping criterion ("when you have the answer, reply without calling a tool"), and any behavior rules. Keep it short — this is cheap context that shapes every turn.
- Stop condition. The canonical signal is "model responded without any
tool_calls". Some teams also add a sentinel tool likefinish(answer)for clarity; unnecessary for simple loops. - Error handling. If a tool raises, return the error as the tool's result string rather than crashing the loop — the model can then adjust. Crashing is the right call only for unrecoverable errors.
- Parallel vs. sequential. The code above executes
msg.tool_callsin order for simplicity. If your tools are independent and slow (network calls), run them concurrently withasyncio.gatheror a threadpool.
Gotchas¶
- Runaway loops. An iteration cap is non-negotiable. Models with weaker instruction-following will happily call the same tool with the same args forever.
- Context growth. Every tool result gets appended to
messages. Long outputs (search hits, file contents) fill the context window fast — covered in Memory. - Tool failures. A tool that sometimes returns an error message and sometimes a value confuses the model. Pick a consistent shape: always a string, always JSON, always an error prefix like
ERROR: .... - Silent model thinking. When
msg.contentis non-empty alongsidetool_calls, that's the model's internal reasoning. Log it — it's free debugging information. - Greedy sampling. For agent loops, set
temperature = 0unless you specifically need variety. A repeatable loop is much easier to debug than a creative one.
Next¶
- Memory — what to do when the tool outputs start eating your context window.