ExecutionContext
We need ExecutionContext to avoid scattering state across multiple variables and to simplify how components share information. Now, let's determine what we need to store by examining what actually happens when an agent runs.
What happends during agent execution?
Recalling our Kipchoge problem, when an agent solves this problem, the following of series of events is likely:
- User asks the question: "How long would it take Kipchoge to run to the Moon?"
- LLM decides it needs information → requests
search_webtool. - Tool executes and returns: "Kipchoge's record is 2:01:09 for 42.195km."
- LLM needs more information → requests
search_wikipediatool. - Tool executes and returns: "Moon's perigee is 356,500 km'.
- LLM can now calculate → requests calculator tool.
- Tool executes and returns: "17034"
- LLM provides the final answer: "Approximately 17,000 hours."
Examing this sequence, we can identify three distinct types of occurences:
Messages are text exchanges in the conversation. The user's initial question and the LLM's final answer are both examples of messages. Each message has a clear role (user, assistant, or system) and text content.
Tool Calls occur when the LLM decides to use a tool. The LLM specifies which tool to call and with what arguments. In step 2 above, the LLM requests search_web with the query "Kipchoge marathon world record pace".
Tool Results capture what happens when tools execute. They include the tool's outpuy and whether execution succeeded or failed. Step 3 would be a successful result containing Kipchoges' marathon time.
Let's define these as data types in Python using Pydantic.
# react_agents/types/contents.py
from typing import Literal, Union
from pydantic import BaseModel
class Message(BaseModel):
"""A text message in the conversation."""
type: Literal["message"] = "message"
role: Literal["system", "user", "assistant"]
content: str
class ToolCall(BaseModel):
"""LLM's request to execute a tool."""
type: Literal["tool_call"] = "tool_call"
tool_call_id: str
name: str
arguments: dict
class ToolResult(BaseModel):
"""Result from tool execution."""
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 in each class serves as a discriminator, making it easy to identify what kind of content we're dealing with. The tool_call_id links a ToolResult bac to it's originating ToolCall which is necessary when multiple tools execute in parallel.
These content tpyes capture what happened, but for debugging and analysis, we also need to know who produced each piece of content and when. We wrap content items with metadata using an Event class.
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
class Event(BaseModel):
"""A recorded occurrence during agent execution."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
execution_id: str
timestamp: float = Field(default_factory=lambda: datetime.now().timestamp())
author: str # "user" or agent name
content: List[ContentItem] = Field(default_factory=list)
The execution_id groups all events from a single agent run, enabling us to trace an entire problem-solving session. The author field distinguishes between user input and agent actions. Here's an illustation of how one step from Kipchoge problem could look as an Event.
Event(
execution_id="abc-123",
author="research_agent",
content=[ToolCall(
tool_call_id="call-1",
name="search_web",
arguments={"query": "Kipchoge marathon world record"}
)]
)
Each step in the our agent's execution becomes an Event, creating a complete audit trail that proves invaluable when debugging.
Implementing ExecutionContext
Now we know what to store, implementing our ExecutionContext is fairly straightforward.
# react_agents/models/execution_context.py
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional
from ..types.events import Event. # location of our previously defined Event
from ..types.contents import Message # location of our previously defined Message
from pydantic import BaseModel
import uuid
@dataclass
class ExecutionContext:
"""Central storage for all execution state."""
execution_id: str = field(default_factory=lambda: str(uuid.uuid4()))
events: List[Event] = field(default_factory=list)
current_step: int = 0
state: Dict[str, Any] = field(default_factory=dict)
final_result: Optional[str | BaseModel] = None
def add_event(self, event: Event):
"""Append an event to the execution history."""
self.events.append(event)
def increment_step(self):
"""Move to the next execution step."""
self.current_step += 1
Each field serves a specific purpose:
execution_id: Unique identifier for this execution session, automatically generatedevents: Chronological list of all Events that occur during executioncurrent_step: Counter to prevent infinite loops (agent stops after max_steps)state: Flexible key-value store for custom data that tools might need, such as API configurations or intermediate resultsfinal_result: Holds the agent's answer when execution completes
What are the
dataclassandfieldimports? These are nothing specific to AI Agents and are Python features.dataclassis a decorator to indicate a class primarily exists to store data. It automatically generates boilerplate methods that you would otherwise have to write manually such as init, repr and eq.fieldis a function used when a simple assigment isn't enough when working with mutable types.
ExecutionContext can also be passed to tools that need access to the execution state. A tool might read from state or examine previous events in the execution history. For this to work, we need a consistent way to define tools that can optionally receive context. To unify the function-based tools under a common interface that supports this capability, let's start our implementation of Tool Abstraction.