What Makes It Agentic
| Pattern | Who decides the next step | Example |
|---|---|---|
| Single call | You, entirely — one prompt in, one response out | Topic 2's chat completion |
| Fixed chain | You, at design time — a hardcoded sequence of calls | Topic 7's retrieve-then-generate pipeline |
| Tool-calling loop | The model, per turn, but bounded to a known tool set | Topic 5's weather/calculator loop |
| Agent | The model, across many turns, toward an open-ended goal | "Research X and summarize the findings" — the number and order of steps isn't known in advance |
The ReAct Pattern
ReAct (Reason + Act) is the foundational agent pattern: at each step, the model explicitly reasons about what it knows and what it needs, then takes one action, observes the result, and repeats. Making the reasoning explicit — not just the action — is what makes the process inspectable and debuggable.
I need next?
a tool
read the result
a final answer
system_prompt = """You are a research assistant. For each step, think about what you need, take one action using an available tool, then observe the result before deciding the next step. When you have enough information, give a final answer instead of calling another tool."""
Building the Loop
This is Topic 5 Section 1's loop again, with one change: the stopping condition is no longer "the model didn't call a tool" alone — it's that, or a guardrail from Section 5 kicking in.
def run_agent(goal: str, tools: dict, tool_schemas: list[dict],
max_steps: int = 8) -> str:
client = OpenAI()
messages = [
{"role": "system", "content": "Work toward the goal step by step, "
"using tools as needed. Stop and answer "
"once you have enough information."},
{"role": "user", "content": goal},
]
for step in range(max_steps):
response = client.chat.completions.create(
model="gpt-4.1-mini", messages=messages, tools=tool_schemas,
)
message = response.choices[0].message
messages.append(message)
if not message.tool_calls:
return message.content # the model decided it's done
for call in message.tool_calls:
args = json.loads(call.function.arguments)
result = tools[call.function.name](**args)
messages.append({"role": "tool", "tool_call_id": call.id,
"content": json.dumps(result)})
return "Reached the step limit without a final answer."
Memory: Short-Term vs Long-Term
| Kind | What it is | Where it lives |
|---|---|---|
| Short-term (working) | The current run's messages list — everything said and done so far in this task | In memory, discarded when the run ends, bounded by the context window (Topic 2 Section 4) |
| Long-term | Facts, past interactions, or documents that should persist across runs | A vector store (Topic 6/12) the agent retrieves from as one of its tools |
search_past_notes(query) tool that retrieves relevant history and hands it back as an observation, exactly like any other tool result.Planning
For genuinely multi-step goals, letting the model plan explicitly before acting — write out the steps, then execute them one at a time — improves reliability over pure step-by-step improvisation, for the same reason Topic 3's chain-of-thought section improved single-call reasoning.
PLANNING_PROMPT = """Before taking any action, write a short numbered plan for how you'll achieve the goal. Then execute it one step at a time, revising the plan if a step's result changes what's needed."""
Stopping Conditions & Guardrails
An agent with no limits is a while(true) with an API bill attached. Every production agent needs explicit, code-enforced limits — never rely on the model to decide to stop on its own.
| Guardrail | Protects against |
|---|---|
Max steps (Section 2's max_steps) | Infinite tool-call loops |
| Cost cap (sum token usage per run, per Topic 2 Section 5) | A single runaway task burning an unbounded budget |
| Timeout (wall-clock, not just step count) | Slow tool calls stalling the whole run |
| Tool allowlist per task (Topic 5 Section 6) | An agent reaching for a destructive tool it doesn't need for this goal |
| Repetition detection (same tool + same args twice in a row) | The model stuck retrying an approach that isn't working |
When Not to Use an Agent
Capstone: A Small Research Agent
An agent with two tools — a fake web search and Topic 6's semantic search over a small knowledge base — that has to combine both to answer a question neither tool alone can fully answer.
import json
from openai import OpenAI
def fake_web_search(query: str) -> dict:
fake_results = {
"pgvector release date": "pgvector was first released in 2021.",
}
match = next((v for k, v in fake_results.items() if k in query.lower()), None)
return {"result": match or "No web results found."}
def search_notes(query: str) -> dict:
index = TinySearchIndex([ # from Topic 6
"Our team adopted pgvector for the RAG pipeline in March 2024.",
"The support team uses a separate ticketing system, not covered here.",
])
results = index.search(query, top_k=1)
return {"result": results[0][0] if results else "No notes found."}
TOOLS = {"web_search": fake_web_search, "search_notes": search_notes}
TOOL_SCHEMAS = [
{"type": "function", "function": {"name": "web_search",
"description": "Search the public web for general facts.",
"parameters": {"type": "object", "properties":
{"query": {"type": "string"}}, "required": ["query"]}}},
{"type": "function", "function": {"name": "search_notes",
"description": "Search our team's internal notes.",
"parameters": {"type": "object", "properties":
{"query": {"type": "string"}}, "required": ["query"]}}},
]
def run_agent(goal: str, max_steps: int = 6) -> str:
client = OpenAI()
messages = [
{"role": "system", "content": "Answer using both web_search and "
"search_notes as needed. Combine facts "
"from both if the question needs it."},
{"role": "user", "content": goal},
]
for _ in range(max_steps):
response = client.chat.completions.create(
model="gpt-4.1-mini", messages=messages, tools=TOOL_SCHEMAS,
)
message = response.choices[0].message
messages.append(message)
if not message.tool_calls:
return message.content
for call in message.tool_calls:
args = json.loads(call.function.arguments)
result = TOOLS[call.function.name](**args)
messages.append({"role": "tool", "tool_call_id": call.id,
"content": json.dumps(result)})
return "Reached the step limit without a final answer."
if __name__ == "__main__":
print(run_agent(
"How long had pgvector existed before our team started using it?"
))
web_search for the release date, search_notes for the adoption date, and combines both into a final answer ("about 3 years") — a fact neither tool alone contains.max_steps=1 and confirmed it hits the guardrail message instead of a partial or hallucinated answer