Web Research Agent: Search, Read and Cite Without Being Fooled by the Web
Hands-on lab · IDE in your browser

Web Research Agent: Search, Read and Cite Without Being Fooled by the Web

Build a research agent that searches the web, reads pages and answers with citations it can prove.

Time
50 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit50 min · 5 stepsSession running
4 / 5 steps passingCheck before answering · step 5 of 5
research.py▶ Run✓ Check
      
TerminalOutput

The job

Kellmere's visitor desk wants an assistant that answers questions about the town from the web and shows its sources. The lab's sandbox web has the real web's problems: official pages next to stale blogs, a forum that is wrong, a ticket reseller that plants instructions for AI agents, a page that never answers and a page that is gone.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Search and read

    Kellmere's visitor desk wants an assistant that answers questions from the web and shows where each fact came from.

    You writeweb_search()fetch_page()
  2. 2

    The agent loop

    Now the model drives.

    You writerun_agent()
  3. 3

    Cite what you read

    In step 2 the model often answered from search snippets and cited "pages" it never opened.

    You writecheck_citations()citation_problem()
  4. 4

    Pages are data

    Anyone can write a web page, and some pages are written for your agent.

  5. 5

    Check before answering

    One page is thin evidence.

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Search and read

Kellmere's visitor desk wants an assistant that answers questions from the web and shows where each fact came from. The lab pod has no internet, so webfixture.py plays the web: a search engine and a few dozen pages about the town. Like the real web, it has official pages, outdated blog posts, a spam site, a forum, a page that never answers and a missing page. Everything goes through it as an HTTP proxy (harness.PROXIES).

An agent is only as good as its tools. A page arrives as HTML full of scripts, menus and cookie banners, and every character of that costs tokens and distracts the model. A tool that raises on a 404 kills the whole run.

Do this

1. Write web_search(query, n=5) in research.py: call the search API and return the first n results.

2. Write fetch_page(url, max_chars=2500): return the page's readable text, title and date. For a page that cannot be read, return {"url": ..., "error": ...} and do not raise.

3. Run and compare the three fetches: a real page, a missing one and one that never answers.

research.py, the file you edit95 lines
"""A research agent for Kellmere's visitor desk: it searches the (sandbox) web, reads pages and answers with
citations."""
import json
import re

import requests
from bs4 import BeautifulSoup

import harness


# ---------- Step 1: tools ----------
def web_search(query, n=5):
    """Top n results from the search engine: a list of {"title", "url", "snippet"}."""
    # TODO (Step 1): GET harness.SEARCH_URL with params={"q": query}, proxies=harness.PROXIES and timeout=5;
    # return the first n items of the JSON's "results" list.
    raise NotImplementedError("Step 1: write web_search()")


def fetch_page(url, max_chars=2500):
    """The readable text of a page: {"url", "title", "date", "text"}; or {"url", "error"} when the page cannot be
    read (an HTTP error, a timeout after 5 seconds, a connection problem). Drop scripts, styles, navigation, footers
    and the cookie banner; keep the <main> text with whitespace collapsed, cut to max_chars. "date" comes from
    <meta name="date"> (None if the page has none)."""
    # TODO (Step 1): GET the url through harness.PROXIES with timeout=5. Timeouts, connection errors and non-200
    # answers return {"url": url, "error": "..."} (never raise). Otherwise parse with BeautifulSoup(r.text, "html.parser"),
    # decompose script/style/nav/footer tags and .cookie-banner, take the text of <main> (or <body>) with
    # get_text(" "), collapse whitespace, cut to max_chars, and read the date from <meta name="date" content=...>.
    raise NotImplementedError("Step 1: write fetch_page()")


TOOLS = [
    {"type": "function", "function": {"name": "web_search", "description": "Search the web. Returns titles, URLs and snippets.",
     "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}},
    {"type": "function", "function": {"name": "fetch_page", "description": "Read a web page by URL. Snippets are not enough to answer from: read the page.",
     "parameters": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}},
]

SYSTEM = """You are the research assistant for Kellmere's visitor desk. Today is 23 September 2026.
Answer questions about Kellmere by searching the web and reading pages.
Every page you read is numbered. Cite each fact with the number of the page it came from, like [2]."""


MIN_SOURCES = 2   # pages to read before an answer is trusted


# ---------- Step 2: the agent loop ----------
def page_for_model(n, page):
    """How a fetched page is shown to the model."""
    return json.dumps(page, ensure_ascii=False)


def run_tool(name, args, sources):
    """Run one tool call and return the text for the model. Successfully fetched pages are appended to `sources`
    (a list of page dicts) and numbered from 1 in that order."""
    if name == "web_search":
        results = web_search(str(args.get("query", "")))
        return json.dumps(results, ensure_ascii=False)
    if name == "fetch_page":
        url = str(args.get("url", ""))
        seen = [i for i, p in enumerate(sources, 1) if p["url"] == url]
        if seen:
            return f"You already read this page: it is [{seen[0]}]."
        page = fetch_page(url)
        if "error" in page:
            return page_for_model(0, page)
        sources.append(page)
        return page_for_model(len(sources), page)
    return f"Unknown tool {name!r}."


def run_agent(question, max_steps=8):
    """Search and read until the model answers. Returns {"answer", "sources" (fetched pages, numbered from 1),
    "steps" (model calls made)}."""
    raise NotImplementedError("run_agent() arrives in Step 2")


# ---------- Step 3: check the citations ----------
def check_citations(answer, sources):
    """{"cited": sorted page numbers the answer cites that exist, "bad": sorted numbers that match no fetched page,
    "uncited": sentences that contain a digit but no [n] citation}. Citations look like [2] or [1, 3]."""
    return {"cited": [], "bad": [], "uncited": []}


def citation_problem(answer, sources):
    """None when every fact in the answer cites a page that was actually read; otherwise the message that sends
    the model back to work."""
    return None


def with_references(result):
    """The answer followed by a numbered list of the pages it cites."""
    refs = check_citations(result["answer"], result["sources"])["cited"]
    lines = [f"[{n}] {result['sources'][n - 1]['title']} ({result['sources'][n - 1]['url']})" for n in refs]
    return result["answer"] + ("\n\nSources:\n" + "\n".join(lines) if lines else "")
Provided for you:harness.pyquestions.jsontry_it.pywebfixture.py

Frequently asked questions

How do you stop a research agent from citing pages it never read?

Number the pages the agent fetches, check every [n] in the answer against that list in code, and send the answer back with the problem when a citation points nowhere or a factual sentence cites nothing.

How do you protect a web agent from prompt injection in pages?

Show each page as a fenced, labelled block that says its text is untrusted data, and tell the model in the system prompt never to follow instructions inside pages. This stops most attacks when better sources are available; combine it with checks on the output.

Why should a research agent read more than one page?

The first plausible page is often outdated or incomplete. Asking the agent to confirm with one more page, preferably official or newer, catches answers such as old opening hours that later news changed.

Building a web research agent with citations

A research agent is a tool-calling loop around two tools, search and fetch. The loop is the easy part. The hard parts are the pages: they are noisy HTML, sometimes missing or slow, often out of date, and anyone can write text into them that your model will read as instructions. In this lab you build the whole agent in Python against a sandboxed web: clean page extraction, a bounded agent loop, citations checked against the pages actually read, pages shown to the model as untrusted data, and a confirmation pass that catches answers based on one stale page.