Deep research agentic loop with rig AgentRunner + Gemma models #2
5 changed files with 114 additions and 9 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1981,6 +1981,7 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"reqwest 0.13.4",
|
"reqwest 0.13.4",
|
||||||
"rig",
|
"rig",
|
||||||
|
"schemars 1.2.2",
|
||||||
"scraper",
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ edition = "2024"
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
reqwest = { version = "0.13.4", features = ["query"] }
|
reqwest = { version = "0.13.4", features = ["query"] }
|
||||||
rig = { version = "0.41.0", features = ["test-utils"] }
|
rig = { version = "0.41.0", features = ["test-utils"] }
|
||||||
|
schemars = "1"
|
||||||
scraper = "0.25"
|
scraper = "0.25"
|
||||||
serde = "1.0.229"
|
serde = "1.0.229"
|
||||||
tokio = { version = "1.53.1", features = ["full"] }
|
tokio = { version = "1.53.1", features = ["full"] }
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
pub mod starter;
|
pub mod starter;
|
||||||
|
mod review;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
|
||||||
57
src/deep_research/review.rs
Normal file
57
src/deep_research/review.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
use rig::client::AgentClientExt;
|
||||||
|
use rig::providers::ollama;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Judging whether a conclusion actually follows from its cited sources is a
|
||||||
|
/// bounded, single-shot classification task, not multi-step reasoning — so
|
||||||
|
/// it doesn't need the researcher's larger model, just a fresh context free
|
||||||
|
/// of the researcher's own (possibly overconfident) framing.
|
||||||
|
const REVIEWER_MODEL: &str = "gemma4-e4b:latest";
|
||||||
|
|
||||||
|
/// A reviewer's structured judgment on a research pass: whether the
|
||||||
|
/// conclusion actually follows from the cited sources, split into what to
|
||||||
|
/// keep and what still needs digging so a follow-up research pass can build
|
||||||
|
/// on this one instead of starting from scratch.
|
||||||
|
#[derive(Deserialize, Serialize, JsonSchema)]
|
||||||
|
pub(crate) struct Review {
|
||||||
|
/// True only if every conclusion in the findings is directly supported by one of its cited sources and the findings adequately cover the topic
|
||||||
|
pub(crate) approved: bool,
|
||||||
|
/// The specific facts and sources from the findings that are well-supported and worth keeping in a follow-up pass
|
||||||
|
pub(crate) solid_findings: String,
|
||||||
|
/// Concrete gaps: conclusions not backed by a source, sources that don't actually support the conclusion drawn from them, or parts of the topic left uncovered
|
||||||
|
pub(crate) gaps: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uses rig's typed extractor — a forced tool call into a `submit(Review)`
|
||||||
|
/// schema — rather than parsing free-text output, so the verdict and its
|
||||||
|
/// two feedback fields always come back structured instead of relying on
|
||||||
|
/// scanning prose for a trailing yes/no.
|
||||||
|
#[tracing::instrument(skip(client, findings), fields(gen_ai.agent.name = "reviewer"))]
|
||||||
|
pub(crate) async fn review_findings(
|
||||||
|
client: &ollama::Client,
|
||||||
|
topic: &str,
|
||||||
|
findings: &str,
|
||||||
|
) -> anyhow::Result<Review> {
|
||||||
|
let reviewer = client
|
||||||
|
.extractor::<Review>(REVIEWER_MODEL)
|
||||||
|
.preamble(
|
||||||
|
"You are a skeptical fact-checker reviewing another researcher's notes before they \
|
||||||
|
get turned into a report. Approve only if every conclusion in the findings is \
|
||||||
|
directly backed by one of its cited sources and the findings adequately cover the \
|
||||||
|
topic. Reject if a conclusion overreaches what its source actually says, if sources \
|
||||||
|
contradict each other without resolution, or if the topic is only partially covered. \
|
||||||
|
Always separate the solid, well-supported findings from the gaps so a follow-up \
|
||||||
|
research pass knows what to keep and what to dig into further.",
|
||||||
|
)
|
||||||
|
.retries(2)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let review = reviewer
|
||||||
|
.extract(format!("Topic: {topic}\n\nResearch findings to review:\n{findings}"))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(approved = review.approved, gaps = %review.gaps, "review complete");
|
||||||
|
|
||||||
|
Ok(review)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use rig::client::{AgentClientExt, Nothing};
|
use rig::client::{AgentClientExt, Nothing};
|
||||||
use rig::completion::Prompt;
|
use rig::completion::Prompt;
|
||||||
use rig::providers::ollama;
|
use rig::providers::ollama;
|
||||||
|
use crate::deep_research::review::{self, Review};
|
||||||
use crate::deep_research::tools::{FetchPage, SearchWeb};
|
use crate::deep_research::tools::{FetchPage, SearchWeb};
|
||||||
|
|
||||||
/// The tool-calling research loop needs to reliably decide what to search
|
/// The tool-calling research loop needs to reliably decide what to search
|
||||||
|
|
@ -11,6 +12,10 @@ use crate::deep_research::tools::{FetchPage, SearchWeb};
|
||||||
const RESEARCHER_MODEL: &str = "gemma4:26b";
|
const RESEARCHER_MODEL: &str = "gemma4:26b";
|
||||||
const WRITER_MODEL: &str = "gemma4-e4b:latest";
|
const WRITER_MODEL: &str = "gemma4-e4b:latest";
|
||||||
const MAX_RESEARCH_TURNS: usize = 12;
|
const MAX_RESEARCH_TURNS: usize = 12;
|
||||||
|
/// 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;
|
||||||
|
|
||||||
const DEFAULT_TOPIC: &str =
|
const DEFAULT_TOPIC: &str =
|
||||||
"What are the latest advances in running large language models locally, on consumer hardware?";
|
"What are the latest advances in running large language models locally, on consumer hardware?";
|
||||||
|
|
@ -41,20 +46,44 @@ pub(crate) async fn start() -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
async fn research(topic: &str) -> anyhow::Result<String> {
|
async fn research(topic: &str) -> anyhow::Result<String> {
|
||||||
let client = ollama::Client::new(Nothing)?;
|
let client = ollama::Client::new(Nothing)?;
|
||||||
|
|
||||||
let findings = gather_findings(&client, topic).await?;
|
let mut findings = String::new();
|
||||||
let report = write_report(&client, topic, &findings).await?;
|
let mut feedback: Option<Review> = None;
|
||||||
|
|
||||||
Ok(report)
|
for round in 1..=MAX_RESEARCH_ROUNDS {
|
||||||
|
findings = gather_findings(&client, topic, feedback.as_ref(), round).await?;
|
||||||
|
|
||||||
|
let review = review::review_findings(&client, topic, &findings).await?;
|
||||||
|
let approved = review.approved;
|
||||||
|
|
||||||
|
tracing::info!(round, approved, "review verdict");
|
||||||
|
|
||||||
|
if approved || round == MAX_RESEARCH_ROUNDS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
feedback = Some(review);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_report(&client, topic, &findings).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wraps the tool-calling research loop in its own span so it's visible as a
|
/// Wraps the tool-calling research loop in its own span so it's visible as a
|
||||||
/// single unit in traces, distinct from the writing phase and nesting rig's
|
/// single unit in traces, distinct from the writing and review phases and
|
||||||
/// own per-turn `chat`/`execute_tool` spans underneath it.
|
/// nesting rig's own per-turn `chat`/`execute_tool` spans underneath it.
|
||||||
#[tracing::instrument(skip(client), fields(gen_ai.agent.name = "researcher"))]
|
#[tracing::instrument(skip(client, feedback), fields(gen_ai.agent.name = "researcher"))]
|
||||||
async fn gather_findings(client: &ollama::Client, topic: &str) -> anyhow::Result<String> {
|
async fn gather_findings(
|
||||||
|
client: &ollama::Client,
|
||||||
|
topic: &str,
|
||||||
|
feedback: Option<&Review>,
|
||||||
|
round: usize,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
let researcher = client
|
let researcher = client
|
||||||
.agent(RESEARCHER_MODEL)
|
.agent(RESEARCHER_MODEL)
|
||||||
.name("researcher")
|
.name("researcher")
|
||||||
|
|
@ -71,14 +100,30 @@ async fn gather_findings(client: &ollama::Client, topic: &str) -> anyhow::Result
|
||||||
.tool(FetchPage)
|
.tool(FetchPage)
|
||||||
.build();
|
.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.\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
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
let findings = researcher
|
let findings = researcher
|
||||||
.runner(topic)
|
.runner(task)
|
||||||
.max_turns(MAX_RESEARCH_TURNS)
|
.max_turns(MAX_RESEARCH_TURNS)
|
||||||
.run()
|
.run()
|
||||||
.await?
|
.await?
|
||||||
.output;
|
.output;
|
||||||
|
|
||||||
tracing::info!(findings = %findings, "research phase complete");
|
tracing::info!(round, findings = %findings, "research phase complete");
|
||||||
|
|
||||||
Ok(findings)
|
Ok(findings)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue