use crate::agent::{run_agent, run_default_agent};
use crate::config::CONFIG;
use crate::message_router::{self, AgentJob, JobKind};
use crate::prompt::{load_prompt, load_prompt_sections, substitute};
use crate::tools::Tool;
use crate::{Agent, ChatMessage, DEFAULT_MAX_TOKENS, Role, Workspace};
use anyhow::Result;
use async_trait::async_trait;
use futures_util::FutureExt;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt::Write as _;
use std::time::Duration;
const PARALLEL_ANALYST_COUNT: usize = 3;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum DispatchMode {
Sync,
Async,
}
impl DispatchMode {
#[must_use]
pub const fn is_async(self) -> bool {
matches!(self, Self::Async)
}
}
pub struct AnalyzeTool {
dispatch_mode: DispatchMode,
pub caller_role: Role,
}
impl AnalyzeTool {
#[must_use]
pub const fn new(dispatch_mode: DispatchMode, caller_role: Role) -> Self {
Self {
dispatch_mode,
caller_role,
}
}
}
#[async_trait]
impl Tool for AnalyzeTool {
fn name(&self) -> &'static str {
"analyze"
}
fn description(&self) -> String {
let base = crate::prompt::load_prompt(&format!("tool/{}.md", self.name()));
if self.dispatch_mode.is_async() {
let async_note = crate::prompt::load_prompt("tool/analyze_async.md");
format!("{base}\n\n{async_note}")
} else {
base
}
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"analyze": {
"type": "string",
"description": "The question to delegate to the analysts"
}
}),
&["analyze"],
)
}
fn side_effects(&self) -> bool {
false
}
async fn execute(&self, ws: &Workspace, args: serde_json::Value) -> Result<String> {
let analyze = super::get_str(&args, "analyze")?;
if self.dispatch_mode.is_async() {
let ws = ws.clone();
let analyze = analyze.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 round = std::panic::AssertUnwindSafe(async {
dispatch_durable_analyze(
&ws,
&analyze,
caller_role,
user_name.clone(),
channel.clone(),
)
.await
})
.catch_unwind()
.await;
let envelope = match round {
Ok(Some(envelope)) => envelope,
Ok(None) => {
return;
}
Err(panic) => {
let panic = crate::util::panic_message(&*panic);
tracing::error!(panic = %panic, "analyze round dispatch panicked");
AgentJob {
content: build_async_analyze_message(&Err(anyhow::anyhow!(
"analyze round dispatch panicked: {panic}"
))),
workspace_name: ws.name.clone(),
user_name,
channel,
kind: JobKind::AnalyzeToolResult,
role: caller_role,
reply_target: None,
pending_job_id: None,
}
}
};
if crate::shutdown::aborting() {
return;
}
message_router::route(&crate::jobs::envelope_target(&envelope), envelope);
});
return Ok("Sub-agent dispatched. Results will follow shortly.".to_string());
}
run_parallel_analysts_and_consolidate(ws, analyze).await
}
}
struct AnalyzeSlot {
agent_id: String,
task: String,
}
enum AnalyzeRunOutcome {
Result(anyhow::Result<String>),
DrainCut,
}
async fn dispatch_durable_analyze(
ws: &Workspace,
analyze: &str,
caller_role: Role,
user_name: String,
channel: String,
) -> Option<AgentJob> {
let job_id = crate::generate_id();
let result = match run_analyze_with_job(
ws,
analyze,
&job_id,
caller_role,
&user_name,
&channel,
false,
)
.await
{
Ok(AnalyzeRunOutcome::DrainCut) => {
tracing::info!(
job = %job_id,
"Analyze round cut short by drain — job stays launched for boot resume",
);
return None;
}
Ok(AnalyzeRunOutcome::Result(result)) => result,
Err(e) => Err(e),
};
let envelope = crate::jobs::complete_durable_job(
&job_id,
build_async_analyze_message(&result),
JobKind::AnalyzeToolResult,
caller_role,
&user_name,
&channel,
&ws.name,
)
.await;
Some(envelope)
}
#[expect(clippy::too_many_lines)]
async fn run_analyze_with_job(
ws: &Workspace,
analyze: &str,
job_id: &str,
caller_role: Role,
user_name: &str,
channel: &str,
resume: bool,
) -> anyhow::Result<AnalyzeRunOutcome> {
let deadline = std::time::Instant::now() + round_timeout();
let (slots, pre_done) = if resume {
let rows = crate::jobs::list_agents_for_job(&crate::session::store().conn, job_id).await?;
let slots: Vec<AnalyzeSlot> = rows
.iter()
.map(|r| AnalyzeSlot {
agent_id: r.agent_id.clone(),
task: r.task.clone(),
})
.collect();
let pre_done: Vec<(String, String)> = rows
.into_iter()
.filter(|r| r.status == crate::jobs::RowStatus::Done.as_str())
.filter_map(|r| r.outcome.map(|o| (r.agent_id, o)))
.collect();
(slots, pre_done)
} else {
let suffix = crate::generate_suffix();
let angles = load_analyst_angles();
let mut slots: Vec<AnalyzeSlot> = Vec::with_capacity(PARALLEL_ANALYST_COUNT);
let mut agents: Vec<crate::jobs::NewAgent> = Vec::with_capacity(PARALLEL_ANALYST_COUNT);
for i in 0..PARALLEL_ANALYST_COUNT {
let slot = analyst_slot(ws, &angles, &suffix, i, analyze);
agents.push(crate::jobs::NewAgent {
agent_id: slot.agent_id.clone(),
kind: crate::jobs::AgentKind::Analyst,
idx: Some(i64::try_from(i).unwrap_or(i64::MAX)),
task: slot.task.clone(),
});
slots.push(slot);
}
crate::jobs::spawn_job(
&crate::session::store().conn,
job_id,
analyze,
&ws.name,
user_name,
channel,
caller_role,
&agents,
&crate::jobs::SpawnChild::Analyze,
)
.await?;
(slots, Vec::new())
};
let fresh_slots: Vec<&AnalyzeSlot> = slots
.iter()
.filter(|s| !pre_done.iter().any(|(id, _)| id == &s.agent_id))
.collect();
let fresh_runs = run_analyze_slots(ws, &fresh_slots, deadline, resume, job_id, analyze).await;
let mut runs: Vec<AnalyzeRun> = Vec::with_capacity(slots.len());
let mut fresh_iter = fresh_runs.into_iter();
for slot in &slots {
if let Some((_, raw)) = pre_done.iter().find(|(id, _)| id == &slot.agent_id) {
let mut agent = crate::Agent::new(
slot.agent_id.clone(),
crate::Role::Analyst,
ws,
None,
String::new(),
String::new(),
Some(crate::registry::ParentKey::AnalyzeRound(job_id.to_string())),
Some(analyze.to_string()),
);
let _ = agent
.session
.init(
&slot.agent_id,
"",
ws,
&crate::Role::Analyst,
None,
"",
"",
None,
)
.await;
runs.push(AnalyzeRun::Completed {
agent,
response: Some(raw.clone()),
});
} else if let Some(run) = fresh_iter.next() {
runs.push(run);
} else {
unreachable!("every fresh slot resolves to exactly one run (1:1 with fresh_slots)");
}
}
let conn = &crate::session::store().conn;
for (slot, run) in slots.iter().zip(&runs) {
let (status, outcome) = match run {
AnalyzeRun::Completed {
response: Some(raw),
..
} => (crate::jobs::RowStatus::Done, raw.clone()),
AnalyzeRun::Completed {
agent,
response: None,
} => (
crate::jobs::RowStatus::Failed,
agent.failure_reason("analyst produced no response"),
),
AnalyzeRun::Failed { reason } => (crate::jobs::RowStatus::Failed, reason.clone()),
};
if let Err(e) =
crate::jobs::write_agent_outcome(conn, job_id, &slot.agent_id, status, Some(&outcome))
.await
{
tracing::warn!(job = %job_id, error = %e, "Failed to checkpoint analyze outcome");
}
}
if crate::shutdown::aborting() {
return Ok(AnalyzeRunOutcome::DrainCut);
}
let result = consolidate_analyst_runs(ws, analyze, runs, deadline, job_id).await;
Ok(AnalyzeRunOutcome::Result(result))
}
async fn run_analyze_slots(
ws: &Workspace,
slots: &[&AnalyzeSlot],
deadline: std::time::Instant,
resume: bool,
round_key: &str,
question: &str,
) -> Vec<AnalyzeRun> {
let members: Vec<_> = slots
.iter()
.map(|slot| {
let ws = ws.clone();
let agent_id = slot.agent_id.clone();
let task = slot.task.clone();
let round_key = round_key.to_string();
let question = question.to_string();
move |round| async move {
let has_session = resume && crate::session::store().has_content(&agent_id).await;
run_agent(
agent_id,
crate::Role::Analyst,
&ws,
None,
if has_session { "" } else { task.as_str() },
String::new(),
String::new(),
None,
resume,
Some(round),
Some(crate::registry::ParentKey::AnalyzeRound(round_key)),
Some(question),
)
.await
}
})
.collect();
let handles = crate::agent::spawn_staggered_round(members, resume).await;
await_round_members(handles, deadline)
.await
.into_iter()
.map(|m| match m {
RoundMember::Done((agent, response)) => AnalyzeRun::Completed { agent, response },
RoundMember::TimedOut => AnalyzeRun::Failed {
reason: "analyst still running when the round deadline expired".to_string(),
},
RoundMember::Panicked => AnalyzeRun::Failed {
reason: "analyst task panicked".to_string(),
},
RoundMember::Cancelled => AnalyzeRun::Failed {
reason: "analyst task was cancelled".to_string(),
},
})
.collect()
}
pub(crate) async fn resume_analyze_round(job_id: &str, ws: &Workspace) {
let Some((caller, caller_role)) = crate::jobs::resume_job_preamble(
&crate::session::store().conn,
job_id,
"Analyze resume",
"Analyze resume",
)
.await
else {
return;
};
let result = match run_analyze_with_job(
ws,
&caller.task,
job_id,
caller_role,
&caller.user_name,
&caller.channel,
true,
)
.await
{
Ok(AnalyzeRunOutcome::Result(result)) => result,
Ok(AnalyzeRunOutcome::DrainCut) => {
tracing::info!(
job = %job_id,
"Analyze resume aborted after analysts completed — job stays for next boot",
);
return;
}
Err(e) => Err(e),
};
if crate::shutdown::aborting() {
tracing::info!(
job = %job_id,
"Analyze resume aborted after analysts completed — job stays for next boot",
);
return;
}
complete_durable_job_and_route(
job_id,
build_async_analyze_message(&result),
JobKind::AnalyzeToolResult,
caller_role,
&caller,
&ws.name,
)
.await;
}
pub(crate) async fn analyze_capped_envelope(job_id: &str, ws: &Workspace) {
let Some((caller, caller_role)) = crate::jobs::resume_job_preamble(
&crate::session::store().conn,
job_id,
"Analyze capped report",
"Analyze cap",
)
.await
else {
return;
};
let result: anyhow::Result<String> = Err(anyhow::anyhow!(format!(
"analyze round aborted after {} boot re-dispatch attempts — the last crash lost the round; \
please re-issue the analyze request",
crate::jobs::MAX_BOOT_REDISPATCH,
)));
complete_durable_job_and_route(
job_id,
build_async_analyze_message(&result),
JobKind::AnalyzeToolResult,
caller_role,
&caller,
&ws.name,
)
.await;
}
fn build_async_analyze_message(result: &anyhow::Result<String>) -> String {
build_async_result_envelope(result, "analyze-tool-result")
}
pub(crate) fn build_async_result_envelope(result: &anyhow::Result<String>, tag: &str) -> String {
match result {
Ok(text) => format!("<{tag}>\n\n{text}</{tag}>"),
Err(e) => {
tracing::debug!(error = %e, %tag, "async tool result failed");
format!("<{tag}>\n\nAn error occurred: {e}</{tag}>")
}
}
}
pub(crate) async fn complete_durable_job_and_route(
job_id: &str,
content: String,
kind: JobKind,
caller_role: Role,
caller: &crate::jobs::JobCaller,
workspace_name: &str,
) {
let envelope = crate::jobs::complete_durable_job(
job_id,
content,
kind,
caller_role,
&caller.user_name,
&caller.channel,
workspace_name,
)
.await;
crate::message_router::route(&crate::jobs::envelope_target(&envelope), envelope);
}
#[expect(clippy::struct_field_names)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Claim {
pub claim: String,
pub source: String,
pub confidence: String,
pub contradictions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct AnalystFindings {
pub claims: Vec<Claim>,
pub unanswered: Vec<String>,
}
enum AnalystOutcome {
NoResponse(String),
Findings {
raw: String,
findings: AnalystFindings,
},
ParseFailed { raw: String, failure: String },
}
#[derive(Debug)]
pub(crate) enum RoundMember<T> {
Done(T),
TimedOut,
Panicked,
Cancelled,
}
pub(crate) async fn await_round_members<T: Send + 'static>(
handles: Vec<tokio::task::JoinHandle<T>>,
deadline: std::time::Instant,
) -> Vec<RoundMember<T>> {
use futures_util::StreamExt;
use futures_util::stream::FuturesUnordered;
let start = std::time::Instant::now();
let cancel = tokio_util::sync::CancellationToken::new();
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) => RoundMember::Done(v),
Err(e) if e.is_panic() => {
let panic = crate::util::panic_message(&*e.into_panic());
tracing::warn!(member = i, %panic, "round member task panicked");
RoundMember::Panicked
}
Err(_) => {
tracing::warn!(member = i, "round member task cancelled externally");
RoundMember::Cancelled
}
}),
() = cancel.cancelled() => {
handle.abort();
(i, RoundMember::TimedOut)
}
}
}
})
.collect();
let mut out: Vec<Option<RoundMember<T>>> = (0..pending.len()).map(|_| None).collect();
let deadline_expired = loop {
if pending.is_empty() {
break false;
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
break true;
}
match tokio::time::timeout(remaining, pending.next()).await {
Ok(Some((i, member))) => out[i] = Some(member),
Ok(None) => break false,
Err(_) => break true,
}
};
if deadline_expired {
cancel.cancel();
while let Some((i, member)) = pending.next().await {
out[i] = Some(member);
}
let aborted = out
.iter()
.filter(|m| matches!(m, Some(RoundMember::TimedOut)))
.count();
tracing::warn!(
members_aborted = aborted,
elapsed_secs = start.elapsed().as_secs_f64(),
"round consolidation deadline expired — aborting stuck members"
);
}
out.into_iter()
.map(|m| m.expect("every round member resolves exactly once"))
.collect()
}
const DEFAULT_ROUND_TIMEOUT_SECS: u64 = 3 * 60 * 60;
pub(crate) fn round_timeout() -> Duration {
crate::util::env_duration_secs("MAHBOT_ROUND_TIMEOUT_SECS", DEFAULT_ROUND_TIMEOUT_SECS)
}
#[expect(clippy::large_enum_variant)] enum AnalyzeRun {
Completed {
agent: Agent,
response: Option<String>,
},
Failed {
reason: String,
},
}
impl AnalyzeRun {
fn response(&self) -> Option<&str> {
match self {
Self::Completed { response, .. } => response.as_deref(),
Self::Failed { .. } => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct VerificationVerdict {
pub verdict: String,
pub evidence: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct VerificationResult {
pub claim: String,
pub verdict: String,
pub evidence: String,
pub tool_calls: usize,
pub searches: usize,
pub queries: Vec<String>,
}
pub(crate) fn validate_verification_verdict(v: &VerificationVerdict) -> Result<(), String> {
if matches!(
v.verdict.as_str(),
"supported" | "contradicted" | "unresolved"
) {
Ok(())
} else {
Err(format!(
"verification verdict '{}' not in [supported, contradicted, unresolved]",
v.verdict
))
}
}
pub(crate) const VERIFY_MAX_ANALYSTS: usize = 4;
async fn run_parallel_analysts_and_consolidate(ws: &Workspace, analyze: &str) -> Result<String> {
let deadline = std::time::Instant::now() + round_timeout();
let round_key = crate::generate_suffix();
let runs =
run_parallel_analysts(ws, analyze, PARALLEL_ANALYST_COUNT, deadline, &round_key).await;
consolidate_analyst_runs(ws, analyze, runs, deadline, &round_key).await
}
pub(crate) fn load_analyst_angles() -> Vec<String> {
load_prompt_sections("analyze/angles.md")
}
fn analyst_slot(
ws: &Workspace,
angles: &[String],
suffix: &str,
i: usize,
analyze: &str,
) -> AnalyzeSlot {
let angle = angles.get(i).cloned().unwrap_or_default();
let task = if angle.is_empty() {
analyze.to_string()
} else {
format!("{analyze}\n\nResearch angle:\n{angle}")
};
AnalyzeSlot {
agent_id: format!("analyze_{}_{}_{}_analyst", ws.name, suffix, i),
task,
}
}
async fn run_parallel_analysts(
ws: &Workspace,
analyze: &str,
count: usize,
deadline: std::time::Instant,
round_key: &str,
) -> Vec<AnalyzeRun> {
let angles = load_analyst_angles();
let slots: Vec<AnalyzeSlot> = (0..count)
.map(|i| analyst_slot(ws, &angles, round_key, i, analyze))
.collect();
let slot_refs: Vec<&AnalyzeSlot> = slots.iter().collect();
run_analyze_slots(ws, &slot_refs, deadline, false, round_key, analyze).await
}
async fn consolidate_analyst_runs(
ws: &Workspace,
analyze: &str,
runs: Vec<AnalyzeRun>,
deadline: std::time::Instant,
round_key: &str,
) -> Result<String> {
let valid_count = runs
.iter()
.filter(|r| r.response().is_some_and(|t| !t.trim().is_empty()))
.count();
match valid_count {
0 => {
let reasons: Vec<&str> = runs
.iter()
.filter_map(|r| match r {
AnalyzeRun::Failed { reason } => Some(reason.as_str()),
AnalyzeRun::Completed { .. } => None,
})
.collect();
let suffix = if reasons.is_empty() {
String::new()
} else {
format!(" ({})", reasons.join("; "))
};
anyhow::bail!("All parallel analysts failed to produce a response{suffix}");
}
1 => Ok(single_raw_response(&runs).expect("exactly one valid response")),
_ => {
let outcomes = extract_findings(runs, deadline).await;
consolidate_findings(ws, analyze, outcomes, round_key).await
}
}
}
fn single_raw_response(runs: &[AnalyzeRun]) -> Option<String> {
runs.iter().find_map(|r| match r {
AnalyzeRun::Completed { response, .. } => response.clone().filter(|t| !t.trim().is_empty()),
AnalyzeRun::Failed { .. } => None,
})
}
async fn extract_findings(
runs: Vec<AnalyzeRun>,
deadline: std::time::Instant,
) -> Vec<AnalystOutcome> {
let extraction_prompt = load_prompt("extraction/findings.md");
let handles: Vec<_> = runs
.into_iter()
.map(|run| {
let extraction_prompt = extraction_prompt.clone();
tokio::spawn(async move {
let (agent, response) = match run {
AnalyzeRun::Completed { agent, response } => (agent, response),
AnalyzeRun::Failed { reason } => {
return AnalystOutcome::NoResponse(crate::util::scrub_credentials(&reason));
}
};
let Some(raw) = response else {
let reason = agent.failure_reason("analyst produced no response");
return AnalystOutcome::NoResponse(crate::util::scrub_credentials(&reason));
};
if raw.trim().is_empty() {
return AnalystOutcome::NoResponse("analyst produced no response".to_string());
}
match agent
.extract_verdict::<AnalystFindings>(&extraction_prompt, None, None)
.await
{
Ok(findings) => AnalystOutcome::Findings { raw, findings },
Err(e) => AnalystOutcome::ParseFailed {
raw,
failure: e.to_string(),
},
}
})
})
.collect();
await_round_members(handles, deadline)
.await
.into_iter()
.map(|m| match m {
RoundMember::Done(outcome) => outcome,
RoundMember::TimedOut => AnalystOutcome::NoResponse(
"findings extraction still running when the round deadline expired".to_string(),
),
RoundMember::Panicked => {
AnalystOutcome::NoResponse("findings extraction task panicked".to_string())
}
RoundMember::Cancelled => {
AnalystOutcome::NoResponse("findings extraction task was cancelled".to_string())
}
})
.collect()
}
#[must_use]
fn claims_per_agent(outcomes: &[AnalystOutcome]) -> Vec<Vec<String>> {
outcomes
.iter()
.map(|o| match o {
AnalystOutcome::Findings { findings, .. } => {
findings.claims.iter().map(|c| c.claim.clone()).collect()
}
_ => Vec::new(),
})
.collect()
}
#[must_use]
fn render_unconsolidated_fallback(
marker: &str,
items_by_agent: &[Vec<String>],
outcomes: &[AnalystOutcome],
) -> String {
let flat = render_flat_claim_list(items_by_agent);
let raw_dump = render_raw_analyst_dump(outcomes);
format!("## Analyst Reports\n\n({marker})\n\n{flat}\n\n{raw_dump}")
}
async fn consolidate_findings(
ws: &Workspace,
analyze: &str,
outcomes: Vec<AnalystOutcome>,
round_key: &str,
) -> Result<String> {
let items_by_agent = claims_per_agent(&outcomes);
let n_valid = items_by_agent.iter().filter(|l| !l.is_empty()).count();
if n_valid == 0 {
let has_parse_failures = outcomes
.iter()
.any(|o| matches!(o, AnalystOutcome::ParseFailed { .. }));
let raw_dump = render_raw_analyst_dump(&outcomes);
let failures = render_extraction_failures(&outcomes);
let marker = if has_parse_failures {
"unconsolidated — findings extraction failed"
} else {
"unconsolidated — no extractable claims from any analyst"
};
return Ok(format!(
"## Analyst Reports\n\n({marker})\n\n{failures}\n\n{raw_dump}"
));
}
if n_valid == 1 {
tracing::info!("Single-analyst consolidation — skipping grouping pass");
return Ok(render_unconsolidated_fallback(
"only one analyst produced parseable claims — grouping skipped",
&items_by_agent,
&outcomes,
));
}
let material = crate::consensus::numbered_items_material(&items_by_agent);
let user =
format!("# Original Question\n\n{analyze}\n\n# Agent Claims (id-numbered)\n\n{material}");
let model = CONFIG.role_model(Role::Analyst);
let routing = CONFIG.model_routing(&model);
let system = format!(
"{}\n\n{}",
load_prompt("synthesis/analyst.md"),
load_prompt("synthesis/grouping_contradictions.md"),
);
let request = crate::consensus::grouping_request(
ws,
"consolidate",
&system,
&user,
model,
Some(
crate::role::role_info(&Role::Analyst)
.default_reasoning_effort
.to_string(),
),
routing.provider_order,
Some(DEFAULT_MAX_TOKENS),
);
let table = crate::consensus::ItemTable::new(&items_by_agent);
let parent = Some(crate::registry::ParentKey::AnalyzeRound(
round_key.to_string(),
));
if crate::shutdown::aborting() {
tracing::warn!(
"Analyst consolidation skipped — shutdown/drain in progress; delivering raw claim list"
);
return Ok(render_unconsolidated_fallback(
"unconsolidated — shutdown during consolidation",
&items_by_agent,
&outcomes,
));
}
match crate::consensus::run_grouping_repair(
ws,
"consolidate",
request,
&items_by_agent,
parent,
Some(analyze.to_string()),
)
.await
{
crate::consensus::RepairOutcome::Repaired { output, references } => Ok(
render_analyze_groups(analyze, &output, &references, &table, n_valid, &outcomes),
),
crate::consensus::RepairOutcome::Fallback => {
tracing::warn!("Analyst consolidation failed — delivering raw claim list");
Ok(render_unconsolidated_fallback(
"unconsolidated — consolidation failed",
&items_by_agent,
&outcomes,
))
}
}
}
#[must_use]
fn render_analyze_groups(
analyze: &str,
output: &crate::consensus::GroupingOutput,
references: &[crate::consensus::GroupingReference],
table: &crate::consensus::ItemTable<'_>,
n_valid: usize,
outcomes: &[AnalystOutcome],
) -> String {
let mut out = String::new();
let summary = output.summary.trim();
if summary.is_empty() {
let _ = writeln!(
out,
"LLM summary unavailable — deterministic member render."
);
} else {
let _ = writeln!(out, "{summary}");
}
for group in &output.groups {
let n = crate::consensus::distinct_agents(group, table).len();
let bracket = crate::consensus::bracket_label(n, n_valid, group.contradiction);
let _ = write!(out, "\n\n**{}** {bracket}", group.heading);
for member in &group.members {
let caveat = member_caveat(member, table, outcomes);
let _ = write!(
out,
"\n- {}",
crate::consensus::render_member_line(member, table)
);
if let Some(c) = caveat {
let _ = write!(out, " — caveat: {c}");
}
}
}
out.push_str(&crate::consensus::render_ungrouped_section(
output,
references,
|member, disputed| {
let mut line = crate::consensus::render_member_line(member, table);
line.push_str(disputed);
if let Some(c) = member_caveat(member, table, outcomes) {
let _ = write!(line, " — caveat: {c}");
}
line
},
));
let _ = write!(out, "\n\n_Original question: {analyze}_");
out
}
fn member_caveat(
member: &crate::consensus::GroupingMember,
table: &crate::consensus::ItemTable<'_>,
outcomes: &[AnalystOutcome],
) -> Option<String> {
let (agent, item) = table.resolve_index(member.id)?;
let AnalystOutcome::Findings { findings, .. } = outcomes.get(agent)? else {
return None;
};
let claim = findings.claims.get(item)?;
if claim.contradictions.is_empty() {
None
} else {
Some(claim.contradictions.join("; "))
}
}
#[must_use]
fn render_flat_claim_list(items_by_agent: &[Vec<String>]) -> String {
let mut out = String::new();
let _ = writeln!(out, "### Claims (grouping input)");
for (agent_idx, claims) in items_by_agent.iter().enumerate() {
for (i, c) in claims.iter().enumerate() {
let _ = writeln!(out, "{}. [Agent {agent_idx}] {c}", i + 1);
}
}
out
}
pub(crate) fn normalize_claim(s: &str) -> String {
s.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
pub(crate) fn max_confidence(a: &str, b: &str) -> String {
let rank = |c: &str| match c {
"high" => 2,
"medium" => 1,
_ => 0,
};
if rank(b) > rank(a) {
b.to_string()
} else {
a.to_string()
}
}
pub(crate) struct VerificationTarget {
pub(crate) claim: String,
pub(crate) sources: String,
pub(crate) contradictions: String,
}
impl VerificationTarget {
pub(crate) fn new(claim: &str, sources: &str, contradictions: &str) -> Self {
Self {
claim: claim.to_string(),
sources: sources.to_string(),
contradictions: contradictions.to_string(),
}
}
}
pub(crate) fn extract_query_telemetry_from_history(
history: &[ChatMessage],
) -> (usize, usize, Vec<String>) {
let mut tool_calls = 0usize;
let mut searches = 0usize;
let mut queries = Vec::new();
for msg in history {
let Some(decoded) = crate::session::decode_native_history_message(msg) else {
continue;
};
let crate::session::DecodedNativeHistoryMessage::Assistant {
tool_calls: Some(calls),
..
} = decoded
else {
continue;
};
tool_calls += calls.len();
for call in calls {
if matches!(call.name.as_str(), "web_search" | "search") {
searches += 1;
if let Some(q) = call.arguments.get("query").and_then(|v| v.as_str()) {
queries.push(q.to_string());
}
}
}
}
(tool_calls, searches, queries)
}
pub(crate) fn extract_query_telemetry(agent: &Agent) -> (usize, usize, Vec<String>) {
extract_query_telemetry_from_history(agent.session.history())
}
#[expect(clippy::too_many_arguments)]
pub(crate) async fn dispatch_claim_verifiers(
ws: &Workspace,
id_prefix: &str,
targets: &[VerificationTarget],
task_extra: &str,
deadline: std::time::Instant,
resume: bool,
run_key: &str,
question: &str,
) -> (Vec<VerificationResult>, Vec<String>) {
let task_template = load_prompt("analyze/verify.md");
let extraction_prompt = load_prompt("extraction/verify.md");
let suffix = crate::generate_suffix();
let mut dispatched: Vec<String> = Vec::new();
let members: Vec<_> = targets
.iter()
.take(VERIFY_MAX_ANALYSTS)
.enumerate()
.map(|(i, t)| {
let ws = ws.clone();
let agent_id = format!("{id_prefix}_{suffix}_{i}");
dispatched.push(agent_id.clone());
let mut task = substitute(
&task_template,
&[
("{{claim}}", &t.claim),
("{{sources}}", &t.sources),
("{{contradictions}}", &t.contradictions),
],
);
task.push_str(task_extra);
let extraction_prompt = extraction_prompt.clone();
let claim_text = t.claim.clone();
let run_key = run_key.to_string();
let question = question.to_string();
move |round| async move {
run_claim_verifier(
&ws,
&agent_id,
&claim_text,
&task,
&extraction_prompt,
round,
&run_key,
&question,
)
.await
}
})
.collect();
let handles = crate::agent::spawn_staggered_round(members, resume).await;
let results = await_round_members(handles, deadline)
.await
.into_iter()
.enumerate()
.map(|(i, m)| match m {
RoundMember::Done(v) => v,
RoundMember::TimedOut | RoundMember::Panicked | RoundMember::Cancelled => {
VerificationResult {
claim: targets[i].claim.clone(),
verdict: "unresolved".to_string(),
evidence: "verifier never completed (round deadline or panic)".to_string(),
tool_calls: 0,
searches: 0,
queries: Vec::new(),
}
}
})
.collect();
(results, dispatched)
}
#[expect(clippy::too_many_arguments)]
async fn run_claim_verifier(
ws: &Workspace,
agent_id: &str,
target_claim: &str,
task: &str,
extraction_prompt: &str,
round: crate::agent::RoundOpts,
run_key: &str,
question: &str,
) -> VerificationResult {
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 (tool_calls, searches, queries) = extract_query_telemetry(&agent);
let Some(raw) = response else {
return VerificationResult {
claim: target_claim.to_string(),
verdict: "unresolved".to_string(),
evidence: "verifier failed to produce a response".to_string(),
tool_calls,
searches,
queries,
};
};
if raw.trim().is_empty() {
return VerificationResult {
claim: target_claim.to_string(),
verdict: "unresolved".to_string(),
evidence: "verifier produced an empty response".to_string(),
tool_calls,
searches,
queries,
};
}
match agent
.extract_verdict::<VerificationVerdict>(
extraction_prompt,
Some(&validate_verification_verdict),
None,
)
.await
{
Ok(v) => VerificationResult {
claim: target_claim.to_string(),
verdict: v.verdict,
evidence: v.evidence,
tool_calls,
searches,
queries,
},
Err(e) => VerificationResult {
claim: target_claim.to_string(),
verdict: "unresolved".to_string(),
evidence: format!("verification extraction failed: {e}"),
tool_calls,
searches,
queries,
},
}
}
pub(crate) fn escape_fences(s: &str) -> String {
s.replace("```", "\\`\\`\\`")
}
fn render_raw_analyst_dump(outcomes: &[AnalystOutcome]) -> String {
let mut sections = Vec::new();
for (i, o) in outcomes.iter().enumerate() {
match o {
AnalystOutcome::Findings { raw, .. } | AnalystOutcome::ParseFailed { raw, .. } => {
sections.push(format!(
"### Report from Analyst {}\n{}",
i + 1,
escape_fences(raw)
));
}
AnalystOutcome::NoResponse(reason) => {
sections.push(format!(
"### Report from Analyst {}\nno response: {reason}",
i + 1
));
}
}
}
sections.join("\n\n")
}
#[must_use]
fn render_extraction_failures(outcomes: &[AnalystOutcome]) -> String {
let mut out = String::new();
for (i, o) in outcomes.iter().enumerate() {
match o {
AnalystOutcome::ParseFailed { failure, .. } => {
let _ = writeln!(
out,
"- Analyst {}: findings extraction failed ({failure})",
i + 1
);
}
AnalystOutcome::NoResponse(reason) => {
let _ = writeln!(out, "- Analyst {}: no response ({reason})", i + 1);
}
AnalystOutcome::Findings { .. } => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::test::{FakeProvider, install_fake_provider, retry_tests_lock};
use crate::workspace::test_ws;
use serde_json::json;
use std::sync::Arc;
#[tokio::test]
async fn test_analyze_missing_args() {
let tool = AnalyzeTool::new(DispatchMode::Sync, Role::Engineer);
let ws = test_ws("/tmp/test_ws");
let result = tool.execute(&ws, json!({})).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Missing required field: analyze"),
"Should mention missing analyze"
);
}
#[tokio::test]
#[ignore = "requires LLM provider"]
async fn test_analyze_analyst() {
let tool = AnalyzeTool::new(DispatchMode::Sync, Role::Engineer);
let ws = test_ws("/tmp/test_ws");
let args = json!({"analyze": "Say 'hello analyst' and nothing else."});
let result = tool.execute(&ws, args).await.expect("execute");
assert!(
result.contains("hello"),
"output should contain hello: {result}"
);
}
fn test_analyst_run(ws: &Workspace, response: Option<&str>) -> AnalyzeRun {
let agent = Agent::new(
format!("analyze_test_{}", crate::generate_suffix()),
Role::Analyst,
ws,
None,
String::new(),
String::new(),
None,
None,
);
AnalyzeRun::Completed {
agent,
response: response.map(ToString::to_string),
}
}
fn test_failed_run(reason: &str) -> AnalyzeRun {
AnalyzeRun::Failed {
reason: reason.to_string(),
}
}
fn findings(claims: Vec<(&str, &str, &str)>) -> AnalystFindings {
AnalystFindings {
claims: claims
.into_iter()
.map(|(claim, source, confidence)| Claim {
claim: claim.to_string(),
source: source.to_string(),
confidence: confidence.to_string(),
contradictions: Vec::new(),
})
.collect(),
unanswered: Vec::new(),
}
}
#[tokio::test]
async fn test_consolidate_zero_responses_returns_error() {
let ws = test_ws("/tmp/test_ws");
let runs = vec![
test_analyst_run(&ws, None),
test_analyst_run(&ws, None),
test_analyst_run(&ws, None),
];
let result = consolidate_analyst_runs(
&ws,
"test question",
runs,
std::time::Instant::now() + Duration::from_mins(1),
"test_round",
)
.await;
assert!(result.is_err(), "0 responses should error");
assert!(
result
.unwrap_err()
.to_string()
.contains("All parallel analysts failed"),
"Error should mention analyst failure"
);
}
#[tokio::test]
async fn test_consolidate_zero_responses_surfaces_failure_reasons() {
let ws = test_ws("/tmp/test_ws");
let runs = vec![
test_failed_run("analyst 0 still running when the round deadline expired"),
test_failed_run("analyst 1 task panicked"),
test_analyst_run(&ws, None),
];
let result = consolidate_analyst_runs(
&ws,
"test question",
runs,
std::time::Instant::now() + Duration::from_mins(1),
"test_round",
)
.await;
let err = result.expect_err("0 valid responses should error");
assert!(
err.to_string().contains("deadline expired"),
"error should surface the stuck-analyst reason: {err}"
);
assert!(
err.to_string().contains("panicked"),
"error should surface the panicked-analyst reason: {err}"
);
}
#[tokio::test]
async fn await_round_members_bounds_stuck_member() {
struct AbortDetector(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for AbortDetector {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
let aborted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = aborted.clone();
let stuck = tokio::spawn(async move {
let _detector = AbortDetector(flag);
loop {
tokio::time::sleep(Duration::from_hours(1)).await;
}
});
let done = tokio::spawn(async { 42u32 });
let deadline = std::time::Instant::now() + Duration::from_millis(300);
let members = await_round_members(vec![stuck, done], deadline).await;
assert!(matches!(members[0], RoundMember::TimedOut), "{members:?}");
assert!(matches!(members[1], RoundMember::Done(42)), "{members:?}");
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
aborted.load(std::sync::atomic::Ordering::SeqCst),
"stuck member task should have been aborted at the deadline"
);
}
#[tokio::test]
async fn await_round_members_surfaces_panics() {
let panicking = tokio::spawn(async { panic!("boom") });
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let members = await_round_members(vec![panicking], deadline).await;
assert!(matches!(members[0], RoundMember::Panicked), "{members:?}");
}
#[tokio::test]
async fn test_consolidate_one_response_returned_directly() {
let ws = test_ws("/tmp/test_ws");
let runs = vec![
test_analyst_run(&ws, Some("only answer")),
test_analyst_run(&ws, None),
test_analyst_run(&ws, None),
];
let result = consolidate_analyst_runs(
&ws,
"test question",
runs,
std::time::Instant::now() + Duration::from_mins(1),
"test_round",
)
.await;
assert!(result.is_ok(), "1 response should succeed");
assert_eq!(
result.unwrap(),
"only answer",
"Should return the single response directly without consolidation"
);
}
#[tokio::test]
async fn test_consolidate_empty_responses_filtered() {
let ws = test_ws("/tmp/test_ws");
let runs = vec![
test_analyst_run(&ws, Some("valid")),
test_analyst_run(&ws, Some("")),
test_analyst_run(&ws, Some(" ")),
];
let result = consolidate_analyst_runs(
&ws,
"test question",
runs,
std::time::Instant::now() + Duration::from_mins(1),
"test_round",
)
.await;
assert!(
result.is_ok(),
"1 valid after filtering empty should succeed"
);
assert_eq!(
result.unwrap(),
"valid",
"Should return the only non-empty response"
);
}
#[test]
fn render_analyze_groups_computes_brackets_from_distinct_agents() {
let outcomes = vec![
AnalystOutcome::Findings {
raw: "r1".into(),
findings: findings(vec![
("alpha is true", "url1", "high"),
("beta is true", "url2", "medium"),
]),
},
AnalystOutcome::Findings {
raw: "r2".into(),
findings: findings(vec![
("alpha is true", "url1", "high"),
("gamma is true", "url3", "low"),
]),
},
AnalystOutcome::NoResponse("analyst produced no response".into()),
];
let items = claims_per_agent(&outcomes);
let table = crate::consensus::ItemTable::new(&items);
let output = crate::consensus::GroupingOutput {
summary: "Two facts, one solo finding.".into(),
groups: vec![
crate::consensus::GroupingGroup {
heading: "Alpha".into(),
contradiction: false,
members: vec![
crate::consensus::GroupingMember { id: 0 },
crate::consensus::GroupingMember { id: 2 },
],
},
crate::consensus::GroupingGroup {
heading: "Beta".into(),
contradiction: false,
members: vec![crate::consensus::GroupingMember { id: 1 }],
},
],
ungrouped: vec![crate::consensus::GroupingMember { id: 3 }],
};
let text = render_analyze_groups("q", &output, &[], &table, 2, &outcomes);
assert!(
text.contains("**Alpha** [2/2]"),
"consensus group renders [2/2] from distinct cited agents: {text}"
);
assert!(
text.contains("**Beta** [1/2]"),
"solo group renders [1/2] without DISPUTED: {text}"
);
assert!(
text.contains("**Ungrouped**"),
"ungrouped list renders: {text}"
);
assert!(
text.contains("- Agent 1: gamma is true"),
"ungrouped member attributes its source: {text}"
);
assert!(
!text.contains("DISPUTED"),
"no contradiction flag means no DISPUTED anywhere: {text}"
);
}
#[test]
fn render_analyze_groups_disputed_only_on_contradiction() {
let outcomes = vec![
AnalystOutcome::Findings {
raw: "r1".into(),
findings: findings(vec![("alpha is true", "url1", "high")]),
},
AnalystOutcome::Findings {
raw: "r2".into(),
findings: findings(vec![("alpha is false", "url2", "high")]),
},
];
let items = claims_per_agent(&outcomes);
let table = crate::consensus::ItemTable::new(&items);
let output = crate::consensus::GroupingOutput {
summary: "Agents disagree on alpha.".into(),
groups: vec![crate::consensus::GroupingGroup {
heading: "Alpha".into(),
contradiction: true,
members: vec![
crate::consensus::GroupingMember { id: 0 },
crate::consensus::GroupingMember { id: 1 },
],
}],
ungrouped: vec![],
};
let text = render_analyze_groups("q", &output, &[], &table, 2, &outcomes);
assert!(
text.contains("**Alpha** [2/2 · DISPUTED]"),
"contradiction group renders [2/2 · DISPUTED]: {text}"
);
}
#[test]
fn render_analyze_groups_surfaces_member_caveats_as_metadata() {
let mut claims = findings(vec![("alpha is true", "url1", "high")]);
claims.claims[0].contradictions = vec!["source B says alpha may be false".into()];
let outcomes = vec![
AnalystOutcome::Findings {
raw: "r1".into(),
findings: claims,
},
AnalystOutcome::Findings {
raw: "r2".into(),
findings: findings(vec![("alpha is true", "url1", "high")]),
},
];
let items = claims_per_agent(&outcomes);
let table = crate::consensus::ItemTable::new(&items);
let output = crate::consensus::GroupingOutput {
summary: "alpha is agreed.".into(),
groups: vec![crate::consensus::GroupingGroup {
heading: "Alpha".into(),
contradiction: false,
members: vec![
crate::consensus::GroupingMember { id: 0 },
crate::consensus::GroupingMember { id: 1 },
],
}],
ungrouped: vec![],
};
let text = render_analyze_groups("q", &output, &[], &table, 2, &outcomes);
assert!(
text.contains("caveat: source B says alpha may be false"),
"self-reported caveat renders as member metadata: {text}"
);
assert!(
!text.contains("DISPUTED"),
"a self-reported caveat is NOT a contradiction: {text}"
);
}
async fn consolidate_with_script(
outcomes: Vec<AnalystOutcome>,
fake: FakeProvider,
) -> anyhow::Result<String> {
let provider: Arc<dyn crate::Provider> = Arc::new(fake);
let _guard = install_fake_provider(provider);
let ws = test_ws("/tmp/test_ws");
consolidate_findings(&ws, "test question", outcomes, "test_round").await
}
fn agreed_outcomes(raw_a: &str, raw_b: &str) -> Vec<AnalystOutcome> {
vec![
AnalystOutcome::Findings {
raw: raw_a.to_string(),
findings: findings(vec![("alpha is true", "url1", "high")]),
},
AnalystOutcome::Findings {
raw: raw_b.to_string(),
findings: findings(vec![("alpha is true", "url1", "high")]),
},
AnalystOutcome::NoResponse("analyst produced no response".into()),
]
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn test_consolidation_fail_open_delivers_raw_reports() {
let _guard = retry_tests_lock();
let _policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let fake = FakeProvider::new()
.err(
crate::retry::FailureClass::Transport,
"error reading response body",
)
.err(
crate::retry::FailureClass::Transport,
"error reading response body",
)
.err(
crate::retry::FailureClass::Transport,
"error reading response body",
);
let result =
consolidate_with_script(agreed_outcomes("report one", "report two"), fake).await;
let text = result.expect("fail-open must succeed with raw reports");
assert!(
text.contains("unconsolidated — consolidation failed"),
"must carry the unconsolidated marker: {text}"
);
assert!(text.contains("## Analyst Reports"), "{text}");
assert!(text.contains("### Report from Analyst 1"), "{text}");
assert!(text.contains("report one"), "{text}");
assert!(text.contains("### Report from Analyst 2"), "{text}");
assert!(text.contains("report two"), "{text}");
assert!(
text.contains("no response: analyst produced no response"),
"no-response analyst renders its reason — nothing silently lost: {text}"
);
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn test_consolidation_non_retryable_fails_open() {
let _guard = retry_tests_lock();
let _policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let fake = FakeProvider::new().err(
crate::retry::FailureClass::NonRetryable,
"insufficient balance",
);
let outcomes = vec![
AnalystOutcome::Findings {
raw: "alpha".into(),
findings: findings(vec![("a", "u1", "low")]),
},
AnalystOutcome::Findings {
raw: "beta".into(),
findings: findings(vec![("a", "u1", "low")]),
},
AnalystOutcome::Findings {
raw: "gamma".into(),
findings: findings(vec![("a", "u1", "low")]),
},
];
let result = consolidate_with_script(outcomes, fake).await;
let text = result.expect("non-retryable consolidation failure fails open");
assert!(
text.contains("unconsolidated — consolidation failed"),
"{text}"
);
assert!(text.contains("alpha"), "{text}");
assert!(text.contains("beta"), "{text}");
assert!(text.contains("gamma"), "{text}");
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn test_consolidation_success_synthesizes() {
let _guard = retry_tests_lock();
let _policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let fake = FakeProvider::new().ok(
r#"{"summary":"alpha is true.","groups":[{"heading":"Alpha","contradiction":false,"members":[{"id":0},{"id":1}]}],"ungrouped":[]}"#,
);
let result = consolidate_with_script(agreed_outcomes("report a", "report b"), fake).await;
let text = result.expect("success");
assert!(text.contains("alpha is true"), "{text}");
assert!(
!text.contains("unconsolidated"),
"successful consolidation must not hit the fail-open marker: {text}"
);
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn test_consolidation_single_parseable_source_skips_grouping() {
let _guard = retry_tests_lock();
let _policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let fake = FakeProvider::new(); let provider: Arc<dyn crate::Provider> = Arc::new(fake);
let _provider_guard = install_fake_provider(provider);
let ws = test_ws("/tmp/test_ws");
let outcomes = vec![
AnalystOutcome::Findings {
raw: "sole report".into(),
findings: findings(vec![("alpha is true", "url1", "high")]),
},
AnalystOutcome::ParseFailed {
raw: "unparseable report".into(),
failure: "parse failed".into(),
},
];
let result = consolidate_findings(&ws, "test question", outcomes, "test_round").await;
let text = result.expect("single parseable source succeeds");
assert!(
text.contains("only one analyst produced parseable claims — grouping skipped"),
"skip marker must be present: {text}"
);
assert!(text.contains("alpha is true"), "{text}");
assert!(text.contains("### Report from Analyst 2"), "{text}");
assert!(text.contains("unparseable report"), "{text}");
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn test_consolidation_fail_open_mixed_failure_classes() {
let _guard = retry_tests_lock();
let _policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let fake = FakeProvider::new()
.err(
crate::retry::FailureClass::Transport,
"error reading response body",
)
.ok("")
.ok("");
let result = consolidate_with_script(agreed_outcomes("only usable", "second"), fake).await;
let text = result.expect("mixed failures still fail open");
assert!(
text.contains("unconsolidated — consolidation failed"),
"{text}"
);
assert!(text.contains("only usable"), "{text}");
assert!(text.contains("second"), "{text}");
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn test_extraction_fail_open_delivers_raw_reports() {
let _lock = retry_tests_lock();
let _policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let fake = FakeProvider::new()
.err(
crate::retry::FailureClass::Transport,
"error reading response body",
)
.err(
crate::retry::FailureClass::Transport,
"error reading response body",
)
.err(
crate::retry::FailureClass::Transport,
"error reading response body",
)
.err(
crate::retry::FailureClass::Transport,
"error reading response body",
)
.err(
crate::retry::FailureClass::Transport,
"error reading response body",
)
.err(
crate::retry::FailureClass::Transport,
"error reading response body",
);
let provider: Arc<dyn crate::Provider> = Arc::new(fake);
let _provider_guard = install_fake_provider(provider);
let ws = test_ws("/tmp/test_ws");
let runs = vec![
test_analyst_run(&ws, Some("report one")),
test_analyst_run(&ws, Some("report two")),
test_analyst_run(&ws, None),
];
let result = consolidate_analyst_runs(
&ws,
"test question",
runs,
std::time::Instant::now() + Duration::from_mins(1),
"test_round",
)
.await;
let text = result.expect("extraction fail-open must deliver raw reports");
assert!(
text.contains("unconsolidated — findings extraction failed"),
"{text}"
);
assert!(text.contains("### Report from Analyst 1"), "{text}");
assert!(text.contains("report one"), "{text}");
assert!(text.contains("### Report from Analyst 2"), "{text}");
assert!(text.contains("report two"), "{text}");
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn test_consolidation_async_envelope_carries_marker() {
let _guard = retry_tests_lock();
let _policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let fake = FakeProvider::new()
.err(crate::retry::FailureClass::Transport, "down")
.err(crate::retry::FailureClass::Transport, "down")
.err(crate::retry::FailureClass::Transport, "down");
let result =
consolidate_with_script(agreed_outcomes("raw report", "raw report 2"), fake).await;
let envelope = build_async_analyze_message(&result);
assert!(envelope.contains("<analyze-tool-result>"), "{envelope}");
assert!(
envelope.contains("unconsolidated — consolidation failed"),
"{envelope}"
);
assert!(envelope.contains("raw report"), "{envelope}");
assert!(
envelope.ends_with("</analyze-tool-result>"),
"envelope must close: {envelope}"
);
}
#[tokio::test]
async fn async_envelope_wraps_sub_agent_errors() {
let envelope = build_async_analyze_message(&Err(anyhow::anyhow!("sub-agent exploded")));
assert!(envelope.contains("<analyze-tool-result>"), "{envelope}");
assert!(
envelope.contains("An error occurred: sub-agent exploded"),
"{envelope}"
);
assert!(
envelope.ends_with("</analyze-tool-result>"),
"envelope must close: {envelope}"
);
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn fail_open_marker_survives_sync_truncation() {
let _guard = retry_tests_lock();
let _policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let fake = FakeProvider::new()
.err(crate::retry::FailureClass::Transport, "down")
.err(crate::retry::FailureClass::Transport, "down")
.err(crate::retry::FailureClass::Transport, "down");
let long_report = "lorem ipsum ".repeat(2_000);
let result = consolidate_with_script(agreed_outcomes(&long_report, "second"), fake).await;
let text = result.expect("fail-open must succeed");
let truncated = crate::util::truncate_tool_output(&text);
assert!(
truncated.contains("unconsolidated — consolidation failed"),
"head-placed marker must survive 5 KB sandwich truncation: {truncated}"
);
}
#[test]
fn render_analyze_groups_renders_disputed_cross_references() {
let outcomes = vec![
AnalystOutcome::Findings {
raw: "r1".into(),
findings: findings(vec![("safe", "url1", "high")]),
},
AnalystOutcome::Findings {
raw: "r2".into(),
findings: findings(vec![("actually unsafe", "url2", "high")]),
},
];
let items = claims_per_agent(&outcomes);
let table = crate::consensus::ItemTable::new(&items);
let output = crate::consensus::GroupingOutput {
summary: "One consensus, one dispute.".into(),
groups: vec![crate::consensus::GroupingGroup {
heading: "Safety".into(),
contradiction: false,
members: vec![crate::consensus::GroupingMember { id: 0 }],
}],
ungrouped: vec![crate::consensus::GroupingMember { id: 1 }],
};
let references = vec![crate::consensus::GroupingReference {
group: 0,
member: crate::consensus::GroupingMember { id: 1 },
}];
let text = render_analyze_groups("q", &output, &references, &table, 2, &outcomes);
assert!(
text.contains("Agent 1: actually unsafe [DISPUTED — contradicts group 0 \"Safety\"]"),
"reference must render with DISPUTED + cross-ref: {text}"
);
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn test_consolidation_partial_success_with_remainder() {
let _guard = retry_tests_lock();
let _policy_guard =
crate::util::test::install_test_retry_policy(crate::retry::tiny_test_policy());
let outcomes = vec![
AnalystOutcome::Findings {
raw: "r1".into(),
findings: findings(vec![
("alpha is true", "url1", "high"),
("beta is true", "url2", "medium"),
]),
},
AnalystOutcome::Findings {
raw: "r2".into(),
findings: findings(vec![("alpha is true", "url1", "high")]),
},
AnalystOutcome::NoResponse("analyst produced no response".into()),
];
let fake = FakeProvider::new()
.ok(
r#"{"summary":"alpha agreed, beta solo.","groups":[{"heading":"Alpha","contradiction":false,"members":[{"id":0},{"id":2}]}],"ungrouped":[{"id":1}]}"#,
)
.ok(r#"{"groups":[],"ungrouped":[{"id":1}]}"#);
let result = consolidate_with_script(outcomes, fake).await;
let text = result.expect("partial success must be delivered");
assert!(
text.contains("**Alpha** [2/2]"),
"frozen group with code-computed bracket renders: {text}"
);
assert!(
text.contains("**Ungrouped**") && text.contains("- Agent 0: beta is true"),
"deterministic remainder renders in the ungrouped section: {text}"
);
assert!(
text.contains("alpha agreed, beta solo."),
"round-1 summary is final: {text}"
);
assert!(
!text.contains("unconsolidated"),
"partial success replaces the raw-dump fallback: {text}"
);
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn resume_analyze_round_reuses_existing_job() {
let _lock = crate::util::test::retry_tests_lock();
crate::util::test::init_management_test_stores().await;
let ws = test_ws("/tmp/test_ws_resume_analyze");
let job_id = "analyze_job_resume_1";
let agent_id = format!("analyze_{}_rs_0_analyst", ws.name);
let conn = &crate::session::store().conn;
crate::jobs::spawn_job(
conn,
job_id,
"question?",
&ws.name,
"caller-user",
"telegram",
crate::Role::Assistant,
&[crate::jobs::NewAgent {
agent_id: agent_id.clone(),
kind: crate::jobs::AgentKind::Analyst,
idx: Some(0),
task: "question?".to_string(),
}],
&crate::jobs::SpawnChild::Analyze,
)
.await
.unwrap();
crate::jobs::write_agent_outcome(
conn,
job_id,
&agent_id,
crate::jobs::RowStatus::Done,
Some("completed analyst response"),
)
.await
.unwrap();
resume_analyze_round(job_id, &ws).await;
let job_rows = conn
.query(
"SELECT id FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(job_rows.len(), 0, "resumed job must be terminalized");
let pending = conn
.query(
"SELECT envelope FROM pending_jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(pending.len(), 1, "envelope persisted with the job id");
let envelope: crate::message_router::AgentJob =
serde_json::from_str(&pending[0].get::<String>(0).unwrap()).unwrap();
assert_eq!(
envelope.role,
crate::Role::Assistant,
"resumed envelope routes to the original caller role, not Manager"
);
assert_eq!(envelope.user_name, "caller-user");
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn analyze_capped_envelope_delivers_error_to_caller() {
let _lock = crate::util::test::retry_tests_lock();
crate::util::test::init_management_test_stores().await;
let ws = test_ws("/tmp/test_ws_analyze_capped");
let job_id = "analyze_job_capped_1";
let conn = &crate::session::store().conn;
crate::jobs::spawn_job(
conn,
job_id,
"question?",
&ws.name,
"caller-user",
"telegram",
crate::Role::Assistant,
&[crate::jobs::NewAgent {
agent_id: format!("analyze_{}_capped_0_analyst", ws.name),
kind: crate::jobs::AgentKind::Analyst,
idx: Some(0),
task: "question?".to_string(),
}],
&crate::jobs::SpawnChild::Analyze,
)
.await
.unwrap();
analyze_capped_envelope(job_id, &ws).await;
let job_rows = conn
.query(
"SELECT id FROM jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(job_rows.len(), 0, "capped job must be terminalized");
let pending = conn
.query(
"SELECT envelope FROM pending_jobs WHERE id = ?1",
crate::turso::params![job_id],
)
.await
.unwrap();
assert_eq!(pending.len(), 1, "error envelope persisted with the job id");
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,
"capped envelope routes to the original caller role, not Manager"
);
assert_eq!(envelope.user_name, "caller-user");
assert!(
envelope.content.contains("analyze round aborted"),
"the envelope must surface the cap failure to the caller"
);
}
}