research — AgentTeamsModule¶
Research and fact-verification — an optional runtime capability
(pip install "agentteams[research] @ git+https://github.com/jlcatonjr/agentteams.git" — never from PyPI, where the name is not this project's).
Unlike every other module documented in this reference, agentteams.research is not part of the
CLI/generator pipeline (analyze, render, build_team) — it has no import-time coupling to it
in either direction. It is a real, importable Python library a consuming project may add as its
own runtime dependency and call directly. See
the SECURITY.md boundary statement
for the disclosed boundary this crosses, and the research-analyst domain-archetype template for
the recommended way to give an LLM agent instructions for orchestrating it.
Stability: the symbols documented below are the supported import surface per the stability policy — covered by the normal SemVer contract like any other documented module.
Source:
agentteams/research/{search,backends,cache,scholarly,reputable,news,verify,browser}.py
Backend fallback at a glance¶
search.web_search (a thin wrapper over web_search_verbose) tries the backend chain in order
(unavailable ones skipped, failures degrade never raise). No hits and not challenged → an honest
empty list immediately ("nothing matched"). Only a challenge (a 202 interstitial) triggers
_broadening_forms to broaden the query progressively toward a floor and retry — and a still-
challenged empty is flagged as such, not as "nothing matched". Generated deterministically from
scripts/gen_api_decision_figures.py (Graphviz).
browser (below) is a further, heavier exception within this already-exceptional subpackage: it
is gated behind its own separate agentteams[browser] install (not folded into agentteams[research])
and is deliberately not re-exported from agentteams.research's package-level __all__ the
way search/reputable/verify are — reach it via from agentteams.research.browser import
browser_fetch, or python -m agentteams.research browser <url>, so that a plain import
agentteams.research never risks pulling in Playwright.
Honesty ceiling (stated once here, back-referenced from each submodule below): allowlisted-domain
retrieval is provenance, not correctness — "reputable" is never "true." Claim-verification verdicts
are "survived" or "refuted" — never "verified" or "proven".
search — no-key web search and page-text fetching¶
Source:
agentteams/research/search.py
web_search(query, k=5, timeout_s=8.0)¶
No-API-key DuckDuckGo HTML-endpoint search.
Args:
query(str) — Search query.k(int) — Maximum results to return. Default:5.timeout_s(float) — Request timeout in seconds. Default:8.0.
Returns: list[Source] — Each with title, url (resolved to the real target, not DDG's
redirect wrapper), and snippet. Empty list on any failure (network down, blocked, parse error) —
never raises.
When the endpoint challenges a request rather than answering it, this retries with
progressively broadened queries, down to a floor, before giving up (see web_search_verbose for
why, and for how to tell the two cases apart).
web_search_verbose(query, k=5, timeout_s=8.0)¶
web_search plus an explanation of any fallback or block.
DuckDuckGo answers a challenged request with HTTP 202 and an interstitial page rather than an
error status, so raise_for_status() never fires and the page simply parses to nothing. Plain
web_search therefore cannot distinguish "you were blocked" from "nothing matched" — both are
[]. That ambiguity is load-bearing for an agent: read as "no such information exists", it
abandons an answerable question. Measured 2026-07-24: a long specific query was challenged on 4/4
attempts while a shortened form returned 10 results.
Args: identical to web_search.
Returns: tuple[list[Source], str | None] — the results, plus None for an ordinary search or
a short human-readable note saying the query was broadened, or that the endpoint challenged the
request and the empty list is not evidence that nothing matched.
python -m agentteams.research search prints this note to stderr, keeping the JSON on stdout
parseable.
Known gap: the note is prose, so a caller distinguishing block-from-absence must substring-match it — use
web_search_with_provenancebelow for a structured form. Also, a429rate-limit still arrives viahttpx.HTTPErrorand remains indistinguishable from "nothing matched" — only the202case is handled.
web_search_with_provenance(query, k=5, timeout_s=8.0)¶
web_search_verbose plus a structured SearchProvenance record — the machine-readable form of
"how was this obtained", which the external-retrieval quality gate requires a summary to be able
to state.
Returns: tuple[list[Source], str | None, SearchProvenance], where SearchProvenance carries
backend, cached, query_used, backends_tried, and challenged.
Three of those change what a claim is worth:
cached—truemeans up to 6 hours old. Re-run withAGENTTEAMS_RESEARCH_NO_CACHE=1before presenting a time-sensitive claim as current.query_used— when it differs from the query you issued, the endpoint challenged the original and the tool retried with a broader one. Broader queries return less precise results; treat such a hit as weaker evidence and say so.backend— not all backends index the same corpus.
python -m agentteams.research search prints all of this to stderr on every query:
fetch_text(url, max_bytes=400_000, timeout_s=8.0, max_chars=4000, max_pdf_bytes=12_000_000, pdf_timeout_s=60.0)¶
Fetch a page and return extracted, bounded text. Public-HTTPS-only with an SSRF guard (no private/
loopback/link-local targets, no redirects). Content-type aware: HTML is tag-stripped; a PDF
response (detected via the Content-Type header, or the %PDF- magic-number prefix as a
fallback) is routed through a lazily-imported pypdf extractor instead.
Args:
url(str) — The URL to fetch.max_bytes(int) — Byte cap on the DOWNLOAD for non-PDF (HTML) responses. Default:400_000. Distinct frommax_chars(which bounds returned text): set too low, a large page is truncated to its<head>/navigation and extraction silently yields chrome with no error — raised from40_000on 2026-07-24 after a Wikipedia article extracted to 342 chars of pure navigation.timeout_s(float) — Wall-clock deadline for non-PDF responses; also httpx's own per-chunk read-gap timeout for every response. Default:8.0.max_chars(int) — Cap on the returned extracted text. Default:4000.max_pdf_bytes(int) — Separate, larger byte cap for PDF responses — a PDF cannot be parsed from an arbitrary truncation the way HTML can. Default:12_000_000.pdf_timeout_s(float) — Separate, larger wall-clock deadline for PDF responses. Default:60.0.
Returns: str — Extracted text, capped at max_chars. Empty string on any failure or guard
rejection — never raises.
Behavior Notes:
import pypdfhappens lazily inside the PDF-handling branch only, never at module level — the baseagentteamsinstall does not requirepypdf; only theresearchextra does.timeout_sbounds per-chunk read gaps, not total transfer time — a server trickling data steadily can exceedtimeout_sin total elapsed time without tripping it. The separatepdf_timeout_swall-clock deadline exists specifically because a real PDF transfer can take meaningfully longer than typical HTML front-matter.python -m agentteams.research fetch <url> [--max-chars N] [--max-bytes N] [--timeout-s S]invokes this function directly and prints{"url": ..., "text": ...}as JSON to stdout.
extract_published_date(html) -> str | None¶
Best-effort publish-date extraction from a page's raw (unstripped) HTML. Tried, in order: JSON-LD
datePublished/dateCreated inside an application/ld+json script block; an
article:published_time meta tag; a date/pubdate/publish-date meta tag; a bare
<time datetime="..."> tag. Returns the raw string as found — never normalizes, guesses, or
fabricates a date. Returns None on no match; never raises.
Must be called against RAW html, before any script/tag stripping — fetch_text's own stripping
removes the <script> blocks a JSON-LD date lives in.
fetch_text_and_date(url, *, max_bytes=400_000, timeout_s=8.0, max_chars=4000, max_pdf_bytes=12_000_000, pdf_timeout_s=60.0) -> tuple[str, str | None]¶
Fetch a page once and return both its extracted text (identical to what fetch_text would return
for the same input) and a best-effort publish date — for a caller who wants both without paying
for two fetches. Additive: does not change fetch_text's own signature or behavior.
Returns: (text, published_at). A PDF response always has published_at=None — PDF structure
has no HTML meta/JSON-LD for this module's regexes to reach.
strip_html_to_text(html, max_chars) -> str¶
Shared HTML→text extraction: drops script/style/nav/footer/header blocks, strips the
remaining tags, unescapes entities, collapses whitespace, and caps the result at max_chars.
Public (not module-private) specifically because agentteams.research.browser reuses this exact
extraction — applied there to a browser's rendered page.content() instead of a raw HTTP response
body — so fetch_text and browser_fetch return text in the same shape regardless of which one a
caller used.
is_public_https(url) -> bool¶
The SSRF guard fetch_text/fetch_text_and_date apply before every request: https scheme only,
and the hostname must not resolve to a private, loopback, link-local, or reserved IP address.
Public (not module-private) specifically so agentteams.research.browser can reuse this exact
check as its own pre-navigation gate — the one deliberate cross-submodule import of a
security-relevant helper in this package. A browser context needs a second, per-request
version of this same check in addition (see browser below) — this function alone is necessary
but not sufficient there, since it only ever runs once, before the first request.
browser — real-browser rendering for JavaScript-heavy pages¶
Source:
agentteams/research/browser.py— requires the separateagentteams[browser]extra (pip install "agentteams[browser] @ git+https://github.com/jlcatonjr/agentteams.git") and a one-timeplaywright install chromium(the extra installs theplaywrightPython package only; browser binaries are a required second step it cannot perform). Not imported byagentteams.research's own__init__.py— see the note at the top of this page.
Use this only once fetch_text has been tried and found insufficient — i.e. the page needs
JavaScript to populate its real content (a client-rendered app, a "loading..." skeleton, content a
framework injects after the initial HTML). Slower and heavier than a plain fetch by design; it is
the escalation tier, not the default.
browser_fetch(url, *, headed=False, wait_until="networkidle", timeout_s=20.0, max_chars=4000) -> str¶
Render url in a real Chromium browser and return extracted, bounded text — same tag-strip/
unescape/whitespace-collapse text shape as fetch_text, so the two are consistent regardless of
which one a caller used.
Args:
headed(bool) — Show the browser window. DefaultFalse(headless): this is normally a one-shot call from an agent's shell tool on a server/container/CI runner with no display attached, where a headed launch would simply fail.headed=Trueis for a human operator co-located with a real display who wants to watch (debugging, demos, a manual login/2FA step) — it changes nothing about the function's return value; the calling agent has no way to perceive a rendered window either way.wait_until(str) — One of Playwright's navigation wait conditions:"load","domcontentloaded","networkidle". Default"networkidle"— a better fit than"load"for the JS-hydration-heavy pages this function exists for, which often fireloadbefore client-side rendering has populated real content. Known tradeoff:"networkidle"never fires on a page with continuous background activity (long-polling, websockets); pass"load"or"domcontentloaded"for those.timeout_s(float) — Navigation timeout. Default20.0.max_chars(int) — Cap on the returned extracted text. Default4000.
Returns: str — Extracted text. Empty string on any failure: playwright not installed,
browser binaries not installed, the initial URL failing the SSRF guard, navigation timeout, or any
other Playwright-raised error. Never raises.
browser_screenshot(url, output_path, *, headed=False, wait_until="networkidle", timeout_s=20.0) -> bool¶
Render url and save a full-page screenshot to output_path. Same args and never-raises contract
as browser_fetch; returns True on success, False on any failure.
Behavior Notes:
- SSRF guard, two layers — not one. The initial URL is checked with
is_public_httpsbefore a browser is even launched, and every subsequent request the live page attempts (redirects, subresources, page-initiated JSfetch/XHR) is re-checked by the same guard via a Playwrightpage.routehandler. A single pre-navigation check alone is insufficient for a real browser, which follows redirects by default and runs arbitrary page JavaScript capable of issuing its own requests to other hosts — a pre-navigation-only check (as a plain HTTP fetch tool correctly uses) does not cover either case. - Named, undefended residual: DNS rebinding. This guard's DNS resolution and Chromium's own subsequent connection are two separate resolutions, not atomically the same one — stated honestly as a known gap, not silently assumed away.
- Content is capped at 2,000,000 characters immediately after
page.content()retrieval, before any text extraction — a cap applied after the DOM is materialized, not a true streaming cap likefetch_text's (Playwright'spage.content()is synchronous and always returns the full rendered DOM; there is no earlier point to interrupt at). - Before installing or first using this capability in a project, see
references/skill-generation.reference.md's Security Rule S-9 (Pathway Safety Verification) gate — the same review path any other new, durable CLI capability in a generated team goes through.
backends — the search fallback chain¶
Source:
agentteams/research/backends.py
web_search does not call one endpoint; it walks a chain. The zero-configuration chain is
duckduckgo → ddg_lite, both key-free — a fallback that requires setup is not a fallback.
| Backend | Enabled by | Notes |
|---|---|---|
duckduckgo |
always | html.duckduckgo.com; the historical default |
ddg_lite |
always | lite.duckduckgo.com; a different renderer, challenged independently |
searxng |
AGENTTEAMS_SEARXNG_URL |
Operator-chosen instance; many disable the JSON API, which degrades to a skip |
brave |
AGENTTEAMS_BRAVE_API_KEY |
Key travels as a header, never a query parameter |
Configured backends always rank after the free ones, so an operator who supplies a key does not pay for every query.
available_backends() / backend_names()¶
The backends usable right now, in chain order. Useful for reporting provenance.
Chain semantics. Every backend is tried on the original query before the query is altered
at all — switching provider loses nothing, whereas broadening discards search terms. Only when
the whole chain has been challenged does web_search broaden, and then progressively, down
to the floor. An honest zero across all backends does not trigger broadening: a query that
genuinely matched nothing will only match less precise nothing when shortened.
cache — TTL disk cache for retrieved results¶
Source:
agentteams/research/cache.py
Search, fetch, and scholarly results are cached for 6 hours by default under
references/research-cache/ (gitignored). Disable with AGENTTEAMS_RESEARCH_NO_CACHE=1;
relocate with AGENTTEAMS_RESEARCH_CACHE_DIR.
This cache persists untrusted third-party bytes, so its failure behaviour is part of its contract: filenames are SHA-256 digests only (no external text reaches a path component), writes are atomic, and a corrupt, oversized, or expired entry is treated as a miss rather than an error. A broken cache degrades to "no cache", never to a broken call.
cache_enabled() -> bool¶
Whether caching is active in this process — implements the AGENTTEAMS_RESEARCH_NO_CACHE behavior
described above: False when that variable is set to anything other than an explicit
"0"/"false"/empty value, True otherwise.
cache_dir() -> Path¶
The directory cache entries live in — implements the AGENTTEAMS_RESEARCH_CACHE_DIR behavior
described above: the path it names when set, else references/research-cache under the current
working directory. Not created here; store() creates it lazily on first write.
make_key(kind, *parts)¶
Every part participates in the digest, so changing k or the backend set produces a different
key rather than silently reusing a differently-shaped result.
load(key, ttl_s=DEFAULT_TTL_S)¶
store(key, value)¶
purge_expired(ttl_s=DEFAULT_TTL_S)¶
scholarly — OpenAlex, Crossref, arXiv¶
Source:
agentteams/research/scholarly.py
A general web search returns a page about a paper; these APIs return the paper's own record, with a DOI. All three are key-free — no credential is read, required, or supported.
scholarly_search(query, k=5, sources=SOURCES, timeout_s=10.0) -> list[ScholarlyWork]¶
Queries the chosen indexes concurrently and deduplicates by DOI, then by normalised title for records that reach one index without a DOI. One index failing never loses the others' results.
python -m agentteams.research scholar <query> [-k N] [--sources ...] [--timeout-s S]
[--citations] invokes this and prints the works as JSON to stdout; --citations additionally
attaches a format_citation() line to each record.
search_openalex(query, k=5, timeout_s=10.0)¶
search_crossref(query, k=5, timeout_s=10.0)¶
search_arxiv(query, k=5, timeout_s=10.0)¶
Single-source variants. Each returns [] on any failure and never raises.
ScholarlyWork¶
title, authors, year, doi, url, abstract, venue, source.
format_citation(work) -> str¶
A compact citation line built only from fields the source actually provided. A missing year
renders (n.d.) rather than a guess.
Honesty ceiling (see top of page) — scholarly specifics: a hit is not a claim the work is correct, replicated, relevant, or un-retracted — retraction status is not checked. Nothing is inferred, normalised, or filled in when a source omits it, which is what makes the output safe to build a bibliography from.
Polite pool. OpenAlex and Crossref grant higher rate limits to requests carrying a contact
address. Set AGENTTEAMS_RESEARCH_CONTACT_EMAIL to opt in. It is deliberately never derived from
git config or any other ambient source — transmitting an operator's address to a third party is
their decision to make explicitly.
reputable — curated-allowlist source rating¶
Source:
agentteams/research/reputable.py
AllowlistConfig¶
Frozen dataclass — the full, data-driven shape a ReputableSourceAllowlist is built from:
tier_by_domain, type_by_domain, topic_primary_repos, path_scope, tier_rank,
default_repos. No domain data is hardcoded into the library itself — every consumer supplies its
own config, or uses DEFAULT_CONFIG. type_by_domain values are drawn from the module's
VALID_TYPES frozenset ("news", "academic", "government", "encyclopedia",
"primary-text", "book") — additive to tier: it describes what kind of source a domain is,
never how much to trust it.
DEFAULT_CONFIG¶
A small, deliberately generic AllowlistConfig — a starting-point convenience, not a
comprehensive claim about source quality for any subject area or language. A real consuming
project should supply its own config sized to its own domain and editorial judgment.
Four general-interest domains with no primary repositories, which in practice reduces
reputable_sources() to "one general search filtered to Wikipedia and three wire services".
Its contents are frozen for back-compatibility: it is the default argument of
ReputableSourceAllowlist.__init__, so changing it would silently change behaviour for every
existing caller. Prefer a preset below.
SOFTWARE_CONFIG, RESEARCH_CONFIG, DATA_CONFIG¶
Larger starting points for the three project archetypes, each with populated tier_by_domain,
type_by_domain, and topic_primary_repos.
The same honesty ceiling applies to all three (see top of page) — worth flagging only because a longer list reads as more authoritative than a short one. A consuming project is expected to edit them, not inherit them uncritically. An official statistics series, for instance, is authoritative about what it measured, which is not the same as being the right series for a question.
config_for_project_type(project_type) -> AllowlistConfig¶
Maps a classify_project_type value to its preset (software/documentation →
SOFTWARE_CONFIG, research/writing → RESEARCH_CONFIG, data-pipeline → DATA_CONFIG).
Unknown, mixed, and unknown types fall back to DEFAULT_CONFIG rather than guessing.
ReputableSourceAllowlist(config=DEFAULT_CONFIG)¶
Methods:
reputable_sources(topic, k=3, timeout_s=8.0) -> list[ReputableSource]— Targetedsite:searches against the topic's primary repositories (pertopic_primary_repos) plus one allowlist-filtered general search, issued concurrently, deduped, ranked bytier_rank. Returns[]honestly when nothing reputable is found.tier_of(url) -> str | None— The reputability tier of a URL's domain, orNoneif not allowlisted.
Behavior Notes:
- Domain resolution prefers the LONGEST matching allowlist key — a subdomain that happens to be a suffix of an unrelated, shorter, already-listed parent domain resolves to itself, not the parent.
- An optional
path_scopeentry restricts a domain to a URL-path prefix, for domains too broad to allowlist wholesale.
ReputableSource¶
Dataclass: title, url, snippet, domain, tier, type (defaults to "unclassified" for a
domain present in tier_by_domain but absent from type_by_domain), license (str | None).
news — perspective attribution for news-typed sources¶
Source:
agentteams/research/news.py
News is a contemporaneous account of perspective on an event — not verified fact, and not the
same epistemic class as an encyclopedia or government source. This module gives the type="news"
tag (already present in reputable.py's VALID_TYPES, previously inert — stored and returned but
never read for behavior) its first real behavior: a consistent attribution string a caller can
present instead of stating a news claim as settled fact. It never adjudicates between outlets
reporting the same event differently.
PerspectiveKind¶
Literal["reported", "contested"]. "reported" — a plain factual claim a news source is the
origin of (what happened). "contested" — a claim about how a source characterized something
(e.g. an outlet's editorializing description of a person or event), which deserves more hedging
than a bare factual report. This module doesn't decide which applies to a given claim — that
judgment needs the claim's own text — it only exports the shared vocabulary so callers across the
framework use the same two labels rather than independently-invented near-synonyms.
is_news_source(source) -> bool¶
True when source.type == "news". Exists so callers don't hardcode the string literal "news"
in more than one place.
perspective_attribution(source, published_at) -> str¶
The single, shared place that formats a consistent attribution string for a news-typed source —
"{domain} reported ({published_at})", or "{domain} reported (date not available)" when no date
was extractable. Degrades honestly rather than fabricating a date.
Publish-date extraction itself lives in search.py's extract_published_date — a
content-parsing concern kept alongside that module's other HTML/PDF extraction, and separated here
specifically to avoid an import cycle (search → news → reputable → search, since
reputable.py already imports from search.py).
verify — claim extraction and dual-lens fact verification¶
Source:
agentteams/research/verify.py
extract_claims, audit_claims, and revise below are all async def coroutines — call each
with await; every one of them awaits its own chat_fn call internally.
ChatFn¶
A Protocol — the minimal chat-completion callable every function below takes as a parameter.
This module has no hardcoded model client; every function that needs an LLM call takes a
caller-supplied ChatFn. Its __call__ returns Awaitable[str], so the supplied callable must
itself be async (or otherwise return an awaitable). Supports an optional want_json hint the
callable may honor, but every function below stays correct even when it's ignored (see
extract_claims's Behavior Notes).
extract_claims(text, chat_fn) -> list[Claim]¶
Extract discrete, checkable claims from text. Instructed to restate only what the text literally
asserts — never invent or complete a claim.
audit_claims(claims, evidence_by_claim, chat_fn, lens="adversarial") -> list[Verdict]¶
Audit each claim against its own evidence only (evidence_by_claim[claim.text]) — never a pooled
blob across claims. lens is "adversarial" (does fresh evidence contradict the claim) or
"conflict" (does the claim conflict with something already established). A claim with no
evidence entry is skipped for that lens.
Behavior Notes:
- Every
contradictedverdict from the LLM is additionally checked against the deterministic, non-LLM_supported_by_evidence()backstop before being accepted as"refuted"— an LLM-proposed correction that doesn't actually derive from the claim's own evidence is downgraded back to"survived".
revise(original_text, verdicts, chat_fn) -> str¶
Minimal-edit revision: changes only the specific spans refuted verdicts identify as wrong, and
copies everything else verbatim — never a creative rewrite, which risks fabricating unstated
detail. Returns original_text unchanged if no verdict refuted anything.
Claim, Verdict¶
Dataclasses. Verdict.status is Literal["survived", "refuted"] (never "verified"/"proven" —
see the honesty ceiling at the top of this page).
Behavior Notes (module-wide, also applies to extract_claims/audit_claims):
- JSON extraction is tolerant of markdown-fenced or prose-wrapped responses (via an internal
_extract_jsonhelper) — a caller'sChatFnis not required to honorwant_jsonfor these functions to work correctly, since small/local models frequently ignore such hints.
See also¶
Security-posture siblings:
- Security Hardening Guide
- Security Workflow Glossary
scan— proactive security scan for generated agent filesredteam— audit internals + corpus coverage/density