2026-08-14 17:43:13 +00:00
|
|
|
use crate::progress::Spinner;
|
|
|
|
|
use crate::review::{self, Review};
|
2026-08-17 10:37:11 +00:00
|
|
|
use crate::stream::write_text_stream;
|
2026-08-14 17:43:13 +00:00
|
|
|
use crate::tools::{FetchPage, SearchWeb};
|
2026-08-14 17:32:10 +00:00
|
|
|
use clap::Parser;
|
2026-08-14 10:40:09 +00:00
|
|
|
use rig::client::{AgentClientExt, Nothing};
|
|
|
|
|
use rig::providers::ollama;
|
2026-08-17 10:37:11 +00:00
|
|
|
use rig::streaming::StreamingPrompt;
|
2026-08-17 10:17:26 +00:00
|
|
|
use std::io::Write;
|
2026-08-14 10:40:09 +00:00
|
|
|
|
|
|
|
|
/// The tool-calling research loop needs to reliably decide what to search
|
|
|
|
|
/// for, when a page is worth fetching, and when it has enough evidence —
|
|
|
|
|
/// that's a reasoning-heavy job best given to the largest local Gemma
|
|
|
|
|
/// variant. Turning the gathered notes into prose afterwards is comparatively
|
|
|
|
|
/// mechanical, so the smaller/faster variant handles that pass instead.
|
|
|
|
|
const RESEARCHER_MODEL: &str = "gemma4:26b";
|
|
|
|
|
const WRITER_MODEL: &str = "gemma4-e4b:latest";
|
|
|
|
|
const MAX_RESEARCH_TURNS: usize = 12;
|
2026-08-14 10:58:39 +00:00
|
|
|
/// Research/review rounds before giving up and writing the report from
|
|
|
|
|
/// whatever the last pass produced, rather than looping forever on a topic
|
|
|
|
|
/// the reviewer can never be satisfied with.
|
|
|
|
|
const MAX_RESEARCH_ROUNDS: usize = 3;
|
2026-08-14 10:40:09 +00:00
|
|
|
|
|
|
|
|
|
2026-08-14 17:32:10 +00:00
|
|
|
/// Deep research agentic loop over local Gemma models: a tool-calling agent
|
|
|
|
|
/// gathers and cross-checks web evidence, a reviewer agent gates it, and a
|
|
|
|
|
/// writer agent turns approved findings into a structured report.
|
|
|
|
|
#[derive(Parser)]
|
|
|
|
|
#[command(name = "doubleo7-research", version, about)]
|
2026-08-17 10:17:26 +00:00
|
|
|
pub(crate) struct Cli {
|
2026-08-14 17:32:10 +00:00
|
|
|
/// Research topic to investigate
|
2026-08-17 10:17:26 +00:00
|
|
|
pub(crate) topic: Option<String>,
|
2026-08-14 17:32:10 +00:00
|
|
|
|
|
|
|
|
/// Emit logs at this level (off by default; passing this also enables a
|
|
|
|
|
/// progress spinner to switch off, since the logs already show progress)
|
|
|
|
|
#[arg(short = 'l', long, value_name = "LEVEL")]
|
2026-08-17 10:17:26 +00:00
|
|
|
pub(crate) log_level: Option<tracing::Level>,
|
2026-08-14 17:32:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Only initializes a subscriber (and thus produces any log output at all)
|
|
|
|
|
/// when the caller opted in via `--log-level` — otherwise tracing's macros
|
|
|
|
|
/// are no-ops, leaving the terminal clean for the spinner.
|
2026-08-17 10:17:26 +00:00
|
|
|
pub(crate) fn initialize_observability(log_level: tracing::Level) {
|
2026-08-14 10:40:09 +00:00
|
|
|
tracing_subscriber::fmt()
|
|
|
|
|
.with_env_filter(
|
|
|
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
2026-08-14 17:32:10 +00:00
|
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level.to_string())),
|
2026-08-14 10:40:09 +00:00
|
|
|
)
|
|
|
|
|
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE)
|
|
|
|
|
.with_writer(std::io::stderr)
|
|
|
|
|
.init();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 10:58:39 +00:00
|
|
|
/// The least-agentic shape that fits: a plain Rust loop putting *this code*,
|
|
|
|
|
/// not a model, in charge of when to stop — re-running research with the
|
|
|
|
|
/// reviewer's feedback folded in until it approves or the round budget runs
|
|
|
|
|
/// out, then writing the report from whatever the last pass produced.
|
2026-08-17 10:17:26 +00:00
|
|
|
pub(crate) async fn research(topic: &str, show_progress: bool) -> anyhow::Result<String> {
|
2026-08-14 10:40:09 +00:00
|
|
|
let client = ollama::Client::new(Nothing)?;
|
|
|
|
|
|
2026-08-14 10:58:39 +00:00
|
|
|
let mut findings = String::new();
|
|
|
|
|
let mut feedback: Option<Review> = None;
|
2026-08-14 10:46:01 +00:00
|
|
|
|
2026-08-14 10:58:39 +00:00
|
|
|
for round in 1..=MAX_RESEARCH_ROUNDS {
|
2026-08-14 17:32:10 +00:00
|
|
|
findings = gather_findings(&client, topic, feedback.as_ref(), round, show_progress).await?;
|
2026-08-14 10:58:39 +00:00
|
|
|
|
2026-08-14 17:32:10 +00:00
|
|
|
let review = review::review_findings(&client, topic, &findings, show_progress).await?;
|
2026-08-14 10:58:39 +00:00
|
|
|
let approved = review.approved;
|
|
|
|
|
|
|
|
|
|
tracing::info!(round, approved, "review verdict");
|
|
|
|
|
|
|
|
|
|
if approved || round == MAX_RESEARCH_ROUNDS {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
feedback = Some(review);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 17:32:10 +00:00
|
|
|
write_report(&client, topic, &findings, show_progress).await
|
2026-08-14 10:46:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Wraps the tool-calling research loop in its own span so it's visible as a
|
2026-08-14 10:58:39 +00:00
|
|
|
/// single unit in traces, distinct from the writing and review phases and
|
|
|
|
|
/// nesting rig's own per-turn `chat`/`execute_tool` spans underneath it.
|
|
|
|
|
#[tracing::instrument(skip(client, feedback), fields(gen_ai.agent.name = "researcher"))]
|
|
|
|
|
async fn gather_findings(
|
|
|
|
|
client: &ollama::Client,
|
|
|
|
|
topic: &str,
|
|
|
|
|
feedback: Option<&Review>,
|
|
|
|
|
round: usize,
|
2026-08-14 17:32:10 +00:00
|
|
|
show_progress: bool,
|
2026-08-14 10:58:39 +00:00
|
|
|
) -> anyhow::Result<String> {
|
2026-08-14 17:06:15 +00:00
|
|
|
let current_date = chrono::offset::Local::now().to_string();
|
|
|
|
|
|
2026-08-14 10:40:09 +00:00
|
|
|
let researcher = client
|
|
|
|
|
.agent(RESEARCHER_MODEL)
|
2026-08-14 10:46:01 +00:00
|
|
|
.name("researcher")
|
2026-08-14 17:06:15 +00:00
|
|
|
.preamble(format!(
|
2026-08-14 10:40:09 +00:00
|
|
|
"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 \
|
2026-08-14 17:06:15 +00:00
|
|
|
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(),
|
2026-08-14 10:40:09 +00:00
|
|
|
)
|
|
|
|
|
.tool(SearchWeb)
|
|
|
|
|
.tool(FetchPage)
|
|
|
|
|
.build();
|
|
|
|
|
|
2026-08-14 10:58:39 +00:00
|
|
|
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 \
|
2026-08-14 17:06:15 +00:00
|
|
|
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\
|
2026-08-14 10:58:39 +00:00
|
|
|
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-14 17:32:10 +00:00
|
|
|
let spinner = Spinner::start(show_progress, "Researching...");
|
2026-08-14 10:40:09 +00:00
|
|
|
let findings = researcher
|
2026-08-14 10:58:39 +00:00
|
|
|
.runner(task)
|
2026-08-14 10:40:09 +00:00
|
|
|
.max_turns(MAX_RESEARCH_TURNS)
|
|
|
|
|
.run()
|
|
|
|
|
.await?
|
|
|
|
|
.output;
|
2026-08-14 17:32:10 +00:00
|
|
|
drop(spinner);
|
2026-08-14 10:40:09 +00:00
|
|
|
|
2026-08-14 10:58:39 +00:00
|
|
|
tracing::info!(round, findings = %findings, "research phase complete");
|
2026-08-14 10:46:01 +00:00
|
|
|
|
|
|
|
|
Ok(findings)
|
|
|
|
|
}
|
2026-08-14 10:40:09 +00:00
|
|
|
|
2026-08-14 10:46:01 +00:00
|
|
|
#[tracing::instrument(skip(client, findings), fields(gen_ai.agent.name = "writer"))]
|
2026-08-14 17:06:15 +00:00
|
|
|
async fn write_report(
|
|
|
|
|
client: &ollama::Client,
|
|
|
|
|
topic: &str,
|
|
|
|
|
findings: &str,
|
2026-08-14 17:32:10 +00:00
|
|
|
show_progress: bool,
|
2026-08-14 17:06:15 +00:00
|
|
|
) -> anyhow::Result<String> {
|
2026-08-14 10:40:09 +00:00
|
|
|
let writer = client
|
|
|
|
|
.agent(WRITER_MODEL)
|
2026-08-14 10:46:01 +00:00
|
|
|
.name("writer")
|
2026-08-14 10:40:09 +00:00
|
|
|
.preamble(
|
2026-08-14 17:06:15 +00:00
|
|
|
"You turn raw research notes into a clear, well-organized report for the reader. The \
|
|
|
|
|
notes use footnote-style citations — a bracketed number like [1] after a fact, with a \
|
|
|
|
|
Sources section mapping numbers to URLs. Preserve this scheme in your report: keep the \
|
|
|
|
|
same [n] markers next to the claims they support (renumbering only if you drop unused \
|
|
|
|
|
sources), and end the report with a 'Sources' section listing every footnote number \
|
|
|
|
|
still in use next to its exact URL. Structure the body with headings, and call out any \
|
|
|
|
|
open questions or contradictions the research turned up. Do not invent facts beyond \
|
|
|
|
|
what the notes provide.",
|
2026-08-14 10:40:09 +00:00
|
|
|
)
|
|
|
|
|
.build();
|
|
|
|
|
|
2026-08-17 10:17:26 +00:00
|
|
|
// Drop the spinner before streaming starts: report text is about to print
|
|
|
|
|
// to the same terminal line, so the two must not race over stdout.
|
2026-08-14 17:32:10 +00:00
|
|
|
let spinner = Spinner::start(show_progress, "Writing report...");
|
2026-08-17 10:37:11 +00:00
|
|
|
let response_stream = writer
|
2026-08-17 10:17:26 +00:00
|
|
|
.stream_prompt(format!("Topic: {topic}\n\nResearch notes:\n{findings}"))
|
|
|
|
|
.await;
|
2026-08-14 17:32:10 +00:00
|
|
|
drop(spinner);
|
2026-08-14 10:40:09 +00:00
|
|
|
|
2026-08-17 10:20:35 +00:00
|
|
|
// Locked once for the whole stream rather than per chunk (as print!
|
|
|
|
|
// would do internally) — chunks arrive in a tight loop, so re-acquiring
|
|
|
|
|
// the lock on every one adds up.
|
|
|
|
|
let stdout = std::io::stdout();
|
|
|
|
|
let mut handle = stdout.lock();
|
2026-08-17 10:37:11 +00:00
|
|
|
let report = write_text_stream(response_stream, &mut handle).await?;
|
2026-08-17 10:20:35 +00:00
|
|
|
writeln!(handle)?;
|
2026-08-17 10:17:26 +00:00
|
|
|
|
2026-08-14 10:40:09 +00:00
|
|
|
Ok(report)
|
|
|
|
|
}
|