Agent Architectures: Control Loops, State & Planning › Code Implementation

Michael BrenndoerferFebruary 4, 202610 min read
Language AI HandbookAll chapters
Part 48 of 67Tool Use and Agents
Chapter 367Agent Architectures: Control Loops, State & Planning › Code Implementation
Reading section 7 of 11 · Code Implementation · 10 minSection 7 of 11 · 10 min

Book contents

Section 7 of 11 · 10 min read

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:

In[7]:
Code
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 = 0

These 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):

In[8]:
Code
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:

In[9]:
Code
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:

In[10]:
Code
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"
)
Out[11]:
Console
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:

In[12]:
Code
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")
Out[13]:
Console
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:

Out[14]:
Visualization
Flowchart showing two agent loop iterations with observation, reasoning, action, and termination check stages connected by arrows.
Agent control loop execution flow across two iterations. The first iteration processes the user query, invokes the calculator tool, and records the result. The second iteration receives the tool output as its observation, determines the goal is complete, and terminates. The Agent State box shows the working memory that persists across iterations, letting context-aware decision making.

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.