What is an AI agent?
An AI agent is not just an LLM that returns text. It is a system that uses a model to decide what to do next, call tools, inspect the result, and continue until it reaches a stopping condition.
A useful agent usually has a few parts:
- Model: interprets the user request and decides the next step.
- Tools: APIs, databases, search, calculators, code execution, or MCP servers that let the model act outside its own context window.
- State: messages, intermediate results, retrieved documents, tool outputs, and checkpoints.
- Control flow: rules for when to call tools, retry, stop, escalate, or ask the user for clarification.
- Guardrails: permission checks, validation, rate limits, allowlists, and tests around tool behavior.
RAG is one common pattern inside an agent, but it does not make the system correct by itself. Retrieval can provide better context, while the model can still misread the context, call the wrong tool, or produce an answer that does not match the user's intent. That is why agent design usually combines tool use with validation and feedback loops.
The ReAct idea
ReAct means reasoning + acting. The original ReAct paper describes a loop where the model interleaves reasoning traces with task-specific actions. In practice, modern tool-calling frameworks often hide the raw "Thought / Action / Observation" text, but the shape is still the same:
- Reason about what information or action is needed next.
- Act by calling a tool.
- Observe the tool result.
- Update the plan and either call another tool or produce a final answer.
This is useful when the answer depends on external state: a database query, a search result, a file, a ticket, an API response, or another service. ReAct does not guarantee correctness. It gives the model a structured way to gather information and revise its next step, but the surrounding application still needs limits and verification.
A small LangChain example
The example below builds a SQLite analyst agent. It uses:
- a dynamic system prompt, so employees and non-employees get different database access instructions;
- runtime context, so the database connection and user role are passed into each run instead of stored globally;
- a tool-level SQL guard, so read-only behavior is enforced by code;
- an in-memory checkpointer, so a demo conversation can preserve thread state while the process is running.
from dataclasses import dataclass
import re
from langchain.agents import create_agent
from langchain.agents.middleware import ModelRequest, dynamic_prompt
from langchain.tools import ToolRuntime, tool
from langchain_community.utilities import SQLDatabase
from langgraph.checkpoint.memory import InMemorySaver
SYSTEM_PROMPT_TEMPLATE = """You are a careful SQLite analyst.
Rules:
- Use the execute_sql tool when the answer requires database data.
- Submit exactly one read-only SELECT statement per tool call.
- Prefer explicit column names instead of SELECT *.
- Limit exploratory queries to 5 rows unless the user asks for more.
{table_limits}
- If the tool returns an error, revise the query and try again.
- In the final answer, summarize the result clearly and mention important assumptions.
"""
@dataclass
class RuntimeContext:
is_employee: bool
db: SQLDatabase
READ_ONLY_QUERY = re.compile(r"^\s*select\b", re.IGNORECASE)
FORBIDDEN_SQL = re.compile(
r"\b(insert|update|delete|alter|drop|create|replace|truncate|attach|pragma)\b",
re.IGNORECASE,
)
@tool
def execute_sql(query: str, runtime: ToolRuntime[RuntimeContext]) -> str:
"""Run one read-only SQLite SELECT query and return the result."""
normalized_query = query.strip().rstrip(";")
if ";" in normalized_query:
return "Error: submit exactly one SELECT statement."
if not READ_ONLY_QUERY.match(normalized_query) or FORBIDDEN_SQL.search(normalized_query):
return "Error: only read-only SELECT statements are allowed."
if " limit " not in normalized_query.lower():
normalized_query = f"{normalized_query} LIMIT 5"
try:
return runtime.context.db.run(normalized_query)
except Exception as exc:
return f"Error: {exc}"
@dynamic_prompt
def dynamic_system_prompt(request: ModelRequest) -> str:
if request.runtime.context.is_employee:
table_limits = ""
else:
table_limits = (
"- Limit access to these tables only: "
"Album, Artist, Genre, Playlist, PlaylistTrack, Track."
)
return SYSTEM_PROMPT_TEMPLATE.format(table_limits=table_limits)
agent = create_agent(
model="openai:gpt-5",
tools=[execute_sql],
middleware=[dynamic_system_prompt],
context_schema=RuntimeContext,
checkpointer=InMemorySaver(),
)
To invoke the agent, pass runtime context and a thread ID:
db = SQLDatabase.from_uri("sqlite:///chinook.db")
result = agent.invoke(
{"messages": [{"role": "user", "content": "Which artists have the most tracks?"}]},
context=RuntimeContext(is_employee=False, db=db),
config={"configurable": {"thread_id": "demo-thread"}},
)
print(result["messages"][-1].content)