How to Build an Autonomous Python Agent with Function Calling in 50 Lines

Quick Start (Complete 50-Line Agent)

You do not need heavy frameworks like LangChain or AutoGen to build an autonomous agent. A production-grade agent is simply a stateful message loop that queries an LLM, inspects tool_calls, executes local Python functions, and feeds results back until completion:

import json, subprocess
from openai import OpenAI

client = OpenAI()

def execute_shell(command: str) -> str:
    """Execute a local bash command safely and return stdout/stderr."""
    res = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=30)
    return res.stdout if res.returncode == 0 else f"Error: {res.stderr}"

tools = [{
    "type": "function",
    "function": {
        "name": "execute_shell",
        "description": "Run a bash command on the local machine",
        "parameters": {
            "type": "object",
            "properties": {"command": {"type": "string"}},
            "required": ["command"]
        }
    }
}]

messages = [
    {"role": "system", "content": "You are an autonomous DevOps assistant. Use execute_shell to solve tasks."},
    {"role": "user", "content": "Check disk space on / and return free GB."}
]

# The Autonomous Execution Loop
while True:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=messages,
        tools=tools
    )
    msg = response.choices[0].message
    messages.append(msg)

    if not msg.tool_calls:
        print(f"\nFinal Answer:\n{msg.content}")
        break

    for call in msg.tool_calls:
        args = json.loads(call.function.arguments)
        print(f"-> Executing: {call.function.name}({args})")
        result = execute_shell(args["command"])
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": str(result)
        })

1. How the Agentic Loop Works (Step-by-Step)

An autonomous agent operates on the ReAct (Reason + Act) pattern divided into four deterministic stages:

[User Request] 


┌──────────────┐
│  LLM Reason  │ ◄─────────────────────────┐
└──────┬───────┘                           │
       │                                   │
   Has tool_calls?                         │
       ├── YES ──► [Execute Local Tool] ───┘ (Append tool message)

       └── NO  ──► [Return Final Response to User]
  1. State Preservation: The messages list acts as the agent’s short-term memory buffer. Every turn appends the assistant’s planned call and the tool’s raw execution output.
  2. Tool Dispatcher: When msg.tool_calls is present, the agent iterates over each call, parses JSON arguments, executes the corresponding Python function, and returns a message with role: "tool".
  3. Termination Condition: When the LLM decides it has sufficient information to answer the user request, it omits tool_calls and outputs natural language text, which triggers break.

2. Minimalist Python Loop vs Heavy Frameworks

Metric / Dimension Raw Python Loop (Above) LangChain / LangGraph CrewAI
Dependencies openai (or httpx) 40+ packages (langchain-core, etc.) crewai, chromadb, etc.
Token Overhead Zero prompt bloat ~500–1,200 hidden token wrappers ~1,000+ persona tokens
Debugging Difficulty Single breakpoint in loop Nested call stacks & internal handlers Complex multi-agent threads
Execution Latency Direct network speed 150ms–400ms framework overhead 500ms+ orchestration overhead
Best For Internal tools, microservices, CLI agents Complex stateful DAGs & visual graphs High-level persona brainstorming

3. Essential Guardrails for Production

A raw loop can run indefinitely or execute destructive commands without safeguards. Add these three guardrails before running on remote servers:

1. Hard Maximum Turns (Recursion Prevention)

MAX_TURNS = 10
turn_count = 0

while turn_count < MAX_TURNS:
    turn_count += 1
    # ... execution logic ...
else:
    raise TimeoutError("Agent exceeded maximum allowed execution turns.")

2. Tool Whitelisting & Sandboxing

Never pass unrestricted user inputs into shell=True without command validation:

ALLOWED_COMMANDS = {"df", "ls", "uptime", "free", "docker ps"}

def execute_shell(command: str) -> str:
    binary = command.strip().split()[0]
    if binary not in ALLOWED_COMMANDS:
        return f"Error: Command '{binary}' is not permitted by security policy."
    # execute safely...

3. Graceful Error Handling (Self-Correction)

Return exceptions as strings inside the tool output instead of crashing the Python process. LLMs can inspect error traces and automatically retry with corrected arguments:

try:
    output = run_command()
except Exception as e:
    return f"Tool Execution Failed: {type(e).__name__} - {str(e)}"

Summary Checklist

  • Standard function calling APIs support fully autonomous loops in under 50 lines.
  • Append both the assistant message and the tool return message to maintain context integrity.
  • Always enforce MAX_TURNS and command whitelists to prevent runaway token costs and unauthorized actions.