Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°66

Build Your Own Agent Loop

Medium AI EngineeringAgentsLoop Engineering

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"}.

SOLVE IT YOURSELF

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.

YOUR TASK

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.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input model_outputs=[{"type":"tool","name":"search","arg":"cats"}, {"type":"final","text":"felis catus"}], tools={"search":{"cats":"felis catus"}}, max_steps=5
Output {'answer': 'felis catus', 'steps': 2, 'observations': ['felis catus'], 'status': 'answered'}
call the tool, observe the result, then answer — two steps
EXAMPLE 2
Input model_outputs=[{"type":"tool","name":"calc","arg":"2+2"}], tools={"calc":{"2+2":"4"}}, max_steps=1
Output {'answer': None, 'steps': 1, 'observations': ['4'], 'status': 'max_steps'}
the cap trips before a final answer ever arrives — no answer, loudly
EXAMPLE 3
Input model_outputs=[{"type":"tool","name":"web","arg":"x"}, {"type":"final","text":"done"}], tools={"search":{"a":"b"}}, max_steps=5
Output {'answer': 'done', 'steps': 2, 'observations': ['unknown_tool'], 'status': 'answered'}
a hallucinated tool name is observed as "unknown_tool" — the loop keeps going
EXAMPLE 4
Input model_outputs=[{"type":"final","text":"hi"}], tools={}, max_steps=0
Output {'answer': None, 'steps': 0, 'observations': [], 'status': 'max_steps'}
edge case: a zero cap reads nothing, even with an answer waiting
CONSTRAINTS
  • One model_outputs entry is consumed per step; steps counts entries consumed (including the final).
  • Tool result: tools[name][arg] when both exist; "unknown_tool" if name is absent; "unknown_arg" if the name exists but the arg does not. Append it to observations in order.
  • A final ends the loop with status "answered" and answer set to its text.
  • Ending any other way — steps == max_steps OR model_outputs exhausted — is status "max_steps" with answer left as None.
Hints 4 ideas
HINTS — 4 IDEAS
  1. Loop while steps < max_steps AND steps < len(model_outputs). Read model_outputs[steps], then increment steps.
  2. On {"type": "final"}, set answer to its text, status "answered", and break.
  3. 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.
  4. 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.

stepmove readbranchobservationsstatus
start——[]"max_steps", answer None
1tool search / "cats"both keys exist: tools["search"]["cats"]["felis catus"]"max_steps"
2tool search / "dogs"name known, arg not: "unknown_arg"["felis catus", "unknown_arg"]"max_steps"
3tool web / "cats"name not in tools: "unknown_tool"["felis catus", "unknown_arg", "unknown_tool"]"max_steps"
4final "felis catus"set answer, breakunchanged"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 = 0 with a final waiting: the guard fails before anything is read, so the result is steps = 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, so steps = 1 and observations = [], 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 tools is true for any plain object, so an in check reports "unknown_arg" instead of "unknown_tool". Object.hasOwn(tools, name) is the exact check.

Common mistakes

  • Incrementing steps before reading model_outputs[steps]: the loop skips the first move and indexes one past the end, so a lone final raises IndexError.
  • Checking arg in tools[name] before confirming name 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 final but forgetting to break: the loop keeps consuming moves, inflating steps and 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), where history is 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.

Finished this one? 0 / 76 Challenges done
NextWrite a Verifier Next on the “Agents & loop engineering” ladder · rung 6 of 9

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges