Build a functional AI agent from scratch by wiring together an LLM, tools, a reasoning loop, and memory — then harden it with error handling and deploy…
Map the four components of an AI agent (LLM, tools, loop, memory) and see how they connect in a single diagram. You'll trace a concrete 'web-research agent' scenario from goal to final answer so every later module has a home in the picture.
Maps the four core components of an AI agent — LLM, tools, reasoning loop, and memory — and shows how they connect.
Why this matters: Every later module (LLM wiring, tool design, memory, multi-agent) is a deeper look at one of these four parts, so getting the picture right here pays off throughout the course.
Decision this forces: Single-agent vs. multi-agent: is one LLM + tools enough, or does the task need specialized sub-agents?
An is a language model in a loop. It reasons, calls a like search or database query, and reads the result.
That result — an — shapes the next decision. The cycle repeats until done. A plain returns one answer from training data. An agent fetches live data, runs code, and revises its plan mid-flight.
Use an agent when steps can't be fully scripted and the model must decide what to do next. Example: auditing competitor SEO pages with unknown structure.
Every agent has four parts that hand work to each other in order.
The loop feeds the LLM's chosen action to the right tool. It collects the observation and appends it to memory. Then it hands control back to the LLM.
Goal: produce an SEO brief for the query "best project management software 2025" by checking live SERPs and competitor page structures.
search(query) first.scrape(url) in a loop.finish(brief), ending the loop.Notice that no step was pre-scripted: the LLM chose how many URLs to scrape based on what it saw. That's the defining trait of an agent — and acting are interleaved, not fixed in advance.
import openai response = openai.chat.completions.create( model="gpt-4o", messages=[ {"role": "user", "content": "List the top 5 competitors for 'project management software 2025'"} ] ) print(response.choices[0].message.content)
This is the obvious first attempt — one LLM call, no tools, no loop. Predict what the output looks like before revealing.
Output: a confident-sounding list like "1. Asana 2. Monday.com 3. Trello…" — but it's drawn from training data, not today's SERPs. Rankings shift weekly; the model has no way to know the current top pages, their word counts, or their H1s. There's no error message — the failure is silent: plausible text, stale facts. This is a hallucination risk (the model may invent rankings) with no observable signal that anything went wrong.
def run_agent(goal: str, tools: dict, max_steps: int = 10): messages = [{"role": "user", "content": goal}] for _ in range(max_steps): response = llm_call(messages, tools=tools) # returns action or finish action = response.get("action") if action == "finish": return response["answer"] obs = tools[action["name"]](**action["args"]) # call the tool messages.append({"role": "tool", "content": str(obs)}) return "max_steps reached"
This adds the : the LLM picks an action, the loop runs the matching tool, the observation is appended to messages (short-term memory), and control returns to the LLM.
The max_steps guard prevents infinite loops — a real production concern.
It returns the string "max_steps reached" — the task is abandoned silently. In production you'd raise an exception or return a partial result with a flag, so the caller knows the agent didn't complete. This is the runaway-loop failure mode: without a hard cap, a buggy tool or ambiguous goal can spin forever and burn tokens.
long_term = load_vector_store("seo_facts") # persistent across sessions def run_agent_with_memory(goal: str, tools: dict): recalled = long_term.search(goal, top_k=3) # fetch relevant past facts messages = [ {"role": "system", "content": build_system_prompt(recalled)}, {"role": "user", "content": goal}, ] for _ in range(10): response = llm_call(messages, tools=tools) action = response.get("action") if action == "finish": long_term.upsert(goal, response["answer"]) # TODO: what goes here? return response["answer"] obs = tools[action["name"]](**action["args"]) messages.append({"role": "tool", "content": str(obs)})
This stage adds long-term via a : past SEO facts are recalled at the start and new findings are persisted at the end.
upsert store, and under what key?The line is already correct as written: long_term.upsert(goal, response["answer"]) — store the answer keyed by the goal string. Changed lines vs Stage 2: (1) recalled = long_term.search(...) added before the loop to seed context; (2) long_term.upsert(...) added inside the finish branch to persist the result. The key matters because the vector store uses it for similarity search — a vague key like 'result' would never be retrieved; the goal string is the natural semantic anchor for future queries on the same topic.
Three failure modes account for most production incidents — and two of them produce no error message.
finish because the goal is ambiguous or a tool keeps returning empty results. Observable: token bill spikes, response never arrives. Fix: enforce max_steps and a timeout; log each step.context_length_exceeded error, or the LLM silently truncates early messages and "forgets" the original goal. Fix: summarise or prune old observations before appending.TypeError: scrape() got unexpected keyword argument 'url_list'. Fix: validate the against your actual function signatures in CI — never trust a generated schema without running it.Wire an LLM into your agent shell using the OpenAI Chat Completions API (or a compatible endpoint): set the system prompt, enforce JSON-schema outputs, and handle retries. You'll complete a partially-written `call_llm()` function for the web-research agent.
How to wire an LLM into an agent loop using the OpenAI Chat Completions API — system prompt, JSON schema enforcement, and retry logic.
Why this matters: Every agent loop depends on a reliable LLM call; getting the wrapper right prevents parsing crashes, silent failures, and runaway API costs.
Decision this forces: Which model to use: GPT-4o vs. a smaller/cheaper model — tradeoff between reasoning quality and cost per loop iteration.
The four components are the , , the , and . This module zooms in on the LLM: how to wire it into the loop so it receives the right instructions and returns usable output.
Right now your agent shell can loop, but the LLM call is a stub. By the end of this module it will send structured requests, enforce JSON responses, and recover from rate-limit errors automatically.
Every agent needs a thin wrapper around the raw API call — a call_llm() function — that handles three responsibilities every time the loop ticks.
response_format so the model's reply is always parseable.Keeping these three concerns inside one wrapper means the rest of your agent code never touches raw HTTP or retry logic.
A good does three things: names the role, states the output contract, and forbids off-contract behaviour.
For the web-research agent, tell the model it is a research assistant that returns only a JSON object with "action" and "action_input" keys — nothing else. This single constraint eliminates free-text replies that would break your parser.
Avoid vague instructions like "be helpful". Prefer explicit negative constraints: "Do not add commentary outside the JSON object." The model follows explicit rules more reliably than implied ones.
import openai def call_llm(messages: list[dict]) -> str: response = openai.chat.completions.create( model="gpt-4o", messages=messages, ) return response.choices[0].message.content
This is the obvious first attempt — a raw API call with no system prompt and no schema. Run it and the model returns whatever format it feels like.
The model returns plain prose: "The top SEO tool is Ahrefs, known for its backlink analysis…" — no JSON keys, no structure. Your agent's parser calls response['action'] and raises KeyError: 'action', crashing the loop. There's no system prompt telling the model what role to play or what format to use.
SYSTEM_PROMPT = """ You are a web-research agent. Respond ONLY with a JSON object: {"action": "<tool_name or FINISH>", "action_input": "<query or answer>"} Do not add commentary outside the JSON object. """ def call_llm(messages: list[dict]) -> dict: full_messages = [{"role": "system", "content": SYSTEM_PROMPT}] + messages response = openai.chat.completions.create( model="gpt-4o", messages=full_messages, response_format={"type": "json_object"}, ) return json.loads(response.choices[0].message.content)
Two changes from Stage 1: the system prompt is prepended to every call, and response_format={"type": "json_object"} tells the API to enforce valid JSON output. The function now returns a parsed dict, not a raw string.
The model returns the string '{"action": "web_search", "action_input": "top SEO tool 2024"}'. json.loads() parses it into {'action': 'web_search', 'action_input': 'top SEO tool 2024'} — a dict your loop can route on immediately.
import time, openai, json MAX_RETRIES = 3 BASE_DELAY = 1.0 # seconds def call_llm(messages: list[dict]) -> dict: full_messages = [{"role": "system", "content": SYSTEM_PROMPT}] + messages for attempt in range(MAX_RETRIES): try: response = openai.chat.completions.create( model="gpt-4o", messages=full_messages, response_format={"type": "json_object"}, ) return json.loads(response.choices[0].message.content) except (openai.RateLimitError, openai.APITimeoutError) as e: if attempt == MAX_RETRIES - 1: raise time.sleep(BASE_DELAY * (2 ** attempt)) # TODO: fill in backoff
This is the completion rung — the retry skeleton is here, but the backoff expression on the last line is left for you to supply.
time.sleep() line has a TODO comment. What expression gives exponential backoff — doubling the wait each attempt, starting at BASE_DELAY? Hint 1: attempt 0 → 1 s, attempt 1 → 2 s, attempt 2 → 4 s. Hint 2: powers of 2.Changed line: time.sleep(BASE_DELAY * (2 ** attempt)) — attempt 0 sleeps 1 s, attempt 1 sleeps 2 s, attempt 2 sleeps 4 s. On the third consecutive error (attempt == MAX_RETRIES - 1), the condition is True so the except block calls raise, re-raising the RateLimitError to the caller instead of silently swallowing it.
| Option | Reasoning quality | Latency per call | JSON schema reliability | When to choose | Cost | Complexity |
|---|---|---|---|---|---|---|
| GPT-4o | Strongest available; handles complex, ambiguous tasks | ~1–3 s per call; noticeable in tight loops | Highly reliable with response_format; rarely produces malformed output | Use when the agent must plan multi-step research, handle ambiguous queries, or produce nuanced JSON — quality failures are expensive to recover from. | ~$5–15 / 1M tokens (input/output blended) | Drop-in; no extra prompting needed for schema compliance |
| GPT-4o-mini | Good for structured extraction; weaker on open-ended planning | ~0.3–1 s; fast enough for real-time loops | Reliable with explicit prompt constraints; occasional schema drift on complex outputs | Use for high-volume loops where the task is well-defined and the system prompt is tight — saves 10–20× on cost with acceptable quality for structured extraction. | ~$0.15–0.60 / 1M tokens | Same API; may need a stricter system prompt to stay on schema |
{"Action": ...} (capital A) or adds an extra key. Symptom: KeyError: 'action'. Fix: normalise keys with .lower() or validate against a Pydantic model.None and continues silently — producing a wrong answer with no error logged. Always re-raise after the last attempt.messages without trimming fills the . Symptom: openai.BadRequestError: maximum context length exceeded. Fix: truncate or summarise older messages before each call.response_format={"type": "json_object"} — not just rely on the prompt?None?RateLimitError and APITimeoutError — not a bare except Exception?With call_llm() returning a reliable dict, the next module gives the model something to act on. You'll define tools as JSON schemas and bind them to the tools parameter so the model can invoke a real web search.
Define tools as JSON schemas, implement their Python callables, and bind them to the LLM's `tools` parameter so the model can invoke them. You'll add a `web_search` tool and a `read_url` tool to the web-research agent, then complete the dispatcher that routes tool calls to the right function.
How to write JSON tool schemas, implement their Python callables, and wire them to the LLM so the model can invoke them.
Why this matters: Your agent is useless without tools it can actually call — this module gives it the web_search and read_url actions it needs to do real research.
Decision this forces: Tool granularity: one broad tool vs. several narrow ones — affects how precisely the model can target an action.
call_llm() function return — a plain text string, or something else? Write your answer, then reveal.Answer: call_llm() returns a — specifically a tool_calls object containing the model-chosen tool name and JSON arguments, not plain text.
That tool_calls object is useless until your code maps each name to a Python function. This module closes that gap: defining tool schemas and routing model choices to real code.
A is a JSON object telling the the tool's name, what it does, and its JSON Schema parameters.
The model reads the schema at inference time and decides whether to call the tool. Your Python callable stays hidden; only the schema is exposed.
OpenAI-compatible APIs expect each schema under a function key, wrapped with "type": "function". The description field is highest-leverage — vague descriptions cause misfire or skipped calls.
Every parameter needs a type and description. Required parameters go in the required array. Missing either causes silent errors or validation failures.
web_search_schema = {
"type": "function",
"function": {
"name": "web_search",
"description": "Run a web search and return the top result snippets.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "The search query."}
},
"required": ["query"]
}
}
}
# read_url schema follows the same pattern — see Stage 2This is the minimal valid OpenAI-compatible for web_search. The outer "type": "function" wrapper is required by the API; everything the model reads lives inside "function".
Notice the description is action-oriented and specific — "Run a web search and return the top result snippets" tells the model both what to call and what it gets back. Vague descriptions like "searches the web" are a leading cause of tool misfire.
The API accepts the call without error, but the model may omit the query argument entirely. Your Python callable then receives an empty or missing argument and either crashes with a KeyError/TypeError or silently runs a blank search — no warning from the model layer.
import json, httpx def web_search(query: str) -> str: # Replace with real search API call results = httpx.get("https://api.search.example/q", params={"q": query}) return json.dumps(results.json()["snippets"]) def read_url(url: str) -> str: response = httpx.get(url, timeout=10) return response.text[:4000] # trim to fit context window tools = [web_search_schema, read_url_schema] # schemas from Stage 1 response = client.chat.completions.create( model="gpt-4o", messages=messages, tools=tools, # <-- binding happens here tool_choice="auto" )
Binding is a single parameter: pass the schema list as tools=tools in the API call. The model now sees both tools on every turn and can choose either, both, or neither.
The Python callables (web_search, read_url) are never sent to the API — they live only in your process. tool_choice="auto" lets the model decide when to call a tool; set it to a specific tool name to force a call.
It contains a list of ToolCall objects, each with .function.name (e.g. "web_search") and .function.arguments (a JSON string of the chosen args). Your code must parse the arguments, look up the matching Python function, call it, and append the result back to messages as a tool role message — that's the dispatcher's job, built in Stage 3.
TOOL_REGISTRY = {
"web_search": web_search,
"read_url": read_url,
}
def dispatch(tool_call) -> dict:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
fn = TOOL_REGISTRY.get(name)
if fn is None:
raise ValueError(f"Unknown tool: {name}")
result = fn(**args) # TODO: call fn with args
return {"role": "tool", "tool_call_id": tool_call.id, "content": result}The is a registry dict that maps tool names to callables, plus the logic to look up, call, and package the result. The tool_call_id in the returned message is required — the API uses it to match results back to the right call.
Stop — attempt the TODO before revealing. The missing line calls fn with the parsed arguments. Hint 1: args is already a dict. Hint 2: use **args to unpack it as keyword arguments.
result = fn(args) — this unpacks the dict as keyword arguments so web_search(query="...") or read_url(url="...") is called correctly. Changed from the TODO: the assignment captures the return value so it can be placed in content. If you wrote fn(args) (no ), the function receives a dict as its first positional arg and raises a TypeError.
Your web-research agent now has two bound tools: web_search finds pages, read_url fetches content, and the dispatcher routes the model's choice to the right function.
But the agent calls a tool once and stops. Real research needs the model to read results, decide if it's enough, and keep going — that's the .
Consider "What are the top three SEO ranking factors in 2025?". The model calls web_search, gets snippet titles, then calls read_url on the best link, then decides it has enough. Each tool result is an appended to message history — the model reads the full history before deciding next.
Module 4 — Reasoning Loop and Decision-Making — wires tool calls into that multi-turn cycle: the model reasons, acts, observes, and decides whether to continue or return a final answer.
| Option | Model targeting precision | Schema complexity | Reusability across tasks | When to choose | Cost | Complexity |
|---|---|---|---|---|---|---|
| One broad tool | Model must infer intent from a single name; misfires on edge cases | Parameters multiply inside one schema; harder to validate | Hard to reuse one piece without dragging in the rest | When actions are tightly coupled and always used together; or when you want to minimize the number of round-trips. | Cheaper per call; fewer tool-call tokens | Low — one schema, one function |
| Several narrow tools | Model picks the exact right action; descriptions stay short and clear | Each schema is small and easy to validate independently | Each tool can be composed into different agents without modification | When actions are independently useful, have distinct inputs, or the model needs to choose between them precisely — e.g. web_search vs. read_url. | Slightly more tokens per turn for the schema list | Medium — one schema + callable per tool |
Three failure patterns cause most tool bugs:
"web_search" but registry key is "websearch". Result: ValueError: Unknown tool: web_search — easy to miss in review."required" and the model omits the argument. Your callable gets TypeError: missing 1 required positional argument: 'query' — or silently runs with empty string if you defaulted the param.read_url called with a search query as URL — no error, just garbage. Fix: start each description with a unique verb.When reviewing schemas, check: (1) schema name matches registry key exactly, (2) all required params are in "required", (3) each description starts with a unique verb, (4) dispatcher raises unknown tool names.
Implement the full ReAct loop: the model reasons, picks a tool, receives an observation, appends it to the message history, and decides whether to continue or return a final answer. You'll trace the web-research agent through two full iterations and then complete the loop's termination logic yourself.
Implements the full ReAct loop — reason, call a tool, read the observation, repeat — and adds a planning step and a step-limit guard.
Why this matters: The reasoning loop is the engine of your SEO/GEO agent: it's what lets the agent gather multiple data points (DA scores, rankings, citations) before writing a final answer, instead of guessing from one shot.
Decision this forces: Max iterations: how many loop turns to allow before forcing a stop — balancing task completion against runaway cost.
Answer: the receives the tool name and arguments from the model's response. It routes to the matching Python callable. It returns the result string.
That result is an — raw feedback the agent reads next. Module 3 produced one observation. This module wires it into a full loop that runs until done.
Your agent is stuck in a single-shot pattern: one tool call, one , then stop. But tasks often need two or three lookups. How do you make it keep going?
The pattern shapes the loop: the model Reasons (writes a thought), Acts (calls a tool), and reads the Observation before deciding to loop or stop.
Each iteration appends two messages: the model's tool-call message and the tool's result. The growing history is the agent's — earlier observations shape later decisions.
The loop exits when the model returns plain text with no tool call (final answer), or when a step limit is hit to prevent runaway cost.
Goal: "What is the current Domain Authority of openai.com and how does it compare to anthropic.com?" Watch the message history grow across two full iterations.
The model now has both data points. It returns a plain text answer — no tool call — so the loop exits and the final answer is returned to the caller. The 6-message history is discarded (or archived) after the run.
def run_agent(goal, tools, max_steps=10): messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": goal}] for step in range(max_steps): response = call_llm(messages, tools) if response.tool_calls is None: # final answer return response.content tool_msg, result_msg = dispatch(response.tool_calls[0], messages) messages += [tool_msg, result_msg] # append observation return "[Step limit reached]"
This skeleton captures the full contract in 9 lines: reason (call_llm), act + observe (dispatch), append, repeat.
The exit check is response.tool_calls is None — when the model stops calling tools it's signalling a final answer. The fallback "[Step limit reached]" is the safety net against infinite loops.
6 messages: system (1) + user goal (2) + assistant tool-call iter-1 (3) + tool result iter-1 (4) + assistant tool-call iter-2 (5) + tool result iter-2 (6). Each iteration appends exactly 2 messages.
def run_agent_with_plan(goal, tools, max_steps=10): plan = call_llm( [{"role": "system", "content": PLANNER_PROMPT}, {"role": "user", "content": goal}], tools=None # planning = no tools ).content # e.g. "1. DA openai.com 2. DA anthropic.com" messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"{goal}\n\nPlan:\n{plan}"}] for step in range(max_steps): # same loop as Stage 1 # … identical to run_agent() body … pass
A step calls the LLM once — with no tools — to decompose the goal into sub-tasks before the action loop starts.
The plan is injected into the user message so every iteration can see it. Planning helps most when the goal has 3+ independent sub-tasks; for simple 1–2 step goals it adds latency with no benefit.
With tools=None the model is forced to produce a plain-text plan rather than immediately calling a tool. If you pass the full tools list, the model may skip planning entirely and jump straight to a tool call — you'd lose the decomposition step.
Three failure patterns account for most production incidents with loops — each has a distinct symptom.
max_steps and log the last 3 tool calls — if they're near-identical, add a dedup check before dispatch."assistant" instead of "tool") so the model doesn't recognise it as external data. Fix: verify the role field on every result message.context_length_exceeded error. This is a silent design flaw — it only surfaces on long tasks. Module 5 covers the trimmer that prevents it.def run_agent(goal, tools, max_steps=10): messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": goal}] for step in range(max_steps): response = call_llm(messages, tools) # TODO: add the two-branch exit check here. # Branch 1 — final answer: return response.content # Branch 2 — tool call: dispatch, append both messages, continue tool_msg, result_msg = dispatch(response.tool_calls[0], messages) messages += [tool_msg, result_msg] return "[Step limit reached — increase max_steps or simplify the goal]"
This is the same loop from Stage 1, but the exit check is missing. Your task: fill in the TODO so the loop returns the final answer when the model stops calling tools, and continues otherwise.
The surrounding code is complete — only the two-branch check on lines 5–7 needs to be written. This is the crux of the loop: everything else is plumbing.
Changed lines (replace the TODO block):
if response.tool_calls is None: # ← NEW: final-answer branch
return response.content # ← NEW: exit the loop cleanly
The dispatch line below stays unchanged — it only runs when tool_calls is not None. These two lines are the entire termination logic: if the model emits no tool call, its content IS the answer. Everything else (the step limit fallback, the append) was already correct.
Distinguish in-context (short-term) memory from external vector-store retrieval (long-term), implement a sliding-window context trimmer to stay within token limits, and persist agent state across sessions. You'll extend the web-research agent to store and retrieve past search results using a simple key-value store.
Teaches two memory tiers for AI agents — in-context message history and external key-value or vector stores — plus a sliding-window trimmer and a session-persistent cache.
Why this matters: A stateful agent that can't manage its context window will crash mid-task; one that can't persist results wastes API calls and loses work between runs — both are blockers for a production-grade web-research agent.
Decision this forces: In-context vs. external memory: when the context window is enough vs. when you need a vector store or database.
— the raw text returned by a tool — gets appended to the message history list and re-sent to the LLM on the next call.
: the model's working memory for one session.
This module asks: what happens when that list grows too long, or when the agent restarts and the list is gone?
splits into two tiers: in-context (short-term) and external (long-term).
In-context memory is the message list you pass on every LLM call. It's fast and needs no setup. But it's bounded by token limits and wiped when the process exits.
(retrieves by semantic similarity, not exact key).
Core rule: use in-context memory for short sessions that fit the token budget. Use external memory for long sessions, persistent data, or high volume.
def run_agent(goal, tools): messages = [{"role": "system", "content": SYSTEM_PROMPT}] while True: messages.append({"role": "user", "content": goal}) response = call_llm(messages, tools) # grows unbounded obs = dispatch(response) messages.append({"role": "tool", "content": obs}) if is_final(response): return response
Every observation is appended with no trimming — the message list grows by at least two entries per loop turn.
After enough turns the total token count exceeds the model's limit and the API raises a context-length error.
The message list alone consumes ≈12 000 tokens (30 × 400), exceeding the 8 000-token limit. The API returns: 'openai.BadRequestError: This model's maximum context length is 8192 tokens. Your messages resulted in N tokens.' The agent crashes and loses all progress.
def trim_to_budget(messages, max_tokens=6000, reserve=500): budget = max_tokens - reserve system = [m for m in messages if m["role"] == "system"] # always keep rest = [m for m in messages if m["role"] != "system"] total = sum(len(m["content"]) // 4 for m in rest) # rough token est. while total > budget and len(rest) > 1: dropped = rest.pop(0) # drop oldest non-system total -= len(dropped["content"]) // 4 return system + rest
(which must never be dropped) from the rolling history, then evicts the oldest messages until the estimate fits the budget.
The reserve (500 tokens here) leaves headroom for the model's reply so you don't hit the ceiling mid-generation.
It always starts with the system message. Without it the model loses its identity, tool descriptions, and output-format rules — every subsequent call would behave as a raw chat completion with no agent instructions.
import json, pathlib STORE_PATH = pathlib.Path("search_cache.json") def load_store(): return json.loads(STORE_PATH.read_text()) if STORE_PATH.exists() else {} def save_result(query: str, result: str): store = load_store() store[query] = result STORE_PATH.write_text(json.dumps(store, indent=2)) def fetch_past_result(query: str) -> str | None: # TODO: load the store and return store.get(query) pass
This key-value store persists search results to disk so the agent can skip a live web call when it has already seen a query.
Complete fetch_past_result — the retrieval step that runs before each loop iteration.
store = load_store()
return store.get(query)
Changed lines vs. save_result: you call load_store() the same way, but instead of writing back you just return the looked-up value. The None return is the contract the loop uses to decide whether to call the web_search tool or skip it.
| Option | Persistence across sessions | Retrieval quality | When to choose | Cost | Complexity |
|---|---|---|---|---|---|
| In-context (message list) | None — wiped on restart | Exact — model sees everything in the window | Single-session tasks where all relevant history fits within the token budget — e.g. a short research run under ~10 tool calls. | Zero infrastructure; pay only for tokens sent per call. | None — it's the list you already have. |
| Key-value store (e.g. Redis, dict file) | Yes — survives restarts | Exact key match only | Structured facts that need exact lookup across sessions — e.g. caching search results by query string. | Low — a local dict or lightweight DB. | Low — get/set by key; no embedding step. |
| Vector store (e.g. FAISS, Chroma) | Yes — durable index | Semantic similarity — finds 'close' results | Large, unstructured history where you need semantic search — e.g. retrieving past research snippets relevant to a new query. | Moderate — embedding calls + store infra. | Medium — embed, index, and query pipeline. |
Three failure patterns appear most often when adding memory to a real agent.
len(text) // 4 is rough; code, JSON, and non-ASCII tokenize differently. Use the model provider's tokenizer (e.g. tiktoken) for exact counts.Your agent now trims context to stay within budget and caches results across sessions — two properties a production agent needs.
But longer-running, stateful agents have more surface area for failure: infinite loops, malformed tool outputs, and unsafe final answers.
step so the agent catches its own mistakes before returning. You'll solo-build a test suite to verify all of it.
Add loop-limit guards, tool-call validation, output sanitization, and a reflection step so the agent can self-correct before returning. You'll solo-build a test harness for the web-research agent — writing three adversarial test cases, verifying AI-generated tool calls against a schema, and documenting the three most common failure modes.
How to add loop guards, tool-call validation, output sanitization, and a reflection step — plus how to write adversarial tests that prove each guard works.
Why this matters: A production agent without these guards will loop indefinitely, hallucinate tool calls, or return unsafe output — all of which break the SEO/GEO page you're building and erode user trust.
Decision this forces: Human-in-the-loop vs. fully autonomous: which actions require a confirmation step before execution?
Answer: the model reasons about the goal, picks a , and reads the . It repeats until it emits a final answer or a stop condition fires.
Module 5 added memory so the agent can persist state across sessions. This final module asks: what happens when the loop goes wrong — and how do you catch it before it reaches the user?
Production agents fail in three recurring patterns, each with a distinct symptom you can observe and a concrete mitigation.
KeyError or silent wrong result when the tries to route the call. Mitigation: validate every tool call against the schema before executing.MAX_ITER = 10 ALLOWED_TOOLS = {"web_search", "read_url"} # from your schema def validate_tool_call(call: dict) -> None: if call["name"] not in ALLOWED_TOOLS: raise ValueError(f"Unknown tool: {call['name']}") if "arguments" not in call: raise ValueError("Tool call missing 'arguments'") def run_agent(goal: str) -> str: history, iterations = [], 0 while True: if iterations >= MAX_ITER: raise RuntimeError("Max iterations reached — possible loop") response = call_llm(history) # returns tool_call or final answer if response.get("final_answer"): return response["final_answer"] validate_tool_call(response["tool_call"]) # … execute tool, append observation …
Two guards fire before any tool executes: the iteration counter catches infinite loops, and validate_tool_call rejects tool names before the ever sees them.
Notice that ALLOWED_TOOLS is derived directly from your — if you add a tool in module 3's schema, add it here too, or the validator will reject valid calls.
It raises ValueError: "Unknown tool: summarize" inside validate_tool_call() — the tool never executes and the loop exits immediately with an exception, not a final answer.
def reflect(history: list, failed_tool: str, error: str) -> str: """Ask the LLM to diagnose a failed sub-task and propose a fix.""" history.append({"role": "user", "content": ( f"Tool '{failed_tool}' failed with: {error}.\n" "Diagnose the problem and decide: retry with different args, " "use a different tool, or return a partial answer." )}) return call_llm(history)["final_answer"] # model self-corrects # In run_agent(), replace bare tool execution with: try: result = dispatch(response["tool_call"]) except Exception as e: return reflect(history, response["tool_call"]["name"], str(e))
The step re-enters the with the error as context, letting the model choose to retry, pivot, or gracefully degrade — instead of crashing or silently returning nothing.
This is a single-call reflection (not a separate planning pass), which keeps latency low while still giving the agent one recovery attempt per failure.
reflect() would raise an unhandled exception and crash the agent. Fix: wrap the call_llm() inside reflect() in a try/except that returns a safe fallback string (e.g. "Agent could not complete the task.") — never let the reflection path itself recurse.
# Three adversarial test cases for the web-research agent import pytest def test_hallucinated_tool_call(monkeypatch): monkeypatch.setattr("agent.call_llm", lambda h: { "tool_call": {"name": "nonexistent_tool", "arguments": {}} }) with pytest.raises(ValueError, match="Unknown tool"): run_agent("Find the capital of France") def test_infinite_loop_guard(monkeypatch): monkeypatch.setattr("agent.call_llm", lambda h: { "tool_call": {"name": "web_search", "arguments": {"query": "loop"}} }) # TODO: assert that run_agent raises RuntimeError after MAX_ITER steps # Hint 1: dispatch() must return a dummy observation so the loop keeps running. # Hint 2: check the exception message contains "Max iterations". def test_output_sanitization(monkeypatch): monkeypatch.setattr("agent.call_llm", lambda h: { "final_answer": "<script>alert('xss')</script>" }) result = run_agent("Summarize AI trends") assert "<script>" not in result # output guardrail must strip tags
This harness covers all three failure modes: a hallucinated tool name, an infinite loop, and toxic output. The second test is your completion rung — the assertion is missing.
Stop — attempt the TODO before revealing. The changed lines are only inside test_infinite_loop_guard; the rest of the harness stays as shown.
monkeypatch.setattr("agent.dispatch", lambda c: {"output": "ok"})
with pytest.raises(RuntimeError, match="Max iterations"):
run_agent("loop forever")
Changed lines vs. the worked examples: dispatch is now also monkeypatched (so the loop keeps cycling), and the assertion mirrors test_hallucinated_tool_call's pattern but targets RuntimeError — exercising the iteration-cap guard, not the schema validator.
Your web-research now has a complete safety shell: a loop-limit , a schema validator that catches tool calls, a step for self-correction, output sanitization, and three adversarial tests that prove each guard fires.
Across all six modules you've wired the LLM, defined tools, built the loop, added memory, and hardened the agent against production failures.
The solo capstone challenge asks you to assemble all these layers into one deployable agent. Document the three failure modes and their mitigations in a short design note your team can use.
| Option | Reversibility of action | Blast radius if wrong | When to choose | Cost | Complexity |
|---|---|---|---|---|---|
| Fully Autonomous | Action can be undone or is read-only | Mistake affects only this session | Read-only or easily reversible actions: web search, summarization, draft generation. | Minimal latency overhead. | Low — no confirmation step needed. |
| Human-in-the-Loop | Action cannot be undone (send, delete, purchase) | Mistake affects external systems or other users | Irreversible or high-impact actions: sending emails, writing to a database, making purchases, deleting records. | Adds latency; may block the loop until a human responds. | Medium — requires a confirmation UI or approval queue. |
When an LLM generates your guard or test code, check these four things before trusting it.
reflect(), creating a second crash path.Before reading the summary: from memory, list the six build steps in order, name the data structure that flows between the loop and the tools, and state the two most important guardrails you'd add before deploying. Then check your answer against the buildOrder below.
Apply what you learned to SEO/GEO page — the page title is the H1 and the exact search query. OPEN with a 40-60 word self-contained, quotable answer capsule that names the entity explicitly in sentence one (no "in this lesson…" preamble) — this is the sentence an LLM lifts. Name the entity by its full name every time (not pronoun-only). Structure each section as ONE sub-question (How it works · X vs Y · When to use it · Example · Common mistakes), each opening with its own 1-2 sentence direct answer then depth. End with a genuine 3-5 question FAQ phrased as real user questions ending in "?" (these become FAQPage schema + the knowledge check). Prefer quotable specifics — concrete numbers, defaults, version names, one short snippet — over vague prose. Self-contained for a reader who arrived cold from a search engine or an LLM. Intent = tutorial: numbered steps + one short runnable code snippet + a common-mistakes section. Target query: "building ai agents"..
An AI agent has four core components. Which of the following correctly names all four?
A. LLM, tools, memory, reasoning loop
B. LLM, prompt, API, database
C. planner, executor, logger, UI
D. model, tokenizer, embedder, retriever
The four core components taught in Agent Architecture are: the LLM (the decision-maker), tools (callable actions), memory (state across turns), and the reasoning loop (the control flow that ties them together). Option B mixes infrastructure details with components. Option C describes a pipeline pattern, not the agent component model. Option D lists ML pipeline stages, not agent architecture pieces.
You are choosing a model for an agent that runs 20-30 loop iterations per user request and does mostly keyword extraction and JSON formatting — no complex multi-step reasoning. What is the best choice?
A. GPT-4o, because agents always need maximum reasoning quality
B. A smaller, cheaper model, because the task does not require deep reasoning and cost compounds across iterations
C. GPT-4o, because structured JSON output is only reliable on the largest models
D. A smaller model only if the context window is under 4 k tokens
The LLM Integration module's core decision is: use a smaller/cheaper model when reasoning demands are low, because cost is paid on every loop iteration — 30 iterations at GPT-4o pricing is significantly more expensive than 30 iterations on a smaller model. Option A ignores cost compounding. Option C is false — modern smaller models (e.g., GPT-4o-mini) reliably produce structured JSON with a well-crafted system prompt. Option D ties the decision to context-window size, which is unrelated to this tradeoff.
Read this tool dispatcher snippet:
result = TOOLS[tool_name](**tool_args)
return result
The model returns tool_name = "web_searche" (a typo). What happens, and what is the correct mitigation?
A. Python silently returns None; fix by adding a default fallback tool
B. Python raises a KeyError; fix by validating tool_name against TOOLS keys before dispatching
C. Python calls the closest matching key automatically; no fix needed
D. Python raises a TypeError; fix by wrapping tool_args in a list
Accessing a dict with a key that does not exist raises a KeyError in Python — it does not return None or fuzzy-match. The Tool Definition module teaches that a tool dispatcher must validate the model-chosen tool name against the registered keys before calling, and reject or surface an error for unknown names. Option A describes dict.get() behavior, not bracket access. Option C is false — Python dicts have no fuzzy matching. Option D confuses a missing-key error with an argument-type error.
Your agent is summarizing a 6-hour support chat transcript. After 15 loop iterations the context window is nearly full and the system prompt is about to be evicted. Which memory strategy should you apply?
A. Increase max_iterations so the loop has more room to finish
B. Switch to external memory — store past summaries in a vector store and retrieve only the relevant chunks before each iteration
C. Use in-context memory; just truncate the oldest user messages to make room
D. Reduce the system prompt length so more chat history fits
The Memory module's decision rule is: use external memory (vector store or database) when data volume exceeds what the context window can hold reliably. A 6-hour transcript is a high-volume, long-duration task — exactly the external-memory case. Option A does not solve the window-size problem; more iterations make it worse. Option C is what the context-window trimmer does, but the module teaches that the system prompt must never be evicted — truncating user messages alone is insufficient for this volume. Option D is a band-aid that does not scale.
Name the top three production failure modes for AI agents and state one concrete mitigation for each.
The Error Handling module lists hallucinated tool calls, infinite loops, and malformed/unsafe tool outputs as the top three failure modes. Each has a paired mitigation: schema validation, a max-iterations guard, and a tool-output validator plus human-in-the-loop for dangerous actions. Partial credit if the learner names the failure mode correctly but gives a vague mitigation.