Skip to content

A research agent: tools that read the web, and a fact checker

An agent that reads the web, a fact checker, and the same agent on a local model.

An agent that answers a question by reading a web page, and a second AI function that checks the answer against its source. It runs on any model; at the end, the same agent on a local model.

Every output below is a real reply. This page is a notebook: open it in Chattering and run it, or run it all with python/.venv/bin/python tools/docs.py run python/examples/local_simple_rag_agent/README.md.

import functai
functai.configure(lm="gpt-4.1-mini", temperature=0)

from functai import ai, _ai

A tool that reads a page

Any typed Python function with a docstring is a tool. This one fetches a page and keeps its text:

import html.parser
import urllib.request

class _Text(html.parser.HTMLParser):
    def __init__(self):
        super().__init__()
        self.parts, self._skip = [], 0
    def handle_starttag(self, tag, attrs):
        self._skip += tag in ("script", "style")
    def handle_endtag(self, tag):
        self._skip -= tag in ("script", "style")
    def handle_data(self, data):
        if not self._skip and data.strip():
            self.parts.append(data.strip())

def read_page(url: str) -> str:
    """The text of a web page (at most 15,000 characters)."""
    req = urllib.request.Request(url, headers={"User-Agent": "functai-example"})
    with urllib.request.urlopen(req, timeout=20) as page:
        parser = _Text()
        parser.feed(page.read().decode("utf-8", "replace"))
    return " ".join(parser.parts)[:15000]

The agent

The agent reads pages with the tool, and returns its answer together with the sentence that supports it:

from dataclasses import dataclass

@dataclass
class Sourced:
    answer: str
    quote: str   # the sentence from the page that supports the answer, verbatim

@ai(tools=[read_page])
def research(question: str) -> Sourced:
    """Answer the question from the pages you read."""
    ...

found = research(
    "Which castle did the physician David Gregory inherit? "
    "Look it up on https://en.wikipedia.org/wiki/David_Gregory_(physician)"
)
found
Sourced(answer='David Gregory inherited Kinnairdy Castle.', quote='He inherited Kinnairdy Castle in 1664.')

Checking the answer

A second AI function checks the claim against the quote, reasoning first:

@ai
def fact_check(claim: str, passage: str) -> bool:
    """Does the passage support the claim?"""
    reasoning: str = _ai["What in the passage supports or contradicts the claim."]
    return _ai

A step that must always happen belongs in code, not in a request to the model. A @module is plain Python that calls AI functions, and is evaluated, optimized and saved as one program:

from functai import module

@module
def checked_answer(question: str) -> str:
    found = research(question)
    if fact_check(found.answer, found.quote):
        return found.answer
    return f"Unverified: {found.answer}"

checked_answer("When was the physician David Gregory born? "
               "See https://en.wikipedia.org/wiki/David_Gregory_(physician)")
'David Gregory (physician) was born on 20 December 1625.'

(When the model should decide whether to check, give the AI function as a tool instead: @ai(tools=[read_page, fact_check]).)

What happened

functai.inspect_history() has every model call, whichever function made it:

for record in functai.inspect_history(3):
    parts = record.response.message.parts
    print(f"{record.function:<10} {record.model:<13}",
          ", ".join(f"calls {p.name}" if p.type == "tool_call" else "answers" for p in parts))
research   gpt-4.1-mini  calls read_page
research   gpt-4.1-mini  answers
fact_check gpt-4.1-mini  answers

On a local model

Only the model name changes. With Ollama running and a model pulled (ollama pull qwen3:8b):

research.lm = "ollama:qwen3:8b"
research("Which castle did the physician David Gregory inherit? ...")

Or any OpenAI-compatible server (vLLM, llama.cpp, LM Studio):

functai.configure(lm="openai:Qwen/Qwen3-8B", base_url="http://localhost:8000/v1", api_key="none")

Models without native tool calling get tool calls written as text; the prompt otherwise stays the same.