Intermediate 25 min

The Agent Loop

The loop is the heart of the agent. It receives messages, decides actions, calls tools, and repeats until done.

Here’s the pseudocode:

while not done:
    send messages + tool definitions to model
    if model asks for tool:
        call local function
        append tool result to messages
    else:
        return final answer

Full Implementation

Here’s the complete agent loop in Python:

import json
import openai
from typing import List, Dict, Any, Optional

def run_agent_conversation(
    user_message: str,
    user_id: Optional[str] = None,
    max_steps: int = 5
) -> Dict[str, Any]:
    """
    Run the agent loop until we get a final answer or escalation.
    
    Returns:
        {
            "type": "answered" | "escalated",
            "reply": str,
            "trace": List[Dict]  # Step-by-step trace
        }
    """
    
    # Initialize
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT}
    ]
    
    if user_id:
        messages.append({
            "role": "system",
            "content": f"User ID: {user_id}"
        })
    
    messages.append({
        "role": "user",
        "content": user_message
    })
    
    trace = []
    step = 0
    
    # Main loop
    while step < max_steps:
        step += 1
        
        # Call LLM
        response = openai.ChatCompletion.create(
            model="gpt-4",
            messages=messages,
            tools=TOOLS,
            tool_choice="auto"
        )
        
        message = response.choices[0].message
        assistant_message = {"role": "assistant", "content": message.content}
        
        # Check for tool calls
        if message.tool_calls:
            tool_calls_data = []
            
            for tool_call in message.tool_calls:
                tool_name = tool_call.function.name
                tool_args = json.loads(tool_call.function.arguments)
                
                # Log to trace
                trace.append({
                    "step": step,
                    "action": "tool_call",
                    "tool_name": tool_name,
                    "tool_args": tool_args,
                    "timestamp": get_timestamp()
                })
                
                # Execute tool
                result = execute_tool(tool_name, tool_args, user_id)
                
                tool_calls_data.append({
                    "tool_call_id": tool_call.id,
                    "name": tool_name,
                    "result": result
                })
                
                # Add tool result to messages
                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": json.dumps(result)
                })
            
            # Add assistant message with tool calls
            assistant_message["tool_calls"] = [
                {
                    "id": tc["tool_call_id"],
                    "type": "function",
                    "function": {
                        "name": tc["name"],
                        "arguments": json.dumps(tc["result"])
                    }
                }
                for tc in tool_calls_data
            ]
            
            messages.append(assistant_message)
            
            # Continue loop to process tool results
            continue
        
        # No tool calls - check if it's escalation or answer
        content = message.content.lower()
        
        if should_escalate(content, user_message):
            trace.append({
                "step": step,
                "action": "escalation",
                "reason": "Detected escalation trigger",
                "timestamp": get_timestamp()
            })
            
            # Log escalation
            log_escalation(user_id or "unknown", user_message, "auto_detected")
            
            return {
                "type": "escalated",
                "reply": "I'm connecting you with our support team. They'll be able to help you better.",
                "trace": trace
            }
        
        # Final answer
        trace.append({
            "step": step,
            "action": "final_answer",
            "final_reply": message.content,
            "timestamp": get_timestamp()
        })
        
        return {
            "type": "answered",
            "reply": message.content,
            "trace": trace
        }
    
    # Max steps reached
    return {
        "type": "escalated",
        "reply": "I'm having trouble processing your request. Let me connect you with a human agent.",
        "trace": trace
    }

def execute_tool(tool_name: str, args: Dict, user_id: Optional[str]) -> Dict:
    """Execute a tool by name."""
    if tool_name == "get_faq_answer":
        return get_faq_answer(args["question"])
    elif tool_name == "get_subscription_status":
        # Use user_id from context if not in args
        uid = args.get("user_id") or user_id
        if not uid:
            return {"error": "user_id required"}
        return get_subscription_status(uid)
    elif tool_name == "log_escalation":
        return log_escalation(
            args.get("user_id") or user_id or "unknown",
            args["message"],
            args["reason"]
        )
    else:
        return {"error": f"Unknown tool: {tool_name}"}

def should_escalate(content: str, original_message: str) -> bool:
    """Check if message should be escalated."""
    risky_keywords = [
        "refund", "chargeback", "cancel", "delete account",
        "legal", "lawsuit", "lawyer"
    ]
    
    return any(keyword in content for keyword in risky_keywords)

Message History

The agent keeps a conversation history. Each message is added to the list:

messages = [
    {"role": "system", "content": SYSTEM_PROMPT},
    {"role": "user", "content": "What's my subscription?"},
    {"role": "assistant", "content": None, "tool_calls": [...]},
    {"role": "tool", "tool_call_id": "...", "content": "..."},
    {"role": "assistant", "content": "Your plan is Pro..."}
]

This history gives the agent context. It can see what tools it called and what results it got.

Max Steps Protection

The max_steps parameter prevents infinite loops:

max_steps = 5  # Stop after 5 iterations

If the agent keeps calling tools without answering, it escalates. This prevents:

  • Infinite tool calling
  • Cost overruns
  • Poor user experience

Trace System

Every step is logged to a trace:

trace = [
    {
        "step": 1,
        "action": "tool_call",
        "tool_name": "get_subscription_status",
        "tool_args": {"user_id": "user-123"},
        "timestamp": "10:23:15"
    },
    {
        "step": 2,
        "action": "final_answer",
        "final_reply": "Your plan is Pro...",
        "timestamp": "10:23:16"
    }
]

This trace helps you:

  • Debug what went wrong
  • Understand agent decisions
  • Show users what happened
  • Improve the agent

Example Runs

Example 1: Simple FAQ

result = run_agent_conversation(
    "What is SimpleSaaS?",
    user_id=None
)

# Result:
# {
#     "type": "answered",
#     "reply": "SimpleSaaS is a subscription management platform...",
#     "trace": [{"step": 1, "action": "final_answer", ...}]
# }

Example 2: Subscription Check

result = run_agent_conversation(
    "What's my current plan?",
    user_id="user-123"
)

# Result:
# {
#     "type": "answered",
#     "reply": "Your current plan is Pro, active until Dec 1, 2025.",
#     "trace": [
#         {"step": 1, "action": "tool_call", "tool_name": "get_subscription_status", ...},
#         {"step": 2, "action": "final_answer", ...}
#     ]
# }

Example 3: Escalation

result = run_agent_conversation(
    "I want a refund for last month",
    user_id="user-123"
)

# Result:
# {
#     "type": "escalated",
#     "reply": "I'm connecting you with our support team...",
#     "trace": [{"step": 1, "action": "escalation", ...}]
# }

Visualizing the Loop

Here’s how the loop flows:

Yes No Loop Start Call LLM Tool Call? Execute Tool Add Result Return Answer

Key Implementation Details

  1. Message format - Follow the LLM provider’s format exactly
  2. Tool execution - Call local functions, not remote APIs
  3. Error handling - Handle tool errors gracefully
  4. Step limits - Prevent infinite loops
  5. Trace logging - Log everything for debugging

What’s Next?

The loop is working, but it’s not very smart yet. The next page adds memory and guardrails to make it safer and more consistent.