Agent Loop Basics
10 examples to get you started with the perceive → reason → act → observe rhythm in Python: one tool call, reading the result, multi-step loops, and the stop rules that keep runs finite.
Search across all documentation pages
10 examples to get you started with the perceive → reason → act → observe rhythm in Python: one tool call, reading the result, multi-step loops, and the stop rules that keep runs finite.
Read Inside the Agent Loop: Perceive, Reason, Act, Observe for the full mental model. These examples stay provider-agnostic: any chat API with tool/function calling works the same way.
You only need a Python 3.10+ environment and a mental model of chat messages (system, user, assistant, tool).
python -m venv .venv
source .venv/bin/activate
# install your preferred OpenAI-compatible client when you wire a real modelConcept focus: Group 1 pages are light on real network calls. Snippets use plain data structures so you can see the loop without a specific SDK.
An agent does not "remember" outside the message list you pass each call.
messages = [
{"role": "system", "content": "You help with order status. Use tools when needed."},
{"role": "user", "content": "Where is order 1042?"},
]messages is the same as never having run the toolDescribe tools so the model can choose them; your code still implements them.
TOOLS = [
{
"name": "get_order",
"description": "Fetch order status by numeric id",
"parameters": {
"type": "object",
"properties": {"order_id": {"type": "integer"}},
"required": ["order_id"],
},
}
]
def get_order(order_id: int) -> dict:
return {"order_id": order_id, "status": "shipped", "eta": "2026-07-18"}The model either proposes a tool call or returns final text.
def fake_model_decide(messages, tools):
# Stand-in for a real LLM tool-calling response
last = messages[-1]["content"]
if "1042" in last and not any(m.get("role") == "tool" for m in messages):
return {"type": "tool_call", "name": "get_order", "args": {"order_id": 1042}}
return {"type": "final", "content": "Order 1042 is shipped; ETA 2026-07-18."}tool_calls on the assistant messagetool_call vs finaldecision = fake_model_decide(messages, TOOLS)
if decision["type"] == "tool_call":
result = get_order(**decision["args"])
messages.append({"role": "assistant", "content": None, "tool_call": decision})
messages.append({"role": "tool", "name": decision["name"], "content": str(result)})tool message{"error": "..."}def run_agent(user_text: str, max_turns: int = 4) -> str:
messages = [
{"role": "system", "content": "Use tools for live order data."},
{"role": "user", "content": user_text},
]
for _ in range(max_turns):
decision = fake_model_decide(messages, TOOLS)
if decision["type"] == "final":
return decision["content"]
result = get_order(**decision["args"])
messages.append({"role": "assistant", "tool_call": decision})
messages.append({"role": "tool", "name": "get_order", "content": str(result)})
return "Stopped: max turns reached."max_turns is a mandatory safety rail, not optional polishRelated: Stopping Conditions: How an Agent Knows When It's Done
Walk a single iteration the way logs should look in production.
trace = []
decision = fake_model_decide(messages, TOOLS)
trace.append({"phase": "reason", "decision": decision})
if decision["type"] == "tool_call":
result = get_order(**decision["args"])
trace.append({"phase": "act", "tool": "get_order", "result": result})
# next model call would be the next reason phaseWhen the model needs intermediate IDs, the loop earns its keep.
def lookup_customer(email: str) -> dict:
return {"customer_id": "c_77", "email": email}
def list_open_orders(customer_id: str) -> list:
return [{"order_id": 1042, "status": "shipped"}]
# Iteration 1: lookup_customer → observe id
# Iteration 2: list_open_orders → observe list
# Iteration 3: final natural-language answerAlways know why the loop ended.
from enum import Enum
class StopReason(str, Enum):
FINAL_ANSWER = "final_answer"
MAX_TURNS = "max_turns"
TOOL_ERROR_BUDGET = "tool_error_budget"
USER_CANCEL = "user_cancel"
def finish(reason: StopReason, content: str) -> dict:
return {"stop_reason": reason.value, "content": content}stop_reason to product UI and metricsMAX_TURNS as a first-class outcome, not an exception dumpdef safe_get_order(args: dict) -> dict:
if "order_id" not in args:
return {"error": "missing order_id"}
try:
oid = int(args["order_id"])
except (TypeError, ValueError):
return {"error": "order_id must be int"}
if oid <= 0:
return {"error": "order_id must be positive"}
return get_order(oid)def evaluate_run(user_goal: str, final_text: str, tool_calls: list[str]) -> dict:
return {
"answered": bool(final_text.strip()),
"used_tools": tool_calls,
"looks_grounded": "1042" in final_text and "shipped" in final_text.lower(),
}Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.
Reviewed by Chris St. John·Last updated Jul 16, 2026