Introduction: Building a Stateless Agent

In the previous course, you learned how to build a tool-using agent loop from scratch. You defined tool schemas, parsed function calls, executed tools, and managed context explicitly. Now, we're going to take those concepts and organize them into a clean, reusable pattern: the stateless reducer agent. This architecture is central to Factor 12 (Make your agent a stateless reducer), which states that each agent step should be a pure function: (state, event) → (next_action, updated_state). By designing your agent this way, you gain powerful benefits like replayability, easier testing, and the ability to scale horizontally. You'll also see Factor 10 (Small, Focused Agents) in action — building small, focused agents that do one thing well.

The Stateless Reducer Pattern

At the heart of this architecture is a simple but powerful idea: your agent is a function that transforms state. Instead of maintaining internal variables that change over time, the agent receives a context list as input, processes one step of work, and returns an updated context list as output. This is the stateless reducer pattern, borrowed from functional programming.

The signature looks like this: (context, event) → (updated_context, status, result). In practice, the "event" is often implicit — it's whatever the model decides to do next based on the current context. The agent takes the context (which includes the conversation history, tool calls, and tool outputs), asks the model what to do next, executes any requested tools, and returns the new context along with a status indicator.

Why does "stateless" matter? Because when your agent doesn't hold state internally, you can replay any step by passing in the same context. You can pause execution, serialize the context to disk or a database, and resume later. You can test individual steps in isolation. You can even run multiple instances of the agent in parallel, each processing different contexts, without worrying about shared mutable state. While the underlying LLM can be stochastic (producing variations for the same prompt), the agent's logic becomes a consistent transformer: for any given set of inputs and model decisions, the tool execution and state transitions are entirely reproducible. This predictability is what makes the pattern so valuable for production systems where you need reliability, debuggability, and the ability to scale.

Organizing Your Agent Files

Before we dive into the code, let's understand how we'll organize the files. This structure keeps your code clean and separates concerns: schemas live in JSON files, tool implementations live in Python modules, and the agent logic lives in its own file.

The agent.py file contains the Agent class that orchestrates the reducer loop. The tools/schemas/ directory holds JSON files that define what tools the model can call — these are the contracts between your agent and the model. The tools/functions/ directory contains the actual Python implementations of those tools. This separation makes it easy to add new tools: create a schema, implement the function, and wire them together in the agent. Let's start by looking at the tool schemas.

Defining Math Tool Schemas

The tool schemas you learned about in the previous course are now organized into JSON files. This makes them easier to maintain and version separately from your code. The math tool schemas live in src/core/tools/schemas/math.json, and each schema describes one mathematical operation.

You've seen this structure before: each schema has a function type, a name, a description, and a parameters object defining the expected arguments. The required array ensures the model must provide both a and b, and setting additionalProperties to false prevents unexpected fields. The file contains similar schemas for multiply_numbers, subtract_numbers, divide_numbers, power, and . By keeping these in a JSON file, you can update the tool descriptions or add new tools without touching your Python code. Now let's look at the special tool that signals completion.

Defining the Final Answer Tool

The final_answer tool is defined separately in src/core/tools/schemas/final_answer.json. This tool signals that the agent's work is complete and provides the final result to the user.

When the model calls final_answer, the agent knows to stop the loop and return the result. This tool takes a single parameter: the answer string containing the final response. By making completion an explicit tool call, we give the model a clear way to signal it has finished, and we can detect that signal in our code to terminate the reducer loop gracefully. With schemas defined, let's look at the tool implementations.

Implementing Tool Functions

The corresponding Python functions live in src/core/tools/functions/math.py. These are the deterministic functions that perform the actual calculations when the model requests them.

Each function validates its inputs. The divide_numbers function checks for division by zero and raises a descriptive error if encountered. The square_root function prevents taking the square root of a negative number. These guardrails ensure that when the model requests an invalid operation, the agent can handle it gracefully and provide feedback that helps the model correct its course.

Setting Up the Agent Class Structure

Now let's build the Agent class. We'll start with the constructor parameters and the system prompt configuration.

The constructor accepts several parameters that control the agent's behavior. The model parameter specifies which model to use. The reasoning_effort parameter is specific to models like gpt-5 that support adjustable reasoning depth. The extra_instructions parameter lets you extend the base system_prompt without editing the class code. The max_steps parameter sets the safety boundary for how many steps the agent can take before stopping.

The system_prompt tells the model that it is an autonomous agent and must use tools for all computations. We keep it inline for now, but in the next units, we'll move it to an external file. Now let's see how the agent loads its tool schemas.

Loading Tool Schemas

The constructor also loads the tool schemas from the JSON files we defined earlier.

The schema loading logic uses Path(__file__).resolve().parent to find the tools/schemas directory relative to the current file, making the code portable across different environments. We load two schema files: math.json containing all the math tool definitions, and final_answer.json containing the completion signal. The schemas are then combined into a single self.tool_schemas list using the unpacking operator *, which the agent will pass to the model on every call. Now let's see how the agent calls the model.

Separating Concerns into Methods

Before we dive into the agent's execution logic, let's understand how we'll organize the code within the Agent class. We'll break the work into three key methods:

  1. _call_llm — Handles communication with the model
  2. _next_step — Executes one complete step: call the model, execute tools, update context
  3. run — Orchestrates the full loop until completion

Why separate these concerns? Because each method does one thing well. If you need to change how you call the model, you change _call_llm. If you need to test a single step in isolation, you call _next_step directly. If you need to add logging or checkpointing, you modify run without touching the core reducer logic. This separation makes the code easier to understand, test, and extend. Let's start with the model communication.

Calling the LLM with Context

The _call_llm method handles communication with the model, passing the full context and requiring it to use a tool.

This method is a thin wrapper around the OpenAI Responses API. It takes the current context as input and passes it directly to the model. The instructions parameter provides the system-level guidance, the input parameter carries the full conversation history, and the tools parameter tells the model which functions it can call. Setting tool_choice to "required" enforces that the model must call a tool — it cannot respond with plain text.

The reasoning parameter is conditional: if the model is gpt-5, we include the reasoning_effort level; otherwise, we omit it. By passing the full context every time, we ensure the model has all the information it needs to make the next decision, implementing : owning your context window. Now we need to handle what happens when the model responds with tool calls.

Processing Tool Calls

Once the model returns a response, we need to extract and record the function calls. The first part of the _next_step method handles this extraction and appending to the context.

The method starts by calling _call_llm to get the model's response, then filters the response.output to extract function_calls. For each function call, it appends a function_call entry to the context, recording what the model requested. If the model called final_answer, the method immediately returns with a status of "complete" and the final answer extracted from the call_arguments. This short-circuits the loop — when the model signals it's done, the agent stops. For all other tools, we need to execute them.

Executing Tools with Match/Case

For non-completion tools, we use Python's match/case syntax to route to the appropriate function and handle execution with error handling.

The match/case block routes to the appropriate function based on the call_name. Each case wraps the function call in a try-except block. If the function succeeds, the result is serialized to JSON and stored in output. If the function raises an exception, the error message is captured and returned as the output. This error handling implements Factor 9: compacting errors into structured feedback that fits in the context window, allowing the model to self-correct on the next step.

After executing the tool, the method appends a function_call_output entry to the context, linking it to the original call via the . Finally, the method returns the updated with a status of , signaling that the work isn't complete yet. Now we need to tie this together in a loop.

The Reducer Loop

The run method ties everything together, implementing the reducer loop that calls _next_step repeatedly until the agent completes or hits the safety boundary.

Note on Implementation: While the reducer pattern conceptually favors immutability (returning a new copy of the state), for simplicity in these early units, we will use standard Python lists and mutate them in place using .append(). The core principle remains: the agent's logic depends only on the input context, not on internal object attributes.

The method initializes three variables: step tracks the current iteration, status starts as "running", and final_answer starts as None. The loop continues as long as the status remains "running" and the count hasn't exceeded . On each iteration, the loop increments and calls , which returns the updated , a new , and potentially a .

Running the Agent

Here's a simple script that creates an Agent and asks it to solve a quadratic equation.

This creates an Agent with default settings. You can customize it by passing model, reasoning_effort, extra_instructions, or max_steps to the constructor as needed.

When you run this script, the agent processes the request step-by-step. It reasons about the problem, calls tools to perform calculations, receives the results, and eventually calls final_answer to provide the solution.

The agent successfully solved the quadratic equation using the available math tools, demonstrating the full reducer loop in action. The status is "complete", indicating the agent finished normally, and the final_answer provides the roots of the equation. This example shows how all the pieces work together: context in, processing through multiple steps, and context out — with a result.

Summary and What's Next

You've now built a stateless reducer agent from the ground up, implementing the core pattern: (context, event) → (updated_context, status, result). This makes your agent's execution path predictable, testable, and composable because it doesn't rely on hidden internal state. You implemented Factor 12 (stateless reducer) and Factor 10 (small, focused agents), creating a foundation that's both reliable and maintainable.

In the upcoming practice exercises, you'll extend this foundation by adding new tools, handling different types of user requests, and exploring how the reducer pattern makes testing and debugging straightforward. You'll also see how easy it is to compose this agent with other components because it has a clean, well-defined interface: context in, context out.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal