AI Tutorial
Home All Courses Blog Interview Prep ↗ 0%
1
Curious builder 0 XP earned · 300 to level 2
0 days Finish a lesson to begin
Badge collection
0 of 5 unlocked
small wins to finish your path
Next lesson →

Project: a research agent

In this project you will build research_agent.py — an agent that takes a question, searches a document collection, reads the most relevant pages, and answers with citations, all under a step budget. It uses the patterns from every lesson: tool definitions, a guarded agent loop, an approval gate for untrusted sources, citation checking, and traces. It runs offline with a deterministic planner, and switches to a real tool-calling model with one environment variable.

Last updated: 01 Nov, 2026

What you are building

RequirementImplemented by
Tools: search, read, finishsearch(), read(), and a final answer with citations
Step budgetMAX_STEPS = 6 in the agent loop
Only trusted sources without approvalALLOWED_DOMAINS plus an approval callback for anything else
Every claim citedcheck_citations() rejects answers that cite pages the agent never read
Trace of every runOne JSON line per run in traces.jsonl
Offline and live modesA deterministic planner, or a real model when AGENT_USE_LLM=1

The code

The whole agent is one file. The document collection is a dictionary so you can run it anywhere; replace search() with the retriever from the RAG course or a web search API later.

research_agent.py
"""research_agent.py - answer questions from documents, with citations."""
from __future__ import annotations

import json
import os
import re
import sys
import time
from urllib.parse import urlparse

MAX_STEPS = 6
ALLOWED_DOMAINS = {"docs.example.com", "blog.example.com"}

PAGES = {
    "https://docs.example.com/rag/hybrid-search": (
        "Hybrid search",
        "Hybrid search runs keyword search and vector search for the same query and merges the "
        "two result lists. Reciprocal rank fusion is a common way to merge them. Keyword search "
        "finds exact terms such as error codes; vector search finds paraphrases."),
    "https://docs.example.com/rag/reranking": (
        "Reranking",
        "A reranker is a cross-encoder that scores each query and passage pair together. It "
        "re-orders the top results from hybrid search and improves precision."),
    "https://blog.example.com/agents/stop-conditions": (
        "Agent stop conditions",
        "Agents need a maximum number of steps, repeated-action detection, and a cost budget."),
    "https://random-forum.test/hybrid": (
        "Forum post",
        "Hybrid search is when you use two databases at once. Trust me."),
}
STOPWORDS = {"what", "is", "a", "an", "the", "in", "of", "and", "how", "do", "does", "to", "for"}


# ---------- tools ----------
def terms(text: str) -> set[str]:
    """Lowercase words without stopwords, crudely stemmed so merge/merges/merged match."""
    out = set()
    for w in re.findall(r"[a-z0-9-]+", text.lower()):
        if w in STOPWORDS:
            continue
        for suffix in ("es", "ed", "s"):
            if w.endswith(suffix) and len(w) > len(suffix) + 3:
                w = w[: -len(suffix)]
                break
        out.add(w[:-1] if w.endswith("e") and len(w) > 4 else w)
    return out


def search(query: str, k: int = 3) -> list[dict]:
    q = terms(query)
    scored = []
    for url, (title, body) in PAGES.items():
        score = 2 * len(q & terms(title)) + len(q & terms(body))      # title matches count double
        if score:
            scored.append({"url": url, "title": title, "score": score})
    return sorted(scored, key=lambda r: -r["score"])[:k]


def read(url: str, approve) -> str:
    if url not in PAGES:
        return "Error: page not found."
    domain = urlparse(url).netloc
    if domain not in ALLOWED_DOMAINS and not approve(url):
        return f"Error: reading {domain} was not approved."
    return PAGES[url][1]


# ---------- planners: decide the next action ----------
def offline_planner(question: str, state: dict) -> dict:
    """Deterministic stand-in for a model: search once, read the top results, then answer."""
    if not state["searched"]:
        return {"tool": "search", "args": {"query": question}}
    unread = [r["url"] for r in state["results"] if r["url"] not in state["notes"]]
    if unread and len(state["notes"]) < 2:
        return {"tool": "read", "args": {"url": unread[0]}}
    q = terms(question)
    candidates = []                                # (overlap, sentence index, sentence, url)
    for url, text in state["notes"].items():
        if text.startswith("Error"):
            continue
        for i, sentence in enumerate(re.split(r"(?<=\.)\s+", text)):
            candidates.append((len(q & terms(sentence)), i, sentence, url))
    if not candidates or max(c[0] for c in candidates) < 2:
        return {"final": "I could not find this in the sources.", "citations": []}
    score, i, best, url = max(candidates, key=lambda c: c[0])
    answer = [best]
    answer += [c[2] for c in candidates if c[3] == url and c[1] == i + 1 and c[0] >= 1]
    return {"final": " ".join(answer) + " [1]", "citations": [url]}


def llm_planner(question: str, state: dict) -> dict:
    from openai import OpenAI
    tools = [
        {"type": "function", "function": {"name": "search", "description": "Search the documents.",
         "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}},
        {"type": "function", "function": {"name": "read", "description": "Read the full text of a page by URL.",
         "parameters": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}},
        {"type": "function", "function": {"name": "finish", "description": "Give the final answer with citations.",
         "parameters": {"type": "object", "properties": {
             "answer": {"type": "string", "description": "Answer with [n] markers matching citations"},
             "citations": {"type": "array", "items": {"type": "string"}}},
             "required": ["answer", "citations"]}}},
    ]
    system = ("Research the question with search and read, then call finish. Cite only pages you have read. "
              "If the sources do not answer the question, say so in finish.")
    history = json.dumps({"results": state["results"], "notes": state["notes"]})
    response = OpenAI().chat.completions.create(
        model=os.getenv("MODEL_NAME", "gpt-4.1-mini"), tools=tools, tool_choice="required", temperature=0,
        messages=[{"role": "system", "content": system},
                  {"role": "user", "content": f"Question: {question}\nResearch so far: {history}"}])
    call = response.choices[0].message.tool_calls[0]
    args = json.loads(call.function.arguments)
    if call.function.name == "finish":
        return {"final": args["answer"], "citations": args["citations"]}
    return {"tool": call.function.name, "args": args}


# ---------- guardrails ----------
def check_citations(final: dict, state: dict) -> list[str]:
    problems = [f"cited unread page {u}" for u in final["citations"] if u not in state["notes"]]
    markers = {int(n) for n in re.findall(r"\[(\d+)\]", final["final"])}
    problems += [f"marker [{n}] has no citation" for n in markers if n > len(final["citations"])]
    return problems


def deny_unknown_domains(url: str) -> bool:
    print(f"  approval needed to read {url} -> denied")
    return False


# ---------- the agent loop ----------
def run(question: str, planner, approve=deny_unknown_domains) -> dict:
    state = {"searched": False, "results": [], "notes": {}}
    steps, start = [], time.perf_counter()
    for step in range(1, MAX_STEPS + 1):
        action = planner(question, state)
        if "final" in action:
            problems = check_citations(action, state)
            if problems:
                action = {"final": "I could not produce a properly cited answer.", "citations": [],
                          "problems": problems}
            break
        name, args = action["tool"], action["args"]
        if name == "search":
            state["searched"], state["results"] = True, search(**args)
            observation = [r["url"] for r in state["results"]]
        elif name == "read":
            state["notes"][args["url"]] = read(args["url"], approve)
            observation = state["notes"][args["url"]][:60] + "..."
        else:
            observation = f"Error: unknown tool {name}"
        steps.append({"step": step, "tool": name, "args": args, "observation": observation})
        print(f"  step {step}: {name}({json.dumps(args)})")
    else:
        action = {"final": "Stopped: step budget used up before an answer.", "citations": []}
    result = {"question": question, "answer": action["final"], "citations": action["citations"],
              "steps": steps, "seconds": round(time.perf_counter() - start, 3)}
    with open("traces.jsonl", "a", encoding="utf-8") as f:
        f.write(json.dumps(result) + "\n")
    return result


if __name__ == "__main__":
    planner = llm_planner if os.getenv("AGENT_USE_LLM") == "1" else offline_planner
    for question in sys.argv[1:] or ["What is hybrid search?"]:
        print(f"Q: {question}")
        result = run(question, planner)
        print(f"A: {result['answer']}")
        for i, url in enumerate(result["citations"], 1):
            print(f"  [{i}] {url}")
        print()

Run it

Terminal
python research_agent.py "What is hybrid search and how are results merged?" "What do agents need to stop safely?" "What is the capital of Peru?"
Q: What is hybrid search and how are results merged? step 1: search({"query": "What is hybrid search and how are results merged?"}) step 2: read({"url": "https://docs.example.com/rag/hybrid-search"}) step 3: read({"url": "https://docs.example.com/rag/reranking"}) A: Hybrid search runs keyword search and vector search for the same query and merges the two result lists. Reciprocal rank fusion is a common way to merge them. [1] [1] https://docs.example.com/rag/hybrid-search Q: What do agents need to stop safely? step 1: search({"query": "What do agents need to stop safely?"}) step 2: read({"url": "https://blog.example.com/agents/stop-conditions"}) A: Agents need a maximum number of steps, repeated-action detection, and a cost budget. [1] [1] https://blog.example.com/agents/stop-conditions Q: What is the capital of Peru? step 1: search({"query": "What is the capital of Peru?"}) A: I could not find this in the sources.

Read each run's steps: the agent searched once, read the most relevant pages, and answered only from what it read, with a citation for the page it used. For the last question, nothing relevant was found, so it said so instead of guessing.

See the guardrails work

The forum page is not on the allowlist. Ask a question it matches and watch the approval gate:

Terminal
python research_agent.py "hybrid search databases trust forum"
Q: hybrid search databases trust forum step 1: search({"query": "hybrid search databases trust forum"}) step 2: read({"url": "https://docs.example.com/rag/hybrid-search"}) approval needed to read https://random-forum.test/hybrid -> denied step 3: read({"url": "https://random-forum.test/hybrid"}) A: Hybrid search runs keyword search and vector search for the same query and merges the two result lists. [1] [1] https://docs.example.com/rag/hybrid-search

The agent tried to read the untrusted page, the approval was denied, and the answer was built only from the trusted page. Change deny_unknown_domains to return True and the forum's dubious claim would become available to the agent — exactly the kind of decision a person should make.

Every run is also appended to traces.jsonl, ready for the evaluation harness from the previous lesson.

Switch to a real model

Terminal
export OPENAI_API_KEY=sk-...
export AGENT_USE_LLM=1
python research_agent.py "What is hybrid search and how are results merged?"
Q: What is hybrid search and how are results merged? step 1: search({"query": "hybrid search merge results"}) step 2: read({"url": "https://docs.example.com/rag/hybrid-search"}) A: Hybrid search runs keyword and vector search for the same query and merges the result lists, commonly with reciprocal rank fusion [1]. [1] https://docs.example.com/rag/hybrid-search

The model now chooses its own queries and pages, but the loop, step budget, approval gate, citation check, and trace are unchanged — those are your code, and they constrain whatever the model decides. Exact steps and wording vary between runs.

Extend it

ExtensionLesson it builds on
Replace search() with the hybrid retriever from docqa.pyBuilding RAG Systems
Add a web search API tool, with every new domain requiring approvalHuman in the loop
Detect repeated actions and stop after three tool errorsReAct agents
Write 15 eval cases and score answers and trajectories from traces.jsonlEvaluating LLM apps
Stream the final answer to a web pageStreaming and batch
Remember the user's previous questions across sessionsA chatbot with memory
Try it
  • Let offline_planner combine the best sentences from two different pages (“How do hybrid search and reranking work together?”) and cite both.
  • Make check_citations also fail answers that contain no [n] markers at all.
  • Lower MAX_STEPS to 2 and confirm the agent stops with a clear message.

Key takeaways

Key takeaways
  • A useful agent is a small loop surrounded by guardrails: budgets, allowlists, approval, and validation.
  • Separate the planner (model or code) from the execution and safety logic you control.
  • Check citations against what the agent actually read before showing an answer.
  • Trace every run so you can debug it and turn it into evaluation data.
  • Build offline first with a deterministic planner, then swap in a real model.