Introduction: The Problem with Scattered State

In the previous two lessons, you built a stateless reducer agent that externalizes prompts and serializes context. The agent works correctly, solving complex problems like quadratic equations through multi-step tool use. However, when you look at the run() method's return signature, you see it returns a tuple of (context, status, final_answer). This scattered state makes the system harder to maintain, harder to debug, and more fragile to pause and resume because there is no single, structured object that captures everything about the agent's current situation. The solution is to create a unified State class that implements Factor 5 of the 12-Factor Agents methodology: unify execution state and business state.

Distinguishing Execution State from Business State

Before building the unified State class, it helps to clarify what execution state and business state mean and why they both belong in the same object.

  • Execution state refers to the metadata about where the agent is in its processing lifecycle, including how many steps have been taken, what the current status is, what work is pending, and whether any fatal errors have occurred.
  • Business state refers to the domain-specific information the agent is working with, including the conversation context, the final answer once the agent completes its work, and any domain-specific data structures you might add later.

By merging execution state and business state into a single State object, you create a unified representation that is easier to reason about, persist, and pass between systems, enabling capabilities like pausing an agent mid-execution and resuming it later.

Planning the State Model Structure

Before we implement the unified State class, let's understand how we need to extend our project structure to support state management. We're going to add a new models/ directory to organize state-related data structures and prepare for future state-related utilities.

We're going to extend the existing structure to include a dedicated directory for state models:

Here's what the new component adds to our architecture:

  • models/ directory — Houses all data models that represent agent state and domain concepts, treating state as a first-class structured object
  • state.py — Defines the State class that unifies execution state (steps, status, pending work) and business state (context, answers, errors) into a single validated structure

This structure separates state representation from agent logic, making it clear that the State object is a pure data container while the Agent class handles the processing logic. By organizing state models this way, you can validate state transitions independently, serialize state for persistence or debugging, and extend the state schema without modifying the agent's control flow. Now let's create the State class and refactor the agent to use it.

Designing the State Class with Pydantic

The unified State class uses Pydantic's BaseModel to provide validation, default values, and a clear structure. The class lives at src/core/models/state.py and defines seven fields that capture both execution and business state.

Create the file src/core/models/state.py and add the following implementation:

Here's what each field represents in the unified state:

  • id — A unique identifier for this state instance, enabling tracking across systems and persistence layers
  • steps — Counts how many processing cycles the agent has completed, used for both debugging and enforcing maximum step limits
  • status — Tracks the agent's lifecycle stage ("running", "complete", "max_steps_reached", or ), making control flow explicit
Incrementing Steps and Processing Pending Tool Calls

The _next_step method is where most state mutations happen, so this is the most significant refactoring. The method now receives a State object and returns a modified State object, implementing the reducer pattern at the state level. The method begins by incrementing the steps counter directly on the state object, then iterates through pending_tool_calls and processes each one. For each function call, the method extracts the call_name, call_arguments, and call_id, then persists the tool call into the unified context history.

This approach makes it clear how the execution progresses step by step, with all mutations flowing through the same State object. Now you need to handle the actual tool execution and update the state accordingly.

Executing Tools and Updating State

After appending the function call to the context, the match/case block handles tool execution. When the final_answer tool is called, the method clears pending_tool_calls, sets the status to "complete", stores the answer in state.final_answer, and returns immediately. For math tools like sum_numbers, the method executes the function within a try-except block to catch any errors, formats the result as JSON, and then removes the processed function call from pending_tool_calls before appending the output to the context.

Calling the LLM and Queueing New Tool Calls

After executing all pending tool calls and appending their outputs to context, the method calls the LLM with the updated context using the existing _call_llm helper. The response contains new function calls, which are extracted and converted into dictionaries. These new tool calls are then added to state.pending_tool_calls for the next step, and the updated State is returned.

This explicit queueing of pending work inside the State object makes the agent's control flow transparent and debuggable. Now you need to update the run() method to orchestrate these steps using the unified State and handle unexpected crashes.

Updating the Run Method to Work with State

The run() method becomes significantly simpler with unified state because it only needs to track a single object. To adhere to the stateless principle, the method creates a deep copy of the incoming state.

Crucially, we now handle resilience by wrapping the execution loop in a try/except block. Before starting, we ensure state.error is cleared. If a fatal error occurs (like an API outage or unhandled exception), we catch it, set state.status to "failed", and record the exception message in state.error.

This control flow is robust: success, timeout, and failure are all captured within the returned State object.

Running the Agent with the New State-Based API

The main script demonstrates how to use the agent with the unified state pattern. You start by creating a State object with a unique id generated by uuid.uuid4() and initial context containing the user's request. You then call agent.run(state) and access the results directly through the returned State object.

Running this script produces the following output, showing that the agent successfully solved the quadratic equation:

The unified state pattern makes the API much cleaner than the previous tuple unpacking approach, and all execution information is available in one object for easy debugging and monitoring.

Summary and What's Next

You have implemented Factor 5 by creating a single source of truth that captures everything about the agent's current situation in one State object. By using model_copy(deep=True), you've ensured that your agent functions as a pure transformer of state, taking one version and returning another without side effects on the input. This design not only simplifies the code but also enables powerful capabilities you will explore in future lessons: serializing state to JSON for persistence, replaying executions for debugging, and scaling horizontally by having different worker processes continue the work. In the upcoming practice exercises, you will extend the State class to handle more complex scenarios and explore how unified state simplifies error handling and recovery.

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