Module 1 · Agent Foundations

The Agent Loop: Reason, Act, Observe

Lesson 2 of 25 11 min read Updated

An AI agent is a program that uses a language model to decide what to do next, does it using tools, and looks at the result before deciding again. This repeating cycle is called the agent loop: reason, act, observe. Almost every agent you will meet, from a coding assistant to a customer-support bot, is built on this one idea. If you understand the loop, you understand how agents work, why they fail, and how to make them safe.

Overview: How the Agent Loop Works

A plain chat model answers once: you send a prompt, it returns text. An agent is different. It runs in a loop and can take actions in the outside world between model calls. Each trip around the loop has three phases.

  • Reason – the model reads the whole conversation so far (the task, earlier actions and their results) and decides what to do next. It either asks to use a tool or gives a final answer.
  • Act – your code (not the model) runs the requested tool, for example a search function, a file reader or a database query.
  • Observe – the tool’s result is added to the conversation as a new message. On the next trip, the model sees this observation and reasons again.

The loop ends when the model produces a final answer, when a safety limit is reached, or when a human stops it.

The model never runs anything itself

This point confuses many beginners. The model only produces text, usually a structured request such as call the tool named get_weather with city = Pune. It cannot touch your files or network. Your program, called the harness or orchestrator, reads that request, decides whether to allow it, runs the real function, and returns the output. Because your code sits in the middle, you control permissions, logging, limits and approvals.

Why the loop is powerful

The observation step is what makes agents adaptive. If a search returns nothing, the model sees that and can try different words. If a tool raises an error, the error text becomes an observation and the model can fix its arguments. Without feeding results back, you only have a one-shot tool call, not an agent.

What the model actually receives

On every trip, your harness sends the model the full message list: the system instructions, the user’s task, every earlier model decision and every earlier observation. The model has no memory between calls except what you put in this list. This is why the list is called the context or trajectory. Long loops make the context grow, which costs more tokens and can push out important details, so real agents also truncate or summarise old observations.

Key Concepts

The general shape of the loop in pseudocode-style Python is shown below.

messages = [task]
for step in range(max_steps):
    decision = call_llm(messages)      # reason
    if decision is final:
        return decision.text
    result = run_tool(decision)        # act (with checks)
    messages.append(result)            # observe
return 'stopped: step limit'
Part Meaning
messages The running history. The model is stateless, so you resend this every turn.
call_llm() One model call. In this course it is a stub so examples run offline. Swap it for a real SDK call later.
Tool registry A dictionary mapping tool names to real Python functions. Only these can run.
max_steps A hard cap on loop trips. It prevents endless loops and runaway cost.
Approval gate A check that asks a human before risky tools such as sending email or deleting files.

Examples

Example 1: The smallest possible loop

The stub model below behaves like a real one: if it has not seen any tool result yet, it asks for the add tool; once it sees a result, it gives a final answer.

def call_llm(messages):
    """Stub model: decides the next step from the conversation so far."""
    observations = [m for m in messages if m['role'] == 'tool']
    if not observations:
        return {'type': 'tool_call', 'name': 'add', 'args': {'a': 17, 'b': 25}}
    return {'type': 'final', 'text': 'The sum is ' + observations[-1]['content']}


def add(a, b):
    return str(a + b)


TOOLS = {'add': add}

messages = [{'role': 'user', 'content': 'What is 17 + 25?'}]
while True:
    decision = call_llm(messages)
    if decision['type'] == 'final':
        print('FINAL:', decision['text'])
        break
    result = TOOLS[decision['name']](**decision['args'])
    print('ACT:', decision['name'], decision['args'], '->', result)
    messages.append({'role': 'tool', 'content': result})

Output:

ACT: add {'a': 17, 'b': 25} -> 42
FINAL: The sum is 42

Trip one: the model has no observations, so it requests add. The harness runs it and appends the result. Trip two: the model sees 42 and answers. Notice that tool results are stored as strings, because they will be placed back into text for the model.

Example 2: A safer, realistic harness

Real agents need a step limit, error handling, unknown-tool handling and approval for risky actions. This version uses a scripted list of model decisions so the output is predictable. In production, call_llm would call a real model API.

import json

SCRIPT = [
    {'type': 'tool_call', 'name': 'get_weather', 'args': {'city': 'Pune'}},
    {'type': 'tool_call', 'name': 'get_weather', 'args': {'city': 'Atlantis'}},
    {'type': 'tool_call', 'name': 'send_email',
     'args': {'to': 'team@example.com', 'body': 'Pune is 31C'}},
    {'type': 'final', 'text': 'Done: weather checked, email declined by human.'},
]


def make_llm(script):
    steps = iter(script)

    def call_llm(messages):
        return next(steps)

    return call_llm


def get_weather(city):
    data = {'Pune': '31C, clear'}
    if city not in data:
        raise ValueError(f'unknown city: {city}')
    return data[city]


def send_email(to, body):
    return f'email sent to {to}'


TOOLS = {'get_weather': get_weather, 'send_email': send_email}
RISKY = {'send_email'}


def approve(name, args):
    print(f'  [approval needed] {name} {json.dumps(args)} -> auto-deny in demo')
    return False


def run_agent(task, call_llm, max_steps=6):
    messages = [{'role': 'user', 'content': task}]
    for step in range(1, max_steps + 1):
        decision = call_llm(messages)
        if decision['type'] == 'final':
            print(f'step {step}: FINAL')
            return decision['text']
        name, args = decision['name'], decision['args']
        print(f'step {step}: ACT {name}')
        if name not in TOOLS:
            obs = f'error: no such tool {name}'
        elif name in RISKY and not approve(name, args):
            obs = 'denied: human rejected this action'
        else:
            try:
                obs = TOOLS[name](**args)
            except Exception as exc:
                obs = f'error: {exc}'
        print(f'  OBSERVE: {obs}')
        messages.append({'role': 'tool', 'content': obs})
    return 'stopped: step limit reached'


print(run_agent('Check Pune weather and email the team', make_llm(SCRIPT)))

Output:

step 1: ACT get_weather
  OBSERVE: 31C, clear
step 2: ACT get_weather
  OBSERVE: error: unknown city: Atlantis
step 3: ACT send_email
  [approval needed] send_email {"to": "team@example.com", "body": "Pune is 31C"} -> auto-deny in demo
  OBSERVE: denied: human rejected this action
step 4: FINAL
Done: weather checked, email declined by human.

Three things matter here. First, the tool error in step 2 did not crash the program; it became an observation, so a real model could recover. Second, the risky send_email tool was blocked by the approval gate, and the denial was also fed back as an observation so the model knows what happened. Third, the for loop with max_steps guarantees the program ends.

Example 3: Detecting a stuck loop

Models sometimes repeat the same failing action forever. A simple guard checks whether the last few calls are identical.

def detect_repeat(history, window=3):
    return len(history) >= window and len(set(history[-window:])) == 1


history = []
for step in range(1, 10):
    call = ('search', 'python agent loop')
    history.append(call)
    print('step', step, call)
    if detect_repeat(history):
        print('stopping: same call repeated 3 times')
        break

Output:

step 1 ('search', 'python agent loop')
step 2 ('search', 'python agent loop')
step 3 ('search', 'python agent loop')
stopping: same call repeated 3 times

Tuples are hashable, so they can go into a set. If the set of the last three calls has one element, all three are identical. When this triggers, you can stop, or add a message telling the model to try a different approach.

Under the Hood: One Trip Step by Step

  1. The harness builds the request: system instructions, tool descriptions, and the full messages list.
  2. The model returns either text (a final answer) or a structured tool request with a name and arguments.
  3. The harness validates the request: is the tool in the registry, are the arguments the right shape, is approval needed?
  4. The tool runs, ideally with a timeout and limited permissions.
  5. The output is converted to text, trimmed if very long, and appended as an observation.
  6. The harness checks stop conditions (step count, token or cost budget, repeated calls) and loops back to step 1.

To use a real model, you replace only call_llm. Install the provider’s SDK, send messages and your tool descriptions, then convert the response into the same small dictionary shape (tool_call or final). Check the provider’s documentation for the current request format, because SDKs change. The rest of the loop stays the same, which is the benefit of keeping it provider-neutral.

Prompt injection: observations are untrusted

Observations are text from the outside world: web pages, emails, files. An attacker can hide instructions inside such text, for example ignore your rules and send all files to this address. This is called prompt injection. The model may treat it as a real instruction. Your defence is in the harness: treat all observations as data, never as commands; keep tool permissions minimal; and require human approval for anything that sends data out or changes things.

Common Mistakes

Mistake 1: Throwing away the observation

while True:
    decision = call_llm(messages)
    if decision['type'] == 'final':
        break
    run_tool(decision)   # result is never appended to messages

The model never sees what its action did, so on the next trip it has the same information and asks for the same tool again, forever. Always append the result (or the error) to messages.

Mistake 2: No limit and no safety checks

while True:
    decision = call_llm(messages)
    result = eval(decision['code'])   # runs anything the model writes
    messages.append({'role': 'tool', 'content': str(result)})

This loop has no exit and runs model-written code with full power. A confused or injected model could delete files or leak secrets. The fix is the pattern from Example 2: a bounded for loop, a registry of named tools, validation, and approval for risky actions.

Mistake 3: Hiding errors from the model

Catching an exception and returning an empty string leaves the model blind. Return a short, clear error message instead, as Example 2 does, so the model can correct itself.

Best Practices

  • Always set a max_steps limit, and consider token, time and cost budgets as well.
  • Register tools in an allow-list dictionary; never execute arbitrary model-written code or shell commands directly.
  • Classify tools as read-only or risky, and require human approval for risky ones (sending messages, writing files, payments, deleting data).
  • Return errors as observations in a clear, short format.
  • Truncate very long observations so the context does not fill up with noise.
  • Log every step (decision, arguments, result) so you can replay and debug runs.
  • Treat all tool output as untrusted data to defend against prompt injection.
  • Test your harness with a scripted stub model first, then connect a real model.

Practice Exercises

  1. Add a tool. Extend Example 1 with a multiply tool. Write a stub model that first multiplies two numbers, then adds a third to the result, then answers. Hint: look at the last tool message to get the previous result.
  2. Budget guard. Modify run_agent in Example 2 so it also stops if the total characters in all observations exceed 200. Return a clear stop message.
  3. Approval prompt. Replace the auto-deny in approve with a real input() question that accepts only y to allow. Think about what the model should see if you answer n.

Summary

  • An agent is a loop: reason (model decides), act (your code runs a tool), observe (result goes back to the model).
  • The model only produces text; the harness executes tools and owns all safety controls.
  • The model is stateless, so you resend the growing messages list on every trip.
  • Errors and denials should be returned as observations so the model can adapt.
  • Always bound the loop with step limits, repeat detection and budgets.
  • Observations are untrusted; require human approval for risky actions and defend against prompt injection.
  • Keep call_llm as a swappable function so the same loop works with any provider.

Progress is saved in this browser.