use anyhow::{Result, anyhow};
use crate::ai::stream::collect_blocking;
use crate::config::Config;
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::{NodeKind, SYSTEM_TAG_FACTS, Store};
use super::batch::{Outcome, process_one_tagged};
use super::extract;
const CONFIDENCE_THRESHOLD: f64 = 0.6;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Stop {
Converged,
BudgetSpent,
RoundCap,
}
impl Stop {
fn describe(self) -> &'static str {
match self {
Stop::Converged => "converged (the critic found no further gaps)",
Stop::BudgetSpent => "budget spent (max_subquestions reached)",
Stop::RoundCap => "round cap reached (max_rounds)",
}
}
}
fn decide_stop(
next_is_empty: bool,
budget_remaining: usize,
rounds_done: usize,
max_rounds: usize,
) -> Option<Stop> {
if budget_remaining == 0 {
Some(Stop::BudgetSpent)
} else if rounds_done >= max_rounds {
Some(Stop::RoundCap)
} else if next_is_empty {
Some(Stop::Converged)
} else {
None
}
}
pub(crate) fn run(
layout: &ProjectLayout,
cfg: &Config,
store: &Store,
topic: &str,
out: Option<&str>,
) -> Result<()> {
if !cfg.research.agentic.enabled {
return Err(anyhow!(
"agentic research is disabled — set `research.agentic.enabled: true` in inkhaven.hjson to enable it"
));
}
let topic = topic.trim();
if topic.is_empty() {
return Err(anyhow!("give a topic to research, e.g. `--agentic \"the causes of the 1918 flu\"`"));
}
let ai = crate::ai::AiClient::from_config(&cfg.llm).map_err(|e| anyhow!("no LLM provider: {e}"))?;
let (model, _env) = ai.resolve_provider(&cfg.llm, None).map_err(|e| anyhow!("provider: {e}"))?;
let (lang, _note) = crate::prose::resolve_prose_language(None, &cfg.language);
let language = extract::language_name(&lang);
let hierarchy = Hierarchy::load(store)?;
let facts_book = hierarchy
.iter()
.find(|n| n.kind == NodeKind::Book && n.system_tag.as_deref() == Some(SYSTEM_TAG_FACTS))
.map(|n| n.id);
if facts_book.is_none() {
return Err(anyhow!("this project has no Facts book — agentic research emits into it"));
}
let mut budget = cfg.research.agentic.max_subquestions.max(1);
let max_rounds = cfg.research.agentic.max_rounds.max(1);
let mut pending = plan_subquestions(&ai, &model, language, topic, budget)?;
if pending.is_empty() {
return Err(anyhow!("the planner returned no sub-questions for `{topic}`"));
}
let mut outcomes: Vec<Outcome> = Vec::new();
let mut emitted = 0usize;
let mut dropped = 0usize;
let mut rounds = 0usize;
let stop;
loop {
let take = pending.len().min(budget);
dropped += pending.len() - take;
rounds += 1;
eprintln!("⟳ agentic round {rounds}: {take} sub-question(s)");
for q in pending.iter().take(take) {
eprintln!("· {q}");
let o = process_one_tagged(
layout, cfg, store, &hierarchy, facts_book, &ai, &model, language, q,
true, CONFIDENCE_THRESHOLD, "agentic",
);
if o.action.starts_with("inserted") {
emitted += 1;
}
outcomes.push(o);
}
budget -= take;
let next = if budget == 0 || rounds >= max_rounds {
Vec::new()
} else {
critique_gaps(&ai, &model, language, topic, &outcomes, budget).unwrap_or_default()
};
if let Some(reason) = decide_stop(next.is_empty(), budget, rounds, max_rounds) {
stop = reason;
break;
}
pending = next;
}
let report = render_report(topic, &outcomes, emitted, dropped, rounds, stop);
match out {
Some(p) => {
std::fs::write(p, &report).map_err(|e| anyhow!("write {p}: {e}"))?;
eprintln!("report → {p} ({emitted} fact(s) emitted over {rounds} round(s))");
}
None => print!("{report}"),
}
eprintln!(
"✓ agentic run complete — {emitted} Fact(s) over {rounds} round(s), {} \
(model provenance, untrusted). Review in the Facts book: promote, dispute, or /factcheck.",
stop.describe()
);
Ok(())
}
fn critique_gaps(
ai: &crate::ai::AiClient,
model: &str,
language: &str,
topic: &str,
outcomes: &[Outcome],
budget_remaining: usize,
) -> Result<Vec<String>> {
let mut established = String::new();
for o in outcomes {
if !o.fact.is_empty() {
established.push_str(&format!("- {}: {}\n", o.title, o.fact));
}
}
if established.is_empty() {
established.push_str("(nothing established yet)\n");
}
let system = format!(
"You are a research critic. Given a TOPIC and the facts established so far, list up to \
{budget_remaining} follow-up sub-questions that are still UNANSWERED or that would \
resolve a CONTRADICTION or gap in what's established. If the topic is already covered \
adequately, reply with nothing at all. Reply with ONLY the questions, one per line, no \
numbering, no commentary. Write them in {language}."
);
let user = format!("TOPIC: {topic}\n\nESTABLISHED SO FAR:\n{established}");
let raw = collect_blocking(ai.client.clone(), model.to_string(), Some(system), user)
.map_err(|e| anyhow!("critic failed: {e}"))?;
Ok(parse_subquestions(&raw, budget_remaining))
}
fn plan_subquestions(
ai: &crate::ai::AiClient,
model: &str,
language: &str,
topic: &str,
max: usize,
) -> Result<Vec<String>> {
let system = format!(
"You are a research planner. Decompose the user's topic into at most {max} SPECIFIC, \
individually-answerable sub-questions that together cover it well. Each must be a single \
factual question, not a task. Reply with ONLY the questions, one per line, no numbering, \
no preamble, no commentary. Write the questions in {language}."
);
let raw = collect_blocking(ai.client.clone(), model.to_string(), Some(system), topic.to_string())
.map_err(|e| anyhow!("planner failed: {e}"))?;
Ok(parse_subquestions(&raw, max))
}
fn parse_subquestions(reply: &str, max: usize) -> Vec<String> {
reply
.lines()
.map(|l| {
l.trim()
.trim_start_matches(|c: char| c.is_ascii_digit() || matches!(c, '.' | ')' | '-' | '*' | '•' | ' ' | '\t'))
.trim()
.to_string()
})
.filter(|l| l.len() > 3)
.take(max)
.collect()
}
fn render_report(
topic: &str,
outcomes: &[Outcome],
emitted: usize,
dropped: usize,
rounds: usize,
stop: Stop,
) -> String {
let mut s = String::from("# Agentic research report\n\n");
s.push_str(&format!(
"**Topic:** {topic}\n\n{} sub-question(s) over {rounds} round(s) · {emitted} Fact(s) \
emitted into the Facts book (model provenance, untrusted — review to promote or dispute) \
· stopped: {}.\n\n",
outcomes.len(),
stop.describe()
));
if dropped > 0 {
s.push_str(&format!(
"> {dropped} proposed sub-question(s) were dropped when the budget was reached — raise \
`research.agentic.max_subquestions` to cover them.\n\n"
));
}
for (i, o) in outcomes.iter().enumerate() {
s.push_str(&format!("## {}. {}\n\n", i + 1, o.question));
if !o.title.is_empty() {
s.push_str(&format!("**{}**\n\n", o.title));
}
if !o.fact.is_empty() {
s.push_str(&format!("{}\n\n_confidence {:.2} · {}_\n\n", o.fact, o.confidence, o.action));
} else {
s.push_str(&format!("_{}_\n\n", o.action));
}
}
s
}
#[cfg(test)]
mod tests {
use super::{Stop, decide_stop, parse_subquestions};
#[test]
fn termination_is_bounded_and_prioritised() {
assert_eq!(decide_stop(false, 0, 1, 3), Some(Stop::BudgetSpent));
assert_eq!(decide_stop(false, 4, 3, 3), Some(Stop::RoundCap));
assert_eq!(decide_stop(true, 4, 1, 3), Some(Stop::Converged));
assert_eq!(decide_stop(false, 4, 1, 3), None);
assert!(decide_stop(false, 5, 1, 1).is_some());
}
#[test]
fn parses_planner_replies_in_many_shapes() {
let reply = "1. What caused the 1918 flu?\n\
2) Where did it originate?\n\
- How many died?\n\
* Why was it called Spanish flu?\n\
\n\
ok\n\
• What ended it?";
let qs = parse_subquestions(reply, 10);
assert_eq!(qs.len(), 5, "numbered/bulleted lines parse, short junk drops");
assert_eq!(qs[0], "What caused the 1918 flu?");
assert_eq!(qs[1], "Where did it originate?");
assert_eq!(qs[4], "What ended it?");
assert_eq!(parse_subquestions(reply, 2).len(), 2);
}
}