use crate::agent::{chat_request, role_tools_and_specs, run_default_agent};
use crate::message_router::{self, AgentJob, JobKind};
use crate::prompt::{load_prompt, substitute};
use crate::retry::FailureClass;
use crate::tools::Tool;
use crate::tools::analyze::{
AnalystFindings, Claim, RoundMember, VerificationResult, VerificationTarget,
await_round_members, build_async_result_envelope, dispatch_claim_verifiers, escape_fences,
extract_query_telemetry, extract_query_telemetry_from_history, load_analyst_angles,
max_confidence, normalize_claim, round_timeout,
};
use crate::{ChatMessage, ChatRequest, ChatRequestMeta, Role, ToolSpec, Workspace};
use anyhow::Result;
use async_trait::async_trait;
use futures_util::FutureExt;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashSet;
use std::fmt::Write as _;
use std::path::Path;
use std::time::{Duration, Instant};
const RESEARCH_MAX_ANALYSTS: usize = 30;
const DECOMPOSE_FAN_OUT: usize = 3;
const GAP_ROUND_WIDTHS: &[usize] = &[4, 3, 2];
const GAP_EXTRACTION_FAILED: &str = "gap extraction failed — remaining gaps unknown";
const PLAN_MERGE_FAILED: &str = "plan merge failed — using first valid decomposition plan verbatim";
const CLAIM_ANNOTATION_FAILED: &str = "claim annotation failed — all new claims treated as novel";
const CONFIRM_FAILED: &str =
"annotation link confirmation failed — mutating links treated as weak/unconfirmed";
const CODER_MIN_REMAINING: Duration = Duration::from_mins(30);
const DEFAULT_WRAP_UP_TIMEOUT_SECS: u64 = 5 * 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ResearchStage {
Decompose,
Round1,
GapRounds,
#[serde(alias = "verification")]
Synthesis,
}
#[derive(Debug, Serialize, Deserialize)]
struct ResearchState {
stage: ResearchStage,
plan: Option<MergedPlan>,
gap_list: Option<GapList>,
acc: AccumulatedEvidence,
ledger: QueryLedger,
markers: Vec<String>,
gap_outcome: GapRoundsOutcome,
budget_spent: usize,
round_index: usize,
verification: Vec<VerificationResult>,
#[serde(skip)]
commands: Vec<String>,
#[serde(skip)]
seen_commands: std::collections::HashSet<String>,
#[serde(default)]
coder_rounds_done: Vec<usize>,
}
impl Default for ResearchState {
fn default() -> Self {
Self {
stage: ResearchStage::Decompose,
plan: None,
gap_list: None,
acc: AccumulatedEvidence::default(),
ledger: QueryLedger::default(),
markers: Vec::new(),
gap_outcome: GapRoundsOutcome::default(),
budget_spent: 0,
round_index: 0,
verification: Vec::new(),
commands: Vec::new(),
seen_commands: std::collections::HashSet::new(),
coder_rounds_done: Vec::new(),
}
}
}
impl ResearchState {
async fn load(job_id: &str) -> Self {
let row = crate::session::store()
.conn
.query_optional(
"SELECT state FROM research_jobs WHERE id = ?1",
crate::turso::params![job_id],
|r| r.get::<String>(0),
)
.await
.ok()
.flatten();
let Some(json) = row else {
return Self::default();
};
if json.trim().is_empty() || json == "{}" {
return Self::default();
}
match serde_json::from_str::<ResearchState>(&json) {
Ok(mut s) => {
s.acc.rebuild_keys();
let run_root = crate::research_cleanup::run_root_path(job_id);
s.commands = crate::research_cleanup::read_command_dump(&run_root).await;
s.seen_commands = s.commands.iter().cloned().collect();
s
}
Err(e) => {
tracing::warn!(job = %job_id, error = %e, "Research state unreadable — fresh run");
Self::default()
}
}
}
async fn save(&self, job_id: &str) {
let json = serde_json::to_string(self).unwrap_or_default();
let now = crate::turso::now();
let conn = &crate::session::store().conn;
let tx = match conn.begin_tx().await {
Ok(tx) => tx,
Err(e) => {
tracing::warn!(job = %job_id, error = %e, "Research checkpoint: failed to begin transaction");
return;
}
};
let outcome: Result<()> = async {
tx.execute(
"UPDATE research_jobs SET state = ?1 WHERE id = ?2",
crate::turso::params![json, job_id],
)
.await?;
tx.execute(
"UPDATE jobs SET status = ?1, updated_at = ?2 WHERE id = ?3",
crate::turso::params![crate::jobs::RowStatus::Launched.as_str(), now, job_id],
)
.await?;
Ok(())
}
.await;
match outcome {
Ok(()) => {
if let Err(e) = tx.commit().await {
tracing::warn!(job = %job_id, error = %e, "Research checkpoint: failed to commit");
}
}
Err(e) => {
tracing::warn!(job = %job_id, error = %e, "Research checkpoint failed — state not persisted");
let _ = tx.rollback().await;
}
}
}
async fn capture_round(&mut self, agent_ids: &[String], run_root: &Path) {
let fresh = crate::research_cleanup::collect_agent_shell_commands(agent_ids).await;
for cmd in fresh {
if self.seen_commands.insert(cmd.clone()) {
self.commands.push(cmd);
}
}
crate::research_cleanup::cap_command_dump(
&mut self.commands,
crate::research_cleanup::COMMAND_DUMP_CAP_BYTES,
);
self.seen_commands = self.commands.iter().cloned().collect();
crate::research_cleanup::write_command_dump(run_root, &self.commands).await;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SubQuestion {
question: String,
evidence_needed: String,
risk: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DecompositionPlan {
sub_questions: Vec<SubQuestion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MergedSubQuestion {
from_id: usize,
#[serde(default)]
also_ids: Vec<usize>,
#[serde(default)]
question: String,
#[serde(default)]
evidence_needed: String,
#[serde(default)]
risk: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DroppedSubQuestion {
id: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MergedPlan {
sub_questions: Vec<MergedSubQuestion>,
dropped: Vec<DroppedSubQuestion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Gap {
#[serde(rename = "type")]
kind: String,
item: String,
traces_to: usize,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
struct GapList {
gaps: Vec<Gap>,
}
#[derive(Debug, Clone, Deserialize)]
struct AnswerabilityCheck {
answerable: bool,
reason: String,
}
#[derive(Debug)]
struct ResearchBudget {
spent: usize,
cap: usize,
}
impl ResearchBudget {
fn new(cap: usize) -> Self {
Self { spent: 0, cap }
}
fn try_reserve(&mut self, n: usize) -> Result<(), String> {
if self.spent + n > self.cap {
return Err(format!(
"research analyst budget exhausted ({}/{})",
self.spent + n,
self.cap
));
}
self.spent += n;
Ok(())
}
fn is_exhausted(&self) -> bool {
self.spent >= self.cap
}
}
pub struct ResearchTool {
pub caller_role: Role,
}
impl ResearchTool {
#[must_use]
pub const fn new(caller_role: Role) -> Self {
Self { caller_role }
}
}
#[async_trait]
impl Tool for ResearchTool {
fn name(&self) -> &'static str {
"research"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"question": {
"type": "string",
"description": "The deep research question to investigate"
}
}),
&["question"],
)
}
fn side_effects(&self) -> bool {
false
}
async fn execute(&self, ws: &Workspace, args: serde_json::Value) -> Result<String> {
let question = super::get_str(&args, "question")?;
let ws = ws.clone();
let question = question.to_string();
let caller_role = self.caller_role;
let user_name = crate::agent::CURRENT_TOOL_USER_NAME
.try_with(String::clone)
.unwrap_or_default();
let channel = crate::agent::CURRENT_TOOL_CHANNEL
.try_with(String::clone)
.unwrap_or_default();
tokio::spawn(async move {
let run = std::panic::AssertUnwindSafe(async {
dispatch_durable_research(
&ws,
&question,
caller_role,
user_name.clone(),
channel.clone(),
)
.await
})
.catch_unwind()
.await;
let envelope = match run {
Ok(Some(envelope)) => envelope,
Ok(None) => {
tracing::info!(
"Research run ended without delivery (aborted or manually cancelled)"
);
return;
}
Err(panic) => {
let panic = crate::util::panic_message(&*panic);
tracing::error!(panic = %panic, "research dispatch panicked");
AgentJob {
content: build_async_research_message(&Err(anyhow::anyhow!(
"research dispatch panicked: {panic}"
))),
workspace_name: ws.name.clone(),
user_name,
channel,
kind: JobKind::ResearchResult,
role: caller_role,
reply_target: None,
pending_job_id: None,
}
}
};
message_router::route(&crate::jobs::envelope_target(&envelope), envelope);
});
Ok(
"Deep research dispatched. One report will be delivered when the run completes."
.to_string(),
)
}
}
async fn dispatch_durable_research(
ws: &Workspace,
question: &str,
caller_role: Role,
user_name: String,
channel: String,
) -> Option<AgentJob> {
let job_id = crate::generate_id();
let _cancel_guard = crate::research_cancel::register(&job_id);
let spawn = async {
crate::jobs::spawn_job(
&crate::session::store().conn,
&job_id,
question,
&ws.name,
&user_name,
&channel,
caller_role,
&[],
&crate::jobs::SpawnChild::Research,
)
.await
};
let spawn_out = spawn.await;
let spawned = spawn_out.is_ok();
let exit = match spawn_out {
Ok(()) => run_deep_research(ws, question, &job_id, false).await,
Err(e) => ResearchExit::Terminal(Err(e)),
};
let result = match exit {
ResearchExit::Aborted => {
tracing::info!(
job = %job_id,
"Research run aborted by shutdown/drain — resumes at next boot"
);
return None;
}
ResearchExit::Cancelled => {
tracing::info!(
job = %job_id,
"Research run manually cancelled — permanent stop, nothing delivered"
);
let _ = crate::research_cancel::sweep_cancelled_run(&job_id).await;
return None;
}
ResearchExit::Terminal(result) => result,
};
if crate::research_cancel::is_cancelled(&job_id) {
tracing::info!(
job = %job_id,
"Research run cancelled during terminalization — permanent stop"
);
let _ = crate::research_cancel::sweep_cancelled_run(&job_id).await;
return None;
}
if spawned {
let state = ResearchState::load(&job_id).await;
let delivered = build_async_research_message(&result);
write_terminalization_artifacts(&job_id, question, &delivered, &state).await;
}
let envelope = crate::jobs::complete_durable_job(
&job_id,
build_async_research_message(&result),
JobKind::ResearchResult,
caller_role,
&user_name,
&channel,
&ws.name,
)
.await;
if crate::research_cancel::is_cancelled(&job_id) {
tracing::info!(
job = %job_id,
"Research completion suppressed by manual cancel — not routed"
);
let _ = crate::research_cancel::sweep_cancelled_run(&job_id).await;
return None;
}
if spawned
&& let Err(e) =
crate::research_cleanup::dispatch_research_cleanup(&job_id, question, ws).await
{
tracing::warn!(
job = %job_id,
error = %e,
"Research cleanup dispatch failed — run folder left for the OS temp sweep"
);
}
Some(envelope)
}
async fn write_terminalization_artifacts(
job_id: &str,
question: &str,
delivered: &str,
state: &ResearchState,
) {
crate::research_cleanup::write_results_md(job_id, question, delivered).await;
let run_root = crate::research_cleanup::ensure_run_root(job_id).await;
crate::research_cleanup::write_command_dump(&run_root, &state.commands).await;
}
async fn terminalize_research(
job_id: &str,
ws: &Workspace,
result: &anyhow::Result<String>,
state: &ResearchState,
caller_role: Role,
caller: &crate::jobs::JobCaller,
) {
if crate::research_cancel::is_cancelled(job_id) {
tracing::info!(job = %job_id, "Research terminalization suppressed by manual cancel");
let _ = crate::research_cancel::sweep_cancelled_run(job_id).await;
return;
}
let delivered = build_async_research_message(result);
write_terminalization_artifacts(job_id, &caller.task, &delivered, state).await;
let envelope = crate::jobs::complete_durable_job(
job_id,
delivered,
JobKind::ResearchResult,
caller_role,
&caller.user_name,
&caller.channel,
&ws.name,
)
.await;
if crate::research_cancel::is_cancelled(job_id) {
tracing::info!(job = %job_id, "Research completion suppressed by manual cancel — not routed");
let _ = crate::research_cancel::sweep_cancelled_run(job_id).await;
return;
}
if let Err(e) =
crate::research_cleanup::dispatch_research_cleanup(job_id, &caller.task, ws).await
{
tracing::warn!(
job = %job_id,
error = %e,
"Research cleanup dispatch failed — run folder left for the OS temp sweep"
);
}
crate::message_router::route(&crate::jobs::envelope_target(&envelope), envelope);
}
pub(crate) async fn resume_research_run(job_id: &str, ws: &Workspace) {
let _cancel_guard = crate::research_cancel::register(job_id);
let Some((caller, caller_role)) = crate::jobs::resume_job_preamble(
&crate::session::store().conn,
job_id,
"Research resume",
"Research resume",
)
.await
else {
return;
};
let result = match run_deep_research(ws, &caller.task, job_id, true).await {
ResearchExit::Aborted => {
tracing::info!(
job = %job_id,
"Research resume aborted after run — job stays for next boot",
);
return;
}
ResearchExit::Cancelled => {
tracing::info!(
job = %job_id,
"Research resume manually cancelled — permanent stop, nothing delivered",
);
let _ = crate::research_cancel::sweep_cancelled_run(job_id).await;
return;
}
ResearchExit::Terminal(result) => result,
};
if crate::research_cancel::is_cancelled(job_id) {
tracing::info!(job = %job_id, "Research resume cancelled during terminalization — permanent stop");
let _ = crate::research_cancel::sweep_cancelled_run(job_id).await;
return;
}
let state = ResearchState::load(job_id).await;
terminalize_research(job_id, ws, &result, &state, caller_role, &caller).await;
}
pub(crate) async fn research_capped_partial_report(job_id: &str, ws: &Workspace) {
let Some((caller, caller_role)) = crate::jobs::resume_job_preamble(
&crate::session::store().conn,
job_id,
"Research capped report",
"Research cap",
)
.await
else {
return;
};
let state = ResearchState::load(job_id).await;
let result: anyhow::Result<String> = Ok(partial_report(
&caller.task,
&state.acc,
"boot re-dispatch cap exceeded — partial report from last checkpoint",
&[],
));
terminalize_research(job_id, ws, &result, &state, caller_role, &caller).await;
}
fn build_async_research_message(result: &anyhow::Result<String>) -> String {
build_async_result_envelope(result, "research-result")
}
#[derive(Debug, Default)]
struct EvidenceRound {
urls: Vec<String>,
claims: Vec<Claim>,
unanswered: Vec<String>,
queries: usize,
repeat_queries: usize,
raw_reports: Vec<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct AccumulatedEvidence {
urls: HashSet<String>,
claims: Vec<Claim>,
unanswered: Vec<String>,
#[serde(skip)]
unanswered_keys: HashSet<String>,
raw_reports: Vec<String>,
weak: WeakLinks,
}
impl AccumulatedEvidence {
fn rebuild_keys(&mut self) {
self.unanswered_keys = self
.unanswered
.iter()
.filter_map(|u| {
let key = crate::tools::analyze::normalize_claim(u);
(!key.is_empty()).then_some(key)
})
.collect();
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct WeakLinks {
duplicates: Vec<(usize, usize)>,
contradictions: Vec<(usize, usize)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ClaimAnnotation {
new_id: usize,
verdict: String,
existing_id: Option<usize>,
contradiction: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct AnnotationPass {
annotations: Vec<ClaimAnnotation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfirmLink {
new_id: usize,
verdict: String,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfirmPass {
links: Vec<ConfirmLink>,
}
enum ConfirmOutcome {
Passed(ConfirmPass),
Failed,
}
impl AccumulatedEvidence {
fn absorb(&mut self, round: &EvidenceRound) -> (usize, Vec<Claim>) {
let novel_urls = round
.urls
.iter()
.filter(|u| self.urls.insert((*u).clone()))
.count();
for u in &round.unanswered {
let key = normalize_claim(u);
if !key.is_empty() && self.unanswered_keys.insert(key) {
self.unanswered.push(u.clone());
}
}
for r in &round.raw_reports {
if !self.raw_reports.contains(r) {
self.raw_reports.push(r.clone());
}
}
(novel_urls, round.claims.clone())
}
fn apply_annotations(
&mut self,
pass: &AnnotationPass,
pending: &[Claim],
confirm: &ConfirmOutcome,
) -> usize {
let confirmed: HashSet<usize> = match confirm {
ConfirmOutcome::Passed(p) => p
.links
.iter()
.filter(|l| l.verdict == "confirm")
.map(|l| l.new_id)
.collect(),
ConfirmOutcome::Failed => HashSet::new(),
};
let mut novel = 0usize;
for a in &pass.annotations {
let pending_claim = &pending[a.new_id];
match a.verdict.as_str() {
"novel" => {
self.claims.push(pending_claim.clone());
novel += 1;
}
"duplicate" => {
let existing_id = a.existing_id.expect("duplicate cites an existing claim");
if confirmed.contains(&a.new_id) {
let existing = &mut self.claims[existing_id];
existing.confidence =
max_confidence(&existing.confidence, &pending_claim.confidence);
for c in &pending_claim.contradictions {
if !existing.contradictions.contains(c) {
existing.contradictions.push(c.clone());
}
}
let mut merged: Vec<String> = existing
.source
.split("; ")
.filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string())
.collect();
for s in pending_claim.source.split("; ") {
let s = s.trim();
if !s.is_empty() && !merged.iter().any(|m| m == s) {
merged.push(s.to_string());
}
}
existing.source = merged.join("; ");
} else {
let id = self.claims.len();
self.claims.push(pending_claim.clone());
self.weak.duplicates.push((id, existing_id));
}
}
"contradicts" => {
let existing_id = a.existing_id.expect("contradicts cites an existing claim");
let note = a.contradiction.as_deref().unwrap_or_default();
let existing = &mut self.claims[existing_id];
if !existing.contradictions.iter().any(|c| c == note) {
existing.contradictions.push(note.to_string());
}
let mut new_claim = pending_claim.clone();
if !new_claim
.contradictions
.iter()
.any(|c| c == &existing.claim)
{
new_claim.contradictions.push(existing.claim.clone());
}
let id = self.claims.len();
self.claims.push(new_claim);
novel += 1;
if !confirmed.contains(&a.new_id) {
self.weak.contradictions.push((id, existing_id));
}
}
_ => unreachable!("validator guarantees the verdict vocabulary"),
}
}
novel
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct QueryLedger {
queries: HashSet<String>,
}
impl QueryLedger {
fn register(&mut self, query: &str) -> bool {
let norm = normalize_claim(query);
!norm.is_empty() && self.queries.insert(norm)
}
fn render(&self) -> String {
if self.queries.is_empty() {
return "none yet".to_string();
}
let mut v: Vec<String> = self.queries.iter().cloned().collect();
v.sort();
v.join("\n")
}
}
#[derive(Debug, Default)]
struct RunStats {
tool_calls: usize,
searches: usize,
repeat_queries: usize,
failed_analysts: usize,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct GapRoundsOutcome {
abstention: Option<String>,
unresolved: Vec<String>,
rounds_dispatched: usize,
incomplete: Option<String>,
}
#[derive(Clone)]
struct WrapUpEntry {
agent_id: String,
params: ChatRequest,
}
struct WrapUpPrepared {
params: ChatRequest,
history: Vec<ChatMessage>,
}
fn wrap_up_timeout() -> Duration {
crate::util::env_duration_secs("MAHBOT_WRAP_UP_TIMEOUT_SECS", DEFAULT_WRAP_UP_TIMEOUT_SECS)
}
fn research_params(
ws: &Workspace,
purpose: &'static str,
agent_id: String,
tool_specs: Option<Vec<ToolSpec>>,
) -> ChatRequest {
ChatRequest {
meta: Some(ChatRequestMeta {
purpose,
agent_id,
role: Role::Analyst.as_str().to_string(),
workspace: ws.name.clone(),
ticket_id: None,
}),
..chat_request(Role::Analyst, tool_specs, Vec::new(), false)
}
}
fn wrap_up_params(ws: &Workspace, agent_id: &str, tool_specs: Vec<ToolSpec>) -> ChatRequest {
research_params(
ws,
"research_wrap_up",
agent_id.to_string(),
Some(tool_specs),
)
}
async fn wrap_up_timed_out(
ws: &Workspace,
timed_out: Vec<WrapUpEntry>,
ledger: &mut QueryLedger,
run_stats: &mut RunStats,
run_key: &str,
question: &str,
) -> Vec<AnalystFindings> {
if timed_out.is_empty() || crate::shutdown::aborting() {
return Vec::new();
}
let stage_deadline = std::time::Instant::now() + wrap_up_timeout();
let mut prepared = Vec::new();
for entry in timed_out {
if crate::shutdown::aborting() {
break;
}
let history = crate::session::store().load(&entry.agent_id).await;
let (tool_calls, searches, queries) = extract_query_telemetry_from_history(&history);
run_stats.tool_calls += tool_calls;
run_stats.searches += searches;
for q in &queries {
if !ledger.register(q) {
run_stats.repeat_queries += 1;
}
}
let has_success = session_has_successful_tool_result(&history);
if has_success {
prepared.push(WrapUpPrepared {
params: entry.params,
history,
});
}
}
if prepared.is_empty() || crate::shutdown::aborting() {
return Vec::new();
}
let _wrap_up_call = crate::call_registry::NON_AGENT_CALLS.register(
"research_wrap_up",
&ws.name,
Some(crate::registry::ParentKey::Research(run_key.to_string())),
false,
Some(question.to_string()),
);
let wrap_up_prompt = load_prompt("research/wrap_up.md");
let handles: Vec<_> = prepared
.into_iter()
.map(|p| {
let wrap_up_prompt = wrap_up_prompt.clone();
tokio::spawn(async move {
let mut messages = p.history;
messages.push(ChatMessage::user(&wrap_up_prompt));
crate::extraction::retry_extract_structured_scoped::<AnalystFindings>(
&messages,
"",
&p.params,
None,
Some(&crate::retry::RetryPolicy::comment()),
)
.await
.ok()
})
})
.collect();
await_wrap_up_batch(handles, stage_deadline)
.await
.into_iter()
.flatten()
.filter(|f| !f.claims.is_empty() || !f.unanswered.is_empty())
.collect()
}
fn session_has_successful_tool_result(history: &[ChatMessage]) -> bool {
history.iter().any(|m| {
matches!(
crate::session::decode_native_history_message(m),
Some(crate::session::DecodedNativeHistoryMessage::ToolResult { content, .. })
if !content.starts_with(crate::tools::TOOL_FAILURE_MARKER)
)
})
}
async fn await_wrap_up_batch(
handles: Vec<tokio::task::JoinHandle<Option<AnalystFindings>>>,
deadline: std::time::Instant,
) -> Vec<Option<AnalystFindings>> {
use futures_util::StreamExt;
use futures_util::stream::FuturesUnordered;
let cancel = tokio_util::sync::CancellationToken::new();
let drain = crate::shutdown::drain_wait();
tokio::pin!(drain);
let mut pending: FuturesUnordered<_> = handles
.into_iter()
.enumerate()
.map(|(i, mut handle)| {
let cancel = cancel.clone();
async move {
tokio::select! {
biased;
r = &mut handle => (i, match r {
Ok(v) => v,
Err(e) if e.is_panic() => {
let panic = crate::util::panic_message(&*e.into_panic());
tracing::warn!(member = i, %panic, "wrap-up task panicked");
None
}
Err(_) => {
tracing::warn!(member = i, "wrap-up task cancelled externally");
None
}
}),
() = cancel.cancelled() => {
handle.abort();
(i, None)
}
}
}
})
.collect();
let mut out: Vec<Option<AnalystFindings>> = (0..pending.len()).map(|_| None).collect();
let mut drain_fired = false;
let mut deadline_fired = false;
while !pending.is_empty() {
tokio::select! {
biased;
() = drain.as_mut(), if !drain_fired => {
drain_fired = true;
cancel.cancel();
}
Some((i, result)) = pending.next() => out[i] = result,
() = tokio::time::sleep_until(deadline.into()), if !deadline_fired => {
deadline_fired = true;
cancel.cancel();
}
}
}
out
}
fn resolve_round_members_with_timeouts<T>(
members: Vec<RoundMember<AnalystRun<T>>>,
snapshots: &[WrapUpEntry],
) -> (Vec<AnalystRun<T>>, Vec<WrapUpEntry>) {
debug_assert!(snapshots.is_empty() || snapshots.len() == members.len());
let mut runs = Vec::with_capacity(members.len());
let mut timed_out = Vec::new();
for (i, m) in members.into_iter().enumerate() {
match m {
RoundMember::Done(run) => runs.push(run),
RoundMember::TimedOut => {
runs.push(AnalystRun::NoResponse);
if let Some(entry) = snapshots.get(i) {
timed_out.push(entry.clone());
} else if !snapshots.is_empty() {
tracing::warn!(
member = i,
"timed-out member has no wrap-up snapshot — findings unrecoverable"
);
}
}
RoundMember::Panicked | RoundMember::Cancelled => runs.push(AnalystRun::NoResponse),
}
}
(runs, timed_out)
}
enum AnalystRun<T> {
NoResponse,
Findings(AnalystRunOutcome<T>),
ParseFailed {
raw: String,
tool_calls: usize,
searches: usize,
queries: Vec<String>,
},
}
struct AnalystRunOutcome<T> {
value: T,
tool_calls: usize,
searches: usize,
queries: Vec<String>,
}
fn resolve_round_members<T>(members: Vec<RoundMember<AnalystRun<T>>>) -> Vec<AnalystRun<T>> {
resolve_round_members_with_timeouts(members, &[]).0
}
async fn run_structured_analyst<T: serde::de::DeserializeOwned>(
ws: &Workspace,
agent_id: &str,
task: &str,
extraction_prompt: &str,
round: crate::agent::RoundOpts,
run_key: &str,
question: &str,
) -> AnalystRun<T> {
let (agent, response) = run_default_agent(
agent_id,
Role::Analyst,
ws,
task,
Some(round),
Some(crate::registry::ParentKey::Research(run_key.to_string())),
Some(question.to_string()),
)
.await;
let Some(raw) = response else {
return AnalystRun::NoResponse;
};
if raw.trim().is_empty() {
return AnalystRun::NoResponse;
}
let (tool_calls, searches, queries) = extract_query_telemetry(&agent);
match agent
.extract_verdict::<T>(extraction_prompt, None, None)
.await
{
Ok(value) => AnalystRun::Findings(AnalystRunOutcome {
value,
tool_calls,
searches,
queries,
}),
Err(_) => AnalystRun::ParseFailed {
raw,
tool_calls,
searches,
queries,
},
}
}
fn make_round_member<T: serde::de::DeserializeOwned + Send>(
ws: Workspace,
agent_id: String,
task: String,
extraction_prompt: String,
run_key: String,
question: String,
) -> impl FnOnce(crate::agent::RoundOpts) -> futures_util::future::BoxFuture<'static, AnalystRun<T>> + Send
{
move |round| {
Box::pin(async move {
run_structured_analyst::<T>(
&ws,
&agent_id,
&task,
&extraction_prompt,
round,
&run_key,
&question,
)
.await
})
}
}
fn collect_evidence(
runs: &[AnalystRun<AnalystFindings>],
ledger: &mut QueryLedger,
run_stats: &mut RunStats,
) -> EvidenceRound {
let mut round = EvidenceRound::default();
for run in runs {
let run = match run {
AnalystRun::NoResponse => {
run_stats.failed_analysts += 1;
continue;
}
AnalystRun::ParseFailed {
raw,
tool_calls,
searches,
queries,
} => {
run_stats.failed_analysts += 1;
run_stats.tool_calls += tool_calls;
run_stats.searches += searches;
for q in queries {
if !ledger.register(q) {
run_stats.repeat_queries += 1;
}
}
round.raw_reports.push(raw.clone());
continue;
}
AnalystRun::Findings(run) => run,
};
run_stats.tool_calls += run.tool_calls;
run_stats.searches += run.searches;
for q in &run.queries {
round.queries += 1;
if !ledger.register(q) {
round.repeat_queries += 1;
run_stats.repeat_queries += 1;
}
}
for claim in &run.value.claims {
round.claims.push(claim.clone());
if !claim.source.is_empty() {
round.urls.push(claim.source.clone());
}
}
round
.unanswered
.extend(run.value.unanswered.iter().cloned());
}
round
}
fn orchestrator_params(ws: &Workspace, purpose: &'static str) -> ChatRequest {
research_params(
ws,
purpose,
format!("research_{}_orchestrator", ws.name),
None,
)
}
async fn orchestrator_extract<T: serde::de::DeserializeOwned>(
ws: &Workspace,
purpose: &'static str,
prompt: &str,
validate: Option<&crate::ExtractionValidator<T>>,
run_key: &str,
question: &str,
) -> Result<T> {
let _call = crate::call_registry::NON_AGENT_CALLS.register(
purpose,
&ws.name,
Some(crate::registry::ParentKey::Research(run_key.to_string())),
false,
Some(question.to_string()),
);
let params = orchestrator_params(ws, purpose);
let mut messages = Vec::with_capacity(2);
crate::prompt::prepend_general_context(&mut messages, ws).await;
messages.push(ChatMessage::user(prompt));
crate::extraction::retry_extract_structured_scoped::<T>(&messages, "", ¶ms, validate, None)
.await
.map_err(|e| anyhow::anyhow!("orchestrator extraction '{purpose}' failed: {e}"))
}
#[expect(clippy::too_many_arguments)]
async fn round0_decompose(
ws: &Workspace,
question: &str,
budget: &mut ResearchBudget,
run_stats: &mut RunStats,
deadline: std::time::Instant,
resume: bool,
run_root: &str,
captured: &mut Vec<String>,
run_key: &str,
) -> Result<(MergedPlan, Option<String>)> {
budget
.try_reserve(DECOMPOSE_FAN_OUT)
.map_err(anyhow::Error::msg)?;
let task_template = load_prompt("research/decompose.md");
let extraction_prompt = load_prompt("extraction/decompose.md");
let members: Vec<_> = (0..DECOMPOSE_FAN_OUT)
.map(|i| {
let ws = ws.clone();
let question = question.to_string();
let task = substitute(
&task_template,
&[("{{question}}", &question), ("{{run_root}}", run_root)],
);
let agent_id = crate::session::research_agent_id(&ws.name, &format!("decompose_{i}"));
captured.push(agent_id.clone());
make_round_member::<DecompositionPlan>(
ws,
agent_id,
task,
extraction_prompt.clone(),
run_key.to_string(),
question.clone(),
)
})
.collect();
let handles = crate::agent::spawn_staggered_round(members, resume).await;
let plans: Vec<AnalystRun<DecompositionPlan>> =
resolve_round_members(await_round_members(handles, deadline).await);
let mut valid = Vec::new();
for run in plans {
match run {
AnalystRun::Findings(o) => valid.push(o.value),
AnalystRun::NoResponse | AnalystRun::ParseFailed { .. } => {
run_stats.failed_analysts += 1;
}
}
}
if valid.is_empty() {
anyhow::bail!("all decomposition analysts failed — no research plan produced");
}
if let Ok(mut plan) = merge_decomposition_plans(ws, question, &valid, run_key).await {
resolve_merged_plan_ids(&mut plan, &valid);
Ok((plan, None))
} else {
let first = &valid[0];
let plan = MergedPlan {
sub_questions: first
.sub_questions
.iter()
.enumerate()
.map(|(i, sq)| MergedSubQuestion {
question: sq.question.clone(),
evidence_needed: sq.evidence_needed.clone(),
risk: sq.risk.clone(),
from_id: i,
also_ids: Vec::new(),
})
.collect(),
dropped: Vec::new(),
};
Ok((plan, Some(PLAN_MERGE_FAILED.to_string())))
}
}
fn render_plans_with_ids(plans: &[DecompositionPlan]) -> String {
let mut out = String::new();
let mut id = 0usize;
for (p, plan) in plans.iter().enumerate() {
let _ = writeln!(out, "Plan {p}:");
for sq in &plan.sub_questions {
let _ = writeln!(
out,
"- {id}: {} [evidence: {}, risk: {}]",
sq.question, sq.evidence_needed, sq.risk
);
id += 1;
}
}
out
}
async fn merge_decomposition_plans(
ws: &Workspace,
question: &str,
plans: &[DecompositionPlan],
run_key: &str,
) -> Result<MergedPlan> {
let prompt = substitute(
&load_prompt("research/decompose_merge.md"),
&[
("{{question}}", question),
("{{plans}}", &render_plans_with_ids(plans)),
],
);
let plans_owned = plans.to_vec();
orchestrator_extract::<MergedPlan>(
ws,
"decompose_merge",
&prompt,
Some(&move |p| validate_merged_plan(p, &plans_owned)),
run_key,
question,
)
.await
}
fn plan_item_table(plans: &[DecompositionPlan]) -> Vec<Vec<String>> {
plans
.iter()
.map(|p| p.sub_questions.iter().map(|s| s.question.clone()).collect())
.collect()
}
fn resolve_merged_plan_ids(plan: &mut MergedPlan, plans: &[DecompositionPlan]) {
let items = plan_item_table(plans);
let table = crate::consensus::ItemTable::new(&items);
for sq in &mut plan.sub_questions {
if let Some((p, i)) = table.resolve_index(sq.from_id) {
let src = &plans[p].sub_questions[i];
sq.question.clone_from(&src.question);
sq.evidence_needed.clone_from(&src.evidence_needed);
sq.risk.clone_from(&src.risk);
}
}
}
fn validate_merged_plan(plan: &MergedPlan, plans: &[DecompositionPlan]) -> Result<(), String> {
let items = plan_item_table(plans);
let table = crate::consensus::ItemTable::new(&items);
let mut covered = HashSet::new();
let mut mark = |id: usize, where_: &str| -> Result<(), String> {
if id >= table.len() {
return Err(format!("{where_}: out-of-range item id {id}"));
}
if !covered.insert(id) {
return Err(format!("{where_}: item {id} covered more than once"));
}
Ok(())
};
for (i, sq) in plan.sub_questions.iter().enumerate() {
mark(sq.from_id, &format!("merged sub-question {i}"))?;
for &id in &sq.also_ids {
mark(id, &format!("merged sub-question {i} also_ids"))?;
}
}
for (i, d) in plan.dropped.iter().enumerate() {
mark(d.id, &format!("dropped entry {i}"))?;
}
for id in 0..table.len() {
if !covered.contains(&id) {
return Err(format!(
"silent dropout: input plan item {id} is never covered by the merged plan or dropped list"
));
}
}
Ok(())
}
#[expect(clippy::too_many_arguments)]
async fn round1_research(
ws: &Workspace,
question: &str,
plan: &MergedPlan,
budget: &mut ResearchBudget,
ledger: &mut QueryLedger,
run_stats: &mut RunStats,
deadline: std::time::Instant,
resume: bool,
run_root: &str,
captured: &mut Vec<String>,
run_key: &str,
) -> Option<(EvidenceRound, Vec<WrapUpEntry>)> {
let spawn_count = plan.sub_questions.len()
+ plan
.sub_questions
.iter()
.filter(|s| s.risk == "high")
.count();
if budget.try_reserve(spawn_count).is_err() {
tracing::warn!(
spent = %budget.spent,
cap = %budget.cap,
"research budget exhausted before round 1"
);
return None;
}
let task_template = load_prompt("research/round1.md");
let extraction_prompt = load_prompt("extraction/findings.md");
let angles = load_analyst_angles();
let ledger_snapshot = ledger.render();
let mut members = Vec::new();
let mut snapshots: Vec<WrapUpEntry> = Vec::new();
let wrap_up_specs = role_tools_and_specs(Role::Analyst, ws).1;
let mut idx = 0usize;
for sq in &plan.sub_questions {
for k in 0..=usize::from(sq.risk == "high") {
let ws = ws.clone();
let question = question.to_string();
let mut task = substitute(
&task_template,
&[
("{{question}}", &question),
("{{sub_question}}", &sq.question),
("{{evidence_needed}}", &sq.evidence_needed),
("{{query_ledger}}", &ledger_snapshot),
("{{run_root}}", run_root),
],
);
if k == 1 && !angles.is_empty() {
task.push_str("\n\nResearch angle:\n");
task.push_str(&angles[idx % angles.len()]);
}
let agent_id = crate::session::research_agent_id(&ws.name, &format!("r1_{idx}"));
captured.push(agent_id.clone());
snapshots.push(WrapUpEntry {
agent_id: agent_id.clone(),
params: wrap_up_params(&ws, &agent_id, wrap_up_specs.clone()),
});
idx += 1;
members.push(make_round_member::<AnalystFindings>(
ws,
agent_id,
task,
extraction_prompt.clone(),
run_key.to_string(),
question.clone(),
));
}
}
let handles = crate::agent::spawn_staggered_round(members, resume).await;
let members_out = await_round_members(handles, deadline).await;
let (runs, timed_out) = resolve_round_members_with_timeouts(members_out, &snapshots);
let round = collect_evidence(&runs, ledger, run_stats);
Some((round, timed_out))
}
async fn extract_gap_list(
ws: &Workspace,
question: &str,
acc: &AccumulatedEvidence,
plan: &MergedPlan,
run_key: &str,
) -> Option<GapList> {
let evidence = render_accumulated_evidence(acc);
let plan_json = serde_json::to_string(plan).unwrap_or_default();
let prompt = substitute(
&load_prompt("research/gap_extract.md"),
&[
("{{question}}", question),
("{{plan}}", &plan_json),
("{{evidence}}", &evidence),
],
);
let plan_owned = plan.clone();
orchestrator_extract::<GapList>(
ws,
"gap_extract",
&prompt,
Some(&move |g| validate_gap_list(g, &plan_owned)),
run_key,
question,
)
.await
.ok()
}
fn validate_gap_list(gaps: &GapList, plan: &MergedPlan) -> Result<(), String> {
for g in &gaps.gaps {
if g.traces_to >= plan.sub_questions.len() {
return Err(format!(
"gap '{}' traces to plan sub-question {} but the merged plan has only {} sub-questions",
g.item,
g.traces_to,
plan.sub_questions.len()
));
}
}
Ok(())
}
fn gap_items(gaps: &[Gap]) -> Vec<String> {
gaps.iter().map(|g| g.item.clone()).collect()
}
#[expect(clippy::too_many_arguments)]
async fn run_gap_round(
ws: &Workspace,
question: &str,
gaps: &[&Gap],
ledger: &QueryLedger,
deadline: std::time::Instant,
resume: bool,
run_root: &str,
captured: &mut Vec<String>,
run_key: &str,
) -> (Vec<AnalystRun<AnalystFindings>>, Vec<WrapUpEntry>) {
let task_template = load_prompt("research/gap.md");
let extraction_prompt = load_prompt("extraction/findings.md");
let ledger_snapshot = ledger.render();
let mut members: Vec<_> = Vec::new();
let mut snapshots: Vec<WrapUpEntry> = Vec::new();
let wrap_up_specs = role_tools_and_specs(Role::Analyst, ws).1;
for (i, gap) in gaps.iter().enumerate() {
let ws = ws.clone();
let question = question.to_string();
let task = substitute(
&task_template,
&[
("{{question}}", &question),
(
"{{gaps}}",
&format!(
"- [{}] {} (traces to: plan sub-question {})",
gap.kind, gap.item, gap.traces_to
),
),
("{{query_ledger}}", &ledger_snapshot),
("{{run_root}}", run_root),
],
);
let agent_id = crate::session::research_agent_id(&ws.name, &format!("gap_{i}"));
captured.push(agent_id.clone());
snapshots.push(WrapUpEntry {
agent_id: agent_id.clone(),
params: wrap_up_params(&ws, &agent_id, wrap_up_specs.clone()),
});
members.push(make_round_member::<AnalystFindings>(
ws,
agent_id,
task,
extraction_prompt.clone(),
run_key.to_string(),
question.clone(),
));
}
let handles = crate::agent::spawn_staggered_round(members, resume).await;
let members_out = await_round_members(handles, deadline).await;
resolve_round_members_with_timeouts(members_out, &snapshots)
}
fn set_coder_marker(state: &mut ResearchState, round_key: usize, suffix: &str) {
let marker = format!("coder round {round_key} {suffix}");
clear_coder_markers(state, round_key);
state.markers.push(marker);
}
fn clear_coder_markers(state: &mut ResearchState, round_key: usize) {
state
.markers
.retain(|m| !m.starts_with(&format!("coder round {round_key} ")));
}
fn claim_coder_round(state: &mut ResearchState, round_key: usize) {
if !state.coder_rounds_done.contains(&round_key) {
state.coder_rounds_done.push(round_key);
}
clear_coder_markers(state, round_key);
}
fn unclaim_coder_round(state: &mut ResearchState, round_key: usize) {
state.coder_rounds_done.retain(|k| *k != round_key);
}
#[expect(clippy::too_many_arguments)]
async fn run_coder_round(
job_id: &str,
run_root: &str,
ws: &Workspace,
question: &str,
gap_list: &GapList,
deadline: std::time::Instant,
state: &mut ResearchState,
round_key: usize,
) {
if crate::shutdown::aborting() {
set_coder_marker(state, round_key, "skipped — shutdown/drain");
return;
}
if crate::research_cancel::is_cancelled(job_id) {
set_coder_marker(state, round_key, "skipped — run cancelled");
return;
}
if std::time::Instant::now() + CODER_MIN_REMAINING >= deadline {
set_coder_marker(
state,
round_key,
"skipped — less than 30 minutes remaining until the round deadline",
);
return;
}
claim_coder_round(state, round_key);
let evidence = render_accumulated_evidence(&state.acc);
let gaps = gap_items(&gap_list.gaps).join("\n");
let task = substitute(
&load_prompt("synthesis/coder_brief.md"),
&[
("{{question}}", question),
("{{evidence}}", &evidence),
("{{gaps}}", &gaps),
("{{run_root}}", run_root),
],
);
let coder_ws = Workspace::ephemeral_run(job_id, Path::new(run_root));
let agent_id = crate::session::research_agent_id(&ws.name, "coder");
let (agent, response) = run_default_agent(
&agent_id,
Role::Coder,
&coder_ws,
&task,
None,
Some(crate::registry::ParentKey::Research(job_id.to_string())),
Some(question.to_string()),
)
.await;
state.capture_round(&[agent_id], Path::new(run_root)).await;
if response.is_some() {
tracing::info!(job = %job_id, coder_round = round_key, "Coder round completed");
} else {
let cancelled = agent.is_cancelled() || crate::shutdown::aborting();
let outcome = if cancelled { "cancelled" } else { "failed" };
unclaim_coder_round(state, round_key);
set_coder_marker(state, round_key, outcome);
}
}
#[expect(clippy::too_many_arguments)]
async fn run_coder_gated(
job_id: &str,
run_root: &str,
ws: &Workspace,
question: &str,
budget: &ResearchBudget,
gap_list: &GapList,
deadline: std::time::Instant,
state: &mut ResearchState,
round_key: usize,
) {
if budget.is_exhausted() {
set_coder_marker(state, round_key, "skipped — analyst budget exhausted");
return;
}
if std::time::Instant::now() >= deadline {
set_coder_marker(state, round_key, "skipped — round deadline expired");
return;
}
run_coder_round(
job_id, run_root, ws, question, gap_list, deadline, state, round_key,
)
.await;
}
#[expect(clippy::too_many_arguments, clippy::too_many_lines)]
async fn gap_rounds(
ws: &Workspace,
question: &str,
plan: &MergedPlan,
budget: &mut ResearchBudget,
state: &mut ResearchState,
run_stats: &mut RunStats,
deadline: std::time::Instant,
job_id: &str,
run_root: &str,
resume: bool,
recovered: &mut Vec<AnalystFindings>,
) -> GapRoundsOutcome {
let mut outcome = GapRoundsOutcome {
abstention: None,
unresolved: Vec::new(),
rounds_dispatched: state.gap_outcome.rounds_dispatched,
incomplete: None,
};
if crate::research_cancel::is_cancelled(job_id) {
return GapRoundsOutcome::default();
}
let initial_list = match state.gap_list.take() {
Some(list) => Some(list),
None => extract_gap_list(ws, question, &state.acc, plan, job_id).await,
};
let Some(mut gap_list) = initial_list else {
outcome.incomplete = Some(GAP_EXTRACTION_FAILED.to_string());
return outcome;
};
if !gap_list.gaps.is_empty() && !state.coder_rounds_done.contains(&0) {
run_coder_gated(
job_id, run_root, ws, question, budget, &gap_list, deadline, state, 0,
)
.await;
}
let mut round_index = state.round_index;
loop {
if crate::shutdown::aborting()
|| crate::research_cancel::is_cancelled(job_id)
|| budget.is_exhausted()
|| std::time::Instant::now() >= deadline
{
outcome.unresolved = gap_items(&gap_list.gaps);
return outcome;
}
let gaps = &gap_list.gaps;
if gaps.is_empty() {
return outcome;
}
let width = GAP_ROUND_WIDTHS[round_index.min(GAP_ROUND_WIDTHS.len() - 1)];
round_index += 1;
let targeted: Vec<&Gap> = gaps.iter().take(width).collect();
if budget.try_reserve(targeted.len()).is_err() {
outcome.unresolved = gap_items(gaps);
return outcome;
}
let mut round_agents: Vec<String> = Vec::new();
let (runs, timed_out) = run_gap_round(
ws,
question,
&targeted,
&state.ledger,
deadline,
resume,
run_root,
&mut round_agents,
job_id,
)
.await;
state
.capture_round(&round_agents, Path::new(run_root))
.await;
let round = collect_evidence(&runs, &mut state.ledger, run_stats);
recovered.extend(
wrap_up_timed_out(
ws,
timed_out,
&mut state.ledger,
run_stats,
job_id,
question,
)
.await,
);
let (new_urls, pending) = state.acc.absorb(&round);
let novel_claims = annotate_round(
ws,
&mut state.acc,
&pending,
&mut state.markers,
job_id,
question,
)
.await;
outcome.rounds_dispatched += 1;
let all_repeat_queries = round.queries > 0 && round.queries == round.repeat_queries;
if (new_urls == 0 && novel_claims == 0) || all_repeat_queries {
if let Some(reason) = check_answerability(ws, question, &state.acc, job_id).await {
outcome.abstention = Some(reason);
outcome.unresolved = gap_items(gaps);
return outcome;
}
}
if new_urls != 0 || novel_claims != 0 {
let Some(next_gap_list) =
extract_gap_list(ws, question, &state.acc, plan, job_id).await
else {
outcome.incomplete = Some(GAP_EXTRACTION_FAILED.to_string());
outcome.unresolved = gap_items(gaps);
return outcome;
};
gap_list = next_gap_list;
if !gap_list.gaps.is_empty() && !state.coder_rounds_done.contains(&round_index) {
run_coder_gated(
job_id,
run_root,
ws,
question,
budget,
&gap_list,
deadline,
state,
round_index,
)
.await;
}
}
state.round_index = round_index;
state.gap_outcome.rounds_dispatched = outcome.rounds_dispatched;
state.budget_spent = budget.spent;
state.gap_list = Some(gap_list.clone());
state.save(job_id).await;
}
}
async fn annotate_claims(
ws: &Workspace,
existing: &[Claim],
weak: &WeakLinks,
pending: &[Claim],
run_key: &str,
question: &str,
) -> Result<AnnotationPass> {
let mut existing_claims = String::new();
for (i, c) in existing.iter().enumerate() {
let _ = writeln!(existing_claims, "{i}: {}", c.claim);
existing_claims.push_str(&render_weak_hints(weak, i));
}
let mut pending_claims = String::new();
for (i, c) in pending.iter().enumerate() {
let _ = writeln!(pending_claims, "{i}: {}", c.claim);
}
let mut user = substitute(
&load_prompt("research/annotate.md"),
&[
("{{existing_claims}}", &existing_claims),
("{{pending_claims}}", &pending_claims),
],
);
user.push_str("\n\n");
user.push_str(&load_prompt("extraction/annotate.md"));
let existing_owned = existing.to_vec();
let pending_owned = pending.to_vec();
orchestrator_extract::<AnnotationPass>(
ws,
"claim_annotate",
&user,
Some(&move |a| validate_annotations(a, &existing_owned, &pending_owned)),
run_key,
question,
)
.await
}
fn validate_annotations(
pass: &AnnotationPass,
existing: &[Claim],
pending: &[Claim],
) -> Result<(), String> {
let mut ids: Vec<usize> = pass.annotations.iter().map(|a| a.new_id).collect();
ids.sort_unstable();
let expected: Vec<usize> = (0..pending.len()).collect();
if ids != expected {
return Err(format!(
"annotation pass must annotate every new claim exactly once: ids {ids:?} != 0..{}",
pending.len()
));
}
for a in &pass.annotations {
let verdict = a.verdict.as_str();
if !matches!(verdict, "novel" | "duplicate" | "contradicts") {
return Err(format!(
"verdict '{verdict}' not in [novel, duplicate, contradicts]"
));
}
let has_note = a
.contradiction
.as_deref()
.is_some_and(|c| !c.trim().is_empty());
if (verdict == "contradicts") != has_note {
return Err(format!(
"verdict '{verdict}' for new claim {} must carry the contradiction note exactly when it contradicts",
a.new_id
));
}
if verdict == "novel" {
if a.existing_id.is_some() {
return Err(format!(
"novel annotation for new claim {} must not cite an existing claim",
a.new_id
));
}
continue;
}
let Some(existing_id) = a.existing_id else {
return Err(format!(
"{verdict} annotation for new claim {} must cite an existing claim",
a.new_id
));
};
if existing_id >= existing.len() {
return Err(format!(
"existing_id {existing_id} out of range ({} existing claims)",
existing.len()
));
}
}
Ok(())
}
async fn confirm_links(
ws: &Workspace,
existing: &[Claim],
pending: &[Claim],
pass: &AnnotationPass,
run_key: &str,
question: &str,
) -> Result<ConfirmPass> {
let mut links = String::new();
for a in pass.annotations.iter().filter(|a| a.verdict != "novel") {
let existing_id = a
.existing_id
.expect("mutating verdict cites an existing claim");
let p = &pending[a.new_id];
let e = &existing[existing_id];
let _ = writeln!(
links,
"- new claim {}: \"{}\" [{}] ↔ existing claim {}: \"{}\" (annotation: {})",
a.new_id, p.claim, p.confidence, existing_id, e.claim, a.verdict
);
}
let mut user = substitute(
&load_prompt("research/confirm.md"),
&[("{{links}}", &links)],
);
user.push_str("\n\n");
user.push_str(&load_prompt("extraction/confirm.md"));
let mutating: Vec<usize> = pass
.annotations
.iter()
.filter(|a| a.verdict != "novel")
.map(|a| a.new_id)
.collect();
orchestrator_extract::<ConfirmPass>(
ws,
"confirm_links",
&user,
Some(&move |c| validate_confirm(c, &mutating)),
run_key,
question,
)
.await
}
fn validate_confirm(pass: &ConfirmPass, mutating: &[usize]) -> Result<(), String> {
let mut ids: Vec<usize> = pass.links.iter().map(|l| l.new_id).collect();
ids.sort_unstable();
let mut expected = mutating.to_vec();
expected.sort_unstable();
if ids != expected {
return Err(format!(
"confirm pass must judge exactly the mutating links (new_ids {expected:?}), got {ids:?}"
));
}
for l in &pass.links {
if !matches!(l.verdict.as_str(), "confirm" | "reject") {
return Err(format!("verdict '{}' not in [confirm, reject]", l.verdict));
}
}
Ok(())
}
async fn annotate_round(
ws: &Workspace,
acc: &mut AccumulatedEvidence,
pending: &[Claim],
markers: &mut Vec<String>,
run_key: &str,
question: &str,
) -> usize {
if pending.is_empty() {
return 0;
}
if acc.claims.is_empty() {
acc.claims.extend(pending.iter().cloned());
return pending.len();
}
let Ok(pass) = annotate_claims(ws, &acc.claims, &acc.weak, pending, run_key, question).await
else {
acc.claims.extend(pending.iter().cloned());
if !markers.iter().any(|m| m == CLAIM_ANNOTATION_FAILED) {
markers.push(CLAIM_ANNOTATION_FAILED.to_string());
}
return pending.len();
};
let confirm = if pass.annotations.iter().any(|a| a.verdict != "novel") {
if let Ok(c) = confirm_links(ws, &acc.claims, pending, &pass, run_key, question).await {
ConfirmOutcome::Passed(c)
} else {
if !markers.iter().any(|m| m == CONFIRM_FAILED) {
markers.push(CONFIRM_FAILED.to_string());
}
ConfirmOutcome::Failed
}
} else {
ConfirmOutcome::Passed(ConfirmPass::default())
};
acc.apply_annotations(&pass, pending, &confirm)
}
async fn check_answerability(
ws: &Workspace,
question: &str,
acc: &AccumulatedEvidence,
run_key: &str,
) -> Option<String> {
let evidence = render_accumulated_evidence(acc);
let prompt = substitute(
&load_prompt("research/abstain.md"),
&[("{{question}}", question), ("{{evidence}}", &evidence)],
);
let verdict = orchestrator_extract::<AnswerabilityCheck>(
ws,
"abstain_check",
&prompt,
None,
run_key,
question,
)
.await
.ok()?;
(!verdict.answerable).then_some(verdict.reason)
}
const SYNTHESIS_TRUNCATED_MARKER: &str =
"final synthesis truncated by the provider — last produced output delivered";
#[derive(Debug)]
struct SynthesisOutput {
text: String,
marker: Option<String>,
}
#[expect(clippy::too_many_lines)]
async fn synthesize(
ws: &Workspace,
question: &str,
acc: &AccumulatedEvidence,
abstention: Option<&str>,
run_key: &str,
) -> Result<SynthesisOutput> {
let _call = crate::call_registry::NON_AGENT_CALLS.register(
"synthesize",
&ws.name,
Some(crate::registry::ParentKey::Research(run_key.to_string())),
false,
Some(question.to_string()),
);
let evidence = render_accumulated_evidence(acc);
let mut base_user = substitute(
&load_prompt("research/synthesize.md"),
&[("{{question}}", question), ("{{evidence}}", &evidence)],
);
if let Some(abstain) = abstention {
let _ = writeln!(
base_user,
"\n\n# Answerability Note\n\nThe research team determined the question is not \
answerable with the available evidence: {abstain}\n\
State this clearly and explain what evidence would be needed."
);
}
let policy = crate::retry::RetryPolicy::synthesis();
let mut params = orchestrator_params(ws, "synthesize");
let mut loop_state = crate::retry::RetryLoop::new(&policy);
let operation_started = Instant::now();
let mut prefix = Vec::with_capacity(2);
crate::prompt::prepend_general_context(&mut prefix, ws).await;
let mut last: Option<String> = None;
let mut last_truncated = false;
let mut any_truncated = false;
let mut feedback = String::new();
let mut transport_failures = 0u32;
for attempt in 1..=policy.max_attempts {
if loop_state.expired() {
break;
}
let mut user = base_user.clone();
if !feedback.is_empty() {
let _ = writeln!(user, "\n\n# Previous Attempt Feedback\n\n{feedback}");
}
let mut messages = prefix.clone();
messages.push(ChatMessage::user(&user));
params.messages = messages;
match crate::providers::chat_scoped(
params.clone(),
policy.idle_timeout,
loop_state.deadline(),
)
.await
{
Ok(resp) => {
let text = resp.text_or_empty().to_string();
let truncated = resp.finish_reason.as_deref() == Some("length");
if truncated {
any_truncated = true;
let err = anyhow::anyhow!(
"synthesis truncated by the provider (finish_reason=length)"
);
let rec = crate::retry::RetryFailureRecord::new_simple(
FailureClass::TruncatedOutput,
&err,
None,
);
loop_state.record(rec);
feedback = "Your previous report was truncated by the output limit — \
produce a SHORTER, more compressed version. Keep every \
load-bearing claim and its source, but tighten the prose so \
the whole report fits within the limit."
.to_string();
if !text.trim().is_empty() {
last = Some(text);
last_truncated = true;
}
continue;
}
if text.trim().is_empty() {
let err = anyhow::anyhow!("synthesis attempt returned empty text");
let rec = crate::retry::RetryFailureRecord::new_simple(
FailureClass::NoResponse,
&err,
None,
);
loop_state.record(rec);
feedback = "Your previous attempt returned an empty response — \
produce the report now."
.to_string();
continue;
}
crate::stats::record_llm_success(¶ms, operation_started, attempt, &resp).await;
return Ok(SynthesisOutput { text, marker: None });
}
Err(err) => {
let non_retryable = !err.class.is_retryable();
loop_state.record(err.record);
if non_retryable {
break;
}
transport_failures += 1;
if attempt < policy.max_attempts
&& let Err(FailureClass::Shutdown) =
loop_state.sleep_between(transport_failures).await
{
break;
}
}
}
}
let final_class = loop_state.final_class();
let exhausted = crate::retry::RetryExhausted::with_last_raw(
loop_state.into_failures(),
final_class,
last.clone(),
);
crate::stats::record_llm_failure(¶ms, operation_started, &exhausted).await;
if let Some(text) = last {
let marker = last_truncated.then(|| SYNTHESIS_TRUNCATED_MARKER.to_string());
Ok(SynthesisOutput { text, marker })
} else if any_truncated {
Err(anyhow::anyhow!(
"final synthesis truncated with no usable output: {SYNTHESIS_TRUNCATED_MARKER}"
))
} else {
Err(anyhow::anyhow!(
"final synthesis produced no usable output after {} attempts",
policy.max_attempts
))
}
}
fn verification_targets(acc: &AccumulatedEvidence) -> (Vec<VerificationTarget>, usize) {
let mut seen = HashSet::new();
let mut targets = Vec::new();
for (i, c) in acc.claims.iter().enumerate() {
if !c.contradictions.is_empty() || c.confidence == "low" {
seen.insert(i);
targets.push(VerificationTarget::new(
&c.claim,
&c.source,
&c.contradictions.join("; "),
));
}
}
let primary_count = targets.len();
for &(claim_id, _) in &acc.weak.duplicates {
if seen.insert(claim_id) {
let c = &acc.claims[claim_id];
targets.push(VerificationTarget::new(&c.claim, &c.source, ""));
}
}
(targets, primary_count)
}
#[expect(clippy::too_many_arguments)]
async fn research_verification_pass(
ws: &Workspace,
acc: &AccumulatedEvidence,
budget: &mut ResearchBudget,
ledger: &mut QueryLedger,
run_stats: &mut RunStats,
deadline: std::time::Instant,
resume: bool,
run_root: &str,
captured: &mut Vec<String>,
run_key: &str,
question: &str,
) -> Vec<VerificationResult> {
let (targets, primary_count) = verification_targets(acc);
if targets.is_empty() {
return Vec::new();
}
let cap = targets
.len()
.min(crate::tools::analyze::VERIFY_MAX_ANALYSTS);
let n = cap.min(budget.cap.saturating_sub(budget.spent));
let mut results = Vec::new();
if n > 0 && std::time::Instant::now() < deadline && budget.try_reserve(n).is_ok() {
let ledger_snapshot = ledger.render();
let task_extra = format!(
"\n# Queries Already Asked (do not repeat these verbatim)\n\n{ledger_snapshot}\n\n\
# Scratch Workspace\n\nTemporary per-run folder (wiped after the run):\n\n{run_root}"
);
let prefix = format!("research_{}_verify", ws.name);
let (verify_results, verify_ids) = dispatch_claim_verifiers(
ws,
&prefix,
&targets[..n],
&task_extra,
deadline,
resume,
run_key,
question,
)
.await;
captured.extend(verify_ids);
results = verify_results;
}
for v in &results {
run_stats.tool_calls += v.tool_calls;
run_stats.searches += v.searches;
for q in &v.queries {
if !ledger.register(q) {
run_stats.repeat_queries += 1;
}
}
}
for t in targets
.iter()
.skip(results.len())
.take(primary_count.saturating_sub(results.len()))
{
results.push(VerificationResult {
claim: t.claim.clone(),
verdict: "unresolved".to_string(),
evidence: "verification skipped — budget exhausted or round deadline expired"
.to_string(),
tool_calls: 0,
searches: 0,
queries: Vec::new(),
});
}
results
}
fn render_weak_hints(weak: &WeakLinks, id: usize) -> String {
let mut out = String::new();
for &(claim, target) in &weak.duplicates {
if claim == id {
let _ = writeln!(
out,
" weak: possibly duplicate of #{target} (unconfirmed)"
);
}
}
for &(claim, target) in &weak.contradictions {
if claim == id {
let _ = writeln!(out, " weak: possibly contradicts #{target} (unconfirmed)");
}
if target == id {
let _ = writeln!(out, " weak: possibly contradicts #{claim} (unconfirmed)");
}
}
out
}
fn source_or_na(source: &str) -> &str {
if source.is_empty() { "n/a" } else { source }
}
fn render_unanswered(out: &mut String, unanswered: &[String], escape: bool) {
if unanswered.is_empty() {
return;
}
let _ = writeln!(out, "\nAnalysts reported these as still unanswered:");
for u in unanswered {
let _ = if escape {
writeln!(out, "- {}", escape_fences(u))
} else {
writeln!(out, "- {u}")
};
}
}
fn render_raw_reports(out: &mut String, raw_reports: &[String], heading: &str) {
if raw_reports.is_empty() {
return;
}
let _ = writeln!(out, "\n{heading} Failed Analyst Reports");
for (i, raw) in raw_reports.iter().enumerate() {
let _ = writeln!(out, "### Report from Analyst {}", i + 1);
let _ = writeln!(out, "{}", escape_fences(raw));
}
}
fn render_accumulated_evidence(acc: &AccumulatedEvidence) -> String {
let mut out = String::new();
for (i, c) in acc.claims.iter().enumerate() {
let source = source_or_na(&c.source);
let _ = writeln!(
out,
"{i}. [{}] {} — source: {source}",
c.confidence, c.claim,
);
if !c.contradictions.is_empty() {
let _ = writeln!(out, " contradictions: {}", c.contradictions.join("; "));
}
out.push_str(&render_weak_hints(&acc.weak, i));
}
render_unanswered(&mut out, &acc.unanswered, false);
if !acc.raw_reports.is_empty() {
let _ = writeln!(
out,
"\nRaw notes from analysts whose structured extraction failed (preserve any \
usable content):"
);
for raw in &acc.raw_reports {
let _ = writeln!(out, "- {raw}");
}
}
out
}
#[expect(clippy::too_many_arguments)]
fn render_run_summary(
run_stats: &RunStats,
budget: &ResearchBudget,
rounds_used: usize,
acc: &AccumulatedEvidence,
abstention: Option<&str>,
unresolved: &[String],
incomplete: Option<&str>,
markers: &[String],
wall: Duration,
) -> String {
let mut out = String::new();
let _ = writeln!(out, "## Run Summary");
let _ = writeln!(out, "- rounds used: {rounds_used}");
if let Some(reason) = incomplete {
let _ = writeln!(out, "- gap rounds incomplete: {reason}");
}
if !markers.is_empty() {
let _ = writeln!(out, "- markers:");
for m in markers {
let _ = writeln!(out, " - {m}");
}
}
let _ = writeln!(out, "- agents spawned: {} / {}", budget.spent, budget.cap);
let _ = writeln!(out, "- tool calls: {}", run_stats.tool_calls);
let _ = writeln!(out, "- searches: {}", run_stats.searches);
let _ = writeln!(
out,
"- repeat queries (no-progress): {}",
run_stats.repeat_queries
);
if run_stats.failed_analysts > 0 {
let _ = writeln!(
out,
"- analysts failed (no response / extraction failure): {}",
run_stats.failed_analysts
);
}
let _ = writeln!(out, "- wall time: {:.0}s", wall.as_secs_f64());
let _ = writeln!(
out,
"- evidence: {} claims, {} unique sources",
acc.claims.len(),
acc.urls.len()
);
let weak_links = acc.weak.duplicates.len() + acc.weak.contradictions.len();
if weak_links > 0 {
let _ = writeln!(out, "- weak/unconfirmed links: {weak_links}");
}
if let Some(a) = abstention {
let _ = writeln!(out, "- answerability: QUESTION ABSTAINED — {a}");
}
if !unresolved.is_empty() {
let _ = writeln!(out, "- unresolved gaps:");
for u in unresolved {
let _ = writeln!(out, " - {}", escape_fences(u));
}
}
out
}
fn render_recovered_findings(recovered: &[AnalystFindings]) -> String {
if recovered.is_empty() {
return String::new();
}
let mut out = String::new();
let _ = writeln!(out, "\n## Recovered from timed-out analysts");
let _ = writeln!(
out,
"Findings recovered from analysts whose work was cut short by the round \
deadline (deadline exceeded, unverified — not subject to verification; each \
analyst's final in-flight turn could not be recovered):"
);
for r in recovered {
for c in &r.claims {
let source = source_or_na(&c.source);
let _ = writeln!(
out,
"- [{}] {} — source: {}",
c.confidence,
escape_fences(&c.claim),
escape_fences(source),
);
if !c.contradictions.is_empty() {
let joined = c.contradictions.join("; ");
let _ = writeln!(out, " contradictions: {}", escape_fences(&joined));
}
}
for u in &r.unanswered {
let _ = writeln!(out, "- unanswered: {}", escape_fences(u));
}
}
out
}
fn partial_report(
question: &str,
acc: &AccumulatedEvidence,
reason: &str,
recovered: &[AnalystFindings],
) -> String {
let mut out = String::new();
let _ = writeln!(out, "## Research Report (incomplete — {reason})");
let _ = writeln!(out);
let _ = writeln!(out, "**Question**: {question}");
let _ = writeln!(out);
out.push_str(&render_recovered_findings(recovered));
let _ = writeln!(out, "### Evidence Gathered So Far");
if acc.claims.is_empty() {
let _ = writeln!(out, "- none");
} else {
for (i, c) in acc.claims.iter().enumerate() {
let source = source_or_na(&c.source);
let _ = writeln!(
out,
"- {i}. [{}] {} — source: {source}",
c.confidence,
escape_fences(&c.claim),
);
out.push_str(&render_weak_hints(&acc.weak, i));
}
}
render_unanswered(&mut out, &acc.unanswered, true);
render_raw_reports(&mut out, &acc.raw_reports, "###");
out
}
enum ResearchExit {
Terminal(anyhow::Result<String>),
Aborted,
Cancelled,
}
#[expect(clippy::too_many_lines)]
async fn run_deep_research(
ws: &Workspace,
question: &str,
job_id: &str,
resume: bool,
) -> ResearchExit {
let _orchestrator_guard = crate::call_registry::NON_AGENT_CALLS.register(
"research_orchestrator",
&ws.name,
Some(crate::registry::ParentKey::Research(job_id.to_string())),
true,
Some(question.to_string()),
);
let start = Instant::now();
let deadline = std::time::Instant::now() + round_timeout();
let mut state = ResearchState::load(job_id).await;
let mut budget = ResearchBudget::new(RESEARCH_MAX_ANALYSTS);
budget.spent = state.budget_spent;
let mut run_stats = RunStats::default();
let run_root = crate::research_cleanup::ensure_run_root(job_id).await;
let run_root_str = run_root.to_string_lossy().to_string();
let mut recovered: Vec<AnalystFindings> = Vec::new();
if crate::research_cancel::is_cancelled(job_id) {
return ResearchExit::Cancelled;
}
let mut round_agents: Vec<String> = Vec::new();
if state.stage == ResearchStage::Decompose {
let round0 = round0_decompose(
ws,
question,
&mut budget,
&mut run_stats,
deadline,
resume,
&run_root_str,
&mut round_agents,
job_id,
)
.await;
state.capture_round(&round_agents, &run_root).await;
round_agents.clear();
let plan = match round0 {
Ok((plan, marker)) => {
if let Some(m) = marker {
state.markers.push(m);
}
plan
}
Err(_) if crate::shutdown::aborting() => {
state.save(job_id).await;
return ResearchExit::Aborted;
}
Err(_) if crate::research_cancel::is_cancelled(job_id) => {
return ResearchExit::Cancelled;
}
Err(e) => {
state.save(job_id).await;
return ResearchExit::Terminal(Err(e));
}
};
state.plan = Some(plan);
state.budget_spent = budget.spent;
state.stage = ResearchStage::Round1;
state.save(job_id).await;
}
if crate::shutdown::aborting() {
return ResearchExit::Aborted;
}
if crate::research_cancel::is_cancelled(job_id) {
return ResearchExit::Cancelled;
}
if state.stage == ResearchStage::Round1 {
let Some(plan) = state.plan.as_ref() else {
return ResearchExit::Terminal(Err(anyhow::anyhow!(
"research state missing plan at round 1"
)));
};
let Some((r1, r1_timed_out)) = round1_research(
ws,
question,
plan,
&mut budget,
&mut state.ledger,
&mut run_stats,
deadline,
resume,
&run_root_str,
&mut round_agents,
job_id,
)
.await
else {
return ResearchExit::Terminal(Ok(partial_report(
question,
&state.acc,
"round 1 skipped — analyst budget exhausted",
&recovered,
)));
};
state.capture_round(&round_agents, &run_root).await;
round_agents.clear();
let (_, pending) = state.acc.absorb(&r1);
annotate_round(
ws,
&mut state.acc,
&pending,
&mut state.markers,
job_id,
question,
)
.await;
state.budget_spent = budget.spent;
state.stage = ResearchStage::GapRounds;
state.save(job_id).await;
recovered.extend(
wrap_up_timed_out(
ws,
r1_timed_out,
&mut state.ledger,
&mut run_stats,
job_id,
question,
)
.await,
);
}
if state.stage == ResearchStage::GapRounds {
let Some(plan) = state.plan.clone() else {
return ResearchExit::Terminal(Err(anyhow::anyhow!(
"research state missing plan at gap rounds"
)));
};
let gap_outcome = gap_rounds(
ws,
question,
&plan,
&mut budget,
&mut state,
&mut run_stats,
deadline,
job_id,
&run_root_str,
resume,
&mut recovered,
)
.await;
if crate::shutdown::aborting() {
state.gap_outcome = gap_outcome;
state.budget_spent = budget.spent;
state.save(job_id).await;
return ResearchExit::Aborted;
}
if crate::research_cancel::is_cancelled(job_id) {
return ResearchExit::Cancelled;
}
state.gap_outcome = gap_outcome;
state.budget_spent = budget.spent;
state.stage = ResearchStage::Synthesis;
state.save(job_id).await;
}
let rounds_used = 2 + state.gap_outcome.rounds_dispatched;
if crate::shutdown::aborting() {
return ResearchExit::Aborted;
}
if crate::research_cancel::is_cancelled(job_id) {
return ResearchExit::Cancelled;
}
let synthesis = match synthesize(
ws,
question,
&state.acc,
state.gap_outcome.abstention.as_deref(),
job_id,
)
.await
{
Ok(s) => {
if let Some(marker) = s.marker
&& !state.markers.contains(&marker)
{
state.markers.push(marker);
}
s.text
}
Err(_) if crate::shutdown::aborting() => {
return ResearchExit::Aborted;
}
Err(_) if crate::research_cancel::is_cancelled(job_id) => {
return ResearchExit::Cancelled;
}
Err(e) => {
return ResearchExit::Terminal(Ok(partial_report(
question,
&state.acc,
&format!("synthesis failed: {e}"),
&recovered,
)));
}
};
let verification = if crate::shutdown::aborting() {
Vec::new()
} else if crate::research_cancel::is_cancelled(job_id) {
return ResearchExit::Cancelled;
} else if !state.verification.is_empty() {
std::mem::take(&mut state.verification)
} else {
state.verification = research_verification_pass(
ws,
&state.acc,
&mut budget,
&mut state.ledger,
&mut run_stats,
deadline,
resume,
&run_root_str,
&mut round_agents,
job_id,
question,
)
.await;
state.capture_round(&round_agents, &run_root).await;
round_agents.clear();
state.budget_spent = budget.spent;
state.save(job_id).await;
std::mem::take(&mut state.verification)
};
let mut report = String::new();
if !state.markers.is_empty() {
let _ = writeln!(report, "## Run markers");
for m in &state.markers {
let _ = writeln!(report, "- {m}");
}
let _ = writeln!(report);
}
report.push_str(&render_recovered_findings(&recovered));
report.push_str(&synthesis);
if !verification.is_empty() {
let _ = writeln!(report);
let _ = writeln!(report, "## Verification");
for v in &verification {
let _ = writeln!(
report,
"- {} → **{}** — {}",
escape_fences(&v.claim),
v.verdict,
escape_fences(&v.evidence),
);
}
}
render_raw_reports(&mut report, &state.acc.raw_reports, "##");
if crate::jobs::job_retry_count(&crate::session::store().conn, job_id).await > 0 {
let _ = writeln!(
report,
"\n> Run telemetry is best-effort: this run resumed from a checkpoint, so tool-call \
and query counts reflect only the post-resume segment."
);
}
let _ = writeln!(report);
report.push_str(&render_run_summary(
&run_stats,
&budget,
rounds_used,
&state.acc,
state.gap_outcome.abstention.as_deref(),
&state.gap_outcome.unresolved,
state.gap_outcome.incomplete.as_deref(),
&state.markers,
start.elapsed(),
));
ResearchExit::Terminal(Ok(report))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_budget_cap_enforced() {
let mut budget = ResearchBudget::new(RESEARCH_MAX_ANALYSTS);
assert!(budget.try_reserve(RESEARCH_MAX_ANALYSTS - 1).is_ok());
assert!(budget.try_reserve(1).is_ok());
assert!(
budget.try_reserve(1).is_err(),
"spawn cap is unconditional and never refunded"
);
assert_eq!(budget.spent, RESEARCH_MAX_ANALYSTS);
assert!(budget.is_exhausted());
}
#[test]
fn test_research_fail_open_envelope() {
let envelope = build_async_research_message(&Err(anyhow::anyhow!(
"all decomposition analysts failed"
)));
assert!(envelope.contains("<research-result>"), "{envelope}");
assert!(
envelope.contains("An error occurred: all decomposition analysts failed"),
"{envelope}"
);
assert!(
envelope.ends_with("</research-result>"),
"envelope must close: {envelope}"
);
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn research_capped_delivers_partial_report_to_caller() {
let _lock = crate::util::test::retry_tests_lock();
crate::util::test::init_management_test_stores().await;
let ws = crate::workspace::test_ws("/tmp/test_ws_research_capped");
let job_id = "research_job_capped_1";
let conn = &crate::session::store().conn;
let now = crate::turso::now();
crate::util::test::JobRowBuilder::new(conn, job_id, "research", "assistant", &ws.name)
.task("question?")
.user_name("caller-user")
.channel("telegram")
.retry_count(crate::jobs::MAX_BOOT_REDISPATCH)
.timestamps(now.clone())
.insert()
.await
.unwrap();
conn.execute(
"INSERT INTO research_jobs (id, state) VALUES (?1, '{}')",
crate::turso::params![job_id],
)
.await
.unwrap();
research_capped_partial_report(job_id, &ws).await;
let job_rows = conn
.query(
"SELECT kind FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(
job_rows.len(),
1,
"research job terminalized; the research_cleanup durability row remains"
);
assert_eq!(
job_rows[0].get::<String>(0).unwrap(),
"research_cleanup",
"the surviving row is the cleanup durability row, not the research job"
);
let pending = conn
.query(
"SELECT envelope FROM pending_jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(pending.len(), 1, "partial-report envelope persisted");
let envelope_json: String = pending[0].get(0).unwrap();
let envelope: crate::message_router::AgentJob =
serde_json::from_str(&envelope_json).unwrap();
assert_eq!(
envelope.role,
crate::Role::Assistant,
"delivered to the original caller role, not Manager"
);
assert_eq!(envelope.user_name, "caller-user");
assert!(
envelope.content.contains("boot re-dispatch cap exceeded"),
"the partial report must surface the cap reason: {envelope_json}"
);
}
#[test]
fn legacy_verification_checkpoint_deserializes_as_synthesis() {
let stage: ResearchStage = serde_json::from_str(r#""verification""#).unwrap();
assert_eq!(stage, ResearchStage::Synthesis);
assert_eq!(
serde_json::to_string(&stage).unwrap(),
r#""synthesis""#,
"new checkpoints must serialize the collapsed name"
);
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn resume_research_run_continues_at_synthesis_stage() {
crate::util::test::init_management_test_stores().await;
let _lock = crate::util::test::retry_tests_lock();
let _policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let fake = crate::util::test::FakeProvider::new()
.ok("final synthesized report for the resumed run");
let _provider_guard = crate::util::test::install_fake_provider(std::sync::Arc::new(fake));
let ws = crate::workspace::test_ws("/tmp/test_ws_research_resume");
let job_id = "research_job_resume_1";
let conn = &crate::session::store().conn;
let now = crate::turso::now();
crate::util::test::JobRowBuilder::new(conn, job_id, "research", "assistant", &ws.name)
.task("question?")
.user_name("caller-user")
.channel("telegram")
.timestamps(now.clone())
.insert()
.await
.unwrap();
let mut state = ResearchState {
stage: ResearchStage::Synthesis,
plan: None,
gap_list: None,
acc: AccumulatedEvidence {
urls: std::collections::HashSet::new(),
claims: vec![crate::tools::analyze::Claim {
claim: "alpha is a real project".into(),
source: "s1".into(),
confidence: "high".into(),
contradictions: vec![],
}],
unanswered: vec![],
unanswered_keys: std::collections::HashSet::new(),
raw_reports: vec![],
weak: WeakLinks::default(),
},
ledger: QueryLedger::default(),
markers: vec![],
gap_outcome: GapRoundsOutcome::default(),
budget_spent: 0,
round_index: 0,
verification: vec![crate::tools::analyze::VerificationResult {
claim: "alpha is a real project".into(),
verdict: "confirmed".into(),
evidence: "primary source".into(),
tool_calls: 0,
searches: 0,
queries: vec![],
}],
commands: vec![],
seen_commands: std::collections::HashSet::new(),
coder_rounds_done: vec![],
};
state.acc.rebuild_keys();
let state_json = serde_json::to_string(&state).unwrap();
conn.execute(
"INSERT INTO research_jobs (id, state) VALUES (?1, ?2)",
crate::turso::params![job_id, state_json],
)
.await
.unwrap();
resume_research_run(job_id, &ws).await;
let job_rows = conn
.query(
"SELECT kind FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(
job_rows.len(),
1,
"research job terminalized; the research_cleanup durability row remains"
);
assert_eq!(
job_rows[0].get::<String>(0).unwrap(),
"research_cleanup",
"the surviving row is the cleanup durability row, not the research job"
);
let pending = conn
.query(
"SELECT envelope FROM pending_jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(pending.len(), 1, "resume envelope persisted");
let envelope_json: String = pending[0].get(0).unwrap();
let envelope: crate::message_router::AgentJob =
serde_json::from_str(&envelope_json).unwrap();
assert_eq!(
envelope.role,
crate::Role::Assistant,
"delivered to the original caller role, not Manager"
);
assert_eq!(envelope.user_name, "caller-user");
assert!(
envelope.content.contains("final synthesized report"),
"the resume envelope must carry the synthesized report: {envelope_json}"
);
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn cancelled_run_exits_cancelled_at_stage_boundary() {
let _lock = crate::util::test::retry_tests_lock();
crate::util::test::init_management_test_stores().await;
let ws = crate::workspace::test_ws("/tmp/test_ws_research_cancel_boundary");
let job_id = "research_job_cancel_boundary_1";
let _guard = crate::research_cancel::register(job_id);
crate::research_cancel::cancel(job_id);
let exit = run_deep_research(&ws, "question?", job_id, true).await;
assert!(
matches!(exit, ResearchExit::Cancelled),
"fired cancel signal must yield Cancelled at the boundary"
);
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn test_synthesis_truncated_output_is_marked_and_transport_fails_open() {
let _lock = crate::util::test::retry_tests_lock();
let _policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let ws = crate::workspace::test_ws("/tmp/test_ws");
let acc = AccumulatedEvidence::default();
let fake = crate::util::test::FakeProvider::new()
.ok_with_finish("report part one", Some("length"))
.ok_with_finish("report part two", Some("length"))
.ok_with_finish("compressed final", Some("length"));
let provider: std::sync::Arc<dyn crate::Provider> = std::sync::Arc::new(fake);
let _provider_guard = crate::util::test::install_fake_provider(provider);
let out = synthesize(&ws, "q", &acc, None, "test_run")
.await
.expect("last produced output must be delivered");
assert_eq!(out.text, "compressed final");
assert!(
out.marker
.as_deref()
.is_some_and(|m| m.contains("truncated")),
"truncated delivery must carry the explicit marker: {:?}",
out.marker
);
let fake = crate::util::test::FakeProvider::new()
.ok_with_finish("part one", Some("length"))
.ok("complete report");
let provider: std::sync::Arc<dyn crate::Provider> = std::sync::Arc::new(fake);
let _provider_guard = crate::util::test::install_fake_provider(provider);
let out = synthesize(&ws, "q", &acc, None, "test_run")
.await
.expect("clean completion wins");
assert_eq!(out.text, "complete report");
assert!(out.marker.is_none(), "clean completion is unmarked");
let fake = crate::util::test::FakeProvider::new()
.err(crate::retry::FailureClass::Transport, "outage")
.err(crate::retry::FailureClass::Transport, "outage")
.err(crate::retry::FailureClass::Transport, "outage");
let provider: std::sync::Arc<dyn crate::Provider> = std::sync::Arc::new(fake);
let _provider_guard = crate::util::test::install_fake_provider(provider);
let err = synthesize(&ws, "q", &acc, None, "test_run")
.await
.expect_err("transport exhaustion must error into the partial-report path");
assert!(err.to_string().contains("no usable output"), "{err}");
}
#[test]
fn test_validate_gap_list_traces_to_plan() {
let plan = MergedPlan {
sub_questions: vec![
MergedSubQuestion {
question: "What is the price of X?".into(),
evidence_needed: "pricing page".into(),
risk: "low".into(),
from_id: 0,
also_ids: vec![],
},
MergedSubQuestion {
question: "Who maintains X?".into(),
evidence_needed: "repo metadata".into(),
risk: "medium".into(),
from_id: 1,
also_ids: vec![],
},
],
dropped: vec![],
};
let in_range = GapList {
gaps: vec![Gap {
kind: "unanswered".into(),
item: "exact price".into(),
traces_to: 0,
}],
};
assert!(validate_gap_list(&in_range, &plan).is_ok());
let out_of_range = GapList {
gaps: vec![Gap {
kind: "unanswered".into(),
item: "unrelated".into(),
traces_to: 5,
}],
};
assert!(
validate_gap_list(&out_of_range, &plan).is_err(),
"out-of-range traces_to is rejected — index-range validation guarantees traceability"
);
}
#[test]
fn test_validate_merged_plan_coverage() {
let sq = |q: &str| SubQuestion {
question: q.into(),
evidence_needed: "e".into(),
risk: "low".into(),
};
let plans = vec![
DecompositionPlan {
sub_questions: vec![sq("q1"), sq("q2")],
},
DecompositionPlan {
sub_questions: vec![sq("q1"), sq("q3")],
},
DecompositionPlan {
sub_questions: vec![sq("q4"), sq("q5")],
},
];
let base = |also: bool, dropped: bool| MergedPlan {
sub_questions: vec![
MergedSubQuestion {
question: String::new(),
evidence_needed: String::new(),
risk: String::new(),
from_id: 0,
also_ids: if also { vec![2] } else { vec![] },
},
MergedSubQuestion {
question: String::new(),
evidence_needed: String::new(),
risk: String::new(),
from_id: 1,
also_ids: vec![],
},
MergedSubQuestion {
question: String::new(),
evidence_needed: String::new(),
risk: String::new(),
from_id: 3,
also_ids: vec![],
},
MergedSubQuestion {
question: String::new(),
evidence_needed: String::new(),
risk: String::new(),
from_id: 4,
also_ids: vec![],
},
],
dropped: if dropped {
vec![DroppedSubQuestion { id: 5 }]
} else {
vec![]
},
};
assert!(
validate_merged_plan(&base(true, true), &plans).is_ok(),
"full coverage via from_id + also_ids + dropped"
);
assert!(
validate_merged_plan(&base(false, true), &plans).is_err(),
"silent dropout: plan 1's q1 (id 2) is never covered"
);
assert!(
validate_merged_plan(&base(true, false), &plans).is_err(),
"silent dropout: q5 (id 5) is never covered"
);
let mut bad = base(true, true);
bad.sub_questions[0].from_id = 9;
assert!(
validate_merged_plan(&bad, &plans).is_err(),
"out-of-range from_id is rejected"
);
let mut bad = base(true, true);
bad.sub_questions[3].from_id = 0;
assert!(
validate_merged_plan(&bad, &plans).is_err(),
"duplicate placement (id 0 twice) is rejected"
);
}
#[test]
fn test_evidence_absorb_counts_novelty() {
let mut acc = AccumulatedEvidence::default();
let round1 = EvidenceRound {
urls: vec!["u1".into(), "u2".into()],
claims: vec![
Claim {
claim: "alpha is true".into(),
source: "u1".into(),
confidence: "high".into(),
contradictions: vec![],
},
Claim {
claim: "beta is true".into(),
source: "u2".into(),
confidence: "medium".into(),
contradictions: vec![],
},
],
unanswered: vec!["how beta relates to alpha".into()],
..Default::default()
};
let (urls, pending) = acc.absorb(&round1);
assert_eq!((urls, pending.len()), (2, 2));
let round2 = EvidenceRound {
urls: vec!["u1".into(), "u3".into()],
claims: vec![
Claim {
claim: "alpha is true".into(),
source: "u1".into(),
confidence: "high".into(),
contradictions: vec![],
},
Claim {
claim: "gamma is true".into(),
source: "u3".into(),
confidence: "low".into(),
contradictions: vec![],
},
],
unanswered: vec!["how beta relates to alpha".into(), "delta timeline".into()],
..Default::default()
};
let (urls, pending) = acc.absorb(&round2);
assert_eq!(
(urls, pending.len()),
(1, 2),
"only new URL (u3); every claim stays pending for annotation"
);
assert_eq!(
acc.unanswered,
vec!["how beta relates to alpha", "delta timeline"],
"unanswered aspects accumulate deduplicated across rounds"
);
}
#[test]
fn test_apply_annotations_contradicts_appends_and_links() {
let mut acc = AccumulatedEvidence::default();
acc.claims.push(Claim {
claim: "alpha costs $100 in 2024".into(),
source: "u1".into(),
confidence: "medium".into(),
contradictions: vec![],
});
let pending = vec![Claim {
claim: "alpha costs $200 in 2024".into(),
source: "u2".into(),
confidence: "high".into(),
contradictions: vec![],
}];
let pass = AnnotationPass {
annotations: vec![ClaimAnnotation {
new_id: 0,
verdict: "contradicts".into(),
existing_id: Some(0),
contradiction: Some("price differs: $200 vs $100".into()),
}],
};
let confirm = ConfirmOutcome::Passed(ConfirmPass {
links: vec![ConfirmLink {
new_id: 0,
verdict: "confirm".into(),
}],
});
let novel = acc.apply_annotations(&pass, &pending, &confirm);
assert_eq!(novel, 1, "a contradicting claim is new evidence");
assert_eq!(
acc.claims.len(),
2,
"the contradiction is preserved, never merged away"
);
assert_eq!(
acc.claims[0].contradictions,
vec!["price differs: $200 vs $100"],
"the existing claim carries the contradiction note — the verification gate fires"
);
assert!(
acc.claims[1]
.contradictions
.contains(&"alpha costs $100 in 2024".to_string()),
"the new claim links back to the existing one"
);
}
#[test]
fn test_apply_annotations_merges_sources_and_upgrades_confidence() {
let mut acc = AccumulatedEvidence::default();
acc.claims.push(Claim {
claim: "alpha is true".into(),
source: "u1; u2".into(),
confidence: "low".into(),
contradictions: vec![],
});
let pending = vec![Claim {
claim: "alpha is true".into(),
source: "u2; u3".into(),
confidence: "high".into(),
contradictions: vec![],
}];
let pass = AnnotationPass {
annotations: vec![ClaimAnnotation {
new_id: 0,
verdict: "duplicate".into(),
existing_id: Some(0),
contradiction: None,
}],
};
let confirm = ConfirmOutcome::Passed(ConfirmPass {
links: vec![ConfirmLink {
new_id: 0,
verdict: "confirm".into(),
}],
});
let novel = acc.apply_annotations(&pass, &pending, &confirm);
assert_eq!(novel, 0, "a duplicate is never counted as novel");
assert_eq!(
acc.claims.len(),
1,
"a duplicate is never dropped — it merges into the existing claim"
);
let c = &acc.claims[0];
assert_eq!(
c.confidence, "high",
"a higher-confidence re-statement upgrades the merged claim"
);
assert_eq!(
c.source, "u1; u2; u3",
"sources merge across rounds without duplicates"
);
}
#[test]
fn test_apply_annotations_weak_duplicate_stays_standalone() {
let mut acc = AccumulatedEvidence::default();
acc.claims.push(Claim {
claim: "alpha is true".into(),
source: "u1".into(),
confidence: "medium".into(),
contradictions: vec![],
});
let pending = vec![Claim {
claim: "alpha is true (restated)".into(),
source: "u2".into(),
confidence: "high".into(),
contradictions: vec![],
}];
let pass = AnnotationPass {
annotations: vec![ClaimAnnotation {
new_id: 0,
verdict: "duplicate".into(),
existing_id: Some(0),
contradiction: None,
}],
};
let confirm = ConfirmOutcome::Passed(ConfirmPass {
links: vec![ConfirmLink {
new_id: 0,
verdict: "reject".into(),
}],
});
let novel = acc.apply_annotations(&pass, &pending, &confirm);
assert_eq!(novel, 0, "a weak duplicate is never counted as novel");
assert_eq!(
acc.claims.len(),
2,
"the weak duplicate stays standalone — never merged"
);
assert_eq!(
acc.weak.duplicates,
vec![(1, 0)],
"the suspected relation is recorded in the side structure"
);
assert!(
acc.weak.contradictions.is_empty(),
"only duplicate hints apply here"
);
}
#[test]
fn test_apply_annotations_weak_contradiction_keeps_notes_marks_unconfirmed() {
let mut acc = AccumulatedEvidence::default();
acc.claims.push(Claim {
claim: "alpha costs $100 in 2024".into(),
source: "u1".into(),
confidence: "medium".into(),
contradictions: vec![],
});
let pending = vec![Claim {
claim: "alpha costs $200 in 2024".into(),
source: "u2".into(),
confidence: "high".into(),
contradictions: vec![],
}];
let pass = AnnotationPass {
annotations: vec![ClaimAnnotation {
new_id: 0,
verdict: "contradicts".into(),
existing_id: Some(0),
contradiction: Some("price differs: $200 vs $100".into()),
}],
};
let confirm = ConfirmOutcome::Passed(ConfirmPass {
links: vec![ConfirmLink {
new_id: 0,
verdict: "reject".into(),
}],
});
let novel = acc.apply_annotations(&pass, &pending, &confirm);
assert_eq!(novel, 1, "a contradiction is new evidence even when weak");
assert_eq!(
acc.claims[0].contradictions,
vec!["price differs: $200 vs $100"],
"the existing claim keeps its contradiction note"
);
assert!(
acc.claims[1]
.contradictions
.contains(&"alpha costs $100 in 2024".to_string()),
"the new claim links back to the existing one"
);
assert_eq!(
acc.weak.contradictions,
vec![(1, 0)],
"the unconfirmed relation lives in the side structure"
);
assert!(
acc.claims
.iter()
.all(|c| c.contradictions.iter().all(|n| !n.contains("unconfirmed"))),
"weakness never leaks into note text"
);
}
#[test]
fn test_verification_targets_primaries_first_weak_dups_fill_empty_slots() {
let mut acc = AccumulatedEvidence::default();
acc.claims.push(Claim {
claim: "a".into(),
source: "u1".into(),
confidence: "low".into(),
contradictions: vec![],
});
acc.claims.push(Claim {
claim: "b".into(),
source: "u2".into(),
confidence: "high".into(),
contradictions: vec!["b vs c".into()],
});
acc.claims.push(Claim {
claim: "a restated".into(),
source: "u3".into(),
confidence: "high".into(),
contradictions: vec![],
});
acc.claims.push(Claim {
claim: "b restated".into(),
source: "u4".into(),
confidence: "low".into(),
contradictions: vec![],
});
acc.weak.duplicates.push((2, 0));
acc.weak.duplicates.push((3, 1));
acc.weak.contradictions.push((1, 2));
let (targets, primary_count) = verification_targets(&acc);
assert_eq!(
primary_count, 3,
"claims 0, 1 and 3 qualify as primary — the weak contradiction is not appended"
);
assert_eq!(targets.len(), 4, "claim 2 fills the only empty slot");
assert_eq!(targets[0].claim, "a");
assert_eq!(targets[1].claim, "b");
assert_eq!(targets[2].claim, "b restated", "primary targets come first");
assert_eq!(
targets[3].claim, "a restated",
"weak duplicate appended last"
);
assert_eq!(
targets[3].contradictions, "",
"weakness never leaks toward verifiers"
);
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn test_annotate_round_confirm_failure_fail_open() {
let _lock = crate::util::test::retry_tests_lock();
let _policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let ws = crate::workspace::test_ws("/tmp/test_ws");
let mut acc = AccumulatedEvidence::default();
acc.claims.push(Claim {
claim: "alpha is true".into(),
source: "u1".into(),
confidence: "medium".into(),
contradictions: vec![],
});
let pending = vec![
Claim {
claim: "alpha is true (restated)".into(),
source: "u2".into(),
confidence: "high".into(),
contradictions: vec![],
},
Claim {
claim: "beta contradicts alpha".into(),
source: "u3".into(),
confidence: "high".into(),
contradictions: vec![],
},
];
let annotation_json = r#"{"annotations": [{"new_id": 0, "verdict": "duplicate", "existing_id": 0}, {"new_id": 1, "verdict": "contradicts", "existing_id": 0, "contradiction": "alpha vs beta differ"}]}"#;
let fake = crate::util::test::FakeProvider::new()
.ok(annotation_json)
.err(crate::retry::FailureClass::Transport, "confirm outage")
.err(crate::retry::FailureClass::Transport, "confirm outage")
.err(crate::retry::FailureClass::Transport, "confirm outage");
let provider: std::sync::Arc<dyn crate::Provider> = std::sync::Arc::new(fake);
let _provider_guard = crate::util::test::install_fake_provider(provider);
let mut markers = Vec::new();
let novel = annotate_round(
&ws,
&mut acc,
&pending,
&mut markers,
"test_run",
"question",
)
.await;
assert_eq!(
novel, 1,
"only the weak contradiction counts as novel — the weak duplicate never does"
);
assert_eq!(
acc.claims.len(),
3,
"both mutating claims stay standalone — never dropped"
);
assert_eq!(acc.weak.duplicates, vec![(1, 0)]);
assert_eq!(acc.weak.contradictions, vec![(2, 0)]);
assert!(
acc.claims[0]
.contradictions
.contains(&"alpha vs beta differ".to_string()),
"the weak contradiction keeps its note — verification still qualifies it"
);
assert!(
markers.iter().any(|m| m.contains("confirmation failed")),
"the confirm failure is never silent: {markers:?}"
);
}
#[test]
fn test_resolve_round_members_with_timeouts_preserves_timed_out() {
let snapshots: Vec<WrapUpEntry> = (0..4)
.map(|i| WrapUpEntry {
agent_id: format!("a{i}"),
params: wrap_up_params(&crate::workspace::test_ws("/tmp/test_ws"), "a", vec![]),
})
.collect();
let members: Vec<RoundMember<AnalystRun<AnalystFindings>>> = vec![
RoundMember::Done(AnalystRun::NoResponse),
RoundMember::TimedOut,
RoundMember::Panicked,
RoundMember::Cancelled,
];
let (runs, timed_out) = resolve_round_members_with_timeouts(members, &snapshots);
assert_eq!(runs.len(), 4);
assert!(runs.iter().all(|r| matches!(r, AnalystRun::NoResponse)));
assert_eq!(timed_out.len(), 1, "only the TimedOut member is recovered");
assert_eq!(timed_out[0].agent_id, "a1", "snapshot is index-parallel");
}
#[test]
fn test_render_recovered_findings_section_is_separate_and_marked() {
assert_eq!(render_recovered_findings(&[]), "");
let recovered = vec![AnalystFindings {
claims: vec![Claim {
claim: "found claim".into(),
source: "u1".into(),
confidence: "medium".into(),
contradictions: vec!["counter".into()],
}],
unanswered: vec!["still open".into()],
}];
let out = render_recovered_findings(&recovered);
assert!(
out.contains("## Recovered from timed-out analysts"),
"{out}"
);
assert!(out.contains("deadline exceeded, unverified"), "{out}");
assert!(out.contains("found claim"), "{out}");
assert!(out.contains("contradictions: counter"), "{out}");
assert!(out.contains("still open"), "{out}");
}
#[test]
fn test_session_has_successful_tool_result_gates_wrap_up_llm_call() {
assert!(!session_has_successful_tool_result(&[]));
assert!(!session_has_successful_tool_result(&[ChatMessage::user(
"task"
)]));
let failed =
crate::tools::format_tool_failure_feedback("search", &json!({"query": "q"}), "boom");
assert!(!session_has_successful_tool_result(&[
ChatMessage::tool_result("t1", &failed)
]));
assert!(session_has_successful_tool_result(&[
ChatMessage::tool_result("t1", "search results")
]));
let mixed = vec![
ChatMessage::tool_result("t1", &failed),
ChatMessage::tool_result("t2", "results ok"),
];
assert!(session_has_successful_tool_result(&mixed));
}
#[test]
fn coder_round_lifecycle_skip_vs_claim_vs_unclaim() {
let mut state = ResearchState::default();
set_coder_marker(&mut state, 0, "skipped — analyst budget exhausted");
assert!(
state
.markers
.iter()
.any(|m| m == "coder round 0 skipped — analyst budget exhausted")
);
assert!(!state.coder_rounds_done.contains(&0));
claim_coder_round(&mut state, 0);
assert!(state.coder_rounds_done.contains(&0));
assert!(!state.markers.iter().any(|m| m.contains("coder round 0 ")));
unclaim_coder_round(&mut state, 0);
set_coder_marker(&mut state, 0, "failed");
assert!(!state.coder_rounds_done.contains(&0));
assert!(state.markers.iter().any(|m| m == "coder round 0 failed"));
assert!(
!state
.markers
.iter()
.any(|m| m.contains("coder round 0 skipped")),
"outcome marker supersedes the stale skip marker"
);
claim_coder_round(&mut state, 0);
assert!(!state.markers.iter().any(|m| m.contains("coder round 0 ")));
set_coder_marker(&mut state, 1, "skipped — round deadline expired");
claim_coder_round(&mut state, 0);
assert!(
state
.markers
.iter()
.any(|m| m == "coder round 1 skipped — round deadline expired")
);
assert!(!state.markers.iter().any(|m| m.contains("coder round 0 ")));
set_coder_marker(&mut state, 1, "skipped — round deadline expired");
assert_eq!(
state
.markers
.iter()
.filter(|m| m.starts_with("coder round 1 "))
.count(),
1,
"skip re-push dedupes per key"
);
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn load_seeds_commands_from_dump_after_crash() {
crate::util::test::init_management_test_stores().await;
let _lock = crate::util::test::retry_tests_lock();
let job_id = "research_dump_reload";
let conn = &crate::session::store().conn;
let now = crate::turso::now();
crate::util::test::JobRowBuilder::new(conn, job_id, "research", "assistant", "ws")
.task("question?")
.user_name("caller-user")
.channel("telegram")
.timestamps(now.clone())
.insert()
.await
.unwrap();
let json = serde_json::to_string(&ResearchState::default()).unwrap();
conn.execute(
"INSERT INTO research_jobs (id, state) VALUES (?1, ?2)",
crate::turso::params![job_id, json],
)
.await
.unwrap();
let run_root = crate::research_cleanup::ensure_run_root(job_id).await;
crate::research_cleanup::write_command_dump(&run_root, &["pre-crash cmd".to_string()])
.await;
let loaded = ResearchState::load(job_id).await;
assert_eq!(
loaded.commands,
vec!["pre-crash cmd".to_string()],
"load() must seed commands from the dump"
);
assert!(
loaded.seen_commands.contains("pre-crash cmd"),
"seen-set rebuilt from the dump — post-resume dedup keeps the pre-crash capture"
);
let _ = tokio::fs::remove_dir_all(crate::research_cleanup::run_root_path(job_id)).await;
conn.execute(
"DELETE FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
}
}