You have a model and some tools. Left to itself, when does it stop? The honest answer is: maybe never. A model that keeps requesting one more tool call — because it was prompted loosely, or got stuck, or simply never learned to declare victory — will happily loop until someone pulls the plug. An agent loop without a guaranteed stop is a program that can run up an unbounded bill or hang a request indefinitely.
The fix is engineering, not hope. Give the loop two independent reasons to stop: a goal predicate that fires the moment the task is genuinely done, and a step budget that caps the number of turns no matter what. Wire them together and the loop terminates by construction — not because the model cooperated, but because the budget is an integer that only goes up and the loop guards on it every single turn.
flowchart TD START["turn begins"] --> COND["steps below budget<br/>AND goal not met?"] COND -->|"yes"| RUN["run the turn:<br/>model, tools, append results"] RUN --> START COND -->|"no"| EXIT["stop: goal reached or budget spent"] style EXIT fill:#166534,color:#fff
Two conditions, not one
The goal predicate is a function you write — isGoal(state) — that returns true when the agent has actually achieved its task. That is a real, semantic check: the target sum is reached, the answer is verified against a source, the file now exists. It is not the model stopped talking; a model can fall silent before the work is finished. The predicate is how the loop learns about success.
The step budget is a hard ceiling on turns — maxSteps. Each turn increments a counter, and the loop continues only while steps < maxSteps. Because that counter only ever rises and the comparison happens every turn, the budget is the one condition the model cannot defeat. Whatever the model returns, the loop stops after at most maxSteps turns. The stop condition is their combination: keep going while !isGoal(state) && steps < maxSteps.
function shouldContinue(state, steps, isGoal, maxSteps) {
return steps < maxSteps && !isGoal(state);
}
const isGoal = (s) => s.sum >= 10;
console.log(shouldContinue({ sum: 0 }, 0, isGoal, 3));
console.log(shouldContinue({ sum: 10 }, 0, isGoal, 3));
console.log(shouldContinue({ sum: 0 }, 3, isGoal, 3));
function scriptedModel(scripts) {
let i = 0;
return { respond() { return scripts[i++]; } };
}
function runAgent(model, tools, state, isGoal, maxSteps) {
let steps = 0;
const messages = [];
while (steps < maxSteps && !isGoal(state)) {
const reply = model.respond(messages);
messages.push(reply);
steps++;
if (!reply.tool_calls || reply.tool_calls.length === 0) break;
for (const call of reply.tool_calls) {
const fn = tools[call.name];
if (fn) state = fn(state, call.arguments);
}
}
return { state, steps, goalReached: isGoal(state) };
}
const model = scriptedModel([
{ role: 'assistant', tool_calls: [{ id: 'c1', name: 'add', arguments: { n: 5 } }] },
{ role: 'assistant', tool_calls: [{ id: 'c2', name: 'add', arguments: { n: 5 } }] },
{ role: 'assistant', content: 'The sum is 10' }
]);
const tools = { add: (s, a) => ({ ...s, sum: s.sum + a.n }) };
const r = runAgent(model, tools, { sum: 0 }, (s) => s.sum >= 10, 100);
console.log('goal', r.goalReached, 'sum', r.state.sum, 'steps', r.steps);
How a turn unfolds
Read the loop from the top. The while line is the whole contract: it runs another turn only while the budget has room and the goal is not yet met. Inside, the agent asks the model for a reply, records that reply in the conversation, and counts the turn against the budget — counting happens before the work, so even a turn that does nothing still spends a step.
If the reply carries tool calls, the agent runs each one and folds the results back into state, then returns to the top and re-checks both conditions. If the reply has no tool calls, the model has nothing more it wants to do, so the loop breaks early rather than spin waiting. Three exits are reachable: the goal predicate turns true, the budget runs out, or the model stops calling tools.
flowchart LR M["model always returns<br/>another tool call"] --> Q["only stop is no-tool-calls"] Q -->|"never fires"| LOOP["loop runs forever"] B["step budget checked<br/>every single turn"] --> STOP["stops at maxSteps"] style LOOP fill:#b91c1c,color:#fff style STOP fill:#166534,color:#fff
function scriptedModel(scripts) { let i = 0; return { respond() { return scripts[i++]; } }; }
function runAgent(model, tools, state, isGoal, maxSteps) {
let steps = 0; const messages = [];
while (steps < maxSteps && !isGoal(state)) {
const reply = model.respond(messages); messages.push(reply); steps++;
if (!reply.tool_calls || reply.tool_calls.length === 0) break;
for (const call of reply.tool_calls) { const fn = tools[call.name]; if (fn) state = fn(state, call.arguments); }
}
return { state, steps, goalReached: isGoal(state) };
}
const model = scriptedModel([
{ role: 'assistant', tool_calls: [{ id: 'c1', name: 'add', arguments: { n: 1 } }] },
{ role: 'assistant', tool_calls: [{ id: 'c2', name: 'add', arguments: { n: 1 } }] },
{ role: 'assistant', tool_calls: [{ id: 'c3', name: 'add', arguments: { n: 1 } }] },
{ role: 'assistant', tool_calls: [{ id: 'c4', name: 'add', arguments: { n: 1 } }] }
]);
const tools = { add: (s, a) => ({ ...s, sum: s.sum + a.n }) };
const r = runAgent(model, tools, { sum: 0 }, (s) => s.sum >= 100, 3);
console.log('goal', r.goalReached, 'sum', r.state.sum, 'steps', r.steps);
Why this cannot loop forever
Termination here is not a hope; it is an argument you can write down. The step counter starts at zero and rises by exactly one each turn. The loop guards on steps < maxSteps. So after at most maxSteps turns the guard is false and the loop exits — full stop, independent of what the model returns, independent of whether the goal is ever reached.
That is the difference between a loop that usually stops and one that must stop. The goal predicate makes stopping timely: the agent quits the moment it has verifiably succeeded, instead of burning the whole budget. The budget makes stopping certain: even a model that never converges is capped. You need both, because timeliness and certainty are different properties, and neither gives you the other.
flowchart LR NEED["a correct agent"] --> G["goal predicate"] NEED --> B["step budget"] G --> T["timely stop<br/>quits on success"] B --> C["certain stop<br/>caps the turns"] style T fill:#166534,color:#fff style C fill:#b45309,color:#fff
How this extends the dispatch loop
The tool-dispatch loop you built earlier stopped when a turn carried no tool calls, and later gained a budget. This loop keeps both of those exits and adds the goal predicate on purpose. The difference is who decides done: before, the model decided by going quiet; now your predicate decides by inspecting the actual state.
That matters when the model is eager. A model that loves calling tools might fire off five more calls after the answer is already computed, burning tokens for nothing. With a goal predicate, the loop stops the instant the target is met, so the extra calls never happen. The budget is still there as the unconditional backstop; the predicate is the new, smarter front door.
What does this print? Each turn adds 5 to the sum. The loop stops when the sum reaches the goal of 10 or the step budget of 3 is spent.
const isGoal = (s) => s.sum >= 10;
const maxSteps = 3;
let state = { sum: 0 };
let steps = 0;
while (steps < maxSteps && !isGoal(state)) {
state = { sum: state.sum + 5 };
steps++;
}
console.log(state.sum);
console.log(steps);
After one turn the sum is 5; after two turns it is 10.
The goal is met at sum 10, so the loop stops before spending all 3 steps.
Write shouldContinue(state, steps, isGoal, maxSteps) that returns true when the agent loop should run one more turn: the goal is not yet met AND the step count is still under the budget.
function shouldContinue(state, steps, isGoal, maxSteps) {
// true when not done and still under budget
}
Return steps < maxSteps first, then AND it with !isGoal(state).
If either the budget is spent or the goal is met, the answer is false.
function shouldContinue(state, steps, isGoal, maxSteps) {
return steps < maxSteps && !isGoal(state);
}
Write runAgent(model, tools, state, isGoal, maxSteps). Each turn: if the budget is spent or the goal is met, stop. Otherwise ask the model for a reply, push it, count a step, and if the reply has no tool_calls break early. If it does have tool_calls, run each (look it up in tools, call with call.arguments) and update state. Return { state, steps, goalReached: isGoal(state) }. The scriptedModel helper is already defined for you.
function scriptedModel(scripts) {
let i = 0;
return { respond() { return scripts[i++]; } };
}
function runAgent(model, tools, state, isGoal, maxSteps) {
// stop on goal or budget; break on no tool_calls; run each call and fold into state
}
Loop while steps < maxSteps && !isGoal(state).
Push the reply, increment steps, then break if there are no tool_calls; otherwise run each tool call against state.
function scriptedModel(scripts) {
let i = 0;
return { respond() { return scripts[i++]; } };
}
function runAgent(model, tools, state, isGoal, maxSteps) {
let steps = 0;
const messages = [];
while (steps < maxSteps && !isGoal(state)) {
const reply = model.respond(messages);
messages.push(reply);
steps++;
if (!reply.tool_calls || reply.tool_calls.length === 0) break;
for (const call of reply.tool_calls) {
const fn = tools[call.name];
if (fn) state = fn(state, call.arguments);
}
}
return { state, steps, goalReached: isGoal(state) };
}
This loop guards only on the goal predicate, with no step budget. If the goal is never reached it spins until the model's scripted replies run out and then crashes. Add the budget guard so the loop also stops at maxSteps. The body is otherwise correct.
function scriptedModel(scripts) { let i = 0; return { respond() { return scripts[i++]; } }; }
function runAgent(model, tools, state, isGoal, maxSteps) {
let steps = 0;
const messages = [];
while (!isGoal(state)) { // BUG: no budget -> loops forever if the goal is never reached
const reply = model.respond(messages);
messages.push(reply);
steps++;
if (!reply.tool_calls || reply.tool_calls.length === 0) break;
for (const call of reply.tool_calls) {
const fn = tools[call.name];
if (fn) state = fn(state, call.arguments);
}
}
return { state, steps, goalReached: isGoal(state) };
}
Change the while line so it also requires steps < maxSteps.
Now an unreachable goal stops at the budget instead of spinning forever.
function scriptedModel(scripts) { let i = 0; return { respond() { return scripts[i++]; } }; }
function runAgent(model, tools, state, isGoal, maxSteps) {
let steps = 0;
const messages = [];
while (steps < maxSteps && !isGoal(state)) {
const reply = model.respond(messages);
messages.push(reply);
steps++;
if (!reply.tool_calls || reply.tool_calls.length === 0) break;
for (const call of reply.tool_calls) {
const fn = tools[call.name];
if (fn) state = fn(state, call.arguments);
}
}
return { state, steps, goalReached: isGoal(state) };
}
What guarantees the agent loop terminates, no matter what the model returns?
Prompts and tools influence the model but guarantee nothing — a stuck model can always request another tool call. Only a step budget, compared against an integer that only rises, proves the loop must stop after a fixed number of turns.
Recap
- An agent loop needs two independent stop conditions: a goal predicate
isGoal(state)for timely success, and a step budgetmaxStepsfor certain termination. - The stop condition is their conjunction: continue while
!isGoal(state) && steps < maxSteps. - The budget guarantees termination — a counter that only rises, checked every turn, caps the turns regardless of model behavior.
- The goal predicate lets the loop quit the moment the task is verifiably done, before the model reaches a final answer.
- Never ship a loop with one exit: timeliness and certainty are different properties, and you need both.
Next you will coordinate several of these agents at once — a supervisor that breaks a task into pieces and hands each to a worker.