use serde::Deserialize;
use super::memory_extract::{extract_json_object, ExtractedMemory};
use super::memory_settings::ValidityGateSettings;
use super::runner;
use super::state::AgentState;
fn heuristic_reject(content: &str, min_chars: usize) -> Option<&'static str> {
let trimmed = content.trim();
if trimmed.chars().count() < min_chars {
return Some("too short to be a durable memory");
}
const FILLER: &[&str] = &[
"ok",
"okay",
"sure",
"got it",
"thanks",
"thank you",
"sounds good",
"no problem",
"yes",
"no",
"done",
"great",
"cool",
"nice",
"understood",
];
if FILLER.contains(&trimmed.to_ascii_lowercase().as_str()) {
return Some("conversational filler");
}
None
}
pub fn extraction_reject(m: &ExtractedMemory, settings: &ValidityGateSettings) -> Option<String> {
if !settings.enabled {
return None;
}
if let Some(c) = m.confidence {
if c < settings.min_confidence {
return Some(format!(
"extraction confidence {c:.2} below {}",
settings.min_confidence
));
}
}
heuristic_reject(&m.content, settings.min_content_chars).map(str::to_string)
}
const VALIDITY_SYSTEM: &str = "You judge whether a piece of text is worth keeping as a \
durable, reusable memory for an AI coding agent — a genuine fact/decision/preference/learning, \
not conversational filler, an unresolved question, or content already obvious from context. \
Return STRICT JSON: {\"keep\": true|false, \"reason\": \"one short sentence\"}. Output ONLY the \
JSON object.";
#[derive(Debug, Deserialize)]
struct KeepVerdict {
keep: bool,
}
async fn llm_judge_keep(state: &AgentState, content: &str) -> bool {
let text = match runner::run_oneshot(
state,
"pensieve-validity-gate",
"Judges whether content is worth keeping as a durable memory.",
VALIDITY_SYSTEM,
content,
)
.await
{
Ok(t) => t,
Err(_) => return true,
};
extract_json_object(&text)
.and_then(|j| serde_json::from_str::<KeepVerdict>(&j).ok())
.map(|v| v.keep)
.unwrap_or(true)
}
pub async fn tool_reject_reason(
state: &AgentState,
content: &str,
settings: &ValidityGateSettings,
) -> Option<String> {
if !settings.enabled {
return None;
}
let heuristic = heuristic_reject(content, settings.min_content_chars)?;
if !settings.llm_escalation_enabled {
return Some(heuristic.to_string());
}
if llm_judge_keep(state, content).await {
None
} else {
Some(format!("{heuristic} (confirmed by validity-gate judge)"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(min_chars: usize) -> ValidityGateSettings {
ValidityGateSettings {
enabled: true,
llm_escalation_enabled: false,
min_confidence: 0.4,
min_content_chars: min_chars,
}
}
fn extracted(content: &str, confidence: Option<f32>) -> ExtractedMemory {
ExtractedMemory {
content: content.to_string(),
title: None,
kind: "fact".to_string(),
importance: 0.5,
valid_at: None,
entity_mentions: vec![],
confidence,
}
}
#[test]
fn disabled_is_passthrough() {
let mut s = settings(20);
s.enabled = false;
assert_eq!(extraction_reject(&extracted("ok", Some(0.1)), &s), None);
}
#[test]
fn low_confidence_rejects_even_long_content() {
let m = extracted("a perfectly reasonable and detailed statement", Some(0.1));
assert!(extraction_reject(&m, &settings(20)).is_some());
}
#[test]
fn missing_confidence_falls_through_to_heuristic() {
let m = extracted("a perfectly reasonable and detailed statement", None);
assert_eq!(extraction_reject(&m, &settings(20)), None);
}
#[test]
fn short_content_rejected_by_heuristic() {
let m = extracted("pensieve", Some(0.9));
assert!(extraction_reject(&m, &settings(20)).is_some());
}
#[test]
fn filler_phrase_rejected_regardless_of_length_setting() {
let m = extracted("thanks", Some(0.9));
assert!(extraction_reject(&m, &settings(1)).is_some());
}
#[test]
fn genuine_fact_passes() {
let m = extracted("pensieve uses DataFusion for query execution", Some(0.9));
assert_eq!(extraction_reject(&m, &settings(20)), None);
}
}