Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Build Your Own Agent Loop
Strip an "AI agent" of its mystique and this is what is left: a loop. Read the model’s next move — a tool call or a final answer — run the tool, feed the result back as an observation, repeat until it answers or the step cap trips. This is the ReAct inner loop, and it is a pure, testable function.
The problem
Implement agent_loop(model_outputs, tools, max_steps). Each step consumes the next entry of model_outputs: either {"type": "final", "text": …} (the agent is done) or {"type": "tool", "name": …, "arg": …} (call a tool). tools is a lookup of {name: {arg: result}}. Execute a tool by looking up its result and appending it to observations; if the tool name is unknown append "unknown_tool", if the name is known but the arg is not append "unknown_arg". Stop on a final (status "answered") or when steps reaches max_steps or model_outputs runs out (status "max_steps", answer stays None). Return {"answer", "steps", "observations", "status"}.
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement agent_loop(model_outputs, tools, max_steps) — step through the model’s moves, execute tools into observations, and stop on a final answer or the step cap. Return the full result dict.
Examples and constraints 4 examples
model_outputs=[{"type":"tool","name":"search","arg":"cats"}, {"type":"final","text":"felis catus"}], tools={"search":{"cats":"felis catus"}}, max_steps=5{'answer': 'felis catus', 'steps': 2, 'observations': ['felis catus'], 'status': 'answered'}model_outputs=[{"type":"tool","name":"calc","arg":"2+2"}], tools={"calc":{"2+2":"4"}}, max_steps=1{'answer': None, 'steps': 1, 'observations': ['4'], 'status': 'max_steps'}model_outputs=[{"type":"tool","name":"web","arg":"x"}, {"type":"final","text":"done"}], tools={"search":{"a":"b"}}, max_steps=5{'answer': 'done', 'steps': 2, 'observations': ['unknown_tool'], 'status': 'answered'}model_outputs=[{"type":"final","text":"hi"}], tools={}, max_steps=0{'answer': None, 'steps': 0, 'observations': [], 'status': 'max_steps'}- One
model_outputsentry is consumed per step;stepscounts entries consumed (including the final). - Tool result:
tools[name][arg]when both exist;"unknown_tool"ifnameis absent;"unknown_arg"if the name exists but the arg does not. Append it toobservationsin order. - A
finalends the loop with status"answered"andanswerset to its text. - Ending any other way —
steps == max_stepsORmodel_outputsexhausted — is status"max_steps"withanswerleft asNone.
Hints 4 ideas
- Loop while steps < max_steps AND steps < len(model_outputs). Read model_outputs[steps], then increment steps.
- On {"type": "final"}, set answer to its text, status "answered", and break.
- On a tool: check name in tools first ("unknown_tool"), then arg in tools[name] ("unknown_arg"), else tools[name][arg]. Append whichever to observations.
- If the loop ends without a final, answer stays None and status is "max_steps" — this covers both hitting the cap and running out of moves.
Approach, complexity & discussion — open after you solve
Start from brute force
The first version most people write is a for out in model_outputs loop that counts steps, indexes tools[out["name"]][out["arg"]] directly, and checks if steps == max_steps: break at the bottom of the body. It handles the happy path in O(n) time and O(n) space for n moves, and it passes every test where the model behaves. It fails in two exact places. A hallucinated tool name or argument raises KeyError: 'web' instead of being observed, so the loop dies on the most common model mistake. And because the cap is checked after a step runs, max_steps = 0 still consumes the final and returns "answered" with steps = 1.
The approach
The loop is a state machine with one cursor. The insight is that steps is both the counter and the index: the move for step n is model_outputs[n], so one integer tracks progress and enforces the budget. The reference starts pessimistic, with answer = None and status = "max_steps", so every exit that is not a final answer is already correct and needs no code of its own. The invariant: steps moves have been consumed, and observations holds one entry per tool move among them, in order.
The guard while steps < max_steps and steps < len(model_outputs) runs before any move is read, which is what makes a zero cap take zero steps. Inside, it reads out = model_outputs[steps], then increments steps, so the final counts as a step. A final sets answer and status = "answered" and breaks. A tool call unpacks name, arg and checks in order: name not in tools appends "unknown_tool", arg not in tools[name] appends "unknown_arg", and otherwise the looked-up result is appended. Checking the name first is what makes the second lookup safe.
Walk through an example
The first example’s tools, with two bad calls added before the answer and max_steps = 5: each pass reads model_outputs[steps], increments steps, and takes exactly one branch.
| step | move read | branch | observations | status |
|---|---|---|---|---|
| start | — | — | [] | "max_steps", answer None |
| 1 | tool search / "cats" | both keys exist: tools["search"]["cats"] | ["felis catus"] | "max_steps" |
| 2 | tool search / "dogs" | name known, arg not: "unknown_arg" | ["felis catus", "unknown_arg"] | "max_steps" |
| 3 | tool web / "cats" | name not in tools: "unknown_tool" | ["felis catus", "unknown_arg", "unknown_tool"] | "max_steps" |
| 4 | final "felis catus" | set answer, break | unchanged | "answered" |
| return | — | — | 3 observations | "answered", steps = 4, answer "felis catus" |
Complexity
Reference: O(s) time, where s is the number of moves consumed (at most min(max_steps, len(model_outputs))), because each pass does a constant number of dict lookups, and O(s) space for observations. Naive for loop: the same O(n) time and space for n moves. It is not slower, it is wrong: it crashes on a hallucinated name and overruns a zero cap.
Edge cases to test
max_steps = 0with a final waiting: the guard fails before anything is read, so the result issteps = 0,answer = None,status = "max_steps".model_outputs = []: the same result, zero steps and"max_steps". Running out of moves is neither an error nor an answer.- The walkthrough’s four moves with
max_steps = 3: three observations,steps = 3,answer = None,"max_steps". The cap wins even though the final was the very next move. - A move after the final, e.g. a final
"a"followed by a tool call: the loop breaks at the final, sosteps = 1andobservations = [], and the trailing call never runs. - A tool whose result is falsy,
tools = {"s": {"a": ""}}: the observation is the empty string"", not"unknown_arg". Membership checks get this right;tools[name].get(arg) or "unknown_arg"does not. - A tool named after an inherited object key, such as
"toString": Python dict membership sees only real keys, but in JavaScript"toString" in toolsis true for any plain object, so anincheck reports"unknown_arg"instead of"unknown_tool".Object.hasOwn(tools, name)is the exact check.
Common mistakes
- Incrementing
stepsbefore readingmodel_outputs[steps]: the loop skips the first move and indexes one past the end, so a lone final raisesIndexError. - Checking
arg in tools[name]before confirmingname in tools— a KeyError on the hallucinated tool. - Collapsing both lookups into
tools.get(name, {}).get(arg, "unknown_arg"): a hallucinated tool name is then reported as"unknown_arg", and the model gets the wrong feedback. - Treating “ran out of moves” and “hit the cap” as different statuses — both are
"max_steps". - Setting the answer on a
finalbut forgetting tobreak: the loop keeps consuming moves, inflatingstepsand running tool calls after the agent said it was done.
Follow-ups an interviewer asks next
- The tools are real functions with side effects. What changes?
- The lookup becomes a call,
tools[name](arg), wrapped so an exception becomes an observation such as"error: timeout"instead of killing the loop. Add a per-call timeout, and make side-effecting tools idempotent or guarded, because the model may repeat a call. The step cap still bounds the total. - The model is called live instead of reading a list.
- Each step becomes
out = model(history), wherehistoryis the conversation so far plus every observation, so the model sees what its tools returned. The loop shape is unchanged: one call per step, the same branches, the same cap. The new costs are latency and tokens, because the history grows every step; long runs summarize or trim it. - How do you cap cost instead of steps?
- Track a budget beside
steps, for example tokens or dollars spent, and add it to the guard:while steps < max_steps and spent < budget. Charge each model call and each tool call as it happens. Keep the step cap too, because a cheap model stuck repeating one call can spin for a long time under a dollar limit. - The model calls the same tool with the same argument over and over.
- Detect the repeat: keep a set of
(name, arg)pairs already executed, and on a repeat either return the cached observation or append a message such as"repeated_call"so the model is told it is stuck. Some agents stop after a few identical calls in a row rather than waiting for the step cap.
Where this shows up
This is the ReAct inner loop every coding agent, deep-research agent, and copilot runs: read the model’s move, execute a tool, feed the observation back, repeat until it answers or the budget trips. Distinguishing this inner loop (act/observe until done) from the outer verify/retry loop is the whole mental model of agent design — and the step cap is what stops a runaway from burning your budget.
Reference solution
Reference solution — Python
def agent_loop(model_outputs, tools, max_steps):
observations = []
answer = None
status = "max_steps"
steps = 0
while steps < max_steps and steps < len(model_outputs):
out = model_outputs[steps]
steps += 1
if out["type"] == "final":
answer = out["text"]
status = "answered"
break
name, arg = out["name"], out["arg"]
if name not in tools:
observations.append("unknown_tool")
elif arg not in tools[name]:
observations.append("unknown_arg")
else:
observations.append(tools[name][arg])
return {"answer": answer, "steps": steps, "observations": observations, "status": status}
T = {"search": {"cats": "felis catus"}, "calc": {"2+2": "4"}}
print(agent_loop([{"type": "tool", "name": "search", "arg": "cats"}, {"type": "final", "text": "felis catus"}], T, 5))
# -> {'answer': 'felis catus', 'steps': 2, 'observations': ['felis catus'], 'status': 'answered'}Reference solution — TypeScript
type Move = { type: 'final'; text: string } | { type: 'tool'; name: string; arg: string };
type Tools = Record<string, Record<string, string>>;
type Result = { answer: string | null; steps: number; observations: string[]; status: 'answered' | 'max_steps' };
function agentLoop(modelOutputs: Move[], tools: Tools, maxSteps: number): Result {
const observations: string[] = [];
let answer: string | null = null;
let status: 'answered' | 'max_steps' = 'max_steps';
let steps = 0;
while (steps < maxSteps && steps < modelOutputs.length) {
const out = modelOutputs[steps];
steps++;
if (out.type === 'final') {
answer = out.text;
status = 'answered';
break;
}
if (!(out.name in tools)) observations.push('unknown_tool');
else if (!(out.arg in tools[out.name])) observations.push('unknown_arg');
else observations.push(tools[out.name][out.arg]);
}
return { answer, steps, observations, status };
}
const T: Tools = { search: { cats: 'felis catus' }, calc: { '2+2': '4' } };
console.log(agentLoop([{ type: 'tool', name: 'search', arg: 'cats' }, { type: 'final', text: 'felis catus' }], T, 5));
// -> { answer: 'felis catus', steps: 2, observations: ['felis catus'], status: 'answered' }Questions about Build Your Own Agent Loop
What is the ReAct agent loop?
ReAct, short for reasoning and acting, is the pattern where a language model alternates between choosing an action, usually a tool call, and reading the result of that action, called an observation, until it decides to answer. The loop around the model is ordinary code: read the next move, run the tool, append the observation, and repeat. This challenge implements that inner loop as a pure function by replacing the live model with a scripted list of moves, which makes every branch testable without an API call.
Why does an agent loop need a step limit?
Because nothing guarantees the model will ever produce a final answer. It can call tools forever, repeat the same failing call, or wander between tools, and every pass costs a model call. A step cap turns an unbounded loop into a bounded one and makes the failure explicit: here the loop returns the status max_steps with no answer instead of hanging or inventing one. Production agents usually pair the step cap with a token or cost budget and a wall-clock timeout.
What should an agent do when the model calls a tool that does not exist?
Report it back as an observation and keep going, rather than crashing. A model can hallucinate a tool name or pass an argument the tool does not accept, and the loop is the only place that can tell it so. In this challenge an unknown name is observed as unknown_tool and a known name with an unknown argument as unknown_arg, which gives the model a chance to correct itself on the next step. Checking the name before the argument avoids a lookup error.
Is the final answer counted as a step?
Yes. A step is one model move consumed, and the final answer is a move like any other, so tool, tool, final is three steps. That is why the reference increments steps immediately after reading a move and before deciding what kind of move it is. It also means a cap of N steps leaves room for at most N minus 1 tool calls if the agent is to answer within budget; with max_steps set to 1, the model must answer on its very first move.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Retry with Exponential BackoffTools time out and APIs rate-limit — a production agent retries without hammering. Exponential backoff waits longer after each failure (1s, 2s, 4s, 8s…) up to a cap. Compute the delay schedule: delay[i] = min(cap, base·2ⁱ). Solve it in Python or TypeScript.Read →
- Trim History to a Token BudgetEvery agent turn re-sends the whole conversation, so history must fit the context budget. Keep the most recent messages that fit and drop the oldest — the trimmer at the heart of context management. Solve it in Python or TypeScript.Read →
- Parse a Tool CallWhen a model wants to act, it emits a call like search(query=cats, limit=5). Parse that string into a function name and typed arguments before the harness can dispatch it — the little parser that turns a model's intent into an action. Solve it in Python or TypeScript.Read →
- Run the Goal LoopThe engine of loop engineering: attempt the front task, let an independent verifier judge it, retry failures with state untouched, and stop when the goal is met — or loudly when the cap trips. Implement the whole outer loop as one pure, testable function. Solve it in Python or TypeScript.Read →
- Write a VerifierIn RL from verifiable rewards, the verifier IS the reward — and a gameable one is worse than none, because every false positive is a lie the model learns. Write a verifier a reward-hacking agent can’t fool: recompute the score from ground truth, demand exact task coverage, and compare types-intact. Solve it in Python or TypeScript, with hidden tests.Read →
- BM25 ScoringThe keyword-ranking function every fancy retriever still has to beat. Implement BM25 — term frequency that saturates, rare terms weighted up, long documents normalized down — the lexical half of every hybrid search pipeline. Solve it in Python or TypeScript.Read →