Tool Calling Is the Easy Part. The Loop Is Where It Gets Real.
My first "agent" wasn't actually an agent. It was one API call that returned a tool call, which I executed, and then I just... stopped, because I hadn't written anything to feed the result back in. It looked like it worked. It didn't — it just did one step and quit.
I was building a small assistant that could search a local notes database and check a to-do list, using Qwen's API. Here's the actual working setup, including the part I got wrong the first time.
Defining Tools
Qwen's function calling uses the same OpenAI-compatible schema most major providers use now — define your functions, pass them with your messages, and the model decides whether to respond with text or a structured tool call.
tools = [
{
"type": "function",
"function": {
"name": "search_notes",
"description": "Search the user's saved notes for a keyword and return matching entries. Use this when the user asks to find or recall something they wrote down.",
"parameters": {
"type": "object",
"properties": {
"keyword": {"type": "string", "description": "The term to search for"}
},
"required": ["keyword"]
}
}
},
{
"type": "function",
"function": {
"name": "check_todo_list",
"description": "Return the user's current to-do list. Use this when the user asks what they need to do or what's pending.",
"parameters": {"type": "object", "properties": {}}
}
}
]
The description fields do more work than they look like they should. My first version just said "Searches notes" and "Checks todos" — the model started picking the wrong tool for ambiguous queries because there wasn't enough signal to differentiate intent. Being explicit about when to use each tool, not just what it does, fixed most of the misfires once I had more than two tools competing.
The Part I Got Wrong: No Loop
Here's the broken version — the one that looks like it works because it doesn't error out:
response = client.chat.completions.create(
model="qwen-plus",
messages=[{"role": "user", "content": "What's on my to-do list, and do I have notes about Berlin?"}],
tools=tools,
)
message = response.choices[0].message
if message.tool_calls:
for call in message.tool_calls:
result = execute_tool(call.function.name, call.function.arguments)
print(result) # dead end — the model never sees this

This runs the tool and prints the result. It never tells the model what happened, so if the query needs more than one tool call, or needs the model to actually respond based on the tool's output, it just stops. For a query needing two tools ("to-do list" and "Berlin notes"), this version might only execute one and call it done.
The Working Version: Actually Looping
messages = [{"role": "user", "content": user_input}]
while True:
response = client.chat.completions.create(
model="qwen-plus",
messages=messages,
tools=tools,
)
message = response.choices[0].message
if message.tool_calls:
messages.append(message)
for call in message.tool_calls:
result = execute_tool(call.function.name, call.function.arguments)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": str(result)
})
else:
print(message.content)
break

The difference: tool results get appended back into messages as a new turn, and the loop calls the API again with that updated context. The model sees what the tool returned and decides whether it has enough to answer or needs another tool call. This is the actual mechanism behind "agent" — not a mode you enable, just a loop that keeps going until the model stops asking for tools.
The Failure Mode Nobody Warns You About
Without a cap, this loop can run indefinitely if the model keeps deciding it needs "one more" tool call, especially if a tool returns something ambiguous or a call fails silently. Add a max-iteration limit:
MAX_ITERATIONS = 6
messages = [{"role": "user", "content": user_input}]
for _ in range(MAX_ITERATIONS):
response = client.chat.completions.create(
model="qwen-plus",
messages=messages,
tools=tools,
)
message = response.choices[0].message
if message.tool_calls:
messages.append(message)
for call in message.tool_calls:
result = execute_tool(call.function.name, call.function.arguments)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": str(result)
})
else:
print(message.content)
break
else:
print("Hit max iterations without a final answer.")
I found this the hard way — a test query looped six times on something that should have taken one, because a tool call returned an empty result and the model kept retrying instead of giving up gracefully.
Where This Actually Got Tested Further
Once the loop was solid, I wanted to check whether tool-selection accuracy held up if I swapped the underlying model — not because Qwen was underperforming, but because I was curious whether the loop logic above was actually model-agnostic or whether I'd built something that happened to work for one provider's quirks. I ran the same loop through RouteAI, an OpenAI-compatible gateway, against a couple of other models, and the loop code above didn't need to change — only the model argument did, since the request format stayed consistent. Worth being clear this wasn't the hard part of the project; the loop and tool descriptions were.
If You're Building Your First Agent
Get one tool working end-to-end, including the loop, before adding a second
Write descriptions that say when to use a tool, not just what it does
Always cap your iterations — an ungated loop will eventually run past what you expect
TL;DR: An "agent" is a loop, not a single API call — tool results have to get fed back into the conversation for the model to actually finish the task. Full working loop with an iteration cap included above; the tool descriptions matter more than most tutorials mention.
Worth exploring if this is relevant to your stack: www.fastrouteai.com
Top comments (0)