All posts
Fundamentals

Why an agent needs more than a message list

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.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 recordsExample from the ticket question
MessageThe user asking, and the agent's final answer
Tool callThe model asking for read_tickets(quarter="2026-Q2")
Tool resultThe 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.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 ExecutionContext so 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.