hubagenticai

Tutorials Beginner

How an AI agent actually works: build the loop behind every framework

Every agent framework wraps the same ~40-line loop: model decides, tool runs, result feeds back. Build it in plain Python and the whole ecosystem stops being magic.

updated 2026-07-04 ⏱ 30 min

Strip away every framework, and an AI agent is one loop: show the model the conversation so far, let it either answer or request a tool, run the tool, append the result, repeat. That’s it. LangChain, CrewAI, the harness inside Claude Code — all wrap this loop with conveniences. Build it once bare and you’ll never be intimidated by an agent framework diagram again.

Step 1 — The anatomy of one turn

flowchart LR
    H["Conversation<br/>history"] --> M["Model:<br/>answer or<br/>tool request?"]
    M -- "tool request" --> T["Run the tool"]
    T -- "result appended<br/>to history" --> H
    M -- "final answer" --> D["Done"]

The model never executes anything — it only emits text saying what it wants. Your code decides whether to honor that request. Keep that split in your head; it’s also where all agent security lives.

Step 2 — Mock model, real loop

As in our orchestration tutorial, we use a deterministic mock so the loop is what you’re debugging. Create agent.py:

import json

TOOLS = {
    "get_weather": lambda city: f"18°C and clear in {city}",
    "get_time": lambda city: f"14:32 in {city}",
}

class MockModel:
    """Emits tool requests as JSON, then a final answer — deterministically."""

    def next_action(self, history: list[dict]) -> dict:
        tool_results = [m for m in history if m["role"] == "tool"]
        if not tool_results:
            return {"type": "tool_call", "tool": "get_weather",
                    "args": {"city": "Zurich"}}
        if len(tool_results) == 1:
            return {"type": "tool_call", "tool": "get_time",
                    "args": {"city": "Zurich"}}
        facts = "; ".join(m["content"] for m in tool_results)
        return {"type": "answer", "content": f"Here's Zurich right now: {facts}"}

def run_agent(model, user_message: str, max_turns: int = 10) -> str:
    history = [{"role": "user", "content": user_message}]
    for turn in range(1, max_turns + 1):
        action = model.next_action(history)

        if action["type"] == "answer":
            return action["content"]

        tool = TOOLS.get(action["tool"])
        if tool is None:
            result = f"error: unknown tool {action['tool']!r}"
        else:
            result = tool(**action["args"])

        print(f"[turn {turn}] {action['tool']}({action['args']}) -> {result}")
        history.append({"role": "tool", "content": result})

    return "Stopped: hit the turn limit without a final answer."

if __name__ == "__main__":
    print(run_agent(MockModel(), "What's it like in Zurich right now?"))

Run it:

python agent.py

You’ll see two tool turns, then the synthesized answer. Read run_agent again — those ~20 lines are the agent. Everything else in the ecosystem is packaging.

Step 3 — Notice the three safety valves

Even this toy loop ships the three controls every production agent needs:

  1. max_turns — agents must have a budget, or a confused model loops forever.
  2. The tool allowlistTOOLS.get(...) means the model can request anything but only run what you registered. The unknown-tool branch feeds the error back instead of crashing — models correct themselves when shown their mistakes.
  3. The history log — the full trajectory is right there to print, store, or audit. Frameworks call this “tracing”; you just built it with a list.

Step 4 — Swap in a real model

With a real API, next_action becomes a call to a model with tool definitions attached, and the JSON parsing gets a retry wrapper. The loop — and the three safety valves — don’t change. From here, go two directions: give one agent better tools via MCP, or scale to several agents with the orchestrator pattern.

Troubleshooting

My real-model version loops calling the same tool repeatedly

The model isn’t seeing its previous results — check that tool outputs are actually appended to the history you resend. If they are, the results may be indistinguishable (e.g. errors that read as success); make tool errors explicit and different from valid output.

TypeError: unexpected keyword argument when a tool runs

The model invented an argument name your function doesn’t have. Validate args against the tool’s signature before calling (real systems use JSON Schema for exactly this), and feed the validation error back as the tool result.

newsletter

One practical agentic-AI guide in your inbox. No news, no hype.

Tutorials and decision frameworks as they ship. Unsubscribe anytime.