Skip to main content
PProfileMatch

AI Agents Explained: The Observe, Decide, Act Loop (With Python)

Generative AILesson 1 of 47 min read

What you'll learn

  • Define an AI agent as a model plus tools plus a loop, and say what each part is responsible for
  • Write a runnable observe-decide-act loop with a step cap and a deterministic stand-in for the model
  • Choose between a single prompt, a fixed workflow and an agent for a given task
  • Spot the loop-level bugs that make agents repeat themselves or run forever

On this page

  1. A prompt is one guess, an agent is a loop
  2. The loop in about forty lines
  3. Failure paths are part of the design
  4. Single prompt, workflow or agent?
  5. Why the blind guess goes wrong
  6. Common mistakes
  7. How this shows up in real projects and interviews

Ask a plain chatbot to "book a room for six people at 2pm, or 3pm if 2pm is taken" and it will often answer with something confident like "Done, Cedar room at 2pm." Nothing was booked. The model produced a sentence that looks like the end of a successful booking, because that is what text after such a request usually looks like. It never looked at a calendar, so it had no way to know that Cedar is only free at 3pm.

The fix is not a smarter sentence. The fix is to let the model ask for the calendar, read what comes back, and decide again. That arrangement, a model whose output is repeatedly turned into real actions and whose results are fed back to it, is what people mean by an agent.

A prompt is one guess, an agent is a loop

A single prompt is one function call: text in, text out. Whatever the model needs to know must already be in the prompt, and whatever it says is the final result. An agent adds three things around the model:

  • A model that chooses the next action. Given the goal and the history so far, it returns a structured decision such as "call search_rooms with these arguments" or "finish with this answer".
  • Tools that do real work. Ordinary functions: query a database, call an API, run a calculation, write a file. The model never executes anything itself; it only asks.
  • A loop you wrote. Plain code that calls the model, runs the chosen tool, appends the result to the history and calls the model again, until the model says it is finished or a limit is hit.

The loop is the part newcomers underestimate. It is not machine learning; it is a for loop. That is good news, because it means everything that makes an agent safe or unsafe (what tools exist, how many steps are allowed, what happens on an error) is code you can read, test and change. The model supplies judgement; your loop supplies the rules.

The loop in about forty lines

To study the loop without paying for API calls or accepting random behaviour, the program below replaces the model with fake_model, an ordinary function that follows fixed rules but has the same interface a real model would have: it receives the goal and the history, and returns a decision. Swap it for a call to a real model and the rest of the file would not change.

room_agent.py
ROOMS = {
    "Birch": {"seats": 4, "free_at": ["14:00", "15:00"]},
    "Cedar": {"seats": 8, "free_at": ["15:00"]},
    "Maple": {"seats": 12, "free_at": ["09:00"]},
}

def search_rooms(min_seats, time):
    return [n for n, r in ROOMS.items() if r["seats"] >= min_seats and time in r["free_at"]]

def book_room(name, time):
    ROOMS[name]["free_at"].remove(time)
    return f"booked {name} at {time}"

TOOLS = {"search_rooms": search_rooms, "book_room": book_room}


def fake_model(goal, history):
    """Stands in for an LLM call: reads the goal and the history, returns the next step."""
    searches = [h for h in history if h["action"] == "search_rooms"]
    if history and history[-1]["action"] == "book_room":
        return {"action": "finish", "args": {"answer": history[-1]["result"]}}
    if searches and searches[-1]["result"]:
        return {"action": "book_room",
                "args": {"name": searches[-1]["result"][0], "time": searches[-1]["args"]["time"]}}
    tried = {s["args"]["time"] for s in searches}
    untried = [t for t in goal["times"] if t not in tried]
    if untried:
        return {"action": "search_rooms", "args": {"min_seats": goal["people"], "time": untried[0]}}
    return {"action": "finish", "args": {"answer": "Nothing is free at those times."}}


def run_agent(goal, model, max_steps=6):
    history = []
    for step in range(1, max_steps + 1):
        decision = model(goal, history)                      # decide
        print(f"step {step}: {decision['action']} {decision['args']}")
        if decision["action"] == "finish":
            return decision["args"]["answer"]
        result = TOOLS[decision["action"]](**decision["args"])   # act
        print(f"        -> {result}")
        history.append({**decision, "result": result})       # observe: the next decision reads this
    return "gave up: step limit reached"


print(run_agent({"people": 6, "times": ["14:00", "15:00"]}, fake_model))

Output

step 1: search_rooms {'min_seats': 6, 'time': '14:00'}
        -> []
step 2: search_rooms {'min_seats': 6, 'time': '15:00'}
        -> ['Cedar']
step 3: book_room {'name': 'Cedar', 'time': '15:00'}
        -> booked Cedar at 15:00
step 4: finish {'answer': 'booked Cedar at 15:00'}
booked Cedar at 15:00

Read the output top to bottom. At step 1 the agent searches for a room at 2pm and observes an empty list, because Birch has only four seats. At step 2 it changes its plan based on that observation and tries 3pm. At step 3 it books Cedar, and at step 4 it finishes with the booking confirmation as its answer. No part of that route was written as a fixed sequence of calls; it emerged from the model reading each result.

Three details in the code matter far beyond this toy. First, finish is just another action, so stopping is a decision the model makes explicitly rather than something that happens when text runs out. Second, history is the agent's entire working memory: if a result is not appended there, the next decision cannot see it. Third, max_steps is a hard ceiling owned by the loop, not by the model.

Failure paths are part of the design

The interesting runs are the ones that do not succeed. Append the following to the same file.

python
print("---")
print(run_agent({"people": 20, "times": ["09:00"]}, fake_model))

def stubborn_model(goal, history):
    # a model stuck in a rut: searches again and never decides to book
    return {"action": "search_rooms", "args": {"min_seats": 6, "time": "09:00"}}

print("---")
print(run_agent({"people": 6, "times": ["09:00"]}, stubborn_model, max_steps=3))

Output

---
step 1: search_rooms {'min_seats': 20, 'time': '09:00'}
        -> []
step 2: finish {'answer': 'Nothing is free at those times.'}
Nothing is free at those times.
---
step 1: search_rooms {'min_seats': 6, 'time': '09:00'}
        -> ['Maple']
step 2: search_rooms {'min_seats': 6, 'time': '09:00'}
        -> ['Maple']
step 3: search_rooms {'min_seats': 6, 'time': '09:00'}
        -> ['Maple']
gave up: step limit reached

With twenty people there is no room at all, so the agent searches once, sees the empty result, has no more times to try and finishes honestly. The stubborn model shows what the step cap is for: it keeps searching forever, and only max_steps=3 stops it. A real model can fall into the same rut when a tool keeps returning something it does not know how to use. Without a cap you find out from the invoice.

Single prompt, workflow or agent?

Agents are the most flexible option and also the least predictable one, so they should be the last thing you reach for, not the first. Here is a practical way to choose.

ApproachWho picks the next stepBest whenCost of being wrong
Single promptNobody; one callRewriting, classifying, summarising text you already haveLow: one bad answer, easy to retry
Fixed workflow (a chain)Your code, in advanceThe steps are known and the same every time, such as extract fields, validate, storeMedium: a bad step is a bug you can find in the code
Agent loopThe model, at run timeThe route depends on what the tools return and cannot be listed up frontHigher: wrong routes, wasted calls, actions with side effects

If you can write the steps on a whiteboard before seeing any data, write them as a workflow in code and use the model only inside individual steps. The room-booking task above is borderline: a workflow that tries 2pm then 3pm would do the same job with no model deciding anything. It becomes a true agent job when the goal is fuzzier, for example "find any slot this week that works for everybody, avoiding Fridays", where the number and order of lookups truly depends on the answers. To see how the underlying model produces its decisions, read Large Language Models, and for how to word the instructions that steer those decisions, see Prompt Engineering.

Why the blind guess goes wrong

To see the problem the loop solves, here is the same request answered by a one-shot call that cannot look anything up.

one_shot.py
free = {"Cedar": ["15:00"], "Maple": ["09:00"]}   # the real calendar

def one_shot_answer(goal):
    # A single completion cannot read `free`. It can only produce plausible text.
    return f"Booked Cedar at {goal['times'][0]} for {goal['people']} people."

print(one_shot_answer({"people": 6, "times": ["14:00", "15:00"]}))
print("Cedar actually free at 14:00?", "14:00" in free["Cedar"])

Output

Booked Cedar at 14:00 for 6 people.
Cedar actually free at 14:00? False

The sentence is fluent and the claim is false. Fluency is not evidence. The agent version is better not because its model is smarter but because its claims are backed by tool results sitting in the history.

Common mistakes

  • No step cap. Symptom: the log shows the same tool call repeated dozens of times, then a timeout or a surprising bill. Always enforce a maximum number of steps in the loop itself.
  • Forgetting to feed results back. If you call the tool but never append its result to the history, the model decides again with no new information and issues the same call forever. The output looks like an endless repeat of step 1.
  • Trusting the final answer without checking the evidence. The finish text is generated, so it can claim success the tools never reported. Where it matters, verify with a second lookup (re-read the booking) rather than the model's words.
  • Letting tools return enormous payloads. A search that dumps a whole table into the history crowds out the goal and everything else. Symptoms are rising latency and, eventually, a context-length error from the provider. Return short, relevant results.
  • Using an agent for a fixed procedure. The result is a slower, costlier and less predictable version of a ten-line script.

How this shows up in real projects and interviews

Most agents that run in production are small, boring loops: two to six tools, a strict step budget, a human checkpoint before anything irreversible. They are closer to the code above than to the science-fiction version. In interviews the common questions are "when would you use an agent instead of a workflow", "how do you stop it from looping" and "how would you test it". The honest answer to the first is "only when the route cannot be known in advance", and the third is answered in the final lesson on reliability and evaluation.

Try it yourself

Extend room_agent.py so that fake_model also accepts a goal key "avoid" (a list of room names) and never books those rooms. Then add a third tool, cancel_booking, and make the agent finish with a clear message if the only free room is one on the avoid list. Run it with and without the avoid list and compare the step-by-step output.

Show a hint

Filter the search results inside fake_model before choosing result[0]. Treat a result list that is empty after filtering the same as an empty search, so the existing 'try the next time, else finish' branches still apply.

Key takeaways

  • An agent is a model that chooses actions, tools that perform them, and a loop that feeds each result back, repeating until a finish action or a limit.
  • The loop is ordinary code you own: step caps, validation and stopping rules belong there, not in the prompt.
  • History is the agent's working memory; any result that is not appended to it cannot influence the next decision.
  • Prefer a single prompt or a fixed workflow when the steps are known in advance; use an agent only when the route depends on tool results.

Test yourself

A trick from this lesson's ride: The model orders; your code cooks. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is the difference between an AI agent and a chatbot?

A chatbot returns text and stops. An agent can also request actions: it picks a tool, your code runs it, and the result goes back to the model so it can decide what to do next. The difference is the surrounding loop and the tools, not a different kind of model.

Do I need a framework to build an AI agent?

No. The core is a loop, a table of tool functions and a way to call a model, which fits in a page of code as the example shows. Frameworks can help with retries, tracing and structured output, but you should understand the bare loop first so you can tell what a framework is doing for you.

When should I not use an AI agent?

Skip it when the steps are known in advance, when a wrong action is expensive or irreversible with no review step, or when you need exactly repeatable behaviour. A fixed workflow, with the model used only inside individual steps, is usually cheaper, faster and easier to test.