Home / Articles

Building an AI Agent From Scratch in Python: The GAME Loop Step by Step

Build a working tool-calling agent in plain Python with Goals, Actions, Memory and Environment, then add a tool registry, hidden dependency injection and typed output.

Publisher: REACTAPP.TOP19 min read4441 words

This article is published in English.

Sources: How to build AI Agent From Scratch in Python

Agent frameworks such as Pydantic AI, AutoGen and Microsoft Agent Framework are good for getting a prototype running quickly. After using several of them, though, a few friction points tend to show up.

  • The ecosystem is still moving. Each framework covers some of the features you need and misses others, and another framework has the pieces the first one lacks. That's normal for a young field, and given time most will handle the common use cases.
  • Abstraction piles up. Frameworks aim to serve a huge range of agent designs, so they add layers that are hard to follow and harder to debug. LangChain is the usual example: when the system misbehaves, the layers get in the way of diagnosing why.
  • You lose sight of the prompt. When abstractions sit between your code and the model, it's difficult to see exactly what is sent to the LLM. That limits how far you can tune prompts, trim latency and control token spend.

The encouraging part is that an agent doesn't have to be complicated. For many jobs a plain implementation is enough. Writing one from scratch gives you full say over what enters the model, how decisions are made and how costs are managed, and knowing the principles makes any later framework easier to trust. What follows is a step-by-step build of a working Python agent from first principles.

The core idea: the agent loop

Start by stripping the idea down. An AI agent is a large language model that can take actions and use their results to decide what to do next. In a traditional LLM exchange you send a prompt and get one answer back. An agent runs in a loop instead. It looks at the current state, picks an action, carries it out, receives feedback, and goes round again until the task is finished.

GAME: a four-part design

To build that loop we'll use the GAME structure, which splits an agent into Goals, Actions, Memory and Environment. It gives a compact mental model, and it keeps the code modular and easy to extend.

Goal

The goal states what the agent is trying to achieve. It holds the task description and any instructions steering behaviour, and it becomes the system prompt.

class Goal(BaseModel):
    name: str
    description: str

Action (tool)

The code base here calls it a Tool, but the terms mean the same thing. An action is something the agent can do: call an API, search documents, query a database or do a calculation.

class Tool(BaseModel):
    name: str
    description: str
    parameters: Dict[str, Any]
    function: Callable
    tags: Optional[List[str]] = None
    terminal: bool = False

Current LLM APIs, OpenAI's among them, accept tool definitions along with the prompt. Those definitions must be written as JSON Schema, giving each tool's name, parameters and expected input.

As a first tool, take a function that adds two numbers:

def add(a: float, b: float) -> float:
    return a + b

Then describe it in a form the model can read:

number_pair = {
    "type": "object",
    "properties": {
        "a": {"type": "number"},
        "b": {"type": "number"},
    },
    "required": ["a", "b"],
}

add_tool = Tool(
    name="add",
    description="Adds two numbers.",
    parameters=number_pair,
    function=add,
)

When the request is built, these action objects are turned into tool definitions and sent to the model. The model never runs the function itself. It only decides which tool to call and generates the arguments for it.

Say the user asks "What is 10 + 20?" The model might reply with something like this:

{
  "name": "add",
  "arguments": {
    "a": 10,
    "b": 20
  }
}

Choosing the action and supplying its arguments is the model's whole job.

Sending actions to the model

This helper passes the conversation history and the available actions to the LLM:

def generate_response(memory: Memory, tools: List[Dict[str, Any]]) -> Any:
    from litellm import completion

    messages = [m.model_dump(exclude_none=True) for m in memory.get()]
    return completion(
        model="openai/gpt-4o",
        messages=messages,
        max_tokens=1024,
        tools=tools,
        tool_choice="required",
        parallel_tool_calls=False,
    )

tools = [
    {
        "type": "function",
        "function": {
            "name": add_tool.name,
            "description": add_tool.description,
            "parameters": add_tool.parameters,
        }
    }
]

result = generate_response(
    memory=Memory(messages=[
        Message(role=Role.SYSTEM, content="Perform arithmetic using the add and multiply tools. "),
        Message(role=Role.USER, content="Add 200 and 10, then multiply the result by 2.")]),
    tools=tools
)
print(json.dumps(result.choices[0].message.model_dump(exclude_none=True), indent=2))

# Result:

{
  "role": "assistant",
  "tool_calls": [
    {
      "function": {
        "arguments": "{\"a\":200,\"b\":10}",
        "name": "add"
      },
      "id": "call_ZDlLQxVB9WLY7fPHwRipeeM1",
      "type": "function"
    }
  ],
  "provider_specific_fields": {
    "refusal": null
  },
  "annotations": []
}

The agent then runs the matching Python function and feeds the result into the next turn of the loop.

Memory

Memory holds what earlier iterations collected, so the agent can take past observations into account when it picks its next step.

For now it stays minimal: new messages are simply appended to a list. LLM context windows are finite, so a real system needs something smarter for trimming or summarising, which this example leaves aside.

class Memory(BaseModel):
    """M in GAME: the agent's conversational record - the running list of
    Messages the LLM sees each turn. A thin, swappable store so strategies
    (windowing, summarization, persistence) can vary later without touching
    Agent.run; for now it is just an append-and-read list."""

    messages: List[Message] = []

    def add(self, message: Message) -> None:
        self.messages.append(message)

    def get(self) -> List[Message]:
        return self.messages

A Message model represents each interaction in the loop. Observations, tool results, user input and assistant replies all end up as messages. The shape closely follows the message format of current LLM APIs, so building prompts from history is straightforward and the memory stays simple to extend.

from enum import Enum
class Role(str, Enum):
    SYSTEM = "system"
    USER = "user"
    ASSISTANT = "assistant"
    TOOL= "tool"

class Message(BaseModel):
    role: str
    content: Optional[str] = None

Environment

You might ask why an Environment exists at all, when the action could just execute itself inside its own class.

class Action:
  ...

  def execute_action(self, **args):
    return function(**args)

That's fine for toy examples. It becomes limiting once you need dependency injection, hidden context, database connections, user sessions or other outside resources, and the later sections show how.

class Environment:

    def execute_action(
        self,
        action: Tool,
        args: dict,
    ) -> Any:
        """Run `action` with the LLM-provided `args`, injecting the hidden context
        into whichever param the tool declares for it (by name or subclass type).
        Returns the tool's native result, or {"error": ...} if it raised."""
        try:
            call_args = dict(args)
            return action.function(**call_args)
        except Exception as e:
            return {"error": str(e)}

Building the agent loop

With the four GAME parts defined, the core loop can be written. It assembles a prompt, asks the LLM for a response, executes the chosen actions, collects the observations and repeats until a stopping condition is met.

class Agent:
    def __init__(self, goal: Goal, actions: List[Action], environment: Optional[Environment] = None):
        self.goal = goal
        self.actions = {a.name: a for a in actions}
        self.environment = environment or Environment()

        self.memory = Memory()
        self.memory.add(Message(role=Role.SYSTEM, content=goal.description))

    def tools_schema(self) -> List[Dict[str, Any]]:
        """Describe every action in the function-calling format the LLM expects."""
        return [
            {
                "type": "function",
                "function": {
                    "name": a.name,
                    "description": a.description,
                    "parameters": a.parameters,
                },
            }
            for a in self.actions.values()
        ]

    def run(self, user_input: str, max_iterations: int = 15) -> Any:
        self.memory.add(Message(role=Role.USER, content=user_input))

        for _ in range(max_iterations):
            # 1. Ask the LLM what to do next, given the conversation + tools.
            response = generate_response(self.memory, tools=self.tools_schema())
            assistant_msg = response.choices[0].message

            # 2. Record the assistant's turn (text and/or tool calls).
            self.memory.add(
                Message(
                    role=Role.ASSISTANT,
                    content=assistant_msg.content,
                    tool_calls=[tc.model_dump() for tc in assistant_msg.tool_calls]
                    if assistant_msg.tool_calls
                    else None,
                )
            )

            # 3. If it asked for tools, run them and feed the results back.
            if assistant_msg.tool_calls:
                for call in assistant_msg.tool_calls:
                    action = self.actions[call.function.name]
                    args = json.loads(call.function.arguments)

                    result = self.environment.execute_action(action, args)

                    self.memory.add(
                        Message(
                            role=Role.TOOL,
                            tool_call_id=call.id,
                            name=action.name,
                            content=str(result),
                        )
                    )

                    # 4. A terminal action ends the loop and carries the result.
                    if action.terminal:
                        return result

        return None


if __name__ == "__main__":

    number_pair = {
        "type": "object",
        "properties": {"a": {"type": "number"}, "b": {"type": "number"}},
        "required": ["a", "b"],
    }

    actions = [
        Action(
            name="add",
            description="Adds two numbers.",
            parameters=number_pair,
            function=add,
        ),
        Action(
            name="multiply",
            description="Multiplies two numbers.",
            parameters=number_pair,
            function=multiply,
        ),
        Action(
            name="finish",
            description="Provide the final answer to the user and end the task.",
            parameters={
                "type": "object",
                "properties": {"answer": {"type": "string"}},
                "required": ["answer"],
            },
            function=finish,
            terminal=True,  # this one ends the loop
        ),
    ]

    agent = Agent(
        goal=Goal(
            name="calculator",
            description=(
                "Perform arithmetic using the add and multiply tools. "
                "When you have the final answer, call finish with it."
            ),
        ),
        actions=actions,
    )

    result = agent.run("Add 200 and 10, then multiply the result by 2.")
    print("\nresult:", result)
    print(json.dumps([m.model_dump(exclude_none=True) for m in agent.memory.get()], indent=2))

## Result


result: 420
[
  {
    "role": "system",
    "content": "Perform arithmetic using the add and multiply tools. When you have the final answer, call finish with it."
  },
  {
    "role": "user",
    "content": "Add 200 and 10, then multiply the result by 2."
  },
  {
    "role": "assistant",
    "tool_calls": [
      {
        "function": {
          "arguments": "{\"a\":200,\"b\":10}",
          "name": "add"
        },
        "id": "call_wYPzGCwLuc71cK7q2Ok81OzF",
        "type": "function"
      }
    ]
  },
  {
    "role": "tool",
    "content": "210",
    "tool_call_id": "call_wYPzGCwLuc71cK7q2Ok81OzF",
    "name": "add"
  },
  {
    "role": "assistant",
    "tool_calls": [
      {
        "function": {
          "arguments": "{\"a\":210,\"b\":2}",
          "name": "multiply"
        },
        "id": "call_5JD4McfNm0m68Poedsm8uODO",
        "type": "function"
      }
    ]
  },
  {
    "role": "tool",
    "content": "420",
    "tool_call_id": "call_5JD4McfNm0m68Poedsm8uODO",
    "name": "multiply"
  },
  {
    "role": "assistant",
    "tool_calls": [
      {
        "function": {
          "arguments": "{\"answer\":\"420\"}",
          "name": "finish"
        },
        "id": "call_nbIGeKvgWtXPh4YlioZxfiyj",
        "type": "function"
      }
    ]
  },
  {
    "role": "tool",
    "content": "420",
    "tool_call_id": "call_nbIGeKvgWtXPh4YlioZxfiyj",
    "name": "finish"
  }
]

A few notes on the details. The message format could be arranged differently from the "tool" role used here, but the structure would need small adjustments. Most LLM APIs share a convention that a tool result must be tied to the tool call that preceded it, normally through a tool_id, so the same identifier appears twice in the history: once in the request and once in the matching result.

Modern models are also trained heavily on tool-calling flows. Reusing the roles and message patterns they saw in training lines up with their expectations and usually gives steadier tool-calling and better overall performance.

The special finish tool deserves a mention too. It gives the model an explicit way to say the task is complete, so it can't get stuck calling tools forever. It is registered like any other tool, on every agent, which is a design point improved on shortly.

Where the first version falls short

This agent works, and it exposes the whole loop with nothing hidden behind a framework, which is ideal for learning. Past toy examples, three limits quickly show up.

  • Tool definitions don't scale. Every action needs a hand-written JSON Schema kept in sync with its Python function. As tools multiply, that is repetitive and error-prone, and a small signature change can leave the schema stale, so the model produces invalid calls.
  • Tools can't reach hidden context. A tool only receives the arguments the LLM wrote. Real tools usually need things the model must never see, such as database connections, API clients, configuration, user sessions or application state. Those need to be injected safely at runtime.
  • Termination is improvised. The finish tool stops the loop and returns a result, but every agent must register it, and it can only return a plain value. More capable agents will want richer stopping rules and structured output.

Each is handled in turn, beginning with how tools are managed and registered.

Problem 1: a tool registry

Start with the most irritating one, keeping schema and function aligned by hand. Completion APIs want a list of JSON schemas that describe the available tools.

In the first version every tool came as two separately written things: a Python function that does the work and a JSON Schema telling the LLM how to call it:

def add(a: float, b: float) -> float:
    return a + b


add_tool = {
    "type": "function",
    "function": {
        "name": "add",
        "description": "Adds two floats.",
        "parameters": {
            "type": "object",
            "properties": {
                "a": {"type": "number"},
                "b": {"type": "number"},
            },
            "required": ["a", "b"],
        },
    },
}

tools = [add_tool]

...

completion(
    model="openai/gpt-4o",
    messages=messages,
    tools=tools,
)

The answer is to generate the schema from the function. The function becomes the single source of truth, and the schema is derived automatically when the tool is registered.

JSON_TYPE_MAP = {
    str: "string",
    int: "integer",
    float: "number",
    bool: "boolean",
}

def get_json_type(python_type: Any) -> str:
    origin = get_origin(python_type)

    if python_type in (list, List) or origin in (list, List):
        return "array"
    if python_type in (dict, Dict) or origin in (dict, Dict):
        return "object"
    if origin in (Union, types.UnionType):
        non_none = [a for a in get_args(python_type) if a is not type(None)]
        return get_json_type(non_none[0]) if non_none else "string"

    return JSON_TYPE_MAP.get(python_type, "string")
def build_tool(func: Callable, ...) -> Tool:
    name = name or func.__name__
    description = description or (func.__doc__.strip() if func.__doc__ else "")

    signature = inspect.signature(func)
    type_hints = get_type_hints(func)

    args_schema = {"type": "object", "properties": {}, "required": []}

    for param_name, param in signature.parameters.items():
        param_type = type_hints.get(param_name, str)
        args_schema["properties"][param_name] = {"type": get_json_type(param_type)}
        if param.default == inspect.Parameter.empty:   # no default ⇒ required
            args_schema["required"].append(param_name)

    return Tool(name=name, description=description,
                parameters=args_schema, function=func)

Next, this builder should run automatically whenever a tool function is defined, so a decorator does the work:

@tool()
def add(a: float, b: float) -> float:
    """Adds two floats."""
    return a + b

The decorator is deliberately modest. It builds the Tool, attaches it to the function object and returns the function untouched:

def tool(name=None, description=None, parameters_override=None, tags=None):
    def decorator(func):
        func._tool_metadata = build_tool(func, name, description,
                                         parameters_override, tags)
        return func
    return decorator

# The decorator automatically add a new attribute _tool_metadata contains the
# information of the tool (Tool)

Trying it out:

serializable@tool()
def add(a: float, b: float) -> float:
    """Adds two floats."""
    return a + b

if __name__ == "__main__":
    # _tool_metadata (Tool) will be added as an attribute of the function after the decorator call
    tool_metadata = add._tool_metadata
    # exclude function as it can not be serializable
    print(json.dumps(tool_metadata.model_dump(exclude={"function"}), indent=2)) #

# Result

{
  "name": "add",
  "description": "Adds two floats.",
  "parameters": {
    "type": "object",
    "properties": {
      "a": {
        "type": "number"
      },
      "b": {
        "type": "number"
      }
    },
    "required": [
      "a",
      "b"
    ]
  },
  "tags": null,
  "terminal": false
}

Once there are many tools and several agents, and agents share tools, a central place to manage them helps. An Action Registry plays that role, tracking which tools a given agent may use.

class ActionRegistry:
    def __init__(self):
        self.tools: Dict[str, Tool] = {}

    def register(self, action: Callable | Tool, terminal: bool = False):
        # accept a raw @tool-decorated function or a pre-built Tool
        if isinstance(action, Tool):
            tool = action
        else:
            tool = getattr(action, "_tool_metadata", None) or build_tool(action)
        if terminal != tool.terminal:
            tool = tool.model_copy(update={"terminal": terminal})
        self.tools[tool.name] = tool

    def get_tool(self, name: str) -> Optional[Tool]:
        return self.tools.get(name)

    def get_tools_schema(self) -> List[Dict[str, Any]]:
        """Tool schemas in the OpenAI / litellm function-calling format."""
        return [
            {"type": "function",
             "function": {"name": t.name,
                          "description": t.description,
                          "parameters": t.parameters}}
            for t in self.tools.values()
        ]

Registering a function is then a single call:

registry.register(add)

The registry reads the metadata that @tool attached and turns it into a Tool instance. For a function that wasn't decorated, it falls back to calling build_tool() automatically. That's a convenient default, and people who want full control can still construct Tool objects by hand.

Notice also that terminal is set at registration and not on the tool:

registry.register(tool, terminal=True)

Whether a tool ends the loop is a property of the agent, not of the function. The same tool might be terminal for one agent and ordinary for another. When a different terminal value is asked for, the registry copies the Tool instead of altering the original, so configuration doesn't leak between registries and one definition can be reused safely across agents.

Problem 2: injecting dependencies

Tools currently work, with one important restriction: they only see arguments produced by the LLM.

Practical tools often need resources the model should never be shown, including database connections, API clients, configuration objects, user sessions and application state. Some also need to look at the agent's memory. Imagine a tool that saves research papers to a database:

@tool()
def save_paper(
    title: str,
    content: str,
    database_url: str,
    api_key: str,
):
    ...

To encapsulate that better, a class carries the action context:

class ActionContext(BaseModel):
  database_url: str,
  api_key: str

# Then the tool becomes:
@tool()
def save_paper(
    title: str,
    content: str,
    action_context: ActionContext
):
    ...

If the context were simply another tool argument, the LLM would have to invent those values, which makes no sense, because they are supposed to be injected. This is where the Environment helps. Context parameters are hidden from the model and filled in by hand at execution time.

Since some tools need context and others don't, the first step is a function that checks, from the type annotations, whether a function takes an ActionContext or a subclass of it:

def context_param(func: Callable) -> Optional[tuple[str, type]]:
    try:
        hints = get_type_hints(func)
    except Exception:
        hints = {}
    for name in inspect.signature(func).parameters:
        ann = hints.get(name)
        if isinstance(ann, type) and issubclass(ann, ActionContext):
            return name, ann
    return None

# Now we modify the `build_tool` function


def build_tool(
    func: Callable,
    name: Optional[str] = None,
    description: Optional[str] = None,
    parameters_override: Optional[Dict[str, Any]] = None,
    tags: Optional[List[str]] = None,
) -> Tool:
    name = name or func.__name__
    description = description or (func.__doc__.strip() if func.__doc__ else "")

    if parameters_override is None:
        signature = inspect.signature(func)
        type_hints = get_type_hints(func)

        args_schema = {
            "type": "object",
            "properties": {},
            "required": [],
        }

        # The injected-context param (by name OR ActionContext-subclass annotation)
        # is filled by the Environment from the hidden ActionContext. Never expose it
        # to the LLM, or the LLM would try to generate the secrets that live inside it.
        ctx = context_param(func)
        ctx_param_name = ctx[0] if ctx else None

        for param_name, param in signature.parameters.items():
            if param_name == ctx_param_name:
                continue

            param_type = type_hints.get(param_name, str)
            param_schema = {
                "type": get_json_type(param_type)
            }

            args_schema["properties"][param_name] = param_schema
            if param.default == inspect.Parameter.empty:
                args_schema["required"].append(param_name)
    else:
        args_schema = parameters_override

    return Tool(
        name=name,
        description=description,
        parameters=args_schema,
        function=func,
        tags=tags or []
    )

A tool that uses an ActionContext parameter has that parameter left out of its JSON schema, so the LLM doesn't know it exists and can't try to generate it. The Environment then puts the context back at execution time:

class Environment:

    def execute_action(
        self,
        action: Tool,
        args: dict,
        action_context: Optional[ActionContext] = None,
    ) -> Any:

        try:
            call_args = dict(args)
            ctx = context_param(action.function)
            if ctx:
                ctx_param_name, _ = ctx
                call_args[ctx_param_name] = action_context or ActionContext()
            return action.function(**call_args)
        except Exception as e:
            return {"error": str(e)}

That's the trick. The Environment acts as the border between the model and the outside world. The model supplies the arguments it knows about, and the Environment adds the resources it shouldn't know about. It's a small idea that makes agents far more practical.

Problem 3: structured output and a built-in finish tool

Two issues remain. First, the agent can currently return only strings, while real workflows often need structured data. Second, the terminating tool ought to be built into the agent, not something you register externally every time.

Both get solved together. For structured output, many completion APIs offer a response_format parameter that forces the LLM to emit JSON matching a schema. That clashes with a tool-calling loop. Once response_format is on, the model usually writes its final answer right away instead of calling tools, and the agent stops after the first completion. Mixing the two has also proved awkward and inconsistent in practice.

The alternative is to reuse tool calling. The output schema becomes the parameters of a special tool, and when the work is done the LLM calls that tool with arguments matching the wanted schema. The agent pulls those arguments out and builds the final output object.

A helper on the Agent class registers a built-in terminate tool automatically:

OutputSchema = Union[type[str], type[BaseModel]]

class Agent:
    ...

    @classmethod
    def _build_terminate_tool(cls, output_schema: OutputSchema) -> Tool:
        """Build the terminate tool that ends the agent loop and carries the final result.

        str is normalized to a one-field model {result: str} and unwrapped on return,
        so a single code path handles both: the LLM-facing schema always comes from
        pydantic's model_json_schema(), and result is the string (for str) or
        the schema instance (for a BaseModel).
        """
        unwrap_field = None
        if output_schema is str:
            output_schema = create_model("StrResult", **{cls._TERMINATE_STR_FIELD: (str, ...)})
            unwrap_field = cls._TERMINATE_STR_FIELD

        def terminate(**kwargs: Any) -> Any:
            """Provide the final result and end the task."""
            obj = output_schema(**kwargs)
            return getattr(obj, unwrap_field) if unwrap_field else obj

        terminate_tool = build_tool(
            func=terminate,
            name=cls.TERMINATE_TOOL_NAME,
            description=f"Provide the final result ({output_schema.__name__}) and end the task.",
            parameters_override=output_schema.model_json_schema(),
        )
        terminate_tool.terminal = True
        return terminate_tool

And here is the finished agent loop:

class Agent:
    TERMINATE_TOOL_NAME = "terminate"
    _TERMINATE_STR_FIELD = "result"

    def __init__(
        self,
        goal: str,
        action_registry: ActionRegistry,
        output_schema: Optional[OutputSchema] = None,
        environment: Optional["Environment"] = None,
        action_context: Optional[ActionContext] = None,
    ):
        self.goal = goal
        self.action_registry = action_registry
        self.output_schema = output_schema
        self.result: Any = None

        self.environment = environment or Environment()
        self.action_context = action_context or ActionContext()


        self._validate_context()

        # Tools the registry marked terminal (excluding the framework's terminate tool).
        terminal_tools = [
            t.name for t in action_registry.tools.values()
            if t.terminal and t.name != self.TERMINATE_TOOL_NAME
        ]

        # No exit declared at all -> default to a text answer (output_schema=str).
        if output_schema is None and not terminal_tools:
            output_schema = self.output_schema = str

        # Because in my design, Agent can stop the loop after a specific tool call. (to save tokens, LLm do not need to generate terminal tool with the same parameters again)
        # This choice make it a little bit complicated when output schema defined in Agent different from the tool's output schema.
        if output_schema is not None:
            if terminal_tools:
                raise ValueError(
                    f"output_schema={output_schema.__name__} conflicts with terminal "
                    f"tool(s) {terminal_tools}: their output does not match the schema. "
                    f"Drop output_schema or unmark those tools as terminal."
                )
            action_registry.register_tool(self._build_terminate_tool(output_schema))
        elif len(terminal_tools) > 1:
            raise ValueError(
                f"Multiple terminal tools {terminal_tools}; an agent must have exactly "
                f"one exit so result has a well-defined type."
            )

        self.memory = Memory()
        self.memory.add(Message(role=Role.SYSTEM, content=self.goal))

    def _validate_context(self) -> None:
        """Verify that the agent's action_context satisfies every tool's declared"""
        for tool in self.action_registry.tools.values():
            ctx = context_param(tool.function)
            if ctx is None:
                continue  # tool declares no context — nothing to satisfy
            _, required = ctx
            if not isinstance(self.action_context, required):
                raise TypeError(
                    f"Tool {tool.name!r} requires action_context: {required.__name__}, "
                    f"but this agent was given {type(self.action_context).__name__}. "
                    f"Pass a context that is-a {required.__name__} (combine schemas via "
                    f"subclassing if multiple tools need different types), or split the "
                    f"tools across agents."
                )

    @classmethod
    def _build_terminate_tool(cls, output_schema: OutputSchema) -> Tool:
        """Build the terminate tool that ends the agent loop and carries the final result.

        str is normalized to a one-field model {result: str} and unwrapped on return,
        so a single code path handles both: the LLM-facing schema always comes from
        pydantic's model_json_schema(), and result is the string (for str) or
        the schema instance (for a BaseModel).
        """
        unwrap_field = None
        if output_schema is str:
            output_schema = create_model("StrResult", **{cls._TERMINATE_STR_FIELD: (str, ...)})
            unwrap_field = cls._TERMINATE_STR_FIELD

        def terminate(**kwargs: Any) -> Any:
            """Provide the final result and end the task."""
            obj = output_schema(**kwargs)
            return getattr(obj, unwrap_field) if unwrap_field else obj

        terminate_tool = build_tool(
            func=terminate,
            name=cls.TERMINATE_TOOL_NAME,
            description=f"Provide the final result ({output_schema.__name__}) and end the task.",
            parameters_override=output_schema.model_json_schema(),
        )
        terminate_tool.terminal = True
        return terminate_tool

    def add_memory(self, message: Message):
        self.memory.add(message)

    @staticmethod
    def _to_content(result: Any) -> str:
        """Render a tool's return value as text for the LLM. """
        if isinstance(result, BaseModel):
            return result.model_dump_json()
        if isinstance(result, (dict, list)):
            return json.dumps(result, ensure_ascii=False)
        return str(result)

    def run(self, user_input: str, max_iterations: int = 15):
        self.add_memory(Message(role=Role.USER, content=user_input))

        for i in range(max_iterations):
            response = generate_response(self.memory, tools=self.action_registry.get_tools_schema())
            assistant_msg = response.choices[0].message

            # Record the assistant turn (may carry content and/or tool_calls).
            self.add_memory(
                Message(
                    role=Role.ASSISTANT,
                    content=assistant_msg.content,
                    tool_calls=[tc.model_dump() for tc in assistant_msg.tool_calls]
                                    if assistant_msg.tool_calls else None,
                )
            )

            if assistant_msg.tool_calls:
                for tool_call in assistant_msg.tool_calls:
                    tool_name = tool_call.function.name
                    tool_args = tool_call.function.arguments

                    tool = self.action_registry.get_tool(tool_name)

                    if not tool:
                        raise ValueError(f"Tool '{tool_name}' not found.")

                    # Execute the tool in the Environment
                    result = self.environment.execute_action(
                        tool, json.loads(tool_args), self.action_context
                    )
                    self.add_memory(
                        Message(
                            role=Role.TOOL,
                            tool_call_id=tool_call.id,
                            name=tool_name,
                            content=self._to_content(result)
                        )
                    )

                    if tool.terminal:
                        self.result = result
                        return self.result

        return self.result

What the exercise shows

At bottom, an agent is a language model running inside a loop. It reads the situation, chooses an action, gets the result and repeats until the goal is reached. Pydantic AI, AutoGen, Microsoft Agent Framework and most others are built over this same pattern.

Step by step, a plain tool-calling loop grew into something more capable. A tool registry made actions reusable, an environment layer injected hidden dependencies safely, and a structured termination mechanism let agents return typed outputs in place of plain text. None of these ideas is hard alone, yet together they underpin much of modern agent design.

Knowing these primitives makes frameworks easier to judge, odd behaviour easier to debug and your own designs easier to fit to your needs. Frameworks speed up development, but they're abstractions over concepts that turn out to be quite simple once laid out in the open.