For a chatbot, the state is a list of messages. We built exactly that earlier in this series: you keep the list, append to it, and re-send it every turn.
An agent run needs to track more than a conversation, and seeing why is the first step to building one that you can debug.
What a run actually has to remember
Watch our ticket agent solve one question and list what has to survive between steps.
- Everything that happened. The user's question, each tool the model asked for, each result that came back, the final answer. Not just the messages: the tool calls and their results too.
- How many steps it has taken. A model can keep asking for tools forever. Something has to count, and stop the run at a limit.
- Which run this is. When an error shows up in a log, you need to know which execution produced it.
- A scratch pad. Intermediate values, task progress, configuration a tool needs mid-run. Somewhere to put data that is neither a message nor a tool result.
- Whether it is done. A flag that says the agent has a final answer and the loop can stop.
A plain message list holds the first of those, badly, and none of the rest.
The trap of loose variables
You could keep those as separate variables and pass them around.
def step(messages, step_count, execution_id, state, done): # and it grows
...
Every method needs the full list. Add memory later (a whole component in a future post) and every signature changes. The parameter lists get longer, the call sites get noisier, and each new piece of state is an edit in a dozen places.
Consolidate everything into one object instead. Methods take that one object, and adding new state means editing one definition.
from dataclasses import dataclass, field
@dataclass
class ExecutionContext:
"""Everything a single agent run needs to remember."""
execution_id: str = field(default_factory=lambda: str(uuid4()))
events: list = field(default_factory=list) # the full history
current_step: int = 0 # the loop counter
state: dict = field(default_factory=dict) # the scratch pad
final_result: str | None = None # set when done
Five fields, one container. Every method receives it, reads what it needs, and writes back to the same place.
Loose variables thread through every signature. One context object holds the state in a single place.
Three things happen, so type all three
The events list is the interesting field, because a run records three distinct kinds of thing, and
blurring them is where agent code gets confusing.
from pydantic import BaseModel
from typing import Literal, Union
class Message(BaseModel):
type: Literal["message"] = "message"
role: Literal["system", "user", "assistant"]
content: str
class ToolCall(BaseModel):
type: Literal["tool_call"] = "tool_call"
tool_call_id: str
name: str
arguments: dict
class ToolResult(BaseModel):
type: Literal["tool_result"] = "tool_result"
tool_call_id: str
name: str
status: Literal["success", "error"]
content: list
ContentItem = Union[Message, ToolCall, ToolResult]
The type field on each is a discriminator: your code reads it and knows exactly what it is holding.
The tool_call_id links a result back to the call that produced it, which is what keeps things
straight when several tools run at once.
| The three things a run records | Example from the ticket question |
|---|---|
| Message | The user asking, and the agent's final answer |
| Tool call | The model asking for read_tickets(quarter="2026-Q2") |
| Tool result | The export coming back, marked success or error |
Wrap each one with who and when
The content types say what happened. For debugging you also want who produced it and when, so wrap each content item in an event.
class Event(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
execution_id: str # which run this belongs to
timestamp: float = Field(default_factory=_now)
author: str # "user" or the agent's name
content: list[ContentItem] = Field(default_factory=list)
Now each step of the run becomes one Event, and the list of events is a complete, timestamped,
attributed record of the whole session.
Event(
execution_id="run-abc-123",
author="ticket_agent",
content=[ToolCall(tool_call_id="call-1",
name="read_tickets",
arguments={"quarter": "2026-Q2"})],
)
Each step becomes one attributed, timestamped Event. The list of events is the run's audit trail.
The record earns more than debugging convenience, and a later post in the security series comes back to it: an attributed, timestamped log of every action an agent took is exactly what an auditor asks for. Getting it as a by-product of good structure is a gift worth keeping.
What to take from this
- A chatbot needs a message list. An agent run needs events, a step counter, an id, a scratch pad and a done flag.
- Consolidate them into one
ExecutionContextso every method takes one object and new state is one edit, not a dozen. - Type the three things that happen (message, tool call, tool result) and wrap each in an attributed, timestamped event. The result is a complete audit trail of the run.
With somewhere to keep the state, the next component is the one that does the work: a single, uniform way to define a tool, whatever it wraps.