Agent Architectures: Control Loops, State & Planning › Code Implementation
Language AI HandbookAll chapters Agent Architectures: Control Loops, State & Planning › Code ImplementationOne section · Sections in this chapter7 / 11 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Let's implement a simplified agent architecture that shows the core loop, state management, and planning components. Our agent will solve mathematical word problems by using a calculator tool, showing how the architectural principles translate into working code.
The goal is not production-ready software but rather a clear illustration of how each architectural component is expressed in code. By tracing the execution of this simplified agent, you can see exactly how the loop drives iterations, how working memory accumulates results, and how termination conditions fire.
We start by defining the data structures that stand for the agent's state:
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class Observation:
"""Input received from the environment at each iteration."""
content: str
source: str # 'user', 'tool', or 'system'
step: int
@dataclass
class Action:
"""The agent's decision about what to do next."""
tool_name: Optional[str] # None signals a final answer (termination)
tool_input: Dict[str, Any]
reasoning: str
@dataclass
class AgentStep:
"""A complete record of one agent loop iteration."""
observation: Observation
thought: str
action: Action
result: Optional[str] = None
@dataclass
class AgentState:
"""The full state of the agent across all iterations."""
steps: List[AgentStep] = field(default_factory=list)
working_memory: Dict[str, Any] = field(default_factory=dict)
current_plan: List[str] = field(default_factory=list)
iteration_count: int = 0These four dataclasses capture the key concepts from our earlier discussion. Observation is input from the environment. Action is the agent's output decision, with tool_name=None serving as the implicit termination signal. AgentStep records a complete iteration for later inspection. AgentState aggregates everything the agent knows and has done, representing its persistent memory.
Now we implement the calculator tool and the tool registry using safe arithmetic (avoiding dynamic code execution by parsing operations explicitly):
import operator
def calculator(expression: str) -> str:
"""
Safe calculator that evaluates simple arithmetic expressions.
Supports addition, subtraction, multiplication, and division.
Parses operations explicitly to avoid any code execution concerns.
"""
# Remove all whitespace
expression = expression.replace(" ", "")
# Try to match a simple two-operand expression
pattern = re.compile(r"^(-?\d+(?:\.\d+)?)([\+\-\*/])(-?\d+(?:\.\d+)?)$")
match = pattern.match(expression)
if not match:
return "Error: Expression must be in the form 'a op b' (e.g. '100 / 4')"
left = float(match.group(1))
op_char = match.group(2)
right = float(match.group(3))
ops = {
"+": operator.add,
"-": operator.sub,
"*": operator.mul,
"/": operator.truediv,
}
if op_char not in ops:
return f"Error: Unsupported operator '{op_char}'"
if op_char == "/" and right == 0:
return "Error: Division by zero"
result = ops[op_char](left, right)
# Return integer if result is whole number
if result == int(result):
return str(int(result))
return str(round(result, 6))
# Tool registry: maps tool names to their functions and schemas
TOOLS = {
"calculator": {
"function": calculator,
"description": "Calculate simple two-operand arithmetic. Input: expression like '150 - 45'.",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "Arithmetic expression to evaluate, e.g. '150 - 45'",
}
},
"required": ["expression"],
},
}
}The core of our agent is the planning and execution logic. Since this example runs without a live LLM API, we simulate the reasoning step with deterministic heuristics that capture the needed decision patterns:
import re
class SimpleAgent:
"""
Agent architecture showing loop, state, planning, and termination.
Uses rule-based reasoning to simulate LLM decision making.
"""
def __init__(self, max_iterations: int = 5, tools: Dict = None):
self.max_iterations = max_iterations
self.tools = tools or {}
self.state = AgentState()
def plan(self, observation: Observation) -> List[str]:
"""
Generate a task plan based on the observation content.
A real agent would call an LLM here.
"""
content = observation.content.lower()
if "calculate" in content or any(
op in content for op in ["+", "-", "*", "/"]
):
return ["extract_expression", "calculate", "verify"]
return ["analyze", "respond"]
def _extract_expression(self, content: str) -> Optional[str]:
"""Extract a simple arithmetic expression from the content string."""
# Strip leading instruction words
text = re.sub(r"calculate\s*", "", content, flags=re.IGNORECASE).strip()
# Match patterns like "150 - 45" or "100/4"
pattern = re.compile(
r"(-?\d+(?:\.\d+)?)\s*([\+\-\*/])\s*(-?\d+(?:\.\d+)?)"
)
match = pattern.search(text)
if match:
return f"{match.group(1)}{match.group(2)}{match.group(3)}"
# Detect word-form operations
numbers = re.findall(r"\d+(?:\.\d+)?", content)
if len(numbers) >= 2:
if "minus" in content.lower() or "subtract" in content.lower():
return f"{numbers[0]}-{numbers[1]}"
if (
"plus" in content.lower()
or "total" in content.lower()
or "sum" in content.lower()
):
return f"{numbers[0]}+{numbers[1]}"
if "divided" in content.lower():
return f"{numbers[0]}/{numbers[1]}"
if "times" in content.lower() or "multiplied" in content.lower():
return f"{numbers[0]}*{numbers[1]}"
return None
def decide_action(self, observation: Observation) -> Action:
"""
Choose the next action based on observation and current plan.
A real agent would call an LLM here.
"""
content = observation.content
# Hard termination: iteration limit exceeded
if self.state.iteration_count >= self.max_iterations:
return Action(
tool_name=None,
tool_input={"answer": "Maximum iterations reached"},
reasoning="Terminating due to iteration limit",
)
# Try to extract and calculate an arithmetic expression
expr = self._extract_expression(content)
if expr:
return Action(
tool_name="calculator",
tool_input={"expression": expr},
reasoning=f"Arithmetic expression detected: {expr}",
)
# Default: answer directly without a tool
return Action(
tool_name=None,
tool_input={"answer": f"Processed: {content}"},
reasoning="No arithmetic detected, giving direct response",
)
def should_terminate(self, action: Action) -> bool:
"""Determine whether the agent should stop after this action."""
if action.tool_name is None:
return True
if self.state.iteration_count >= self.max_iterations:
return True
return False
def run(self, query: str) -> Dict:
"""Execute the agent loop until a termination condition is met."""
print(f"Starting agent with query: '{query}'\n")
observation = Observation(content=query, source="user", step=0)
while True:
self.state.iteration_count += 1
step_num = self.state.iteration_count
print(f"Iteration {step_num}")
print(f" Observation: {observation.content[:70]}")
# Planning phase: generate plan on first iteration
if not self.state.current_plan:
self.state.current_plan = self.plan(observation)
print(f" Plan: {' -> '.join(self.state.current_plan)}")
# Reasoning phase: decide next action
action = self.decide_action(observation)
print(f" Reasoning: {action.reasoning}")
if action.tool_name:
print(f" Action: {action.tool_name}({action.tool_input})")
else:
print(" Action: Final answer")
# Termination check
if self.should_terminate(action):
step = AgentStep(
observation=observation,
thought=action.reasoning,
action=action,
result=action.tool_input.get("answer", "Done"),
)
self.state.steps.append(step)
print(f"\nTerminated after {step_num} iteration(s)")
return {
"answer": action.tool_input.get("answer"),
"iterations": step_num,
"history": self.state.steps,
}
# Execution phase: call the tool
tool_func = self.tools.get(action.tool_name, {}).get("function")
if tool_func:
try:
result = tool_func(**action.tool_input)
print(f" Result: {result}")
except Exception as e:
result = f"Error: {str(e)}"
print(f" Result: {result}")
else:
result = f"Unknown tool: {action.tool_name}"
print(f" Result: {result}")
# Update working memory with tool result
self.state.working_memory[f"step_{step_num}_result"] = result
# Record the completed step
step = AgentStep(
observation=observation,
thought=action.reasoning,
action=action,
result=result,
)
self.state.steps.append(step)
# The tool result becomes the next observation
observation = Observation(
content=result, source="tool", step=step_num
)
# Advance the plan
if self.state.current_plan:
self.state.current_plan.pop(0)
print()The run method is where all four architectural components converge. The while True loop with an explicit return is our agent loop. The self.state object is our state management. The self.plan() call is our planning phase. The self.should_terminate() check implements our termination conditions. Notice how naturally each component maps to a distinct section of code.
Let's run the agent on a word problem that requires calculation:
agent = SimpleAgent(max_iterations=3, tools=TOOLS)
result = agent.run(
"If I have 150 apples and give away 45, how many remain? Calculate 150 - 45"
)Final Answer: Processed: 105 Total iterations: 2 Working memory keys: ['step_1_result']
The agent recognized the arithmetic expression, invoked the calculator tool with 150-45, received the result 105, and terminated with a final answer. Working memory contains one entry recording the tool result, which would be available for subsequent reasoning if the task required further computation.
Now let's examine state accumulation more explicitly with a stateful extension:
class StatefulAgent(SimpleAgent):
"""Extended agent with explicit state reporting capabilities."""
def get_state_summary(self) -> str:
"""Generate a human-readable summary of the agent's current state."""
lines = [
f"Iterations completed: {self.state.iteration_count}",
f"Working memory entries: {len(self.state.working_memory)}",
f"Conversation length: {len(self.state.steps)} step(s)",
]
if self.state.steps:
last_step = self.state.steps[-1]
lines.append(
f"Last action: {last_step.action.tool_name or 'final_answer'}"
)
lines.append(f"Last result: {str(last_step.result)[:60]}")
if self.state.working_memory:
lines.append("Working memory contents:")
for key, val in self.state.working_memory.items():
lines.append(f" {key}: {val}")
return "\n".join(lines)
stateful_agent = StatefulAgent(max_iterations=3, tools=TOOLS)
stateful_result = stateful_agent.run("Calculate 100 / 4")Final Answer: Processed: 25 Total iterations: 2 Final State Summary: Iterations completed: 2 Working memory entries: 1 Conversation length: 2 step(s) Last action: final_answer Last result: Processed: 25 Working memory contents: step_1_result: 25
The state summary reveals what the agent retained after execution: the number of iterations, the working memory entries with their calculated values, and the action history. In a more complex agent, working memory might contain dozens of entries representing facts gathered across many iterations, all available for synthesis at the final step.
Let's visualize the agent control loop across iterations:

The diagram captures the needed control flow: two iterations connected by an arrow representing the feedback loop where tool results become the next iteration's observations. The state box on the right accumulates working memory across iterations, representing the agent's persistent context.