//! The harvest half of a Continuous Research mission. //! //! Finding papers is NOT agent work. `library::run_to_vault` already does arXiv //! search → seen-set check → PDF fetch → blob shelf → vault note, deterministically //! and in seconds, and it takes a `mission_id` so the run is attributed. Asking an //! agent to redo it would be slower, non-repeatable, and would abandon the //! `corpus_items` seen-set — which is the entire reason a recurring mission knows //! what it already covered. `corpus.rs` puts it plainly: "A recurring mission's //! hard problem is not running the agent — that is 23 seconds — it is knowing //! what it already did last time." //! //! So the harvest runs here, at launch, and the agents start from its output. //! //! The manifest path (`ContinuousResearch//harvest.jsonl`) is not invented: //! `templates/teams/continuous_research.toml` has told the `signal_harvester` //! role to write exactly that file since the template was authored. This makes //! the code produce what the prompt already promised, rather than leaving a role //! to fabricate it. use std::sync::Arc; use serde_json::json; use uuid::Uuid; /// Template kind that triggers a harvest at launch. pub const TEMPLATE_KIND: &str = "continuous_research"; /// Today's manifest, relative to the vault root. pub fn manifest_path(date: &str) -> String { format!("ContinuousResearch/{date}/harvest.jsonl") } /// UTC date stamp, the same key the vault folders use. pub fn today() -> String { let now = time::OffsetDateTime::now_utc(); format!( "{:04}-{:02}-{:02}", now.year(), now.month() as u8, now.day() ) } /// The arXiv queries this mission tracks. /// /// `config.topics` on the mission when the operator set them, otherwise the /// project-wide defaults. Read from config rather than a new column because the /// wizard already round-trips `config` untouched, so a topic list needs no /// schema change and no UI work to reach here. pub fn topics_for(config: &serde_json::Value) -> Vec { config .get("topics") .and_then(|v| v.as_array()) .map(|a| { a.iter() .filter_map(|t| t.as_str()) .map(str::trim) .filter(|t| !t.is_empty()) .map(str::to_string) .collect::>() }) .filter(|t: &Vec| !t.is_empty()) .unwrap_or_else(crate::library::default_topics) } /// Run the harvest for a mission and leave a manifest the agents can read. /// /// Non-fatal by contract: a launch whose harvest fails still starts its phases, /// because a quiet day and a broken day must be distinguishable and the phase /// itself is what reports which happened. What is NOT acceptable is failing /// silently, so every outcome is logged with its counts. pub async fn harvest_for_mission( pool: &sqlx::PgPool, blobs: &Arc, workspace_id: Uuid, mission_id: Uuid, topics: &[String], per_topic: usize, ) -> Result, String> { let work_root = std::env::temp_dir().join("clawmates-library"); let run = crate::library::run_to_vault( pool, blobs, workspace_id, crate::routes::library::DEFAULT_CORPUS, crate::routes::library::DEFAULT_VAULT_URL, &work_root, topics, per_topic, Some(mission_id), ) .await?; let shelved = run.harvest.shelved.len(); // A quiet day is not a failure. `Harvest::healthy()` (nothing errored) is a // different question from `added_anything()` (something new arrived), and // collapsing them is the defect class this codebase keeps paying for. eprintln!( "continuous_research: mission {mission_id} harvested {} candidate(s), {} already had, \ {} shelved, {} failed", run.harvest.candidates, run.harvest.already_had, shelved, run.harvest.failed.len() ); for (source_id, why) in &run.harvest.failed { eprintln!("continuous_research: {source_id} not shelved: {why}"); } Ok(run.harvest.papers) } /// Write the run manifest into the MISSION's checkout. /// /// Not into the vault. The manifest is per-RUN input for one mission, and the /// vault path is per-DATE and shared, so a second run on the same day rewrites /// a file that already exists — which `auto_merge` correctly refuses, because /// it only merges provably additive diffs: /// /// "diff is not additive (1 non-add change(s), first: /// M ContinuousResearch/2026-08-18/harvest.jsonl); left for a human" /// /// The branch was then left unmerged, `main` kept the previous run's manifest, /// and the next mission cloned STALE papers while every log line said the /// harvest succeeded. Writing into the checkout keeps the vault additive and /// gives each mission exactly its own papers. The agents commit it alongside /// their analysis through the normal delivery path. pub fn write_manifest( checkout: &std::path::Path, papers: &[crate::papers::Paper], date: &str, triage: &[PaperTriage], ) -> Result { let rel = manifest_path(date); let abs = checkout.join(&rel); if let Some(parent) = abs.parent() { std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; } let body = manifest_lines(papers, date, triage); std::fs::write(&abs, format!("{body}\n")).map_err(|e| format!("write {}: {e}", abs.display()))?; Ok(abs) } /// What the decision model said about one harvested paper. `topic_tags` /// was written as `[]` on every manifest line from the day the manifest /// existed — a slot the agents were told to read and nothing filled. /// /// **A score has to discriminate within the population it scores.** The /// first version asked "how relevant is this to an agent platform" on a /// four-level scale, and MEASURED on the ten papers of mission 01a0c940 it /// answered 2.93–3.00 — a spread of 0.07, no ranking information at all. /// Of course: the harvest runs the operator's own arXiv topic queries, so /// every paper in it is about agents by construction. "How actionable is /// it" saturated the same way (spread 0.20). What did discriminate on the /// same ten abstracts was the strength of the evidence behind the claims /// (1.36–3.00, spread 1.64) and what KIND of paper it is. So the manifest /// carries those two and no relevance number: a digest phase ranking /// already-relevant papers needs to know which ones measured something. #[derive(Debug, Clone, serde::Serialize, Default)] pub struct PaperTriage { pub topic_tags: Vec, /// `benchmark` | `method` | `measurement` | `survey` | `position`, and /// how peaked that choice was — a paper the model cannot place is one /// the reader should look at rather than trust the label for. pub kind: Option, pub kind_confidence: Option, /// 0 = position piece, no experiments … 3 = measured on real systems /// with ablations. `None` when no triage ran (no key, or a failed call). pub evidence: Option, pub evidence_confidence: Option, } const EVIDENCE_LEVELS: [&str; 4] = [ "Position, opinion, or framework description; no experiments.", "Illustrative examples, a demo, or a single small case study.", "Benchmarked with numbers, on a suite the authors assembled.", "Measured on real systems or at scale, with ablations or failure analysis.", ]; const PAPER_KINDS: [(&str, &str); 5] = [ ("benchmark", "Introduces a dataset or benchmark to measure something"), ("method", "Proposes a technique, architecture, or algorithm"), ("measurement", "Measures the behaviour of existing systems without proposing a new one"), ("survey", "Reviews or categorises a body of existing work"), ("position", "Argues a viewpoint or proposes an agenda"), ]; /// Triage every harvested paper in one call each. Best-effort: a missing key /// or a failed call leaves that paper's tags empty and relevance `None`, the /// state the manifest has always been in. pub async fn triage_papers( papers: &[crate::papers::Paper], topics: &[String], ) -> Vec { use cm_decide::{Answer, Decider as _, Question}; let Some(jev) = cm_decide::jev::Jev::from_env() else { return vec![PaperTriage::default(); papers.len()]; }; // Topics are arXiv query strings; the option NAME the model sees is the // readable form (`all:"agent memory" AND all:"long-term"` → `agent memory // long-term`), the value the query itself for precision. let mut criteria: std::collections::BTreeMap> = topics .iter() .map(|t| (readable_topic(t), Some(t.clone()))) .collect(); criteria.insert("none".into(), Some("Fits none of the listed topics".into())); let questions: std::collections::BTreeMap = [ ( "topic".to_string(), Question::Choice { instructions: "Which of these research topics is this paper about?".into(), criteria, }, ), ( "kind".to_string(), Question::choice( "What kind of paper is this?", PAPER_KINDS.map(|(k, d)| (k, Some(d))), ), ), ( "evidence".to_string(), Question::score( "How strong is the evidence behind this paper's claims?", EVIDENCE_LEVELS, ), ), ] .into_iter() .collect(); let mut out = Vec::with_capacity(papers.len()); for p in papers { let state = format!("Title: {}\n\nAbstract: {}", p.title, p.summary); let decided = tokio::time::timeout( std::time::Duration::from_secs(10), jev.decide(&state, &questions), ) .await; let mut t = PaperTriage::default(); match decided { Ok(Ok(d)) => { if let Some(Answer::Choice { probabilities, .. }) = d.answers.get("topic") { let mut tags: Vec<(String, f64)> = probabilities .iter() .filter(|(k, p)| k.as_str() != "none" && **p >= 0.3) .map(|(k, p)| (k.clone(), *p)) .collect(); tags.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); t.topic_tags = tags.into_iter().map(|(k, _)| k).collect(); } if let Some(Answer::Choice { choice, confidence, .. }) = d.answers.get("kind") { t.kind = Some(choice.clone()); t.kind_confidence = Some((*confidence * 100.0).round() / 100.0); } if let Some(Answer::Score { score, confidence, .. }) = d.answers.get("evidence") { t.evidence = Some((*score * 100.0).round() / 100.0); t.evidence_confidence = Some((*confidence * 100.0).round() / 100.0); } } Ok(Err(e)) => eprintln!("continuous_research: triage of {} failed: {e}", p.arxiv_id), Err(_) => eprintln!("continuous_research: triage of {} timed out", p.arxiv_id), } out.push(t); } let tagged = out.iter().filter(|t| !t.topic_tags.is_empty()).count(); let scores: Vec = out.iter().filter_map(|t| t.evidence).collect(); eprintln!( "continuous_research: triaged {} paper(s) with {}: {tagged} tagged, {} with evidence scored", papers.len(), jev.name(), scores.len() ); // A score that came back the same for every paper ranked nothing. Said // out loud because the first version of this question did exactly that // and looked like a working feature — ten confident numbers, no // information. See `PaperTriage`. let spread = cm_decide::patterns::spread(&scores); if scores.len() > 2 && spread < cm_decide::patterns::SATURATED_BELOW { eprintln!( "continuous_research: WARNING — evidence scores span only {spread:.2} across \ {} papers. The question is not separating this harvest; the ranking phase \ gets no signal from it.", scores.len() ); } out } /// `all:"agent memory" AND all:"long-term"` → `agent memory long-term`. fn readable_topic(query: &str) -> String { let words: Vec<&str> = query .split(|c: char| c == '"' || c.is_whitespace() || c == '(' || c == ')') .filter(|w| !w.is_empty()) .filter(|w| !matches!(*w, "AND" | "OR" | "NOT")) .map(|w| w.strip_prefix("all:").unwrap_or(w)) .map(|w| w.strip_prefix("ti:").unwrap_or(w)) .map(|w| w.strip_prefix("abs:").unwrap_or(w)) .filter(|w| !w.is_empty()) .collect(); words.join(" ") } /// The manifest lines for a set of freshly shelved papers. /// /// Shape matches what `skills/research/arxiv-daily.md` documents: /// `{ source, url, title, snippet, first_seen, topic_tags }`, plus `kind` /// and `evidence` since 2026-09-22 (see [`PaperTriage`]). `triage` is /// positional with `papers`; shorter means the rest are untriaged. pub fn manifest_lines( papers: &[crate::papers::Paper], first_seen: &str, triage: &[PaperTriage], ) -> String { papers .iter() .enumerate() .map(|(i, p)| { let t = triage.get(i).cloned().unwrap_or_default(); json!({ "source": p.source_id(), "url": format!("https://arxiv.org/abs/{}", p.arxiv_id), "title": p.title, "snippet": p.summary.chars().take(400).collect::(), "first_seen": first_seen, "topic_tags": t.topic_tags, "kind": t.kind.as_ref().map(|k| json!({ "is": k, "confidence": t.kind_confidence, })), "evidence": t.evidence.map(|e| json!({ "score": e, "confidence": t.evidence_confidence, "scale": "0 position piece, no experiments … 3 measured on real systems with ablations", })), }) .to_string() }) .collect::>() .join("\n") } #[cfg(test)] mod tests { use super::*; #[test] fn the_manifest_path_matches_what_the_team_template_promises() { // templates/teams/continuous_research.toml tells signal_harvester to // write ContinuousResearch//harvest.jsonl. If this drifts, the // agents read a file nothing writes and silently review nothing. assert_eq!( manifest_path("2026-08-17"), "ContinuousResearch/2026-08-17/harvest.jsonl" ); } /// An operator's topic list must win over the defaults, and a blank or /// missing list must fall back rather than harvesting nothing. #[test] fn topics_come_from_config_and_fall_back_when_absent() { assert_eq!( topics_for(&serde_json::json!({"topics": ["world models", " robots "]})), vec!["world models".to_string(), "robots".to_string()], "operator topics win, and are trimmed" ); for empty in [ serde_json::json!({}), serde_json::json!({"topics": []}), serde_json::json!({"topics": [" "]}), ] { assert_eq!( topics_for(&empty), crate::library::default_topics(), "an absent or blank list must fall back, not harvest nothing: {empty}" ); } } #[test] fn arxiv_queries_become_readable_option_names() { assert_eq!( readable_topic(r#"all:"agent memory" AND all:"long-term""#), "agent memory long-term" ); assert_eq!( readable_topic(r#"all:"agentic topology" OR all:"multi-agent topology""#), "agentic topology multi-agent topology" ); } #[test] fn the_date_stamp_is_zero_padded() { let d = today(); assert_eq!(d.len(), 10, "YYYY-MM-DD, got {d:?}"); assert_eq!(d.matches('-').count(), 2, "{d:?}"); } /// One JSON object per line, and every key the template's prompt names — /// an agent instructed to read `topic_tags` must not find it absent. #[test] fn manifest_lines_carry_every_documented_key() { let p = crate::papers::Paper { arxiv_id: "2401.12345".into(), title: "A Paper".into(), authors: vec!["A. Author".into()], summary: "x".repeat(900), published: "2026-08-17".into(), pdf_url: "https://arxiv.org/pdf/2401.12345".into(), }; let out = manifest_lines(std::slice::from_ref(&p), "2026-08-17", &[]); assert_eq!(out.lines().count(), 1); let v: serde_json::Value = serde_json::from_str(&out).expect("each line is JSON"); for key in ["source", "url", "title", "snippet", "first_seen", "topic_tags", "kind", "evidence"] { assert!(v.get(key).is_some(), "missing {key} in {v}"); } // Untriaged: the slots are there and empty, as they always were. assert_eq!(v["topic_tags"], serde_json::json!([])); assert!(v["kind"].is_null() && v["evidence"].is_null()); // Triaged: the tags, the kind and the evidence score land on the line. let t = PaperTriage { topic_tags: vec!["agent memory long-term".into()], kind: Some("benchmark".into()), kind_confidence: Some(0.91), evidence: Some(1.36), evidence_confidence: Some(0.62), }; let out = manifest_lines(std::slice::from_ref(&p), "2026-08-17", std::slice::from_ref(&t)); let v: serde_json::Value = serde_json::from_str(&out).unwrap(); assert_eq!(v["topic_tags"][0], "agent memory long-term"); assert_eq!(v["kind"]["is"], "benchmark"); assert_eq!(v["evidence"]["score"], 1.36); assert_eq!(v["source"], "arxiv:2401.12345"); assert!( v["snippet"].as_str().unwrap().chars().count() <= 400, "snippet must be trimmed, not the whole abstract" ); } }