DEV Community

Cover image for A Framework-Free Walkthrough of the Control Loop Behind Every Tool-Calling AI Agent
SUDIP
SUDIP

Posted on

A Framework-Free Walkthrough of the Control Loop Behind Every Tool-Calling AI Agent

Most "build an AI agent" tutorials skip straight to higher-level frameworks like LangGraph, CrewAI, or AutoGen. As a result, developers call .invoke() without seeing the mechanics underneath. Beneath every agent framework lies a single, surprisingly straightforward control loop.

To make this walkthrough accessible, the examples in this guide use a Gemini model. Because anyone with a Google Account can obtain a free-tier API key, you can run the code and follow along directly without running into paid platform barriers or complex setups.

The Loop at a Glance

Every AI agent, regardless of framework, executes the same four-step cycle:

  1. Send Context: Pass the conversation history and tool definitions to the LLM.

  2. Evaluate Response: The LLM returns either a final text response or a tool execution request.

  3. Execute and Append: If the model requests a tool call, run the corresponding local function, append the output to the conversation history, and return to Step 1.

  4. Terminate: Repeat the cycle until the LLM produces a final answer or reaches a designated safety ceiling.


The Core Concept: LLMs Direct Execution, Code Performs It

LLMs do not execute code, query databases, or call external APIs directly. Instead, the model outputs structured decision data specifying which tool to execute and which parameters to pass. Your application receives this instruction, runs the corresponding local function, and returns the output to the model.

Writing Effective Tool Definitions

An LLM evaluates available functions solely through tool definitions—API specifications provided in the request payload. High-quality descriptions act as operational instructions for the model, directly determining how accurately it selects tools:

tool_definition = {
    "name": "check_service_health",
    "description": (
        "Checks current operational status and latency for a given service. "
        "Use this first when investigating incident alerts."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "service_name": {
                "type": "string",
                "description": "The target service identifier (e.g., 'auth-api')"
            }
        },
        "required": ["service_name"]
    }
}
Enter fullscreen mode Exit fullscreen mode

Executing Tool Calls and Managing State

Routing Requests to Local Functions

When the model returns a tool call request, your application must dispatch that request to the target function. For simple agents with few tools, a standard match/case block or lookup table handles dispatch cleanly:

def dispatch_tool_call(tool_name: str, arguments: dict):
    match tool_name:
        case "check_service_health":
            return check_service_health(**arguments)
        case "check_recent_deployments":
            return check_recent_deployments(**arguments)
        case "search_runbook":
            return search_runbook(**arguments)
        case _:
            raise ValueError(f"Unknown tool requested: {tool_name}")
Enter fullscreen mode Exit fullscreen mode

Maintaining Conversation Memory

Because LLMs are stateless, your runtime loop must manage conversation state across every turn. Each tool-calling cycle requires appending two distinct messages to the chat history:

# 1. Append the model's tool execution request
messages.append(response.message)

# 2. Append the function output as a tool-role message
messages.append({
    "role": "tool",
    "tool_call_id": tool_call.id,
    "content": str(tool_result)
})
Enter fullscreen mode Exit fullscreen mode

Enforcing Loop Termination and Safety Boundaries

Assembling the Complete Control Loop

Assemble the step-by-step cycle inside a single loop function, checking for final text responses on each iteration. Always define an explicit iteration ceiling (max_iterations = 10) to protect against infinite loops if a model fails to converge:

def run_agent_loop(user_prompt: str, max_iterations: int = 10) -> str:
    messages = [{"role": "user", "content": user_prompt}]

    for _ in range(max_iterations):
        response = llm.generate(messages=messages, tools=tools)

        # Exit condition: Model produced final text response
        if not response.tool_calls:
            return response.text

        # Execution state: Run requested functions and update history
        for tool_call in response.tool_calls:
            result = dispatch_tool_call(tool_call.name, tool_call.args)
            messages.append(response.message)
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": str(result)
            })

    raise RuntimeError("Agent exceeded maximum iteration limit.")
Enter fullscreen mode Exit fullscreen mode

Conclusion

Understanding this core loop removes the ambiguity surrounding agent frameworks. Higher-level orchestration layers build on this foundation to manage edge cases, state persistence, and complex routing, but the core mechanism remains simple: structured model decisions guiding local code execution.

Link to the original Post

You will find the complete example HERE.

Top comments (0)