1use crate::agent::{Agent, Conversation, RunContext};
57use crate::mcp::McpClient;
58use crate::tool::{Capabilities, Tool, ToolCtx, ToolOutput};
59use anyhow::{Context, Result};
60use async_trait::async_trait;
61use serde::{Deserialize, Serialize};
62use serde_json::{json, Value};
63use std::sync::Arc;
64
65pub struct LensedSearch {
71 client: Arc<McpClient>,
72 label: String,
73 sources: Vec<String>,
74 since: String,
75 until: String,
76 description: String,
77}
78
79impl LensedSearch {
80 pub fn new(
81 client: Arc<McpClient>,
82 label: &str,
83 sources: Vec<String>,
84 since: &str,
85 until: &str,
86 ) -> Self {
87 let description = format!(
88 "Search the user's knowledge graph. You can see ONLY these sources: {}. \
89 Evidence is limited to {since}..{until}. Another assistant is reading \
90 different sources and can see things you cannot.",
91 sources.join(", ")
92 );
93 LensedSearch {
94 client,
95 label: label.to_string(),
96 sources,
97 since: since.to_string(),
98 until: until.to_string(),
99 description,
100 }
101 }
102
103 pub fn lens_schema() -> Value {
107 json!({
110 "type": "object",
111 "properties": {
112 "query": {"type": "string", "description": "What to look for"},
113 "k": {"type": "integer", "description": "Max results (default 10)"}
114 },
115 "required": ["query"]
116 })
117 }
118
119 pub fn lens_capabilities() -> Capabilities {
121 Capabilities {
122 private_data: true,
124 untrusted_input: true,
127 external_send: false,
128 destructive: false,
129 }
130 }
131}
132
133#[async_trait]
134impl Tool for LensedSearch {
135 fn name(&self) -> &str {
136 "kg_search"
137 }
138 fn description(&self) -> &str {
139 &self.description
140 }
141 fn input_schema(&self) -> Value {
142 Self::lens_schema()
143 }
144 fn read_only(&self) -> bool {
145 true
146 }
147 fn capabilities(&self) -> Capabilities {
148 Self::lens_capabilities()
149 }
150 async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
151 let Some(query) = input.get("query").and_then(Value::as_str) else {
152 return Ok(ToolOutput::err("missing required string argument `query`"));
153 };
154 let args = json!({
155 "query": query,
156 "k": input.get("k").and_then(Value::as_u64).unwrap_or(10),
157 "include_private": true,
158 "sources": self.sources,
159 "since": self.since,
160 "until": self.until,
161 "scope": "evidence_only",
171 "probe": true,
179 });
180 let mut out = self.client.call_tool("kg_search", args).await?;
181 out.content = format!("[{} view]\n{}", self.label, out.content);
184 Ok(out)
185 }
186}
187
188pub struct GraphTool {
201 client: Arc<McpClient>,
202 name: String,
203 description: String,
204 schema: Value,
205}
206
207impl GraphTool {
208 pub fn verify(client: Arc<McpClient>) -> Self {
209 GraphTool {
210 client,
211 name: "kg_verify".into(),
212 description: "Check what the graph BELIEVES against what its evidence \
213 actually says — deterministic, no model in the loop. Give a `node` \
214 (name or id) for every live claim about it. Verdicts include \
215 supported, contradicted, denied, stale, residue, unrooted."
216 .into(),
217 schema: json!({
218 "type": "object",
219 "properties": {
220 "node": {"type": "string", "description": "Entity name, alias or id"},
221 "fact": {"type": "string", "description": "A single fact uid"},
222 "limit": {"type": "integer"}
223 }
224 }),
225 }
226 }
227
228 pub fn entity(client: Arc<McpClient>) -> Self {
239 GraphTool {
240 client,
241 name: "kg_entity".into(),
242 description: "Look up an entity's record: node id, aliases, identifiers \
243 (emails, Slack ids), which sources cover it, interaction count and \
244 when it was last seen. Use this for claims about the graph's own \
245 bookkeeping rather than about events."
246 .into(),
247 schema: json!({
248 "type": "object",
249 "properties": {
250 "name_or_id": {"type": "string", "description": "Entity name, alias or id"}
251 },
252 "required": ["name_or_id"]
253 }),
254 }
255 }
256
257 pub fn search_everything(client: Arc<McpClient>) -> Self {
258 GraphTool {
259 client,
260 name: "kg_search".into(),
261 description: "Search the whole knowledge graph — every source, no time \
262 limit, facts as well as evidence. Use it to find whether anything \
263 actually supports a claim."
264 .into(),
265 schema: json!({
266 "type": "object",
267 "properties": {
268 "query": {"type": "string"},
269 "k": {"type": "integer", "description": "Max results (default 10)"}
270 },
271 "required": ["query"]
272 }),
273 }
274 }
275}
276
277#[async_trait]
278impl Tool for GraphTool {
279 fn name(&self) -> &str {
280 &self.name
281 }
282 fn description(&self) -> &str {
283 &self.description
284 }
285 fn input_schema(&self) -> Value {
286 self.schema.clone()
287 }
288 fn read_only(&self) -> bool {
289 true
290 }
291 fn capabilities(&self) -> Capabilities {
292 Capabilities {
293 private_data: true,
294 untrusted_input: true,
295 external_send: false,
296 destructive: false,
297 }
298 }
299 async fn call(&self, mut input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
300 if self.name == "kg_search" {
301 if let Some(o) = input.as_object_mut() {
302 o.insert("include_private".into(), json!(true));
303 }
304 }
305 self.client.call_tool(&self.name, input).await
306 }
307}
308
309pub fn family(source: &str) -> &'static str {
318 match source {
319 s if s.starts_with("bee.") => "spoken",
320 "reflect.note" => "reflected.note",
321 "reflect.daily" => "reflected.daily",
322 s if s.starts_with("reflect.") => "reflected",
323 s if s.starts_with("session.") || s.starts_with("agent:") => "agentic",
324 "calendar.event" => "scheduled",
325 "slack.thread" | "mbox" | "email.thread" => "written",
326 _ => "other",
327 }
328}
329
330pub fn family_of_origin(origin: &str) -> Option<&'static str> {
341 if origin.starts_with("agent:") {
342 return Some(family(origin));
343 }
344 match origin.split_once(':') {
345 Some(("bee", _)) => Some("spoken"),
346 Some(_) => None,
347 None if origin == "llm" => None,
348 None => Some(family(origin)),
349 }
350}
351
352#[derive(Debug, Clone, Deserialize)]
354pub struct SourceCoverage {
355 pub source: String,
356 pub episodes: i64,
357}
358
359pub fn choose_vantages(coverage: &[SourceCoverage], min: i64) -> Option<(Vantage, Vantage)> {
373 let mut viable: Vec<&SourceCoverage> = coverage.iter().filter(|c| c.episodes >= min).collect();
374 viable.sort_by_key(|c| -c.episodes);
375 let first = *viable.first()?;
376 let second = viable
377 .iter()
378 .find(|c| family(&c.source) != family(&first.source))
379 .copied()
380 .or_else(|| viable.get(1).copied())?;
381 Some((
382 Vantage {
383 label: family(&first.source).into(),
384 sources: vec![first.source.clone()],
385 },
386 Vantage {
387 label: family(&second.source).into(),
388 sources: vec![second.source.clone()],
389 },
390 ))
391}
392
393pub async fn coverage(
397 client: &McpClient,
398 entity: &str,
399) -> Result<(String, Vec<SourceCoverage>, Vec<String>)> {
400 let out = client
401 .call_tool("kg_entity", json!({ "name_or_id": entity }))
402 .await
403 .context("kg_entity")?;
404 let body: Value = serde_json::from_str(&out.content)
405 .with_context(|| format!("kg_entity returned non-JSON: {}", out.content))?;
406 if let Some(cands) = body.get("ambiguous").and_then(Value::as_array) {
407 let names = cands
408 .iter()
409 .map(|c| format!("{} ({})", c["name"], c["id"]))
410 .collect();
411 return Ok((String::new(), vec![], names));
412 }
413 anyhow::ensure!(
414 body["found"] != json!(false),
415 "no entity matching '{entity}'"
416 );
417 let name = body["node"]["name"].as_str().unwrap_or(entity).to_string();
418 let sources: Vec<SourceCoverage> =
419 serde_json::from_value(body["sources"].clone()).unwrap_or_default();
420 Ok((name, sources, vec![]))
421}
422
423pub async fn coverage_best(
440 client: &McpClient,
441 entity: &str,
442) -> Result<(String, Vec<SourceCoverage>, bool)> {
443 let ask = |q: String| async move {
444 let out = client
445 .call_tool("kg_entity", json!({ "name_or_id": q }))
446 .await?;
447 let body: Value = serde_json::from_str(&out.content)
448 .with_context(|| format!("kg_entity returned non-JSON: {}", out.content))?;
449 anyhow::Ok(body)
450 };
451
452 let body = ask(entity.to_string()).await?;
453 if let Some(cands) = body.get("ambiguous").and_then(Value::as_array) {
454 let Some(best) = cands.iter().max_by_key(|c| {
458 c.get("interaction_count")
459 .and_then(Value::as_i64)
460 .unwrap_or(0)
461 }) else {
462 return Ok((String::new(), vec![], true));
463 };
464 let Some(id) = best["id"].as_str().filter(|s| !s.is_empty()) else {
470 return Ok((String::new(), vec![], true));
471 };
472 let body = ask(id.to_string()).await?;
473 if body["found"] == json!(false) {
474 return Ok((String::new(), vec![], true));
475 }
476 let name = body["node"]["name"].as_str().unwrap_or(entity).to_string();
477 let sources = serde_json::from_value(body["sources"].clone()).unwrap_or_default();
478 return Ok((name, sources, true));
479 }
480 if body["found"] == json!(false) {
481 return Ok((String::new(), vec![], false));
482 }
483 let name = body["node"]["name"].as_str().unwrap_or(entity).to_string();
484 let sources = serde_json::from_value(body["sources"].clone()).unwrap_or_default();
485 Ok((name, sources, false))
486}
487
488pub async fn windowed_coverage(
498 client: &McpClient,
499 entity: &str,
500 sources: &[SourceCoverage],
501 since: &str,
502 until: &str,
503) -> Result<Vec<SourceCoverage>> {
504 let mut out = Vec::new();
505 for c in sources {
506 let res = client
507 .call_tool(
508 "kg_search",
509 json!({
510 "query": entity, "k": 25, "include_private": true,
511 "scope": "evidence_only", "sources": [c.source.clone()],
512 "since": since, "until": until,
513 }),
514 )
515 .await?;
516 let body: Value = serde_json::from_str(&res.content).unwrap_or_else(|_| json!({}));
517 let n = body["items"].as_array().map(|a| a.len()).unwrap_or(0) as i64;
518 if n > 0 {
519 out.push(SourceCoverage {
520 source: c.source.clone(),
521 episodes: n,
522 });
523 }
524 }
525 Ok(out)
526}
527
528pub fn asker(
537 provider: Box<dyn crate::provider::Provider>,
538 tool_ctx: ToolCtx,
539 agent_cfg: crate::config::AgentConfig,
540 model: Option<String>,
541) -> Result<Agent> {
542 let approver = Arc::new(crate::tool::ModeApprover {
543 mode: crate::config::PermissionMode::ReadOnly,
544 });
545 let mut cfg = agent_cfg;
546 cfg.system_prompt = Some(FOLLOWUP_SYS.to_string());
547 let mut agent = Agent::new(
548 provider,
549 crate::tool::Registry::new(),
550 approver,
551 tool_ctx,
552 cfg,
553 model,
554 )?;
555 agent.set_cache_contended();
556 Ok(agent)
557}
558
559pub fn extractor(
564 provider: Box<dyn crate::provider::Provider>,
565 tool_ctx: ToolCtx,
566 agent_cfg: crate::config::AgentConfig,
567 model: Option<String>,
568) -> Result<Agent> {
569 let approver = Arc::new(crate::tool::ModeApprover {
570 mode: crate::config::PermissionMode::ReadOnly,
571 });
572 let mut cfg = agent_cfg;
573 cfg.system_prompt = Some(EXTRACT_SYS.to_string());
574 let mut agent = Agent::new(
575 provider,
576 crate::tool::Registry::new(),
577 approver,
578 tool_ctx,
579 cfg,
580 model,
581 )?;
582 agent.set_cache_contended();
583 Ok(agent)
584}
585
586pub fn verifier(
599 provider: Box<dyn crate::provider::Provider>,
600 client: Arc<McpClient>,
601 tool_ctx: ToolCtx,
602 agent_cfg: crate::config::AgentConfig,
603 model: Option<String>,
604) -> Result<Agent> {
605 let approver = Arc::new(crate::tool::ModeApprover {
606 mode: crate::config::PermissionMode::ReadOnly,
607 });
608 let mut registry = crate::tool::Registry::new();
609 registry.insert(Arc::new(GraphTool::verify(Arc::clone(&client))));
610 registry.insert(Arc::new(GraphTool::entity(Arc::clone(&client))));
611 registry.insert(Arc::new(GraphTool::search_everything(client)));
612 let mut cfg = agent_cfg;
613 cfg.system_prompt = Some(VERIFY_SYS.to_string());
614 let mut agent = Agent::new(provider, registry, approver, tool_ctx, cfg, model)?;
615 agent.set_cache_contended();
622 Ok(agent)
623}
624
625#[derive(Debug, Clone, Serialize, Deserialize)]
627pub struct Vantage {
628 pub label: String,
630 pub sources: Vec<String>,
631}
632
633pub struct ReaderSetup {
640 pub client: Arc<McpClient>,
641 pub vantage: Vantage,
642 pub since: String,
645 pub until: String,
646 pub tool_ctx: ToolCtx,
647 pub agent_cfg: crate::config::AgentConfig,
648 pub model: Option<String>,
649 pub system_prompt: String,
650}
651
652pub fn reader(provider: Box<dyn crate::provider::Provider>, setup: ReaderSetup) -> Result<Agent> {
653 let ReaderSetup {
654 client,
655 vantage,
656 since,
657 until,
658 tool_ctx,
659 agent_cfg,
660 model,
661 system_prompt,
662 } = setup;
663 let mut registry = crate::tool::Registry::new();
664 registry.insert(Arc::new(LensedSearch::new(
665 client,
666 &vantage.label,
667 vantage.sources.clone(),
668 &since,
669 &until,
670 )) as Arc<dyn Tool>);
671
672 let approver = Arc::new(crate::tool::ModeApprover {
676 mode: crate::config::PermissionMode::ReadOnly,
677 });
678
679 let mut cfg = agent_cfg;
680 cfg.system_prompt = Some(system_prompt);
681 let mut agent = Agent::new(provider, registry, approver, tool_ctx, cfg, model)?;
682 agent.set_cache_contended();
683 Ok(agent)
684}
685
686#[derive(Debug, Clone, Serialize)]
688pub struct Round {
689 pub n: u32,
690 pub asked: Vec<(String, String)>,
692 pub answered: Vec<(String, String)>,
694 #[serde(skip_serializing_if = "Vec::is_empty")]
705 pub stalled: Vec<(String, String)>,
706}
707
708#[derive(Debug, Clone, Serialize)]
710pub struct Exchange {
711 pub entity: String,
712 pub vantages: Vec<Vantage>,
713 pub rounds: Vec<Round>,
714}
715
716pub const ANSWER_SYS: &str = "\
718You and another assistant are each reading DIFFERENT sources about one \
719person, comparing notes. Search your sources and answer from what you find.
720
721You are talking to the other assistant, not to a user. Never address the \
722user, never offer to look something up, never ask what they need — there is \
723nobody there to answer, and an offer is a wasted turn.
724
725At most three sentences. Say plainly and briefly when your sources do not \
726cover it: the other assistant may see what you cannot, and 'my sources show \
727nothing about that' is a real contribution. Report only what you read — do \
728not speculate, and do not pad a thin answer by listing what you would need \
729in order to answer.";
730
731pub const FOLLOWUP_SYS: &str = "\
733You and another assistant each read DIFFERENT sources about one person, so \
734each of you can see things the other cannot.
735
736You have both just answered and you can see their answer. Ask ONE question \
737that THEIR sources might answer and yours cannot — aim at what they seem to \
738have seen and you did not. Prefer relationships, roles, commitments and the \
739reasons behind things over dates and logistics. If their sources turned up \
740nothing, ask instead about something yours hinted at and could not settle.
741
742Output the question and nothing else: one interrogative sentence. Do not \
743answer it yourself, do not summarise what was said, do not explain your \
744reasoning. You have no tools and nothing to look up — the question IS your \
745whole output.";
746
747pub async fn exchange(
753 answerers: &[(Vantage, Agent)],
754 askers: &[(Vantage, Agent)],
755 cx: &RunContext,
756 entity: &str,
757 seed: &str,
758 rounds: u32,
759) -> Result<Exchange> {
760 anyhow::ensure!(
761 answerers.len() == 2,
762 "gossip is a pair; got {}",
763 answerers.len()
764 );
765 anyhow::ensure!(askers.len() == 2, "one asker per reader");
766 let agents = answerers;
767 let mut questions: Vec<String> = vec![seed.to_string(), seed.to_string()];
768 let mut out = Exchange {
769 entity: entity.to_string(),
770 vantages: agents.iter().map(|(v, _)| v.clone()).collect(),
771 rounds: vec![],
772 };
773
774 let mut said: Vec<Vec<(String, String)>> = vec![vec![], vec![]];
782 let mut stalled: Vec<(String, String)> = vec![];
783
784 for n in 1..=rounds {
785 let mut answers = Vec::new();
787 for (i, (vantage, agent)) in agents.iter().enumerate() {
788 let mut prior = String::new();
789 for (q, a) in &said[i] {
790 prior.push_str(&format!("\nEarlier you were asked: {q}\nYou said: {a}\n"));
791 }
792 let mut convo = Conversation::user(format!(
797 "The person is {entity}.{prior}\nThe other assistant asks you: {}",
798 questions[i]
799 ));
800 let outcome = agent
801 .run_in(cx, &mut convo, None)
802 .await
803 .with_context(|| format!("{} reader, round {n}", vantage.label))?;
804 let answer = strip_user_directed(outcome.text.trim());
805 said[i].push((questions[i].clone(), answer.clone()));
806 answers.push(answer);
807 }
808
809 out.rounds.push(Round {
810 stalled: std::mem::take(&mut stalled),
811 n,
812 asked: agents
813 .iter()
814 .enumerate()
815 .map(|(i, (v, _))| (v.label.clone(), questions[i].clone()))
816 .collect(),
817 answered: agents
818 .iter()
819 .enumerate()
820 .map(|(i, (v, _))| (v.label.clone(), answers[i].clone()))
821 .collect(),
822 });
823
824 if n == rounds {
825 break;
826 }
827
828 let mut next = questions.clone();
831 for (i, (vantage, _)) in agents.iter().enumerate() {
832 let other = 1 - i;
833 let brief = |s: &String| -> String { s.chars().take(700).collect() };
840 let reveal = format!(
841 "The person is {entity}.\n\nYou read: {}\nYou answered: {}\n\n\
842 They read: {}\nThey answered: {}\n\n\
843 Now ask them ONE question. Do not summarise either answer. \
844 Your entire output is a single sentence ending in a question \
845 mark.",
846 vantage.sources.join(", "),
847 brief(&answers[i]),
848 agents[other].0.sources.join(", "),
849 brief(&answers[other]),
850 );
851 let mut convo = Conversation::user(reveal);
856 let mut outcome = askers[i].1.run_in(cx, &mut convo, None).await?;
857 if usable_question(&outcome.text).is_none() {
863 let mut bare = Conversation::user(format!(
864 "They said this about {entity}: {}\n\n\
865 Ask them one question about it. Output only the question.",
866 brief(&answers[other]),
867 ));
868 outcome = askers[i].1.run_in(cx, &mut bare, None).await?;
869 }
870 match usable_question(&outcome.text) {
875 Some(q) => next[other] = q,
876 None => stalled.push((
877 agents[other].0.label.clone(),
878 outcome.text.trim().chars().take(300).collect(),
879 )),
880 }
881 }
882 questions = next;
883 }
884 Ok(out)
885}
886
887pub fn strip_user_directed(text: &str) -> String {
901 let serves_a_user = |l: &str| {
902 let lower = l.to_lowercase();
903 l.ends_with('?')
904 || lower.starts_with("let me know")
905 || lower.starts_with("would you")
906 || lower.starts_with("if you'd like")
907 || lower.starts_with("i can look")
908 || lower.starts_with("i can dig")
909 };
910 let mut lines: Vec<&str> = text.lines().collect();
911 while let Some(last) = lines.last() {
912 let t = last.trim().trim_start_matches(['*', '-', '#', '>', ' ']);
913 if t.is_empty() || serves_a_user(t) {
914 lines.pop();
915 } else {
916 break;
917 }
918 }
919 let cut = lines.join("\n").trim().to_string();
920 if cut.is_empty() {
924 "(no answer — the reader only offered to look things up)".to_string()
925 } else {
926 cut
927 }
928}
929
930fn elicits_a_preference(line: &str) -> bool {
941 let l = line.to_lowercase();
942 [
943 "interested in",
944 "would you like",
945 "do you want",
946 "should i",
947 "can i help",
948 "what would you",
949 "how can i",
950 "anything else",
951 ]
952 .iter()
953 .any(|p| l.contains(p))
954}
955
956pub fn usable_question(text: &str) -> Option<String> {
965 text.lines()
966 .map(str::trim)
967 .find(|l| {
968 l.ends_with('?')
969 && l.len() > 10
970 && !l.contains("tool:")
973 && !l.contains("args:")
974 && !l.starts_with('{')
975 && !elicits_a_preference(l)
976 })
977 .map(|l| l.trim_start_matches(['*', '-', '#', ' ']).to_string())
978}
979
980pub const EXTRACT_SYS: &str = "\
984You are given a transcript in which two assistants discussed one person. \
985List the factual claims they made about that person or about the graph's \
986records of them.
987
988One claim per line, each a single short sentence that stands on its own — \
989resolve pronouns and back-references so a line can be checked without the \
990transcript. Include claims you suspect are wrong; judging them is not your \
991job. Exclude questions, hedges about what a source failed to contain, and \
992statements about the assistants themselves.
993
994Output only the list. No numbering, no headings, no commentary.";
995
996pub const VERIFY_SYS: &str = "\
998You check one claim against a knowledge graph. You can see everything: all \
999sources, all time, facts as well as evidence.
1000
1001Use kg_search to look for evidence, and kg_verify to see what the graph \
1002already believes about an entity and whether its own evidence holds up.
1003
1004Then answer in exactly this form, two lines:
1005VERDICT: supported | unsupported | contradicted
1006BASIS: one sentence, naming what you found
1007
1008'supported' means you found evidence that actually says this. 'contradicted' \
1009means the graph or its evidence says otherwise. 'unsupported' means you \
1010looked and found nothing either way — which is the verdict for anything the \
1011assistant knew from outside the graph, however true it may be in the world. \
1012Absence of evidence is 'unsupported', never 'contradicted'.";
1013
1014#[derive(Debug, Clone, Serialize)]
1016pub struct ClaimVerdict {
1017 pub claim: String,
1018 pub verdict: String,
1019 pub basis: String,
1020}
1021
1022pub fn parse_verdict(text: &str) -> (String, String) {
1028 const WORDS: [&str; 3] = ["supported", "unsupported", "contradicted"];
1029 let word_at = |s: &str| -> Option<String> {
1030 let v = s.trim().to_lowercase();
1031 let head = v
1032 .split(|c: char| !c.is_ascii_alphabetic())
1033 .find(|w| !w.is_empty())?;
1034 WORDS.contains(&head).then(|| head.to_string())
1035 };
1036
1037 let mut verdict = String::new();
1038 let mut basis = String::new();
1039 for line in text.lines() {
1040 let l = line
1041 .trim()
1042 .trim_start_matches(['*', '-', '#', '>', ' '])
1043 .trim_matches(['*', '`', ' '])
1044 .to_string();
1045 let upper = l.to_uppercase();
1046 if upper.starts_with("VERDICT:") {
1047 if let Some(w) = word_at(&l[8..]) {
1048 verdict = w;
1049 }
1050 } else if upper.starts_with("BASIS:") {
1051 basis = l[6..].trim().to_string();
1052 } else if verdict.is_empty() && WORDS.contains(&l.to_lowercase().as_str()) {
1053 verdict = l.to_lowercase();
1057 }
1058 }
1059 if verdict.is_empty() {
1060 let said: String = text.trim().chars().take(200).collect();
1065 return (
1066 "unchecked".into(),
1067 if said.is_empty() {
1068 "the adjudicator said nothing at all".into()
1069 } else {
1070 format!("not in form; it said: {}", said.replace('\n', " "))
1071 },
1072 );
1073 }
1074 (verdict, basis)
1075}
1076
1077pub fn claim_lines(text: &str, max: usize) -> Vec<String> {
1087 let is_intent = |l: &str| {
1088 let lower = l.to_lowercase();
1089 lower.contains("search_query")
1090 || lower.contains("tool:")
1091 || lower.starts_with("i will ")
1092 || lower.starts_with("i'll ")
1093 || lower.starts_with("let me ")
1094 || lower.starts_with("i need to ")
1095 || lower.starts_with("first, i")
1096 };
1097 text.lines()
1098 .map(|l| {
1099 l.trim()
1100 .trim_start_matches(['*', '-', '#', '•', ' '])
1101 .trim_start_matches(|c: char| c.is_ascii_digit() || c == '.' || c == ')')
1102 .trim()
1103 .to_string()
1104 })
1105 .filter(|l| l.len() > 15 && !l.ends_with(':') && !l.ends_with('?') && !is_intent(l))
1106 .take(max)
1107 .collect()
1108}
1109
1110pub async fn graph_findings(client: &McpClient, entity: &str) -> Result<String> {
1119 let out = client
1120 .call_tool("kg_verify", json!({ "node": entity, "limit": 20 }))
1121 .await
1122 .context("kg_verify")?;
1123 Ok(out.content)
1124}
1125
1126pub async fn audit(
1134 extractor: &Agent,
1135 verifier: &Agent,
1136 cx: &RunContext,
1137 exchange: &Exchange,
1138 max_claims: usize,
1139) -> Result<Vec<ClaimVerdict>> {
1140 let mut convo = Conversation::user(format!(
1147 "The person is {}.\n\n{}\n\n\
1148 Now list the factual claims made about {} in the transcript above. \
1149 One per line. Do not search, do not comment, do not explain — you \
1150 have no tools and the list is your whole output.",
1151 exchange.entity,
1152 render(exchange),
1153 exchange.entity,
1154 ));
1155 let listed = extractor
1156 .run_in(cx, &mut convo, None)
1157 .await
1158 .context("extracting claims from the exchange")?;
1159
1160 let mut out = Vec::new();
1161 for claim in claim_lines(&listed.text, max_claims) {
1162 let mut convo = Conversation::user(format!(
1167 "The person is {}.\n\nClaim to check: {claim}\n\n\
1168 Search first, then reply with exactly two lines:\n\
1169 VERDICT: supported | unsupported | contradicted\n\
1170 BASIS: one sentence naming what you found",
1171 exchange.entity
1172 ));
1173 let res = verifier
1174 .run_in(cx, &mut convo, None)
1175 .await
1176 .with_context(|| format!("checking claim: {claim}"))?;
1177 let (verdict, basis) = parse_verdict(&res.text);
1178 out.push(ClaimVerdict {
1179 claim,
1180 verdict,
1181 basis,
1182 });
1183 }
1184 Ok(out)
1185}
1186
1187pub fn render_audit(verdicts: &[ClaimVerdict]) -> String {
1190 let rank = |v: &str| match v {
1191 "contradicted" => 0,
1192 "unsupported" => 1,
1193 "unchecked" => 2,
1194 _ => 3,
1195 };
1196 let mut sorted: Vec<&ClaimVerdict> = verdicts.iter().collect();
1197 sorted.sort_by_key(|c| rank(&c.verdict));
1198
1199 let mut s = String::from("\nAudit\n");
1200 for c in &sorted {
1201 s.push_str(&format!(
1202 " [{}] {}\n {}\n",
1203 c.verdict, c.claim, c.basis
1204 ));
1205 }
1206 let n = |v: &str| verdicts.iter().filter(|c| c.verdict == v).count();
1207 s.push_str(&format!(
1208 " — {} claim(s): {} supported, {} unsupported, {} contradicted, {} unchecked\n",
1209 verdicts.len(),
1210 n("supported"),
1211 n("unsupported"),
1212 n("contradicted"),
1213 n("unchecked"),
1214 ));
1215 s
1216}
1217
1218pub fn render(x: &Exchange) -> String {
1220 let mut s = format!("Gossip about {} \n", x.entity);
1221 for v in &x.vantages {
1222 s.push_str(&format!(" {} reads: {}\n", v.label, v.sources.join(", ")));
1223 }
1224 for r in &x.rounds {
1225 s.push_str(&format!("\nRound {}\n", r.n));
1226 for ((who, q), (_, a)) in r.asked.iter().zip(r.answered.iter()) {
1227 if let Some((_, raw)) = r.stalled.iter().find(|(l, _)| l == who) {
1228 let raw = if raw.is_empty() {
1229 "(nothing at all)".to_string()
1230 } else {
1231 raw.replace('\n', " ")
1232 };
1233 s.push_str(&format!(
1234 " ! {who}'s asker produced no question. It emitted: {raw}\n"
1235 ));
1236 }
1237 s.push_str(&format!(" {who} was asked: {q}\n {who} said: {a}\n"));
1238 }
1239 }
1240 s
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245 use super::*;
1246
1247 fn cov(pairs: &[(&str, i64)]) -> Vec<SourceCoverage> {
1248 pairs
1249 .iter()
1250 .map(|(s, n)| SourceCoverage {
1251 source: s.to_string(),
1252 episodes: *n,
1253 })
1254 .collect()
1255 }
1256
1257 #[test]
1258 fn an_offer_to_the_user_never_reaches_the_other_reader() {
1259 let answered = "Slack shows he ran a hyperscanning practice with Rutgers.\n\
1262 His birthday is May 28.\n\n\
1263 Would you like me to dig deeper into one of these workstreams?";
1264 let cut = strip_user_directed(answered);
1265 assert!(cut.ends_with("His birthday is May 28."));
1266 assert!(!cut.contains("dig deeper"));
1267 assert!(cut.contains("hyperscanning"));
1269
1270 assert_eq!(
1272 strip_user_directed("My sources show nothing about that."),
1273 "My sources show nothing about that."
1274 );
1275 assert!(strip_user_directed("Would you like me to search?").starts_with("(no answer"));
1278 }
1279
1280 #[test]
1281 fn a_verdict_is_computed_from_two_sightings_not_asked_for() {
1282 use Sighting::*;
1283 assert_eq!(corroboration_verdict(Seen, Seen), "corroborated");
1284 assert_eq!(corroboration_verdict(Seen, Unseen), "single_source");
1285 assert_eq!(corroboration_verdict(Unseen, Seen), "single_source");
1286 assert_eq!(corroboration_verdict(Unseen, Unseen), "unseen");
1287 assert_eq!(corroboration_verdict(Seen, Contradicted), "contradicted");
1290 assert_eq!(corroboration_verdict(Contradicted, Seen), "contradicted");
1291 assert_eq!(corroboration_verdict(Seen, Unclear), "unclear");
1295 assert_eq!(corroboration_verdict(Unclear, Unseen), "unclear");
1296 }
1297
1298 #[test]
1299 fn a_sighting_is_parsed_or_admitted() {
1300 let (s, cite) = parse_sighting("SIGHTING: SEEN\nCITE: slack #random, 2026-05-28");
1301 assert_eq!(s, Sighting::Seen);
1302 assert_eq!(cite, "slack #random, 2026-05-28");
1303 assert_eq!(
1304 parse_sighting("SIGHTING: UNSEEN\nCITE: nothing").0,
1305 Sighting::Unseen
1306 );
1307 assert_eq!(
1308 parse_sighting("**SIGHTING:** CONTRADICTED").0,
1309 Sighting::Contradicted
1310 );
1311 assert_eq!(parse_sighting("UNSEEN").0, Sighting::Unseen);
1313 let (s, basis) = parse_sighting("I have not seen anything like this claim.");
1315 assert_eq!(s, Sighting::Unclear);
1316 assert!(
1317 basis.contains("I have not seen"),
1318 "the rejected text is kept"
1319 );
1320 }
1321
1322 #[test]
1323 fn corroboration_never_reads_the_source_it_came_from() {
1324 let spread = cov(&[
1328 ("bee.conversation", 40),
1329 ("bee.daily", 35),
1330 ("slack.thread", 30),
1331 ("reflect.daily", 20),
1332 ]);
1333 let (a, b) = vantages_excluding(&spread, Some("bee.conversation"), 3).unwrap();
1337 for v in [&a, &b] {
1338 assert!(!v.sources.iter().any(|s| s.starts_with("bee.")), "{v:?}");
1339 }
1340 let (a, b) = vantages_excluding(&spread, Some("bee:suggested"), 3).unwrap();
1344 for v in [&a, &b] {
1345 assert!(!v.sources.iter().any(|s| s.starts_with("bee.")), "{v:?}");
1346 }
1347 let only = cov(&[("bee.conversation", 40), ("bee.daily", 9)]);
1350 assert!(vantages_excluding(&only, Some("bee:suggested"), 3).is_none());
1351 }
1352
1353 #[test]
1354 fn every_origin_bars_its_own_family_not_a_reconstruction() {
1355 let spread = cov(&[
1360 ("slack.thread", 40),
1361 ("bee.conversation", 30),
1362 ("calendar.event", 20),
1363 ]);
1364 for origin in ["slack.thread", "mbox", "email.thread"] {
1365 let (a, b) = vantages_excluding(&spread, Some(origin), 3).unwrap();
1366 for v in [&a, &b] {
1367 assert!(
1368 !v.sources.iter().any(|s| s == "slack.thread"),
1369 "origin {origin} left its own family eligible: {v:?}"
1370 );
1371 }
1372 }
1373 let (a, b) = vantages_excluding(&spread, Some("calendar.event"), 3).unwrap();
1374 for v in [&a, &b] {
1375 assert!(!v.sources.iter().any(|s| s == "calendar.event"), "{v:?}");
1376 }
1377 assert!(vantages_excluding(&spread, Some("llm:commitment"), 3).is_none());
1381 assert!(vantages_excluding(&spread, Some("llm"), 3).is_none());
1382 assert_eq!(family_of_origin("agent:mecha"), Some("agentic"));
1384 }
1385
1386 #[test]
1387 fn an_unparseable_verdict_is_never_guessed() {
1388 assert_eq!(
1389 parse_verdict("VERDICT: contradicted\nBASIS: the graph lists one node."),
1390 ("contradicted".into(), "the graph lists one node.".into())
1391 );
1392 let (v, basis) = parse_verdict("I think this is probably supported by the Slack thread.");
1396 assert_eq!(v, "unchecked");
1397 assert!(basis.contains("I think this is probably supported"));
1400
1401 assert_eq!(parse_verdict("supported").0, "supported");
1403 assert_eq!(parse_verdict("**VERDICT:** contradicted").0, "contradicted");
1404 assert_eq!(
1405 parse_verdict("Verdict: unsupported\nBasis: nothing found").0,
1406 "unsupported"
1407 );
1408 assert_eq!(parse_verdict("").0, "unchecked");
1409 assert_eq!(
1411 parse_verdict("VERDICT: mostly true\nBASIS: x").0,
1412 "unchecked"
1413 );
1414 }
1415
1416 #[test]
1417 fn claim_extraction_drops_scaffolding() {
1418 let listed = "**Claims:**\n\
1419 1. Dana Whitfield works at Dartmouth.\n\
1420 - py-feat is a tool for fNIRS analysis.\n\
1421 Is she the lab PI?\n\
1422 short\n\
1423 She maintains the /srv/example/Git directory.";
1424 let claims = claim_lines(listed, 8);
1425 assert_eq!(claims.len(), 3, "got {claims:?}");
1426 assert!(claims[0].starts_with("Dana Whitfield works"));
1427 assert!(
1428 !claims.iter().any(|c| c.ends_with('?')),
1429 "questions are not claims"
1430 );
1431 assert!(!claims.iter().any(|c| c.contains("Claims:")));
1432 assert_eq!(claim_lines(listed, 2).len(), 2);
1434
1435 let intent = "I will search the knowledge graph for the Slack handle U0EXAMPLE01.\n\
1438 search_query: dana@email.example.edu\n\
1439 Let me check whether the two entities are distinct.\n\
1440 Dana Whitfield presented a poster on April 19, 2026.";
1441 let claims = claim_lines(intent, 8);
1442 assert_eq!(
1443 claims,
1444 vec!["Dana Whitfield presented a poster on April 19, 2026."]
1445 );
1446 }
1447
1448 #[test]
1449 fn a_question_must_probe_sources_not_preferences() {
1450 assert_eq!(
1453 usable_question(
1454 "What specific aspect of Dana Whitfield's work or background \
1455 are you most interested in?"
1456 ),
1457 None
1458 );
1459 assert_eq!(usable_question("Would you like me to dig deeper?"), None);
1460 for q in [
1463 "Are you referring to the Dana Whitfield associated with the Chang \
1464 lab at Dartmouth and the 'py-feat' paper?",
1465 "Can you confirm if Dana Whitfield is associated with the Chang lab?",
1466 ] {
1467 assert!(usable_question(q).is_some(), "rejected a real probe: {q}");
1468 }
1469 }
1470
1471 #[test]
1472 fn a_non_question_never_propagates() {
1473 assert_eq!(
1477 usable_question("tool:kg_search\nargs:{\"query\": \"ljchang\"}"),
1478 None
1479 );
1480 assert_eq!(
1481 usable_question("Based on my searches, here is what I found:"),
1482 None
1483 );
1484 assert_eq!(usable_question(""), None);
1485 assert_eq!(
1486 usable_question("ok?"),
1487 None,
1488 "too short to be a real question"
1489 );
1490
1491 assert_eq!(
1493 usable_question("Who does she collaborate with on the grant?").as_deref(),
1494 Some("Who does she collaborate with on the grant?")
1495 );
1496 assert_eq!(
1497 usable_question("Some preamble.\n- What role does he hold in the lab?").as_deref(),
1498 Some("What role does he hold in the lab?"),
1499 "the question is found past preamble and stripped of its bullet"
1500 );
1501 }
1502
1503 #[test]
1504 fn vantages_prefer_independence_over_volume() {
1505 let c = cov(&[("slack.thread", 400), ("mbox", 300), ("calendar.event", 50)]);
1509 let (a, b) = choose_vantages(&c, 3).unwrap();
1510 assert_eq!(a.sources, vec!["slack.thread"]);
1511 assert_eq!(
1512 b.sources,
1513 vec!["calendar.event"],
1514 "a second family beats a bigger sibling"
1515 );
1516 assert_ne!(a.label, b.label);
1517 }
1518
1519 #[test]
1520 fn a_thin_source_is_not_a_witness() {
1521 let c = cov(&[("slack.thread", 493), ("bee.conversation", 2)]);
1525 assert!(
1526 choose_vantages(&c, 3).is_none(),
1527 "one witness and a silence is not a pair"
1528 );
1529 assert!(choose_vantages(&c, 2).is_some());
1531 }
1532
1533 #[test]
1534 fn same_family_is_better_than_no_pair() {
1535 let c = cov(&[("slack.thread", 40), ("mbox", 30)]);
1538 let (a, b) = choose_vantages(&c, 3).unwrap();
1539 assert_eq!(
1540 (a.sources[0].as_str(), b.sources[0].as_str()),
1541 ("slack.thread", "mbox")
1542 );
1543 }
1544
1545 #[test]
1546 fn families_split_the_kinds_of_account_apart() {
1547 assert_eq!(family("bee.conversation"), family("bee.daily"));
1548 assert_ne!(family("calendar.event"), family("slack.thread"));
1549 assert_ne!(family("reflect.note"), family("bee.conversation"));
1550 assert_ne!(family("reflect.note"), family("reflect.daily"));
1554 }
1555
1556 #[test]
1557 fn lensed_search_hides_what_it_pins() {
1558 let schema = LensedSearch::lens_schema();
1564 let props = schema["properties"].as_object().unwrap();
1565 for pinned in ["sources", "since", "until", "include_private", "scope"] {
1566 assert!(
1567 !props.contains_key(pinned),
1568 "{pinned} must not be nameable by the child"
1569 );
1570 }
1571 }
1572
1573 #[test]
1574 fn the_readonly_approver_blocks_rather_than_asks() {
1575 struct Probe {
1582 ro: bool,
1583 }
1584 #[async_trait]
1585 impl Tool for Probe {
1586 fn name(&self) -> &str {
1587 "probe"
1588 }
1589 fn description(&self) -> &str {
1590 "test probe"
1591 }
1592 fn input_schema(&self) -> Value {
1593 json!({"type": "object"})
1594 }
1595 fn read_only(&self) -> bool {
1596 self.ro
1597 }
1598 async fn call(&self, _input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
1599 Ok(ToolOutput::ok("ok"))
1600 }
1601 }
1602 use crate::tool::Approver as _;
1603 let a = crate::tool::ModeApprover {
1604 mode: crate::config::PermissionMode::ReadOnly,
1605 };
1606 let rt = tokio::runtime::Builder::new_current_thread()
1607 .build()
1608 .unwrap();
1609 let read = rt.block_on(a.approve(&Probe { ro: true }, &json!({})));
1610 assert!(matches!(read, crate::tool::Decision::Allow));
1611 let write = rt.block_on(a.approve(&Probe { ro: false }, &json!({})));
1612 assert!(matches!(write, crate::tool::Decision::Blocked(_)));
1615 }
1616
1617 #[test]
1618 fn a_reader_declares_private_and_untrusted_but_never_send() {
1619 let caps = LensedSearch::lens_capabilities();
1625 assert!(caps.private_data && caps.untrusted_input);
1626 assert!(
1627 !caps.external_send,
1628 "a gossip reader with a way to send is the leak the interlock exists for"
1629 );
1630 assert!(!caps.destructive);
1631 }
1632}
1633
1634#[derive(Debug, Clone, Deserialize)]
1638pub struct Candidate {
1639 pub candidate_id: i64,
1640 pub statement: String,
1641 #[serde(default)]
1642 pub subject: Option<String>,
1643 #[serde(default)]
1644 pub origin_source: Option<String>,
1645 #[serde(default)]
1649 pub subject_ambiguous: bool,
1650 #[serde(default)]
1651 pub confidence: Option<f64>,
1652 #[serde(default)]
1653 pub predicate: Option<String>,
1654 #[serde(default)]
1657 pub evidence: Option<EvidenceClip>,
1658}
1659
1660#[derive(Debug, Clone, Serialize, Deserialize)]
1662pub struct EvidenceClip {
1663 pub source: String,
1664 #[serde(default)]
1665 pub occurred_at: String,
1666 pub body: String,
1667}
1668
1669pub async fn pending_about(
1683 client: &McpClient,
1684 entity: &str,
1685 limit: usize,
1686 unjudged_by: Option<&str>,
1687 include_evidence: bool,
1688) -> Result<Vec<Candidate>> {
1689 let out = client
1690 .call_tool(
1691 "kg_pending",
1692 json!({
1693 "entity": entity,
1694 "limit": limit,
1695 "unjudged_by": unjudged_by,
1696 "include_evidence": include_evidence,
1697 }),
1698 )
1699 .await
1700 .context("kg_pending")?;
1701 let body: Value = serde_json::from_str(&out.content)
1702 .with_context(|| format!("kg_pending returned non-JSON: {}", out.content))?;
1703 if let Some(e) = body.get("error").and_then(Value::as_str) {
1704 anyhow::bail!("kg_pending: {e}");
1705 }
1706 Ok(serde_json::from_value(body["items"].clone()).unwrap_or_default())
1707}
1708
1709pub async fn pending(
1715 client: &McpClient,
1716 proposed_by: &str,
1717 predicate: &str,
1718 limit: usize,
1719 unjudged_by: Option<&str>,
1720 include_evidence: bool,
1721) -> Result<Vec<Candidate>> {
1722 let out = client
1723 .call_tool(
1724 "kg_pending",
1725 json!({
1726 "proposed_by": proposed_by,
1727 "predicate": predicate,
1728 "limit": limit,
1729 "unjudged_by": unjudged_by,
1730 "include_evidence": include_evidence,
1731 }),
1732 )
1733 .await
1734 .context("kg_pending")?;
1735 let body: Value = serde_json::from_str(&out.content)
1736 .with_context(|| format!("kg_pending returned non-JSON: {}", out.content))?;
1737 if let Some(e) = body.get("error").and_then(Value::as_str) {
1738 anyhow::bail!("kg_pending: {e}");
1739 }
1740 Ok(serde_json::from_value(body["items"].clone()).unwrap_or_default())
1741}
1742
1743pub async fn file_verdict(
1745 client: &McpClient,
1746 candidate_id: i64,
1747 mechanism: &str,
1748 verdict: &str,
1749 basis: &str,
1750 model: Option<&str>,
1751) -> Result<()> {
1752 client
1753 .call_tool(
1754 "kg_verdict",
1755 json!({
1756 "candidate_id": candidate_id, "mechanism": mechanism,
1757 "verdict": verdict, "basis": basis, "model": model,
1758 }),
1759 )
1760 .await
1761 .context("kg_verdict")?;
1762 Ok(())
1763}
1764
1765#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1767pub enum Sighting {
1768 Seen,
1769 Unseen,
1770 Contradicted,
1771 Unclear,
1775}
1776
1777pub fn parse_sighting(text: &str) -> (Sighting, String) {
1778 let mut sighting = None;
1779 let mut basis = String::new();
1780 for line in text.lines() {
1781 let l = line
1782 .trim()
1783 .trim_start_matches(['*', '-', '#', '>', ' '])
1784 .trim_matches(['*', '`', ' '])
1785 .to_string();
1786 let upper = l.to_uppercase();
1787 let body = upper.strip_prefix("SIGHTING:").map(str::trim);
1788 let word = body.unwrap_or(&upper);
1789 if (body.is_some() || sighting.is_none()) && sighting.is_none() {
1790 let head = word
1791 .split(|c: char| !c.is_ascii_alphabetic())
1792 .find(|w| !w.is_empty())
1793 .unwrap_or_default();
1794 if body.is_some() || word.trim() == head {
1797 sighting = match head {
1798 "SEEN" => Some(Sighting::Seen),
1799 "UNSEEN" => Some(Sighting::Unseen),
1800 "CONTRADICTED" => Some(Sighting::Contradicted),
1801 _ => None,
1802 };
1803 }
1804 }
1805 if let Some(rest) = l.strip_prefix("CITE:").or(l.strip_prefix("Cite:")) {
1806 basis = rest.trim().to_string();
1807 }
1808 }
1809 match sighting {
1810 Some(s) => (s, basis),
1811 None => (
1812 Sighting::Unclear,
1813 format!("not in form; it said: {}", {
1814 let t: String = text.trim().chars().take(160).collect();
1815 t.replace('\n', " ")
1816 }),
1817 ),
1818 }
1819}
1820
1821pub fn corroboration_verdict(a: Sighting, b: Sighting) -> &'static str {
1828 use Sighting::*;
1829 match (a, b) {
1830 (Contradicted, _) | (_, Contradicted) => "contradicted",
1833 (Seen, Seen) => "corroborated",
1834 (Seen, Unseen) | (Unseen, Seen) => "single_source",
1835 (Unseen, Unseen) => "unseen",
1836 _ => "unclear",
1838 }
1839}
1840
1841pub const SIGHT_SYS: &str = "\
1842You are checking whether a claim about a person shows up in YOUR sources. \
1843Another assistant is checking DIFFERENT sources.
1844
1845The claim came from somewhere else entirely; your job is not to judge \
1846whether it sounds right, but whether your own evidence shows it. Search, \
1847then answer. 'UNSEEN' is the honest and expected answer for most claims, \
1848and it is a real contribution — a generalisation drawn from one \
1849conversation and visible nowhere else is exactly what needs finding.
1850
1851Reply in exactly this form, two lines:
1852SIGHTING: SEEN | UNSEEN | CONTRADICTED
1853CITE: what you found, or 'nothing' — quote or name the episode
1854
1855CONTRADICTED means your evidence shows the opposite, not merely that it is \
1856absent. Absence is UNSEEN.";
1857
1858#[derive(Debug, Clone, Serialize)]
1860pub struct Corroboration {
1861 pub candidate_id: i64,
1862 pub statement: String,
1863 pub verdict: &'static str,
1864 pub sightings: Vec<(String, Sighting, String)>,
1865 pub rechecked: bool,
1866 pub pre_reveal: Option<(String, Sighting, String)>,
1871}
1872
1873pub async fn corroborate(
1881 readers: &[(Vantage, Agent)],
1882 cx: &RunContext,
1883 cand: &Candidate,
1884) -> Result<Corroboration> {
1885 anyhow::ensure!(readers.len() == 2, "corroboration is a pair");
1886 let ask = format!(
1887 "Claim to check against your sources:\n\n{}\n\n\
1888 Search your sources, then reply with exactly two lines:\n\
1889 SIGHTING: SEEN | UNSEEN | CONTRADICTED\n\
1890 CITE: what you found, or 'nothing'",
1891 cand.statement
1892 );
1893
1894 let mut found = Vec::new();
1895 for (v, agent) in readers {
1896 let mut convo = Conversation::user(ask.clone());
1897 let out = agent
1898 .run_in(cx, &mut convo, None)
1899 .await
1900 .with_context(|| format!("{} reader on candidate {}", v.label, cand.candidate_id))?;
1901 let (s, basis) = parse_sighting(&out.text);
1902 found.push((format!("{} [{}]", v.label, v.sources.join(",")), s, basis));
1909 }
1910
1911 let mut rechecked = false;
1915 let mut pre_reveal = None;
1916 let seen_at = found.iter().position(|(_, s, _)| *s == Sighting::Seen);
1917 let unseen_at = found.iter().position(|(_, s, _)| *s == Sighting::Unseen);
1918 if let (Some(hit), Some(miss)) = (seen_at, unseen_at) {
1919 rechecked = true;
1920 pre_reveal = Some(found[miss].clone());
1921 let mut convo = Conversation::user(format!(
1922 "Claim: {}\n\nAnother assistant, reading {}, found this:\n{}\n\n\
1923 Search YOUR sources once more with that in mind. Do not take \
1924 their word for it — report only what your own evidence shows.\n\
1925 SIGHTING: SEEN | UNSEEN | CONTRADICTED\n\
1926 CITE: what you found, or 'nothing'",
1927 cand.statement,
1928 readers[hit].0.sources.join(", "),
1929 found[hit].2,
1930 ));
1931 let out = readers[miss].1.run_in(cx, &mut convo, None).await?;
1932 let (s, basis) = parse_sighting(&out.text);
1933 found[miss] = (
1934 format!(
1935 "{} [{}]",
1936 readers[miss].0.label,
1937 readers[miss].0.sources.join(",")
1938 ),
1939 s,
1940 basis,
1941 );
1942 }
1943
1944 Ok(Corroboration {
1945 candidate_id: cand.candidate_id,
1946 statement: cand.statement.clone(),
1947 verdict: corroboration_verdict(found[0].1, found[1].1),
1948 sightings: found,
1949 rechecked,
1950 pre_reveal,
1951 })
1952}
1953
1954pub fn vantages_excluding(
1969 coverage: &[SourceCoverage],
1970 origin: Option<&str>,
1971 min: i64,
1972) -> Option<(Vantage, Vantage)> {
1973 let barred = match origin {
1974 Some(o) => Some(family_of_origin(o)?),
1977 None => None,
1978 };
1979 let kept: Vec<SourceCoverage> = coverage
1980 .iter()
1981 .filter(|c| barred != Some(family(&c.source)))
1982 .cloned()
1983 .collect();
1984 choose_vantages(&kept, min)
1985}
1986
1987#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1998pub enum Vet {
1999 Supported,
2001 Unsupported,
2004 Misattributed,
2009 Overreach,
2011 Mistyped,
2016 Unclear,
2018}
2019
2020impl Vet {
2021 pub fn as_str(self) -> &'static str {
2022 match self {
2023 Vet::Supported => "supported",
2024 Vet::Unsupported => "unsupported",
2025 Vet::Misattributed => "misattributed",
2026 Vet::Overreach => "overreach",
2027 Vet::Mistyped => "mistyped",
2028 Vet::Unclear => "unclear",
2029 }
2030 }
2031}
2032
2033pub const VET_SYS: &str = "\
2034You judge whether a piece of evidence supports a claim that was extracted \
2035from it. Only the evidence in front of you counts — no outside knowledge, \
2036no guessing at what other conversations might show. You will be told the \
2037exact reply form; keep to it.";
2038
2039pub fn vet_judge(
2045 provider: Box<dyn crate::provider::Provider>,
2046 tool_ctx: ToolCtx,
2047 agent_cfg: crate::config::AgentConfig,
2048 model: Option<String>,
2049) -> Result<Agent> {
2050 let approver = Arc::new(crate::tool::ModeApprover {
2051 mode: crate::config::PermissionMode::ReadOnly,
2052 });
2053 let mut cfg = agent_cfg;
2054 cfg.system_prompt = Some(VET_SYS.to_string());
2055 Agent::new(
2056 provider,
2057 crate::tool::Registry::new(),
2058 approver,
2059 tool_ctx,
2060 cfg,
2061 model,
2062 )
2063}
2064
2065pub fn vet_question(cand: &Candidate, ev: &EvidenceClip) -> String {
2068 format!(
2069 "Evidence — one episode from {}, {}:\n\n---\n{}\n---\n\n\
2070 Claim extracted from that evidence:\n\n {}\n{}{}\n\
2071 Judge whether THIS evidence supports THAT claim. Absence from the \
2072 evidence is UNSUPPORTED even if the claim sounds plausible. If the \
2073 evidence shows the statement but credits it to a different person \
2074 than the claim's subject, that is MISATTRIBUTED. If the evidence \
2075 shows a weaker or narrower version, that is OVERREACH. If the \
2076 evidence supports the content but the relation label mislabels it \
2077 — a one-time event filed as a durable property, or simply the \
2078 wrong relation — that is MISTYPED.\n\n\
2079 Reply in exactly this form:\n\
2080 VERDICT: SUPPORTED | UNSUPPORTED | MISATTRIBUTED | OVERREACH | MISTYPED\n\
2081 WHO: only for MISATTRIBUTED — who the evidence actually shows\n\
2082 PREDICATE: only for MISTYPED — a better relation name, lowercase_with_underscores\n\
2083 QUOTE: the evidence line that decides it, or 'nothing'",
2084 ev.source,
2085 ev.occurred_at,
2086 ev.body,
2087 cand.statement,
2088 cand.subject
2089 .as_deref()
2090 .map(|s| format!(" (subject: {s})\n"))
2091 .unwrap_or_default(),
2092 cand.predicate
2093 .as_deref()
2094 .map(|p| format!(" (relation label: {p})\n"))
2095 .unwrap_or_default(),
2096 )
2097}
2098
2099pub fn parse_vet(text: &str) -> (Vet, Option<String>, Option<String>, String) {
2102 let mut verdict = None;
2103 let mut who = None;
2104 let mut predicate = None;
2105 let mut quote = String::new();
2106 for line in text.lines() {
2107 let l = line
2108 .trim()
2109 .trim_start_matches(['*', '-', '#', '>', ' '])
2110 .trim_matches(['*', '`', ' '])
2111 .to_string();
2112 let upper = l.to_uppercase();
2113 let body = upper.strip_prefix("VERDICT:").map(str::trim);
2114 let word = body.unwrap_or(&upper);
2115 if verdict.is_none() {
2116 let head = word
2117 .split(|c: char| !c.is_ascii_alphabetic())
2118 .find(|w| !w.is_empty())
2119 .unwrap_or_default();
2120 if body.is_some() || word.trim() == head {
2123 verdict = match head {
2124 "SUPPORTED" => Some(Vet::Supported),
2125 "UNSUPPORTED" => Some(Vet::Unsupported),
2126 "MISATTRIBUTED" => Some(Vet::Misattributed),
2127 "OVERREACH" => Some(Vet::Overreach),
2128 "MISTYPED" => Some(Vet::Mistyped),
2129 _ => None,
2130 };
2131 }
2132 }
2133 if let Some(rest) = l.strip_prefix("WHO:").or(l.strip_prefix("Who:")) {
2134 let w = rest.trim();
2135 if !w.is_empty() && !w.eq_ignore_ascii_case("n/a") {
2136 who = Some(w.to_string());
2137 }
2138 }
2139 if let Some(rest) = l
2140 .strip_prefix("PREDICATE:")
2141 .or(l.strip_prefix("Predicate:"))
2142 {
2143 let p = rest
2144 .trim()
2145 .trim_matches('`')
2146 .to_lowercase()
2147 .replace(' ', "_");
2148 if !p.is_empty() && p != "n/a" {
2149 predicate = Some(p);
2150 }
2151 }
2152 if let Some(rest) = l.strip_prefix("QUOTE:").or(l.strip_prefix("Quote:")) {
2153 quote = rest.trim().to_string();
2154 }
2155 }
2156 match verdict {
2157 Some(v) => (v, who, predicate, quote),
2158 None => (
2159 Vet::Unclear,
2160 None,
2161 None,
2162 format!("not in form; it said: {}", {
2163 let t: String = text.trim().chars().take(160).collect();
2164 t.replace('\n', " ")
2165 }),
2166 ),
2167 }
2168}
2169
2170#[derive(Debug, Clone, Serialize)]
2172pub struct Vetting {
2173 pub candidate_id: i64,
2174 pub statement: String,
2175 pub verdict: Vet,
2176 pub who: Option<String>,
2179 pub predicate: Option<String>,
2182 pub quote: String,
2183}
2184
2185pub async fn vet(agent: &Agent, cx: &RunContext, cand: &Candidate) -> Result<Vetting> {
2188 let ev = cand
2189 .evidence
2190 .as_ref()
2191 .context("candidate has no origin evidence to vet against")?;
2192 let mut convo = Conversation::user(vet_question(cand, ev));
2193 let out = agent
2194 .run_in(cx, &mut convo, None)
2195 .await
2196 .with_context(|| format!("vet judge on candidate {}", cand.candidate_id))?;
2197 let (verdict, who, predicate, quote) = parse_vet(&out.text);
2198 Ok(Vetting {
2199 candidate_id: cand.candidate_id,
2200 statement: cand.statement.clone(),
2201 verdict,
2202 who,
2203 predicate,
2204 quote,
2205 })
2206}
2207
2208#[cfg(test)]
2209mod vet_tests {
2210 use super::*;
2211
2212 #[test]
2213 fn a_vet_verdict_is_parsed_or_admitted() {
2214 let (v, who, _, quote) =
2215 parse_vet("VERDICT: MISATTRIBUTED\nWHO: Mara\nQUOTE: Mara said she prefers DIY.");
2216 assert_eq!(v, Vet::Misattributed);
2217 assert_eq!(who.as_deref(), Some("Mara"));
2218 assert!(quote.contains("prefers DIY"));
2219
2220 let (v, _, predicate, _) =
2222 parse_vet("VERDICT: MISTYPED\nPREDICATE: cared for\nQUOTE: was caring for the twins");
2223 assert_eq!(v, Vet::Mistyped);
2224 assert_eq!(predicate.as_deref(), Some("cared_for"));
2225
2226 assert_eq!(
2227 parse_vet("SUPPORTED").0,
2228 Vet::Supported,
2229 "a bare word alone is a format"
2230 );
2231 assert_eq!(parse_vet("**VERDICT:** OVERREACH").0, Vet::Overreach);
2232
2233 let (v, _, _, quote) = parse_vet("I believe this is supported by the transcript.");
2236 assert_eq!(v, Vet::Unclear);
2237 assert!(quote.contains("I believe"), "the rejected text is kept");
2238 }
2239
2240 #[test]
2241 fn the_question_puts_the_imperative_last() {
2242 let cand = Candidate {
2246 candidate_id: 1,
2247 statement: "Luke prefers DIY.".into(),
2248 subject: Some("Dana Whitfield".into()),
2249 origin_source: None,
2250 subject_ambiguous: false,
2251 confidence: None,
2252 predicate: Some("related_to".into()),
2253 evidence: Some(EvidenceClip {
2254 source: "bee.conversation".into(),
2255 occurred_at: "2026-08-01".into(),
2256 body: "a long transcript".into(),
2257 }),
2258 };
2259 let q = vet_question(&cand, cand.evidence.as_ref().unwrap());
2260 let ev_at = q.find("a long transcript").unwrap();
2261 let claim_at = q.find("Luke prefers DIY.").unwrap();
2262 let form_at = q.rfind("VERDICT:").unwrap();
2263 assert!(ev_at < claim_at && claim_at < form_at);
2264 }
2265}