ai
Five assistants, one loop

The admin side of our platform has a chat box, and behind the chat box there is not one assistant but several. A router reads the message and picks one of five — configuration, bookings, member communication, insights, help — and a website builder and a couple of member-facing ones sit alongside. Each has its own prompt and its own tools. What they share is more interesting than what separates them: every one of them runs on the same loop, and the loop is short enough to print.
The loop
Strip the types and it is this. Bind the assistant's tools to the model, put the system prompt and the conversation in front of it, and go round:
for (let round = 0; round < maxRounds; round++) {
const response = await llmWithTools.invoke(messages)
if (!response.tool_calls?.length) return response.content // done: text for the user
messages.push(response)
for (const call of response.tool_calls) {
let result
try {
result = await toolsByName[call.name].invoke(call.args)
} catch (err) {
result = `Error: ${err.message}` // the model sees failures as text, not exceptions
}
messages.push(new ToolMessage({ content: result, tool_call_id: call.id }))
}
}
// Out of rounds: one last call with no tools, so the user gets a sentence, not silence.
return (await llm.invoke(messages)).content
The model decides which tools to call. We run them, append each result as a
ToolMessage, and ask again. When a turn comes back with no tool calls, the
text is the answer. That is the whole agent. LangChain's bindTools does the
schema plumbing, and the model behind it is gpt-4o-mini unless an assistant
asks for something else.
Three details in there are doing more work than their size suggests.
Failures are data. A tool that throws does not end the conversation; its error message becomes the tool's result and goes back to the model. This is deliberate. "Contact not found" is information the model can act on — ask the user to spell the name, try a broader search — and an exception would have thrown that information away.
The cap is per assistant. Help gets six rounds, bookings and configuration eight, the website builder ten, because building a page legitimately takes more steps than answering a how-to question. Nobody gets unlimited.
Running out of rounds is not an error. The final call has the tools removed, so the model has no option but to write to the user with whatever it has. The alternative — a timeout and an empty bubble — is what every early version of this did, and it is what users remember.
What the router adds
The router is an assistant with no tools. It returns a JSON object: which of the five to hand off to, a confidence, and — when confidence is under 0.8 — a clarifying question instead of a guess. The alternative — one assistant carrying everyone's tools — fails in the way you would predict: forty tool schemas in front of the model on every turn, and a much better chance of the configuration tool being called for a question about revenue. Five narrow assistants with six to ten tools each behave better and are easier to test, because each one's tool set is small enough to write assertions about.
The tools themselves are closures over the organisation. The insights
assistant's get_aggregated_metrics takes a date range and nothing else; the
organisation ID was bound when the tool was created for this request, and the
model cannot supply a different one. There is no text-to-SQL anywhere in this.
Every insights tool calls a scoped domain service that already existed for the
dashboard, and the assistant is a new front door on old, tested doors.
The retry that would not stop
Here is the failure that prompted this post. The help assistant answers from
documentation, and one of its tools fetches a document by name. A user asked
about a feature whose document did not exist yet. The tool returned
Document not found. The model, reasonably, tried again. Same name, same
arguments, same result. Six rounds later the cap fired and the user received a
confident summary of nothing.
It happened often enough to have a shape: a failed call with a clear error, and a model that reads the error, decides the right response is to try again, and tries again identically. Not a different document, not a broader search. The same call. A person does not do that; a model on a low temperature does it with some enthusiasm.
The fix that landed on Tuesday is small enough to quote:
const argsKey = JSON.stringify(call.args ?? {})
const priorFailure = executed.find(
(p) => p.name === call.name && JSON.stringify(p.args ?? {}) === argsKey
&& /^Error:|not found|not implemented/i.test(p.result)
)
if (priorFailure) {
result = `Stop: this exact tool call already failed above with: ${priorFailure.result}.
Do NOT retry with the same arguments. Reply to the user explaining the problem
and what they need to do.`
} else {
result = await tool.invoke(call.args) // as before
}
Before running a tool, look back at what has already run in this conversation. If the same tool was called with byte-identical arguments and the result was a failure, do not run it. Feed back an instruction instead — one that names the earlier error and tells the model to talk to the user. The loop still counts the round, so a stubborn model still hits the cap, but in practice it takes the hint on the first try. The user gets "there is no documentation for that yet; here is what I can tell you" instead of a wall.
Why this is the interesting bit
I keep noticing that the parts of agent code that matter are not the parts that look like AI. Bind tools, call model, run tools, repeat — that is a for loop, and any framework will hand you one. The judgement is all in the edges: what the model sees when a tool fails, how many times it may go round, what happens when it runs out, and what you refuse to let it do twice. Those are ordinary engineering decisions about a slightly unusual caller, and they are where the quality lives.
The guard is a regular expression over a string. It is not clever. It removed the single most visible failure our assistants had, and it took an afternoon. I would take that trade every week.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


