By AI Engineering Team

Build a Tool-Using AI Agent - Building the Agent Loop

Intermediate 7 min
AI AgentsLLMFunction Calling

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.

Discussion

Join the conversation and share your thoughts

Discussion

0 / 5000