The Agent Loop by Hand
By the Syncfusion DevTutorials team · Last updated
How an agent works inside an app: it asks the model, runs a tool, reads the result and repeats. Learn why every loop needs a stop rule, a step limit and a budget.
The Agent Loop by Hand
A customer types, "Where is order 1099?"
There is no order 1099. Shop Helper asks to look it up. The shop says, "No order 1099 found."
So Shop Helper asks for the same lookup again. And again. And again.
The customer waits. The bill grows. Nothing in the app tells it to stop.
This lesson shows how an agent works, one round at a time. It also shows how to make sure every round ends.
We build the loop by hand. That means no framework does the work for us, so you can see every move. Frameworks come later, in Framework Tour: The Same Agent, Five Ways.
Simple lesson: an agent is a loop, and every loop needs a way out.
A Real-World Example
Think about a kid looking for a lost shoe.
- They look under the bed. No shoe.
- They look in the closet. No shoe.
- They look under the bed again. Still no shoe.
A parent says, "Look in three places. If you still cannot find it, come and tell me."
That rule stops the search. The kid also gives an honest report: "I could not find it."
An agent needs the same kind of rule, because it can keep looking forever.
Simple lesson: a good search has a limit and an honest report at the end.
What You Will Learn
In this lesson, you will learn:
- The five moves of the agent loop.
- The difference between a tool request and a final answer.
- Why each model call costs more than the one before.
- How a step limit and a budget limit stop a stuck loop.
- How to read a stop reason and tell the customer the truth.
The Five Moves
In Workflow or Agent? you saw that an agent picks its own steps. Now look at how it does that.
Loop = a set of moves the app repeats, round after round, until something tells it to stop.
Each round of the agent loop has five moves:
- The app sends the chat and the tool list to the model.
- The model replies with a final answer or a tool request.
- The app checks the tool is allowed, then runs it.
- The tool result goes back into the chat.
- The app repeats, until a final answer, a limit, or a person pauses it.
The model only replies in move 2. Everything else is done by the app.
Two kinds of reply matter here.
Tool request = a reply that asks the app to run one tool, with its inputs. For example, "Look up order: order 1042."
Final answer = a reply with no tool request. It is the text the customer will read.
Real models can also ask for several tools in one reply. These are called parallel tool calls. The app runs them all and sends back all the results. In this lab, the model asks for one tool at a time.
Watch one full loop for a simple question.
Press Play and watch the ring of five moves.
- The lit move: it shows which move is happening now.
- The chat on the right: a tool request and a tool result join it in round 1.
- The cost strip: call 2 costs a little more than call 1.
- The road sign: the loop ends with a final answer.
Simple lesson: the model asks, the app acts, and the result goes back into the chat.
The App Is in Charge
The model never runs a tool by itself. It can only ask.
In move 3, the app looks at the request first. Is this tool on the allowed list? Only then does it run it.
This is why the app can also stop the loop. It decides whether to make the next call.
Real apps usually check much more here, such as who the customer is. That is the topic of Guardrails, Permissions and Human Approval. In this lab, the app only checks that the tool is on the list.
Simple lesson: the model decides what to ask for, and the app decides what really happens.
The Chat Grows Every Round
Each round adds two messages to the chat. One is the tool request. The other is the tool result.
The model does not remember the last call. So the app sends the whole chat again, every time.
That means each call is bigger than the one before. A bigger call costs more coins.
Press Play and watch the chat and the costs.
- Round 1: the model looks up order 1044. It is late.
- Round 2: the model searches the help pages for the late-parcel rule.
- The message count: it goes from 1 to 3 to 5.
- The cost of each call: every bar is longer than the one before.
Why the whole chat is sent again
- A model call has no memory of earlier calls.
- So the app sends the full chat, plus the tool list, every round.
- Long chats have other problems too. State, Memory and Context Limits covers them.
Simple lesson: more rounds mean a longer chat, and a longer chat means dearer calls.
A Loop With No Way Out
Now back to order 1099.
The pretend model follows a fixed rule here. If a tool error says how to fix the problem, it tries the fix. If the error gives no fix, it does not know what else to do.
"No order 1099 found" gives no fix. So the model asks for the same lookup again.
Press Play and watch the requests and the coins.
- Round 1: a normal lookup and an error. One error is fine.
- Round 2: the exact same request. This is the first sign the loop is stuck.
- Rounds 4 and 8: the same request, again and again. The calls keep getting dearer.
- The customer: still has no reply at all.
A real model may try something a little different. It may also give up sooner. But real agents do get stuck in loops like this, so you cannot count on the model to stop.
A model may never stop by itself.
It stops when it writes a final answer. If it never does, the loop runs on.
The app must have its own rule to stop it.
Simple lesson: a stuck loop costs more each round and helps nobody.
Stop Rules
Stop rule = a check the app makes before every model call, to decide if the loop must end.
There are two common stop rules.
Step limit = the most model calls one customer message may use.
Budget limit = the most coins one customer message may spend.
The app checks both before each call. If either one would be broken, it does not make the call.
Every model call counts toward both limits, including a call that retries or repairs a failed step.
Then the app must still reply. It should say what happened, in plain words. It should not guess.
Press Play and watch the meter.
- Calls 1 to 3: the same stuck lookup as before.
- The limit: before call 4, the app sees 3 of 3 calls used.
- The honest message: it says the order was not found and asks the customer to check the number.
- The cost: a small part of what the open loop spent.
What a good stop message does
- It tells the truth: the helper could not finish.
- It says what it could not find, when it knows.
- It gives the customer a next step, such as checking the number.
Simple lesson: the app stops the loop, and the reply tells the customer the truth.
How to Choose the Limits
A limit that is too low cuts off good jobs. A limit that is too high wastes coins on stuck ones.
Start from your test chats. Count how many calls the hardest good job needs. Then add a little room.
For example, "Where is order 1043? Please send it to 7 Mill Lane." needs three calls:
- one to look up the order,
- one to change the address,
- one to write the final answer.
So a step limit of 4 fits best: the 3 calls the job needs, plus one spare. A limit of 3 also works, but leaves no spare room. A limit of 12 would let a stuck job burn many coins first.
A budget limit catches a different problem. A few calls with a very long chat can cost a lot. The step limit would not notice that.
Many teams use both. Whichever comes first stops the loop.
Simple lesson: set the step limit to your hardest good job's calls, plus one spare.
Three Ways a Loop Ends
Every loop ends for a reason.
Stop reason = the label the app records when a loop ends. Examples are "final answer" and "step limit".
A person can also pause a loop, for example to approve a refund. You will see that in Guardrails, Permissions and Human Approval.
Press Play and read each road sign.
- Run A: it finds the order and ends with a final answer.
- Run B: it is stuck, and the step limit stops it.
- Run C: it has no step limit, so the budget limit stops it instead.
- The red tags: they mark each repeated request.
The stop reason is a useful clue. Many stops on limits mean something needs fixing. Often a tool error gives no hint about what to try next. Tool Design shows how to write better errors.
Simple lesson: record why every loop ended, because the reason tells you what to fix.
Where This Is Used
Almost every AI agent inside an app runs a loop like this:
- Support helpers look up orders and change details, then answer.
- Coding helpers run a test, read the result and try again.
- Research helpers search, read a page and search again.
Real tools often call it a "tool use loop" or "agent loop". Many agent toolkits have a setting for the most steps or turns. Its name varies from tool to tool.
Simple lesson: the same five moves power most real agents.
In Code
Here is the developer view. The code is the five moves as a TypeScript loop, with the same tools as Shop Helper: lookup_order and search_help_centre.
The model is a scripted fake, so the output stays the same every run. In this lab, a call costs one coin per message in the chat. The chat grows every round, so each call costs more.
The Agent Loop on Order 1044
type ToolCall = { id: string; name: string; input: Record<string, string> };
type Message =
| { role: "user" | "assistant"; text: string; calls?: ToolCall[] }
| { role: "tool"; callId: string; text: string };
type Reply = { text: string; calls: ToolCall[] };
// The tools the app owns. The model can only ask for them.
const tools: Record<string, (input: Record<string, string>) => string> = {
lookup_order: (i) => i.orderId === "1044"
? "1044: Rain jacket, shipped, late, now due Monday 3 Nov"
: `No order ${i.orderId}. Check the number.`,
search_help_centre: (i) => "Late parcels: a new one for free, or a full refund.",
};
// A scripted fake model: it reads the chat and picks its next move.
function fakeModel(chat: Message[]): Reply {
const last = chat[chat.length - 1];
if (last.role === "user") {
return { text: "", calls: [{ id: "c1", name: "lookup_order", input: { orderId: "1044" } }] };
}
if (last.role === "tool" && last.text.startsWith("1044")) {
const call: ToolCall = { id: "c2", name: "search_help_centre", input: { query: "late" } };
return { text: "", calls: [call] };
}
return { text: "It is late, due Mon 3 Nov. We can send a new one or refund you.", calls: [] };
}
const STEP_LIMIT = 4; // hardest good job needs 3 calls, plus one spare
const BUDGET = 20; // coins per customer message
const chat: Message[] = [{ role: "user", text: "Where is order 1044?" }];
let steps = 0;
let coins = 0;
let stopReason = "";
while (true) {
const cost = chat.length; // the chat grows, so each call costs more
if (steps >= STEP_LIMIT) { stopReason = "step limit"; break; }
if (coins + cost > BUDGET) { stopReason = "budget limit"; break; }
steps++;
coins += cost;
const reply = fakeModel(chat);
chat.push({ role: "assistant", text: reply.text, calls: reply.calls });
if (reply.calls.length === 0) {
console.log(`round ${steps}: final answer: ${reply.text}`);
stopReason = "final answer";
break;
}
for (const call of reply.calls) {
const run = tools[call.name];
const result = run ? run(call.input) : `Unknown tool ${call.name}`;
chat.push({ role: "tool", callId: call.id, text: result });
console.log(`round ${steps}: ${call.name} -> ${result}`);
}
}
console.log(`stop reason: ${stopReason}, steps ${steps}/${STEP_LIMIT}, coins ${coins}/${BUDGET}`);
Expected output:
round 1: lookup_order -> 1044: Rain jacket, shipped, late, now due Monday 3 Nov
round 2: search_help_centre -> Late parcels: a new one for free, or a full refund.
round 3: final answer: It is late, due Mon 3 Nov. We can send a new one or refund you.
stop reason: final answer, steps 3/4, coins 9/20
tools: the app owns the tools. The model can only ask for one by name.- The two checks at the top: they are the stop rules. They run before every model call, not after.
reply.calls.length === 0: a reply with no tool request is a final answer, so the loop ends.callId: each tool result carries the id of the request it answers. That is how the model matches results to requests.
Now the same loop on an order that does not exist.
A Stuck Loop on Order 1099
type ToolCall = { id: string; name: string; input: Record<string, string> };
type Message =
| { role: "user" | "assistant"; text: string; calls?: ToolCall[] }
| { role: "tool"; callId: string; text: string };
type Reply = { text: string; calls: ToolCall[] };
const tools: Record<string, (input: Record<string, string>) => string> = {
lookup_order: (i) => `No order ${i.orderId}.`, // no hint on what to try next
};
// The error gives no hint, so the fake model repeats the same request (rule 3).
let callNo = 0;
function fakeModel(_chat: Message[]): Reply {
callNo++;
const call: ToolCall = { id: `c${callNo}`, name: "lookup_order", input: { orderId: "1099" } };
return { text: "", calls: [call] };
}
const STEP_LIMIT = 4;
const BUDGET = 20;
const chat: Message[] = [{ role: "user", text: "Where is order 1099?" }];
let steps = 0;
let coins = 0;
let stopReason = "";
while (true) {
const cost = chat.length;
if (steps >= STEP_LIMIT) { stopReason = "step limit"; break; }
if (coins + cost > BUDGET) { stopReason = "budget limit"; break; }
steps++;
coins += cost;
const reply = fakeModel(chat);
chat.push({ role: "assistant", text: reply.text, calls: reply.calls });
if (reply.calls.length === 0) { stopReason = "final answer"; break; }
for (const call of reply.calls) {
const run = tools[call.name];
const result = run ? run(call.input) : `Unknown tool ${call.name}`;
chat.push({ role: "tool", callId: call.id, text: result });
console.log(`round ${steps}: ${call.name} -> ${result} [coins used: ${coins}]`);
}
}
console.log(`stop reason: ${stopReason}, steps ${steps}/${STEP_LIMIT}, coins ${coins}/${BUDGET}`);
if (stopReason !== "final answer") {
// The app writes the reply itself. It tells the truth and does not guess.
console.log("Shop Helper: I could not find order 1099. Please check the number.");
}
Expected output:
round 1: lookup_order -> No order 1099. [coins used: 1]
round 2: lookup_order -> No order 1099. [coins used: 4]
round 3: lookup_order -> No order 1099. [coins used: 9]
round 4: lookup_order -> No order 1099. [coins used: 16]
stop reason: step limit, steps 4/4, coins 16/20
Shop Helper: I could not find order 1099. Please check the number.
- The repeat: the error gives no hint, so the fake model asks for the same lookup again (rule 3).
- The coins: each round costs more than the one before, because the chat keeps growing.
- The step limit: before call 5, the app sees 4 of 4 used, so it never makes that call.
- The honest message: the app writes it, not the model. It says what went wrong and gives a next step.
In a Real App
Here is the same loop against a real model API. It is simplified. It uses a small ModelClient interface you would write around your provider's SDK.
type ToolCall = { id: string; name: string; input: unknown };
type Message =
| { role: "user"; content: string }
| { role: "assistant"; content: string; toolCalls: ToolCall[] }
| { role: "tool"; toolCallId: string; content: string };
interface ToolSpec { name: string; description: string; inputSchema: object }
interface ModelReply {
content: string;
toolCalls: ToolCall[]; // empty means a final answer
usage: { inputTokens: number; outputTokens: number };
}
interface ModelClient {
send(req: { messages: Message[]; tools: ToolSpec[] }): Promise<ModelReply>;
}
type ToolFn = (input: unknown) => Promise<string>;
export async function runAgent(
client: ModelClient, tools: Map<string, { spec: ToolSpec; run: ToolFn }>,
question: string, stepLimit = 4, budgetTokens = 20_000,
): Promise<{ answer: string; stopReason: string }> {
const messages: Message[] = [{ role: "user", content: question }];
const specs = [...tools.values()].map((t) => t.spec);
let steps = 0;
let used = 0;
while (true) {
// Stop checks come BEFORE the call.
if (steps >= stepLimit) return { answer: honestStop(), stopReason: "step limit" };
if (used >= budgetTokens) return { answer: honestStop(), stopReason: "budget limit" };
const reply = await client.send({ messages, tools: specs });
steps++;
used += reply.usage.inputTokens + reply.usage.outputTokens;
messages.push({ role: "assistant", content: reply.content, toolCalls: reply.toolCalls });
if (reply.toolCalls.length === 0) return { answer: reply.content, stopReason: "final answer" };
// Several tool calls may come back in one reply. Answer every one, by id.
for (const call of reply.toolCalls) {
const tool = tools.get(call.name);
let result: string;
try {
result = tool ? await tool.run(call.input) : `Unknown tool "${call.name}".`;
} catch (err) {
result = `Tool failed: ${String(err)}`; // errors go back as results
}
messages.push({ role: "tool", toolCallId: call.id, content: result });
}
}
}
function honestStop(): string {
return "I could not finish this. Please check the order number, " +
"or I can pass you to a person.";
}
Real APIs name these parts differently. Some use "tool_use" and "tool_result" blocks, others use "tool_calls" and "tool" messages. When the model asks for several tools at once, they come back together in one reply, and the app sends all the results back before the next call.
The budget here counts tokens, the way real bills do. In this lab, coins play that part.
Simple lesson: the loop is a few lines of your own code, and the stop checks run before every call.
TL;DR
- Loop = moves the app repeats, round after round, until something stops it.
- Tool request = a model reply that asks the app to run one tool.
- Final answer = a model reply with no tool request. It ends the loop.
- Stop rule = a check before every call that decides if the loop must end.
- Step limit = the most model calls one customer message may use.
- Budget limit = the most coins one customer message may spend.
- Stop reason = the label the app records when the loop ends.
- The cost rule = the chat grows every round, so each call costs more.
Common mistakes
| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| No step limit | "The model will stop when it is done." | The app must have its own stop rule. | A stuck loop runs on and the bill keeps growing. |
| A very high limit | "A big limit is safer, so nothing gets cut off." | Set it just above what your hardest good job needs. | Stuck jobs waste many coins before they stop. |
| A very low limit | "Fewer calls is always cheaper." | Count the calls a good job needs first. | Good jobs get cut off before the final answer. |
| Guessing when stopped | "Say something nice so the customer is happy." | Say what could not be done and what to do next. | The customer is told something false. |
| Thinking every call costs the same | "Ten calls cost ten times one call." | Each call carries the whole chat, so later calls cost more. | The real bill is much bigger than planned. |
| Ignoring stop reasons | "It stopped, so it is fine." | Log the reason and look at the limit stops. | The same broken tool keeps wasting money. |
Check Your Understanding
Exercises
What to Remember
- An agent is a loop of five moves: send, reply, run, add the result, repeat.
- The model only asks. The app runs tools and decides when to stop.
- The chat grows every round, so each call costs more.
- A model may never stop by itself. Always set a step limit and a budget limit.
- When a loop stops early, tell the customer the truth and give a next step.
- Record the stop reason. It tells you what to fix.
Next: Tool Design, how the model picks the right tool, and how to write tool cards it understands.