Your Scheduling Agent Moved One Meeting and Said "Done." Teach the Loop What Finished Looks Like.
An agent loop ends when the model stops asking for tools, not when your task is finished. Here is why that gap produces confident half-done work, and a small done-check that closes it.
You ask your assistant agent to "move all my Thursday meetings to Friday, I'm out sick." It calls the calendar tool once, gets back a success, and replies: Done, your meetings are moved to Friday. On Thursday morning three people are waiting for you on a call you never cancelled.
Nothing crashed. No tool returned an error. The agent simply stopped early, and it did so because of how almost every agent loop decides it is finished. Once you see that rule, this failure is easy to spot and cheap to fix.
🔁 How the loop actually decides to stop
Strip any agent framework down and the core loop looks like this:
messages = [system_prompt, user_request]
while True:
reply = model(messages, tools=tools)
if reply.tool_calls:
results = run_tools(reply.tool_calls)
messages += [reply, results]
else:
return reply.text # no tool call means "finished"
The stop rule is the last two lines. The loop ends the first time the model answers with plain text instead of a tool call. That is all "done" means to the loop.
Notice what is missing: nothing checks whether the task the user asked for is complete. The loop trusts the model's decision to stop talking to tools as proof that the work is finished. Most of the time those two things line up. When they don't, you get a confident summary of work that was only partly done.
🔍 Why the model stops after one step
Walk through the Thursday example turn by turn.
- The model reads "move all my Thursday meetings" and calls
list_events(day="Thursday"). Four meetings come back. - It calls
move_event(id="standup", to="Friday"). The tool returns{"status": "ok"}. - The model now sees a request about moving meetings, followed by a successful move. The most natural next message in that conversation is a confirmation. So it writes one.
The model isn't lazy here. A short, successful tool result looks like a finished story, and "ok" carries no hint that three other items are still waiting. The longer the list, and the further the original request sits from the latest tool result, the more likely the model is to treat one success as the whole job.
Common shapes of the same failure:
- "Reply to every unanswered email from this week" → replies to the first one.
- "Add these five books to my reading list" → adds two, summarises all five.
- "Rename the files in this folder to include the date" → renames the batch the first tool call returned, never asks for the next page.
The pattern is the same each time: the task has several parts, the tools work on one part per call, and nothing outside the model is counting.
✅ The fix: a done-check the loop runs before it returns
Don't let the model's decision to stop be the only signal. Write down what "finished" means for this task in a form code can check, and check it before handing the answer back.
For the calendar task, "finished" is easy to state: Thursday has no meetings you own or accepted.
def thursday_is_clear():
remaining = calendar.list_events(day="Thursday", mine=True)
return [e.title for e in remaining]
while True:
reply = model(messages, tools=tools)
if reply.tool_calls:
messages += [reply, run_tools(reply.tool_calls)]
continue
left = thursday_is_clear()
if not left:
return reply.text
messages.append(user_note(
f"Not finished yet. Still on Thursday: {left}. Continue."
))
Three things make this work:
- The check reads the real world, not the conversation. It asks the calendar what is on Thursday. It doesn't ask the model whether it thinks it's done.
- A failed check goes back into the loop as feedback. The model gets a concrete list of what's left, which is far easier to act on than a vague "are you sure?"
- Only a passing check lets the loop return. The model still decides when to stop calling tools, but it no longer decides alone when the job is over.
Add a cap on how many times the check can bounce the model back, say three. If the check still fails after that, return an honest message ("I moved 3 of 4 meetings; the design review couldn't be moved because you're not the organiser") instead of looping forever.
🧩 Writing a done-check when the task is fuzzy
Calendar clearing has an obvious finish line. Many personal tasks don't. A few ways to get a checkable definition anyway:
- Count the inputs first. Before acting, have the agent list the items it will work on, and store that list outside the conversation. Done means every item has a recorded result.
- Check the end state, not the actions. "Every email in the list has a reply in Sent" beats "the agent called
send_replyseveral times." - Ask the user once, up front. For something like "tidy my notes," one question ("done means every note has a tag?") turns a vague goal into a check.
- Fall back to a checklist the model fills in. If you truly can't check the world, make the model return a per-item status table. A missing row is easy for code to catch; a missing sentence in a friendly summary is not.
⚠️ What a done-check will not save you from
A done-check catches stopping early. It won't catch doing the wrong thing thoroughly. If the agent moves every meeting to Saturday instead of Friday, "Thursday is clear" still passes. Pair the check with the destination condition too ("and each moved meeting is on Friday") when getting it wrong costs something.
It also costs a tool call or two per run. For a personal assistant that is nothing next to the cost of a missed meeting.
The takeaway
In a basic agent loop, "finished" means "the model stopped calling tools." Your task has its own definition of finished, and the loop doesn't know it until you tell it. Write that definition as a small function that looks at the real state of the world, and make the loop pass it before it's allowed to say "done."