use crate::agent::{run_agent, run_default_agent};
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 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(&format!("tool/{}_async.md", self.name()));
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
}
fn preserve_full_output(&self) -> bool {
true
}
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 job_id = crate::generate_id();
super::SyncDurableCore::Analyze.spawn_dispatch(
ws,
analyze,
self.caller_role,
job_id.clone(),
);
return Ok(format!(
"Sub-agent dispatched (job {job_id}). Results will follow shortly."
));
}
run_sync_analyze(ws, analyze, self.caller_role).await
}
}
struct AnalyzeSlot {
agent_id: String,
task: String,
}
#[expect(clippy::too_many_lines)]
pub(crate) async fn run_analyze_with_job(
ws: &Workspace,
analyze: &str,
args: crate::tools::CoreJobArgs<'_>,
) -> anyhow::Result<crate::tools::SyncCoreOutcome> {
let crate::tools::CoreJobArgs {
job_id,
caller_role,
user_name,
channel,
resume,
caller_agent_id,
fail_on_checkpoint_error,
} = args;
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 split = crate::jobs::split_slot_resume(&rows);
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)> = split
.done
.iter()
.filter_map(|r| r.outcome.clone().map(|o| (r.agent_id.clone(), 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,
caller_agent_id,
)
.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(),
false,
Some(crate::agent::registry::ParentKey::AnalyzeRound(
job_id.to_string(),
)),
Some(analyze.to_string()),
);
let _ = agent.session.init(&slot.agent_id).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("no-response: 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
{
if fail_on_checkpoint_error {
return Err(e.context("failed to checkpoint analyze outcome"));
}
tracing::warn!(job = %job_id, error = %e, "Failed to checkpoint analyze outcome");
}
}
if crate::shutdown::aborting() {
return Ok(crate::tools::SyncCoreOutcome::DrainCut);
}
let result = consolidate_analyst_runs(ws, analyze, runs, deadline, job_id).await;
Ok(crate::tools::SyncCoreOutcome::Terminal(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(),
false,
None,
resume,
Some(round),
Some(crate::agent::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: "timeout: analyst still running when the round deadline expired"
.to_string(),
},
RoundMember::Panicked => AnalyzeRun::Failed {
reason: "panic: analyst task panicked".to_string(),
},
RoundMember::Cancelled => AnalyzeRun::Failed {
reason: "cancelled: analyst task was cancelled".to_string(),
},
})
.collect()
}
pub(crate) async fn resume_analyze_round(job_id: &str, ws: &Workspace) {
crate::tools::SyncDurableCore::Analyze
.resume_durable_round(job_id, ws)
.await;
}
#[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)]
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>,
}
impl VerificationResult {
pub(crate) fn unresolved(
claim: &str,
evidence: impl Into<String>,
tool_calls: usize,
searches: usize,
queries: Vec<String>,
) -> Self {
Self {
claim: claim.to_string(),
verdict: "unresolved".to_string(),
evidence: evidence.into(),
tool_calls,
searches,
queries,
}
}
}
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_sync_analyze(ws: &Workspace, analyze: &str, caller_role: Role) -> Result<String> {
crate::tools::SyncDurableCore::Analyze
.run_sync_dispatch(ws, analyze, caller_role)
.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 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<String> = runs
.iter()
.map(|r| match r {
AnalyzeRun::Failed { reason } => reason.clone(),
AnalyzeRun::Completed { agent, .. } => {
agent.failure_reason("no-response: analyst produced no response")
}
})
.collect();
let suffix = if reasons.is_empty() {
String::new()
} else {
format!(" ({})", reasons.join("; "))
};
Err(super::internal_fault(&format!(
"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, deadline).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("no-response: analyst produced no response");
return AnalystOutcome::NoResponse(crate::util::scrub_credentials(&reason));
};
if raw.trim().is_empty() {
return AnalystOutcome::NoResponse(
"no-response: 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(
"timeout: findings extraction still running when the round deadline expired"
.to_string(),
),
RoundMember::Panicked => {
AnalystOutcome::NoResponse("panic: findings extraction task panicked".to_string())
}
RoundMember::Cancelled => AnalystOutcome::NoResponse(
"cancelled: 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,
deadline: std::time::Instant,
) -> 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 derived = crate::agent::role_chat_params(Role::Analyst);
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,
derived.model,
derived.reasoning_effort,
derived.provider_order,
Some(DEFAULT_MAX_TOKENS),
);
let table = crate::consensus::ItemTable::new(&items_by_agent);
let parent = Some(crate::agent::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 } => {
let verification = verify_disputed_groups(
ws, analyze, &output, &table, &outcomes, round_key, deadline,
)
.await;
Ok(render_analyze_groups(
&output,
&references,
&table,
n_valid,
&outcomes,
&verification,
))
}
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(
output: &crate::consensus::GroupingOutput,
references: &[crate::consensus::GroupingReference],
table: &crate::consensus::ItemTable<'_>,
n_valid: usize,
outcomes: &[AnalystOutcome],
verification: &str,
) -> 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
},
));
if !verification.is_empty() {
let _ = write!(out, "\n\n{verification}");
}
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,
parent_key: Option<crate::agent::registry::ParentKey>,
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 parent_key = parent_key.clone();
let question = question.to_string();
move |round| async move {
run_claim_verifier(
&ws,
&agent_id,
&claim_text,
&task,
&extraction_prompt,
round,
parent_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::unresolved(
&targets[i].claim,
"verifier never completed (round deadline or panic)",
0,
0,
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,
parent_key: Option<crate::agent::registry::ParentKey>,
question: &str,
) -> VerificationResult {
let (agent, response) = run_default_agent(
agent_id,
Role::Analyst,
ws,
task,
false,
Some(round),
parent_key,
Some(question.to_string()),
)
.await;
let (tool_calls, searches, queries) = extract_query_telemetry(&agent);
let Some(raw) = response else {
return VerificationResult::unresolved(
target_claim,
"verifier failed to produce a response",
tool_calls,
searches,
queries,
);
};
if raw.trim().is_empty() {
return VerificationResult::unresolved(
target_claim,
"verifier produced an empty response",
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::unresolved(
target_claim,
format!("verification extraction failed: {e}"),
tool_calls,
searches,
queries,
),
}
}
pub(crate) fn escape_fences(s: &str) -> String {
s.replace("```", "\\`\\`\\`")
}
fn verification_units<'a>(
disputed: &[&'a crate::consensus::GroupingGroup],
) -> Vec<Vec<&'a crate::consensus::GroupingGroup>> {
if disputed.len() <= 1 {
return vec![disputed.to_vec()];
}
let first_count = disputed.len().div_ceil(2);
let (first, second) = disputed.split_at(first_count);
vec![first.to_vec(), second.to_vec()]
}
fn synthesize_verification_target(
unit: &[&crate::consensus::GroupingGroup],
table: &crate::consensus::ItemTable<'_>,
outcomes: &[AnalystOutcome],
) -> VerificationTarget {
let mut claim_parts = Vec::new();
let mut sources = Vec::new();
let mut contradictions = Vec::new();
for group in unit {
let mut group_claims = Vec::new();
for member in &group.members {
if let Some((_, text)) = table.resolve(member.id) {
group_claims.push(text.to_string());
}
if let Some((agent_idx, item_idx)) = table.resolve_index(member.id)
&& let Some(AnalystOutcome::Findings { findings, .. }) = outcomes.get(agent_idx)
&& let Some(claim) = findings.claims.get(item_idx)
{
if !claim.source.is_empty() {
sources.push(claim.source.clone());
}
contradictions.extend(claim.contradictions.iter().cloned());
}
}
let claim = if group_claims.is_empty() {
group.heading.clone()
} else {
format!("{}: {}", group.heading, group_claims.join("; "))
};
claim_parts.push(claim);
}
VerificationTarget::new(
&claim_parts.join("\n"),
&sources.join("; "),
&contradictions.join("; "),
)
}
fn render_verification_section(results: &[VerificationResult]) -> String {
if results.is_empty() {
return String::new();
}
let mut out = String::from("## Verification");
for v in results {
let claim_line = escape_fences(&v.claim).replace('\n', "; ");
let evidence_line = escape_fences(&v.evidence).replace('\n', "; ");
let _ = write!(
out,
"\n- {claim_line} → **{}** — {evidence_line}",
v.verdict,
);
}
out
}
fn verification_window_open(deadline: std::time::Instant) -> bool {
let Some(anchor) = deadline.checked_sub(round_timeout() / 2) else {
return false;
};
std::time::Instant::now() <= anchor
}
async fn verify_disputed_groups(
ws: &Workspace,
analyze: &str,
output: &crate::consensus::GroupingOutput,
table: &crate::consensus::ItemTable<'_>,
outcomes: &[AnalystOutcome],
round_key: &str,
deadline: std::time::Instant,
) -> String {
let disputed: Vec<&crate::consensus::GroupingGroup> =
output.groups.iter().filter(|g| g.contradiction).collect();
if disputed.is_empty() {
return String::new();
}
if !verification_window_open(deadline) {
tracing::warn!(
disputed_groups = disputed.len(),
"Analyze verification skipped — more than half the round window elapsed"
);
return String::new();
}
if crate::shutdown::aborting() {
tracing::warn!(
disputed_groups = disputed.len(),
"Analyze verification skipped — shutdown/drain in progress"
);
return String::new();
}
let units = verification_units(&disputed);
let targets: Vec<VerificationTarget> = units
.iter()
.map(|unit| synthesize_verification_target(unit, table, outcomes))
.collect();
tracing::info!(
disputed_groups = disputed.len(),
verifiers = units.len(),
"Analyze verification round dispatching fresh analysts"
);
let prefix = format!("analyze_{}_verify", ws.name);
let (results, _dispatched) = dispatch_claim_verifiers(
ws,
&prefix,
&targets,
"",
deadline,
false,
Some(crate::agent::registry::ParentKey::AnalyzeRound(
round_key.to_string(),
)),
analyze,
)
.await;
render_verification_section(&results)
}
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};
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("usage: missing required argument \"analyze\""),
"Should mention missing analyze"
);
}
#[tokio::test]
#[ignore = "requires LLM provider; runs only when explicitly invoked"]
async fn test_analyze_analyst() {
crate::util::test::init_management_test_stores().await;
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(),
false,
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_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("internal: all parallel analysts failed"),
"error should mention analyst failure: {err}"
);
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}"
);
assert!(
err.to_string().contains("analyst produced no response"),
"error should surface the no-response 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,
..Default::default()
},
crate::consensus::GroupingMember {
id: 2,
..Default::default()
},
],
},
crate::consensus::GroupingGroup {
heading: "Beta".into(),
contradiction: false,
members: vec![crate::consensus::GroupingMember {
id: 1,
..Default::default()
}],
},
],
ungrouped: vec![crate::consensus::GroupingMember {
id: 3,
..Default::default()
}],
};
let text = render_analyze_groups(&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("**Other**"), "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,
..Default::default()
},
crate::consensus::GroupingMember {
id: 1,
..Default::default()
},
],
}],
ungrouped: vec![],
};
let text = render_analyze_groups(&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,
..Default::default()
},
crate::consensus::GroupingMember {
id: 1,
..Default::default()
},
],
}],
ungrouped: vec![],
};
let text = render_analyze_groups(&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",
std::time::Instant::now() + std::time::Duration::from_mins(60),
)
.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]
#[serial_test::serial(provider, drain)] async fn test_consolidation_fail_open_delivers_raw_reports() {
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]
#[serial_test::serial(provider, drain)] async fn test_consolidation_non_retryable_fails_open() {
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]
#[serial_test::serial(provider, drain)] async fn test_consolidation_success_synthesizes() {
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]
#[serial_test::serial(provider, drain)] async fn test_consolidation_single_parseable_source_skips_grouping() {
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",
std::time::Instant::now() + std::time::Duration::from_mins(60),
)
.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]
#[serial_test::serial(provider, drain)] async fn test_consolidation_fail_open_mixed_failure_classes() {
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]
#[serial_test::serial(provider, drain)] async fn test_extraction_fail_open_delivers_raw_reports() {
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]
#[serial_test::serial(provider, drain)] async fn test_consolidation_async_envelope_carries_marker() {
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 =
crate::tools::SyncDurableCore::Analyze.build_async_message("job123", &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 = crate::tools::SyncDurableCore::Analyze
.build_async_message("job123", &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]
#[serial_test::serial(provider, drain)] async fn analyze_output_preserved_full_no_sandwich_truncation() {
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 tool = AnalyzeTool::new(DispatchMode::Sync, Role::Engineer);
let formatted = tool.format_output(&text);
assert_eq!(
formatted, text,
"analyze output must be preserved in full (no sandwich truncation)"
);
assert!(
formatted.contains("unconsolidated — consolidation failed"),
"fail-open marker present: {formatted}"
);
}
#[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,
..Default::default()
}],
}],
ungrouped: vec![crate::consensus::GroupingMember {
id: 1,
..Default::default()
}],
};
let references = vec![crate::consensus::GroupingReference {
group: 0,
member: crate::consensus::GroupingMember {
id: 1,
..Default::default()
},
}];
let text = render_analyze_groups(&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]
#[serial_test::serial(provider, drain)] async fn test_consolidation_partial_success_with_remainder() {
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("**Other**") && 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]
#[serial_test::serial(drain)] async fn resume_analyze_round_reuses_existing_job() {
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,
None,
)
.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::db::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::db::params![job_id],
)
.await
.unwrap();
assert_eq!(pending.len(), 1, "envelope persisted with the job id");
let envelope: crate::agent::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");
}
async fn seed_analyze_job(ws: &Workspace, job_id: &str, pin: &str, outcome: Option<&str>) {
let conn = &crate::session::store().conn;
let analyst_id = format!("{job_id}_analyst");
crate::jobs::spawn_job(
conn,
job_id,
"analyze task",
&ws.name,
"caller-user",
"telegram",
crate::Role::Engineer,
&[crate::jobs::NewAgent {
agent_id: analyst_id.clone(),
kind: crate::jobs::AgentKind::Analyst,
idx: Some(0),
task: "analyze task".to_string(),
}],
&crate::jobs::SpawnChild::Analyze,
Some(pin),
)
.await
.unwrap();
if let Some(outcome) = outcome {
crate::jobs::write_agent_outcome(
conn,
job_id,
&analyst_id,
crate::jobs::RowStatus::Done,
Some(outcome),
)
.await
.unwrap();
}
}
#[tokio::test]
async fn find_owned_launched_jobs_ignores_other_kinds() {
crate::util::test::init_management_test_stores().await;
let ws = test_ws("/tmp/test_ws_find_owned");
let pin = "sync_analyze_find_pin";
let conn = &crate::session::store().conn;
crate::jobs::spawn_job(
conn,
"research_job",
"research task",
&ws.name,
"caller",
"telegram",
crate::Role::Analyst,
&[crate::jobs::NewAgent {
agent_id: "research_a".to_string(),
kind: crate::jobs::AgentKind::Analyst,
idx: Some(0),
task: "r".to_string(),
}],
&crate::jobs::SpawnChild::Research,
Some(pin),
)
.await
.unwrap();
let ticket_id = crate::util::test::make_ticket(
crate::pipeline::board::store(),
&ws,
"FindOwnedJob",
crate::pipeline::board::TicketPhase::Analysis,
)
.await;
crate::jobs::spawn_job(
conn,
"phase_job",
"phase task",
&ws.name,
"caller",
"telegram",
crate::Role::Engineer,
&[crate::jobs::NewAgent {
agent_id: "phase_a".to_string(),
kind: crate::jobs::AgentKind::Engineer,
idx: Some(0),
task: "p".to_string(),
}],
&crate::jobs::SpawnChild::Phase {
phase: crate::pipeline::board::TicketPhase::Analysis,
ticket_id: ticket_id.clone(),
},
Some(pin),
)
.await
.unwrap();
seed_analyze_job(&ws, "analyze_newer", pin, None).await;
seed_analyze_job(&ws, "analyze_older", pin, None).await;
conn.execute(
"UPDATE jobs SET created_at = ?1 WHERE id = ?2",
crate::db::params!["2023-01-02T00:00:00+00:00", "analyze_older"],
)
.await
.unwrap();
let owned = crate::jobs::find_owned_launched_jobs(conn, pin)
.await
.unwrap();
let ids: Vec<&str> = owned.iter().map(|j| j.id.as_str()).collect();
assert_eq!(
ids,
vec!["analyze_newer", "analyze_older"],
"only analyze jobs returned, newest first"
);
}
fn disputed_group(heading: &str, members: &[usize]) -> crate::consensus::GroupingGroup {
crate::consensus::GroupingGroup {
heading: heading.to_string(),
contradiction: true,
members: members
.iter()
.map(|id| crate::consensus::GroupingMember {
id: *id,
..Default::default()
})
.collect(),
}
}
#[test]
fn verification_units_single_group_is_one_unit() {
let groups = [disputed_group("Alpha", &[0, 1])];
let units = verification_units(&groups.iter().collect::<Vec<_>>());
assert_eq!(units.len(), 1, "exactly one disputed group → one unit");
assert_eq!(units[0].len(), 1);
assert_eq!(units[0][0].heading, "Alpha");
}
#[test]
fn verification_units_splits_by_group_count_first_takes_extra() {
let group = |h: &str| disputed_group(h, &[]);
let groups = [group("A"), group("B"), group("C")];
let refs = groups.iter().collect::<Vec<_>>();
let units = verification_units(&refs);
assert_eq!(units.len(), 2);
assert_eq!(units[0].len(), 2, "first part takes the extra group");
assert_eq!(units[1].len(), 1);
assert_eq!(units[0][1].heading, "B");
assert_eq!(units[1][0].heading, "C");
let groups = [group("A"), group("B"), group("C"), group("D")];
let refs = groups.iter().collect::<Vec<_>>();
let units = verification_units(&refs);
assert_eq!(units[0].len(), 2);
assert_eq!(units[1].len(), 2);
}
#[test]
fn synthesize_verification_target_merges_heading_claims_sources_contradictions() {
let mut f1 = findings(vec![("alpha is true", "url1", "high")]);
f1.claims[0].contradictions = vec!["src says alpha may be false".into()];
let f2 = findings(vec![("alpha is false", "url2", "high")]);
let mut f3 = findings(vec![("beta is true", "url3", "medium")]);
f3.claims[0].contradictions = vec!["src says beta unknown".into()];
let f4 = findings(vec![("beta is false", "url4", "medium")]);
let outcomes = vec![
AnalystOutcome::Findings {
raw: "r1".into(),
findings: f1,
},
AnalystOutcome::Findings {
raw: "r2".into(),
findings: f2,
},
AnalystOutcome::Findings {
raw: "r3".into(),
findings: f3,
},
AnalystOutcome::Findings {
raw: "r4".into(),
findings: f4,
},
];
let items = claims_per_agent(&outcomes);
let table = crate::consensus::ItemTable::new(&items);
let group = |heading: &str, ids: &[usize]| disputed_group(heading, ids);
let groups = [group("Alpha", &[0, 1])];
let unit = groups.iter().collect::<Vec<_>>();
let target = synthesize_verification_target(&unit, &table, &outcomes);
assert!(
target
.claim
.contains("Alpha: alpha is true; alpha is false"),
"claim synthesizes heading + member claims: {}",
target.claim
);
assert!(
target.sources.contains("url1") && target.sources.contains("url2"),
"sources aggregate member sources: {}",
target.sources
);
assert!(
target
.contradictions
.contains("src says alpha may be false"),
"member self-reported contradictions aggregate: {}",
target.contradictions
);
}
#[test]
fn render_verification_section_formats_verdicts() {
let results = vec![
VerificationResult {
claim: "Alpha: x".into(),
verdict: "supported".into(),
evidence: "primary source confirms".into(),
tool_calls: 1,
searches: 2,
queries: vec!["q".into()],
},
VerificationResult {
claim: "Beta: y".into(),
verdict: "unresolved".into(),
evidence: "no evidence found".into(),
tool_calls: 0,
searches: 1,
queries: vec![],
},
];
let text = render_verification_section(&results);
assert_eq!(
text,
"## Verification\n- Alpha: x → **supported** — primary source confirms\n- Beta: y → **unresolved** — no evidence found",
"verdicts render as annotation lines"
);
assert!(
render_verification_section(&[]).is_empty(),
"empty results render an empty section"
);
}
#[test]
#[expect(clippy::unchecked_time_subtraction)] fn verification_window_open_guards_half_round_window() {
let open = verification_window_open(std::time::Instant::now() + round_timeout());
assert!(
open,
"verification runs while ≤ half the window has elapsed"
);
let closed = verification_window_open(std::time::Instant::now() - round_timeout() * 2);
assert!(
!closed,
"verification skipped when more than half the window elapsed"
);
}
#[tokio::test]
#[serial_test::serial(drain)] async fn sync_analyze_draincut_returns_call_suspended_error() {
crate::util::test::init_management_test_stores().await;
let ws = test_ws("/tmp/test_ws_sync_analyze_draincut");
let pin = "sync_analyze_draincut_pin";
let conn = &crate::session::store().conn;
crate::shutdown::drain_begin();
let tool = AnalyzeTool::new(DispatchMode::Sync, crate::Role::Engineer);
let res = crate::agent::CURRENT_TOOL_AGENT_ID
.scope(Some(pin.to_string()), async {
tool.execute(&ws, json!({"analyze": "analyze task"})).await
})
.await;
crate::shutdown::drain_clear();
let err = res.expect_err("drain must cut the sync analyze dispatch");
assert!(
err.downcast_ref::<crate::tools::CallSuspended>().is_some(),
"CallSuspended carrier expected: {err:#}"
);
let rows = conn
.query(
"SELECT status, caller_agent_id FROM jobs WHERE caller_agent_id = ?1 AND kind = 'analyze'",
crate::db::params![pin],
)
.await
.unwrap();
assert_eq!(rows.len(), 1, "one launched analyze job");
assert_eq!(rows[0].get::<String>(0).unwrap(), "launched");
assert_eq!(
rows[0].get::<String>(1).unwrap(),
pin,
"job is caller-owned by the session pin"
);
}
#[tokio::test]
#[serial_test::serial(provider, drain)] async fn resume_hook_completion_resumes_durable_analyze() {
let fake = std::sync::Arc::new(FakeProvider::new());
let _seam = crate::util::test::install_retry_seam_dyn(fake.clone());
crate::util::test::init_management_test_stores().await;
let ws = test_ws("/tmp/test_ws_analyze_resume_hook");
let pin = "sync_analyze_resume_pin";
let conn = &crate::session::store().conn;
let job_id = "sync_analyze_resume_job";
crate::util::test::seed_session_row(conn, pin, "user", "analyze task").await;
let frame = crate::providers::reasoning::assistant_replay_payload(
Some(""),
&[crate::ToolCall {
id: "call_analyze_f".to_string(),
name: "analyze".to_string(),
arguments: json!({"analyze": "analyze task"}),
}],
None,
)
.to_string();
crate::util::test::seed_session_row(conn, pin, "assistant", &frame).await;
seed_analyze_job(&ws, job_id, pin, Some("RESULT_ONE")).await;
let mut session = crate::session::Session::default();
session.init(pin).await.unwrap();
let pending = session
.pending_tool_frame()
.expect("dangling analyze call")
.calls;
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, "call_analyze_f");
let outcome = crate::tools::SyncDurableCore::Analyze
.resume_sync_core(&ws, job_id, true)
.await
.unwrap();
let crate::jobs::SyncResumeOutcome::Terminal(_, _, result) = outcome else {
panic!("expected a terminal resume outcome");
};
let text = result.expect("resumed analyze result");
assert_eq!(text, "RESULT_ONE");
crate::jobs::terminalize_job(conn, job_id).await.unwrap();
session
.settle_tool_results(pin, &[("call_analyze_f".to_string(), text.clone())], &[])
.await
.unwrap();
assert!(
fake.request_fingerprints.lock().unwrap().is_empty(),
"resume of an all-Done roster must not call the model"
);
let jobs = conn
.query(
"SELECT id FROM jobs WHERE id = ?1",
crate::db::params![job_id],
)
.await
.unwrap();
assert!(jobs.is_empty(), "resumed job must be terminalized");
let rows = conn
.query(
"SELECT id, role, content FROM sessions WHERE agent_id = ?1 ORDER BY id",
crate::db::params![pin],
)
.await
.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[1].get::<String>(1).unwrap(), "assistant");
assert_eq!(rows[2].get::<String>(1).unwrap(), "tool");
let tool_content = rows[2].get::<String>(2).unwrap();
let payload: crate::ToolResultPayload = serde_json::from_str(&tool_content).unwrap();
assert_eq!(payload.tool_call_id, "call_analyze_f");
assert_eq!(payload.content, "RESULT_ONE");
let pending_jobs = conn
.query(
"SELECT id FROM pending_jobs WHERE id = ?1",
crate::db::params![job_id],
)
.await
.unwrap();
assert!(pending_jobs.is_empty(), "no envelope pending after resume");
}
}