Deep research¶
DeepResearch is the three-agent shape that keeps reappearing on top of Agent: split a
question into sub-questions, research each one independently, then write a single report from
the answers.
import asyncio
from deepharness import OpenAI
from deepharness.prebuilt import DeepResearch
from deepharness.tools import TavilySearch
search = TavilySearch() # reads TAVILY_API_KEY, or pass api_key=
research = DeepResearch(OpenAI("gpt-4o-mini"), tools=[search.as_tool()])
result = asyncio.run(research.arun("How do UK master's student visas work?"))
print(result.report)
for finding in result.findings:
print(finding.question, "->", finding.answer)
╭══════╮ ╭──────────╮ ╭════════════╮
│ plan │ ───▶ │ research │ ───▶ │ synthesize │ ───▶ ResearchResult
╰══════╯ ╰──────────╯ ╰════════════╯
│ │ │
│ │ ╰─ one report, from every
│ │ finding and no tools
│ ╰─ one Agent per sub-question, n_parallel at a
│ time, each with its own context and the tools
╰─ up to max_sub_questions, or the question itself as a fallback
The three agents differ only in prompt and tools: the planner returns a list of sub-questions
and holds no tools, each researcher is its own Agent — so one sub-question's evidence never
enters another's context — and the synthesizer sees every finding but no tools; it reports, it
does not research.
What DeepResearch does not decide is where the facts come from. The tools are injected and
the three prompts are replaceable, so the same workflow runs against a web search, an internal
document store, or a stub in tests. It is deliberately a plain sequence of three steps rather
than a Graph — a caller who needs a different shape (a loop back to more research, a review
step, branching) is better served composing Agents into a Graph directly. See
Agents as nodes.
Result¶
arun() returns a ResearchResult:
| Field | What it holds |
|---|---|
query |
The original question. |
sub_questions |
What the planner split it into. |
findings |
One Finding(question, answer) per sub-question. |
report |
The synthesizer's final report. |
usage |
TokenUsage for the whole run — planner, every researcher, and the synthesizer. |
Watching a run live¶
astream_events() is the actual driver — arun() just consumes it and keeps the last event, the
same relationship Agent.astream_events()/arun() have. Iterate it directly to show progress as
it happens, or read the synthesizer's report as it is written:
from deepharness import TextDelta
from deepharness.prebuilt.research import (
Planned,
Planning,
Researched,
Researching,
ResearchFinished,
Synthesizing,
)
async for event in research.astream_events(query):
match event:
case Planning(q):
print(f'planning: "{q}"')
case Planned(sub_questions):
print(f"{len(sub_questions)} sub-question(s)")
case Researching(count):
print(f"researching {count} at a time")
case Researched(index, question):
print(f" [{index}] {question} done")
case Synthesizing(findings):
print(f"synthesizing from {findings} finding(s)")
case TextDelta(text):
print(text, end="", flush=True)
case ResearchFinished(result):
print(f"\n\n{len(result.findings)} findings")
ResearchFinished is deliberately not the same name as Agent's Finished: the two wrap
different things (result: ResearchResult vs state: AgentState), and this stream and an
Agent's are commonly read in the same file. Importing Finished from deepharness here
would be the wrong class — case Finished(...): would compile but never match, silently.
Nothing here prints on its own — the workflow only emits typed events, so it stays usable where
stdout is not free to write to (a server, a queued job). A caller that wants nothing at all just
calls arun() and never iterates the stream.
Parallel, capped, or sequential¶
Left unset, n_parallel leaves the degree of parallelism to the model: the planner decides how
many sub-questions the question needs, and all of them run at once — one researcher for a single
factual question, several for a question with genuinely separate parts.
Setting it to an int caps that instead, for limits the model cannot see: fanning six researchers
at a provider that allows two concurrent requests turns a fast run into a wall of 429s, and a
tool backed by one server may not survive six callers at once.
sequential=True runs one researcher at a time, each seeing every earlier answer — the only mode
that can do so, since parallel researchers structurally cannot: each starts before any other has
finished. Use it when later sub-questions build on earlier ones (find the things, then look up
details about the things found):
sequential=True and n_parallel= are mutually exclusive — sequential already fixes the
degree of parallelism at one, so pass one or the other.
Prompts and limits¶
from deepharness import Budget
DeepResearch(
model,
tools=[search.as_tool()],
max_sub_questions=4,
researcher_system="Answer only from primary sources. Quote exactly.",
budget=Budget(steps=6, tokens=200_000),
)
max_sub_questionscaps how many sub-questions the planner may return; a planner that returns nothing usable falls back to the original question as a single sub-question.planner_system/researcher_system/synthesizer_systemreplace the default prompt for each agent. All three reach their agent verbatim — the sub-question cap is appended to the planner's rather than interpolated into it, so a custom prompt containing braces is not mistaken for a format string.budgetreaches every researcher (not the planner or synthesizer), the sameBudgetanAgenttakes.