Split core.rs into one file per concern
core.rs had grown into a 612-line grab-bag mixing six unrelated concerns:
CLI arg parsing, logging setup, top-level orchestration, the researcher
agent phase, chat-history reconstruction utilities, the summarizer agent
phase, and the writer agent phase — while review.rs, tools.rs, stream.rs,
and progress.rs already correctly isolated their own concerns. This
splits core.rs to match that existing pattern instead of being the one
file that doesn't follow it:
- cli.rs — Cli struct + DEFAULT_TOPIC
- observability.rs — initialize_observability
- models.rs — RESEARCHER_MODEL / WRITER_MODEL (previously duplicated
across call sites, now a single source of truth)
- history.rs — pure chat-history parsing/reconstruction helpers
(partial_findings_from_history, annotated_transcript_from_history, and
their private helpers), plus their unit tests. Also dedupes
MAX_TOOL_RESULT_CHARS, which was previously defined twice.
- researcher.rs — gather_findings + GatheredFindings (the tool-calling
research phase)
- summarizer.rs — summarize_partial_history (the max-turns recovery
agent)
- writer.rs — write_report
- research.rs — the top-level research() orchestration loop
main.rs now only does argument parsing, logging setup, and the top-level
call — no orchestration logic of its own. Unit tests stay co-located
with the code they test per Rust convention (not pulled into separate
files) rather than under "prefer new files" — that applies to
production code organization here.
No behavior changes; cargo test/clippy/fmt all clean.
2026-08-18 10:59:03 +00:00
|
|
|
use crate::models::RESEARCHER_MODEL;
|
|
|
|
|
use crate::progress::{RESEARCH_EMOJI, Spinner};
|
|
|
|
|
use crate::review::Review;
|
|
|
|
|
use crate::summarizer::summarize_partial_history;
|
|
|
|
|
use crate::tools::{FetchPage, SearchWeb};
|
|
|
|
|
use rig::client::AgentClientExt;
|
|
|
|
|
use rig::completion::PromptError;
|
|
|
|
|
use rig::providers::ollama;
|
|
|
|
|
|
|
|
|
|
const MAX_RESEARCH_TURNS: usize = 12;
|
|
|
|
|
|
|
|
|
|
/// Findings gathered by a research pass, and whether the researcher was cut
|
|
|
|
|
/// off by the turn budget before it could conclude on its own.
|
|
|
|
|
pub(crate) struct GatheredFindings {
|
|
|
|
|
pub(crate) findings: String,
|
|
|
|
|
pub(crate) incomplete: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Wraps the tool-calling research loop in its own span so it's visible as a
|
|
|
|
|
/// single unit in traces, distinct from the writing and review phases and
|
|
|
|
|
/// nesting rig's own per-turn `chat`/`execute_tool` spans underneath it.
|
2026-08-18 12:27:54 +00:00
|
|
|
#[tracing::instrument(skip(client, feedback, doc_context), fields(gen_ai.agent.name = "researcher"))]
|
Split core.rs into one file per concern
core.rs had grown into a 612-line grab-bag mixing six unrelated concerns:
CLI arg parsing, logging setup, top-level orchestration, the researcher
agent phase, chat-history reconstruction utilities, the summarizer agent
phase, and the writer agent phase — while review.rs, tools.rs, stream.rs,
and progress.rs already correctly isolated their own concerns. This
splits core.rs to match that existing pattern instead of being the one
file that doesn't follow it:
- cli.rs — Cli struct + DEFAULT_TOPIC
- observability.rs — initialize_observability
- models.rs — RESEARCHER_MODEL / WRITER_MODEL (previously duplicated
across call sites, now a single source of truth)
- history.rs — pure chat-history parsing/reconstruction helpers
(partial_findings_from_history, annotated_transcript_from_history, and
their private helpers), plus their unit tests. Also dedupes
MAX_TOOL_RESULT_CHARS, which was previously defined twice.
- researcher.rs — gather_findings + GatheredFindings (the tool-calling
research phase)
- summarizer.rs — summarize_partial_history (the max-turns recovery
agent)
- writer.rs — write_report
- research.rs — the top-level research() orchestration loop
main.rs now only does argument parsing, logging setup, and the top-level
call — no orchestration logic of its own. Unit tests stay co-located
with the code they test per Rust convention (not pulled into separate
files) rather than under "prefer new files" — that applies to
production code organization here.
No behavior changes; cargo test/clippy/fmt all clean.
2026-08-18 10:59:03 +00:00
|
|
|
pub(crate) async fn gather_findings(
|
|
|
|
|
client: &ollama::Client,
|
|
|
|
|
topic: &str,
|
|
|
|
|
feedback: Option<&Review>,
|
2026-08-18 12:27:54 +00:00
|
|
|
doc_context: Option<&str>,
|
Split core.rs into one file per concern
core.rs had grown into a 612-line grab-bag mixing six unrelated concerns:
CLI arg parsing, logging setup, top-level orchestration, the researcher
agent phase, chat-history reconstruction utilities, the summarizer agent
phase, and the writer agent phase — while review.rs, tools.rs, stream.rs,
and progress.rs already correctly isolated their own concerns. This
splits core.rs to match that existing pattern instead of being the one
file that doesn't follow it:
- cli.rs — Cli struct + DEFAULT_TOPIC
- observability.rs — initialize_observability
- models.rs — RESEARCHER_MODEL / WRITER_MODEL (previously duplicated
across call sites, now a single source of truth)
- history.rs — pure chat-history parsing/reconstruction helpers
(partial_findings_from_history, annotated_transcript_from_history, and
their private helpers), plus their unit tests. Also dedupes
MAX_TOOL_RESULT_CHARS, which was previously defined twice.
- researcher.rs — gather_findings + GatheredFindings (the tool-calling
research phase)
- summarizer.rs — summarize_partial_history (the max-turns recovery
agent)
- writer.rs — write_report
- research.rs — the top-level research() orchestration loop
main.rs now only does argument parsing, logging setup, and the top-level
call — no orchestration logic of its own. Unit tests stay co-located
with the code they test per Rust convention (not pulled into separate
files) rather than under "prefer new files" — that applies to
production code organization here.
No behavior changes; cargo test/clippy/fmt all clean.
2026-08-18 10:59:03 +00:00
|
|
|
round: usize,
|
|
|
|
|
show_progress: bool,
|
|
|
|
|
) -> anyhow::Result<GatheredFindings> {
|
|
|
|
|
let current_date = chrono::offset::Local::now().to_string();
|
|
|
|
|
|
|
|
|
|
let researcher = client
|
|
|
|
|
.agent(RESEARCHER_MODEL)
|
|
|
|
|
.name("researcher")
|
|
|
|
|
.preamble(format!(
|
|
|
|
|
"You are a meticulous research assistant. Use the search_web and fetch_page tools to \
|
|
|
|
|
investigate the user's topic: run several searches with varied phrasing, fetch the \
|
|
|
|
|
most promising pages, and cross-check claims across at least two sources before \
|
|
|
|
|
trusting them. Ensure sources are up to date: the current date is {current_date}. \
|
|
|
|
|
Once you are confident you have enough evidence, stop calling tools and reply with a \
|
|
|
|
|
plain-text dump of every fact you gathered, using footnote-style citations: write each \
|
|
|
|
|
fact followed by a bracketed number like [1], then at the end of your reply list a \
|
|
|
|
|
'Sources' section mapping each number to the exact URL it came from, one per line, e.g. \
|
|
|
|
|
'[1] https://example.com/page'. Reuse the same number when multiple facts come from the \
|
|
|
|
|
same URL — do not give one URL two different numbers. Also call out any open questions \
|
|
|
|
|
or contradictions between sources, citing the footnotes involved. This is raw research \
|
|
|
|
|
material for a writer, not a final report, so favor completeness over polish.")
|
|
|
|
|
.as_str(),
|
|
|
|
|
)
|
|
|
|
|
.tool(SearchWeb)
|
|
|
|
|
.tool(FetchPage)
|
|
|
|
|
.build();
|
|
|
|
|
|
|
|
|
|
let task = match feedback {
|
|
|
|
|
None => topic.to_string(),
|
|
|
|
|
Some(review) => format!(
|
|
|
|
|
"Topic: {topic}\n\n\
|
|
|
|
|
You already ran a research pass on this topic. A reviewer checked it against its \
|
|
|
|
|
cited sources and found it insufficient. Do more research to address the reviewer's \
|
|
|
|
|
feedback, then produce an updated findings dump: carry forward what's solid, and \
|
|
|
|
|
add, correct, or better-source whatever the gaps call for. Gaps include out-of-date,\
|
|
|
|
|
irrelevant, or clearly wrong information. The date is {current_date}.\n\n\
|
|
|
|
|
Solid findings from the last pass — keep and build on these:\n{}\n\n\
|
|
|
|
|
Gaps the reviewer found — conclusions not actually backed by their source, sources \
|
|
|
|
|
that don't line up with the conclusion drawn from them, or parts of the topic still \
|
|
|
|
|
uncovered:\n{}",
|
|
|
|
|
review.solid_findings, review.gaps
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-18 12:27:54 +00:00
|
|
|
let task = match doc_context {
|
2026-08-19 13:13:07 +00:00
|
|
|
Some(context) => format!(
|
2026-08-18 12:27:54 +00:00
|
|
|
"{task}\n\n\
|
|
|
|
|
Relevant excerpts from documents the user uploaded — treat these as trusted primary \
|
|
|
|
|
sources alongside anything you find on the web, and cite them with the same \
|
|
|
|
|
footnote scheme (their Sources entry can just be the document path shown below):\n\
|
|
|
|
|
{context}"
|
|
|
|
|
),
|
2026-08-19 13:13:07 +00:00
|
|
|
None => task,
|
2026-08-18 12:27:54 +00:00
|
|
|
};
|
|
|
|
|
|
Split core.rs into one file per concern
core.rs had grown into a 612-line grab-bag mixing six unrelated concerns:
CLI arg parsing, logging setup, top-level orchestration, the researcher
agent phase, chat-history reconstruction utilities, the summarizer agent
phase, and the writer agent phase — while review.rs, tools.rs, stream.rs,
and progress.rs already correctly isolated their own concerns. This
splits core.rs to match that existing pattern instead of being the one
file that doesn't follow it:
- cli.rs — Cli struct + DEFAULT_TOPIC
- observability.rs — initialize_observability
- models.rs — RESEARCHER_MODEL / WRITER_MODEL (previously duplicated
across call sites, now a single source of truth)
- history.rs — pure chat-history parsing/reconstruction helpers
(partial_findings_from_history, annotated_transcript_from_history, and
their private helpers), plus their unit tests. Also dedupes
MAX_TOOL_RESULT_CHARS, which was previously defined twice.
- researcher.rs — gather_findings + GatheredFindings (the tool-calling
research phase)
- summarizer.rs — summarize_partial_history (the max-turns recovery
agent)
- writer.rs — write_report
- research.rs — the top-level research() orchestration loop
main.rs now only does argument parsing, logging setup, and the top-level
call — no orchestration logic of its own. Unit tests stay co-located
with the code they test per Rust convention (not pulled into separate
files) rather than under "prefer new files" — that applies to
production code organization here.
No behavior changes; cargo test/clippy/fmt all clean.
2026-08-18 10:59:03 +00:00
|
|
|
let spinner = Spinner::start(show_progress, format!("{RESEARCH_EMOJI} Researching..."));
|
|
|
|
|
let run_result = researcher
|
|
|
|
|
.runner(task)
|
|
|
|
|
.max_turns(MAX_RESEARCH_TURNS)
|
|
|
|
|
.run()
|
|
|
|
|
.await;
|
|
|
|
|
drop(spinner);
|
|
|
|
|
|
|
|
|
|
let gathered = match run_result {
|
|
|
|
|
Ok(response) => GatheredFindings {
|
|
|
|
|
findings: response.output,
|
|
|
|
|
incomplete: false,
|
|
|
|
|
},
|
|
|
|
|
Err(PromptError::MaxTurnsError { chat_history, .. }) => {
|
|
|
|
|
tracing::warn!(round, "researcher hit its turn budget before concluding");
|
|
|
|
|
GatheredFindings {
|
|
|
|
|
findings: summarize_partial_history(client, topic, &chat_history, show_progress)
|
|
|
|
|
.await,
|
|
|
|
|
incomplete: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(err) => return Err(err.into()),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
tracing::info!(round, findings = %gathered.findings, incomplete = gathered.incomplete, "research phase complete");
|
|
|
|
|
|
|
|
|
|
Ok(gathered)
|
|
|
|
|
}
|