Build a working AI agent from the raw loop: define tools, let the model choose one, execute it in Python, append the observation, and add the guardrails that keep agents reliable.
A repo assistant has access to file search, test logs, issue history, and a command runner. You give it a goal; it chooses the next tool; your code executes that tool; and the result becomes context for the next decision. That's the core of an AI agent.
Frameworks make this comfortable. Start with the raw loop so LangGraph, CrewAI, OpenAI tool calling, and MCP-style tool servers are easier to reason about later.[1][2] The important lesson is simple: the model chooses; your runtime acts.
Strip away framework names and an agent has three parts:
Compared with a single LLM call, an agent loop can iterate through observations, call tools through your runtime, retry or switch tools after errors, and carry message plus tool history across steps.
The LLM doesn't touch files, databases, or APIs by itself. It emits structured intent. Your Python code validates that intent, runs allowed tools, and appends the result back into the conversation.
Use this loop only when it earns the complexity. Anthropic's agent guidance starts with the simplest effective system: deterministic workflows beat open-ended agents when the path is already known.[3] Reach for an agent when the task genuinely requires deciding the next step from new evidence.
馃挕 Key insight: An agent is a runtime contract, not a smarter prompt. The model chooses intent; your code owns validation, execution, memory, limits, and audit logs.
Suppose a developer asks, "Can this retry PR break duplicate-execution safety?"
The agent has three tools:
read_file(path) reads a repository filesearch_repo(query) finds call sites and testsrun_tests(target) runs an approved test targetA useful trajectory might look like this:
read_file({"path": "retry_backoff.py"}).retry_backoff submit.tests/test_retry_backoff.py.The model chose the next steps and wrote the answer. File reads, search, and test execution stayed in the runtime.
The smallest useful implementation needs a tool schema, a registry of Python functions, and a loop that preserves every model output item before appending the matching tool result. That ordering matters because a function_call_output must carry the call_id of the request that produced it. The Responses API also returns reasoning and message items that belong to the next turn, so don't rebuild history from visible text alone.[1]
This example defaults to GPT-5.6 Luna for a small, cost-sensitive tool call. Set OPENAI_MODEL to a pinned model ID that fits your workload and evals instead of scattering model names through application code.[4]
1import json
2import os
3from openai import OpenAI
4
5client = OpenAI()
6MODEL = os.getenv("OPENAI_MODEL", "gpt-5.6-luna")
7
8tools = [{
9 "type": "function",
10 "name": "estimate_test_runtime",
11 "description": "Estimate total runtime for a focused test target.",
12 "strict": True,
13 "parameters": {
14 "type": "object",
15 "properties": {
16 "test_count": {"type": "integer"},
17 "average_ms": {"type": "number"},
18 },
19 "required": ["test_count", "average_ms"],
20 "additionalProperties": False,
21 },
22}]
23
24def estimate_test_runtime(test_count: int, average_ms: float) -> str:
25 seconds = test_count * average_ms / 1000
26 return f"{seconds:.1f}s"
27
28TOOL_REGISTRY = {
29 "estimate_test_runtime": estimate_test_runtime,
30}
31
32def run_agent(user_message: str, max_steps: int = 6) -> str:
33 history = [
34 {"role": "user", "content": user_message},
35 ]
36
37 for _ in range(max_steps):
38 response = client.responses.create(
39 model=MODEL,
40 instructions="Use tools when exact calculation helps.",
41 input=history,
42 tools=tools,
43 )
44 history.extend(response.output)
45 calls = [item for item in response.output if item.type == "function_call"]
46
47 if not calls:
48 return response.output_text
49
50 for call in calls:
51 name = call.name
52 args = json.loads(call.arguments)
53 if name not in TOOL_REGISTRY:
54 result = f"Error: unknown tool {name}"
55 else:
56 result = TOOL_REGISTRY[name](**args)
57
58 history.append({
59 "type": "function_call_output",
60 "call_id": call.call_id,
61 "output": result,
62 })
63
64 return "Stopped after step limit."
65
66print(run_agent("How long will 42 tests take at 31ms each?"))The exact wording depends on the model, but the final answer should include roughly 1.3s. This is already an agent: the model chooses a tool, the runtime executes it, and the observation feeds the next model call. Keeping response.output in history preserves the provider's function-call, reasoning, and message items in their original order.
Strict tool schemas help because tool inputs are model-generated.[1] Treat them as untrusted until the schema, parser, allowlist, sandbox, or approval gate says otherwise.
Adding repo tools is less about clever prompting and more about safe interfaces. Good tools are narrow, typed, bounded, and explicit about when to use them.
Common repo tools should stay narrow:
read_file(path): reject paths outside the repo and truncate long files.search_repo(query): use fixed-string rg, a timeout, and an output cap.run_tests(target): allowlist test paths and return only the useful tail.The tool description is routing policy. "Search repository text" is weaker than "Use this when you don't know which file contains a symbol or call site." The model needs to know what the tool does and when it should pick it.
This loop matches the outer shape popularized by ReAct (Reasoning + Acting): model decision, action, observation, then another decision.[5] Modern APIs often hide or compress the reasoning trace, but the control loop is still visible.
Each iteration has three phases:
| Pattern | Control shape | Best use | Main risk |
|---|---|---|---|
| Deterministic workflow | Fixed steps, optional model calls | Known paths such as classify, retrieve, draft, check | Brittle when evidence changes the next step |
| ReAct loop | Model picks each next action from observations | Search, inspect, test, explain, and recover tasks | Loops, wrong tools, and growing context |
| Plan-and-execute | Planner creates steps, workers execute them | Longer tasks with separable subtasks | Stale plans after tool results |
| Graph workflow | Explicit nodes and transitions | Production flows with review, retry, and approval gates | More code and state-machine upkeep |
For architecture variants, the LeetLLM lesson on ReAct, Plan-and-Execute, and other agentic architectures covers when to move from this loop to a planner or graph.
Once you leave calculator demos, the same failures show up quickly.
Early failures have predictable controls:
These controls turn an open-ended loop into a bounded runtime. The code isn't glamorous: check the tool name, parse arguments, cap the loop, cap output length, and return errors as observations instead of throwing raw exceptions into the process.
An agent has short-term state by default: the messages list. That's enough for one task, but long-running products need separate memory layers.
Keep current task context in the messages array, knowing token cost grows every turn. Retrieval memory is better for fuzzy recall across docs or past sessions, not exact truth. Put permissions, workflow state, deployed revisions, and anything that needs transactions in a database or key-value store.
Don't use a vector database as the source of truth for exact state. Retrieval is for fuzzy recall. Exact state belongs in an authoritative system exposed through tools.
The raw loop is only the middle of a production agent. Reliability comes from controls around it.
Production controls wrap the loop:
A thread timeout can keep your loop moving, but it can't reliably kill arbitrary stuck Python code. For untrusted or side-effectful tools, run the tool in a separate process or sandbox you can terminate.
Final-answer checks aren't enough for agents. Two trajectories can produce the same answer, and one may be much riskier. As a safety and system-design recommendation, evaluate whether the agent chose an allowed tool, stopped at the right time, recovered from errors, and used evidence correctly. This recommendation isn't a conclusion established by final-answer benchmarks such as GAIA or SWE-bench, and an LLM judge needs its own calibration before it can grade any part of the run.
鈿狅笍 Common mistake: Evaluating only the final answer. A risky trajectory can get lucky once, so review tool sequence, retries, approvals, and stop reason beside answer quality.
Start with one or two tools and watch the trace. Add more tools only when the task needs them, put approvals in front of side effects, keep exact state outside retrieval memory, and stop repeated loops before the model decides to stop.
The single-agent ReAct loop is the smallest useful version. More structured systems, such as Plan-and-Execute, multi-agent orchestration, DAG workflows, and MCP, arrange the same pieces into stricter control flows for larger workloads.[7][2]
Next, ReAct and Plan-and-Execute architectures shows when to switch from a simple loop to a planner, Function Calling and Tool Use digs into schemas and argument shapes, and Agent Failure States and Recovery catalogs recovery patterns.
Function calling
OpenAI 路 2026 路 OpenAI API Docs
Introducing the Model Context Protocol
Anthropic 路 2024
Building Effective Agents
Anthropic 路 2024
GPT-5.6 Luna Model
OpenAI 路 2026
ReAct: Synergizing Reasoning and Acting in Language Models.
Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K., & Cao, Y. 路 2022 路 ICLR 2023
Structured outputs
OpenAI 路 2024
The Landscape of Emerging AI Agent Architectures for Reasoning, Planning, and Tool Calling.
Masterman, T., et al. 路 2024 路 arXiv preprint