1use std::collections::{HashMap, HashSet};
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, LazyLock, Mutex, RwLock};
21
22use async_trait::async_trait;
23use futures::StreamExt;
24use rand_core::{OsRng, RngCore};
25use serde::Deserialize;
26
27use meerkat_client::{FactoryError, LlmClient, LlmDoneOutcome, LlmError, LlmEvent, LlmRequest};
28use meerkat_core::{Message, Provider, SessionLlmIdentity, UserMessage};
29
30use crate::identity_first::agent_memory::{
31 AgentMemoryError, AgentMemoryRecord, compact_whitespace, truncate_utf8_boundary,
32};
33use crate::memory::records::{MemoryScope, RecordMeta, TrustTier};
34
35pub const SELECTOR_ENV_VAR: &str = "MOBKIT_AGENT_MEMORY_SELECTOR";
39
40pub const EMBEDDED_PROMPT_V0: &str = include_str!("selector_prompt_v0.md");
45
46const MANIFEST_PLACEHOLDER: &str = "{{manifest}}";
47const TURN_TEXT_PLACEHOLDER: &str = "{{turn_text}}";
48const SUPPRESSION_PLACEHOLDER: &str = "{{suppression_list}}";
49
50const MAX_PROMPT_TURN_BYTES: usize = 8 * 1024;
53const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 1024;
55pub const FULL_SWEEP_CHUNK_BYTES: usize = 100 * 1024;
58pub const FULL_SWEEP_SOFT_CEILING_RECORDS: usize = 4_000;
64pub const FULL_SWEEP_HARD_CEILING_RECORDS: usize = 4 * FULL_SWEEP_SOFT_CEILING_RECORDS;
65
66#[derive(Debug)]
71pub enum SelectorError {
72 Profile(String),
74 Auth(String),
76 Client(String),
78 Parse(String),
80}
81
82impl std::fmt::Display for SelectorError {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match self {
85 Self::Profile(msg) => write!(f, "selector profile error: {msg}"),
86 Self::Auth(msg) => write!(f, "selector auth error: {msg}"),
87 Self::Client(msg) => write!(f, "selector client error: {msg}"),
88 Self::Parse(msg) => write!(f, "selector parse error: {msg}"),
89 }
90 }
91}
92
93impl std::error::Error for SelectorError {}
94
95#[derive(Debug, Clone, Deserialize)]
102pub struct SelectorParams {
103 #[serde(default = "default_temperature")]
104 pub temperature: f32,
105 #[serde(default = "default_selection_bar")]
106 pub selection_bar: String,
107 #[serde(default = "default_shuffle_manifest")]
108 pub shuffle_manifest: bool,
109 #[serde(default = "default_recall_timeout_ms")]
110 pub recall_timeout_ms: u64,
111 #[serde(default = "default_working_set_k")]
113 pub working_set_k: usize,
114 #[serde(default = "default_max_output_tokens")]
115 pub max_output_tokens: u32,
116}
117
118fn default_temperature() -> f32 {
119 0.0
120}
121fn default_selection_bar() -> String {
122 "certain-to-be-helpful".to_string()
123}
124fn default_shuffle_manifest() -> bool {
125 true
126}
127fn default_recall_timeout_ms() -> u64 {
128 500
129}
130fn default_working_set_k() -> usize {
131 200
132}
133fn default_max_output_tokens() -> u32 {
134 DEFAULT_MAX_OUTPUT_TOKENS
135}
136
137impl Default for SelectorParams {
138 fn default() -> Self {
139 Self {
140 temperature: default_temperature(),
141 selection_bar: default_selection_bar(),
142 shuffle_manifest: default_shuffle_manifest(),
143 recall_timeout_ms: default_recall_timeout_ms(),
144 working_set_k: default_working_set_k(),
145 max_output_tokens: default_max_output_tokens(),
146 }
147 }
148}
149
150#[derive(Debug, Clone)]
153pub struct SelectorProfile {
154 pub stage: String,
155 pub version: String,
156 pub model: String,
157 pub provider: Provider,
158 pub prompt_bundle: String,
160 pub prompt_template: String,
161 pub params: SelectorParams,
162}
163
164#[derive(Debug, Deserialize)]
165struct RawProfile {
166 stage: String,
167 version: String,
168 model: String,
169 #[serde(default)]
171 provider: Option<String>,
172 prompt_bundle: String,
173 #[serde(default)]
174 params: Option<SelectorParams>,
175}
176
177impl SelectorProfile {
178 pub fn embedded_default() -> Self {
182 Self {
183 stage: "selector".to_string(),
184 version: "0".to_string(),
185 model: "claude-haiku-4-5".to_string(),
186 provider: Provider::Anthropic,
187 prompt_bundle: "prompts/selector-v0.md".to_string(),
188 prompt_template: EMBEDDED_PROMPT_V0.to_string(),
189 params: SelectorParams::default(),
190 }
191 }
192
193 pub fn load(path: &Path) -> Result<Self, SelectorError> {
198 let text = std::fs::read_to_string(path).map_err(|err| {
199 SelectorError::Profile(format!("cannot read profile '{}': {err}", path.display()))
200 })?;
201 let raw: RawProfile = toml::from_str(&text).map_err(|err| {
202 SelectorError::Profile(format!("invalid profile '{}': {err}", path.display()))
203 })?;
204 if raw.stage != "selector" {
205 return Err(SelectorError::Profile(format!(
206 "profile '{}' is for stage '{}', not 'selector'",
207 path.display(),
208 raw.stage
209 )));
210 }
211 if raw.model.trim().is_empty() || raw.model == "PLACEHOLDER" {
212 return Err(SelectorError::Profile(format!(
213 "profile '{}' does not name a model",
214 path.display()
215 )));
216 }
217 let provider = match raw.provider.as_deref() {
218 Some(name) => Provider::parse_strict(name).ok_or_else(|| {
219 SelectorError::Profile(format!(
220 "profile '{}': unknown provider '{name}'",
221 path.display()
222 ))
223 })?,
224 None => meerkat_models::infer_provider(&raw.model).ok_or_else(|| {
225 SelectorError::Profile(format!(
226 "profile '{}': model '{}' is not in the catalog; set `provider` explicitly",
227 path.display(),
228 raw.model
229 ))
230 })?,
231 };
232 let base = path.parent().unwrap_or_else(|| Path::new("."));
233 let candidates = [
234 base.join(&raw.prompt_bundle),
235 base.parent()
236 .unwrap_or_else(|| Path::new("."))
237 .join(&raw.prompt_bundle),
238 ];
239 let bundle_path = candidates.iter().find(|p| p.is_file()).ok_or_else(|| {
240 SelectorError::Profile(format!(
241 "profile '{}': prompt_bundle '{}' does not resolve",
242 path.display(),
243 raw.prompt_bundle
244 ))
245 })?;
246 let prompt_template = std::fs::read_to_string(bundle_path).map_err(|err| {
247 SelectorError::Profile(format!(
248 "cannot read prompt bundle '{}': {err}",
249 bundle_path.display()
250 ))
251 })?;
252 let profile = Self {
253 stage: raw.stage,
254 version: raw.version,
255 model: raw.model,
256 provider,
257 prompt_bundle: raw.prompt_bundle,
258 prompt_template,
259 params: raw.params.unwrap_or_default(),
260 };
261 profile.validate()?;
262 Ok(profile)
263 }
264
265 fn validate(&self) -> Result<(), SelectorError> {
266 for placeholder in [
267 MANIFEST_PLACEHOLDER,
268 TURN_TEXT_PLACEHOLDER,
269 SUPPRESSION_PLACEHOLDER,
270 ] {
271 if !self.prompt_template.contains(placeholder) {
272 return Err(SelectorError::Profile(format!(
273 "prompt bundle '{}' is missing placeholder `{placeholder}`",
274 self.prompt_bundle
275 )));
276 }
277 }
278 Ok(())
279 }
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum Coverage {
291 Sufficient,
292 NeedDeeperSweep,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct Selection {
297 pub selected_ids: Vec<String>,
298 pub coverage: Coverage,
299}
300
301#[derive(Deserialize)]
302struct RawSelection {
303 selected_ids: Vec<String>,
304 coverage: RawCoverage,
305}
306
307#[derive(Deserialize)]
308#[serde(rename_all = "snake_case")]
309enum RawCoverage {
310 Sufficient,
311 NeedDeeperSweep,
312}
313
314pub async fn select<S: std::hash::BuildHasher>(
325 manifest: &[RecordMeta],
326 turn_text: &str,
327 suppressed_ids: &HashSet<String, S>,
328 profile: &SelectorProfile,
329 client: &dyn LlmClient,
330) -> Result<Selection, SelectorError> {
331 let prompt = render_prompt(profile, manifest, turn_text, suppressed_ids);
332 let reply = complete_text(client, profile, prompt).await?;
333 let raw = match parse_selection(&reply) {
334 Ok(raw) => raw,
335 Err(first_err) => {
336 let repair_prompt = format!(
339 "The following reply was supposed to be exactly one JSON object of the form \
340 {{\"selected_ids\": [\"...\"], \"coverage\": \"sufficient\" | \"need_deeper_sweep\"}} \
341 but did not parse ({first_err}). Reply with ONLY the corrected JSON object, \
342 no other text.\n\n{reply}"
343 );
344 let repaired = complete_text(client, profile, repair_prompt).await?;
345 parse_selection(&repaired).map_err(SelectorError::Parse)?
346 }
347 };
348 let known: HashSet<&str> = manifest.iter().map(|meta| meta.id.as_str()).collect();
349 let mut seen = HashSet::new();
350 let mut selected_ids = Vec::new();
351 for id in raw.selected_ids {
352 if !known.contains(id.as_str()) {
353 tracing::warn!(id = %id, "selector returned an id not present in the manifest; dropped");
354 continue;
355 }
356 if suppressed_ids.contains(&id) {
357 tracing::warn!(id = %id, "selector returned a suppressed id; dropped");
358 continue;
359 }
360 if seen.insert(id.clone()) {
361 selected_ids.push(id);
362 }
363 }
364 Ok(Selection {
365 selected_ids,
366 coverage: match raw.coverage {
367 RawCoverage::Sufficient => Coverage::Sufficient,
368 RawCoverage::NeedDeeperSweep => Coverage::NeedDeeperSweep,
369 },
370 })
371}
372
373async fn complete_text(
374 client: &dyn LlmClient,
375 profile: &SelectorProfile,
376 prompt: String,
377) -> Result<String, SelectorError> {
378 let request = LlmRequest::new(
379 &profile.model,
380 vec![Message::User(UserMessage::text(prompt))],
381 )
382 .with_max_tokens(profile.params.max_output_tokens)
383 .with_temperature(profile.params.temperature);
384 let mut stream = client.stream(&request);
385 let mut text = String::new();
386 while let Some(event) = stream.next().await {
387 match event.map_err(classify_llm_error)? {
388 LlmEvent::TextDelta { delta, .. } => text.push_str(&delta),
389 LlmEvent::Done { outcome } => match outcome {
390 LlmDoneOutcome::Success { .. } => break,
391 LlmDoneOutcome::Error { error } => return Err(classify_llm_error(error)),
392 },
393 _ => {}
394 }
395 }
396 Ok(text)
397}
398
399fn classify_llm_error(error: LlmError) -> SelectorError {
400 match error {
401 LlmError::AuthenticationFailed { .. } | LlmError::InvalidApiKey => {
402 SelectorError::Auth(error.to_string())
403 }
404 other => SelectorError::Client(other.to_string()),
405 }
406}
407
408fn parse_selection(reply: &str) -> Result<RawSelection, String> {
409 let trimmed = reply.trim();
410 if let Ok(raw) = serde_json::from_str::<RawSelection>(trimmed) {
411 return Ok(raw);
412 }
413 let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) else {
415 return Err("no JSON object in reply".to_string());
416 };
417 if start >= end {
418 return Err("no JSON object in reply".to_string());
419 }
420 serde_json::from_str::<RawSelection>(&trimmed[start..=end]).map_err(|err| err.to_string())
421}
422
423fn render_prompt<S: std::hash::BuildHasher>(
428 profile: &SelectorProfile,
429 manifest: &[RecordMeta],
430 turn_text: &str,
431 suppressed_ids: &HashSet<String, S>,
432) -> String {
433 let rows: Vec<&RecordMeta> = if profile.params.shuffle_manifest {
434 shuffled(manifest)
435 } else {
436 manifest.iter().collect()
437 };
438 let manifest_text = if rows.is_empty() {
439 "(no records)".to_string()
440 } else {
441 rows.iter()
442 .map(|meta| render_manifest_row(meta))
443 .collect::<Vec<_>>()
444 .join("\n")
445 };
446 let mut suppressed: Vec<&str> = suppressed_ids.iter().map(String::as_str).collect();
447 suppressed.sort_unstable();
448 let suppression_text = if suppressed.is_empty() {
449 "(none)".to_string()
450 } else {
451 suppressed
452 .iter()
453 .map(|id| format!("- {id}"))
454 .collect::<Vec<_>>()
455 .join("\n")
456 };
457 let turn = truncate_utf8_boundary(&compact_whitespace(turn_text), MAX_PROMPT_TURN_BYTES);
458 profile
459 .prompt_template
460 .replace(MANIFEST_PLACEHOLDER, &manifest_text)
461 .replace(SUPPRESSION_PLACEHOLDER, &suppression_text)
462 .replace(TURN_TEXT_PLACEHOLDER, &turn)
463}
464
465pub(crate) fn render_manifest_row(meta: &RecordMeta) -> String {
468 let rank = match meta.rank {
469 Some(rank) => format!("rank {rank}"),
470 None => "unranked".to_string(),
471 };
472 let mut row = format!(
473 "- {} [{}, {}, {}] {}",
474 meta.id,
475 meta.kind.as_str(),
476 age_phrase(meta.age_days),
477 rank,
478 compact_whitespace(&meta.title),
479 );
480 let description = compact_whitespace(&meta.description);
481 if !description.is_empty() {
482 row.push_str(" — ");
483 row.push_str(&description);
484 }
485 row
486}
487
488fn age_phrase(age_days: u64) -> String {
489 match age_days {
490 0 => "saved today".to_string(),
491 1 => "saved 1 day ago".to_string(),
492 n => format!("saved {n} days ago"),
493 }
494}
495
496fn shuffled(manifest: &[RecordMeta]) -> Vec<&RecordMeta> {
499 let mut seed = {
500 let mut bytes = [0u8; 8];
501 OsRng.fill_bytes(&mut bytes);
502 u64::from_le_bytes(bytes)
503 };
504 let mut next = move || {
505 seed = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
506 let mut z = seed;
507 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
508 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
509 z ^ (z >> 31)
510 };
511 let mut rows: Vec<&RecordMeta> = manifest.iter().collect();
512 for i in (1..rows.len()).rev() {
513 let j = (next() % (i as u64 + 1)) as usize;
514 rows.swap(i, j);
515 }
516 rows
517}
518
519pub fn chunk_manifest(manifest: &[RecordMeta]) -> Vec<&[RecordMeta]> {
523 let mut chunks = Vec::new();
524 let mut start = 0;
525 let mut bytes = 0usize;
526 for (index, meta) in manifest.iter().enumerate() {
527 let row_bytes = render_manifest_row(meta).len() + 1;
528 if index > start && bytes + row_bytes > FULL_SWEEP_CHUNK_BYTES {
529 chunks.push(&manifest[start..index]);
530 start = index;
531 bytes = 0;
532 }
533 bytes += row_bytes;
534 }
535 if start < manifest.len() {
536 chunks.push(&manifest[start..]);
537 }
538 chunks
539}
540
541pub fn truncate_full_manifest(
548 mut manifest: Vec<RecordMeta>,
549 hard_ceiling: usize,
550) -> (Vec<RecordMeta>, Vec<String>) {
551 if manifest.len() <= hard_ceiling {
552 return (manifest, Vec::new());
553 }
554 manifest.sort_by(|a, b| match (a.rank, b.rank) {
555 (Some(a_rank), Some(b_rank)) => a_rank.cmp(&b_rank),
556 (Some(_), None) => std::cmp::Ordering::Less,
557 (None, Some(_)) => std::cmp::Ordering::Greater,
558 (None, None) => a.age_days.cmp(&b.age_days),
559 });
560 let dropped = manifest
561 .split_off(hard_ceiling)
562 .into_iter()
563 .map(|meta| meta.id)
564 .collect();
565 (manifest, dropped)
566}
567
568#[async_trait]
575pub trait SelectorHandle: Send + Sync {
576 async fn client(&self) -> Result<Arc<dyn LlmClient>, SelectorError>;
577 fn invalidate(&self);
579}
580
581pub struct FactorySelectorHandle {
587 factory: meerkat::AgentFactory,
588 config: meerkat::Config,
589 realm: String,
590 identity: SessionLlmIdentity,
591 cache: Mutex<HashMap<(String, String), Arc<dyn LlmClient>>>,
592}
593
594impl FactorySelectorHandle {
595 pub fn new(
596 store_path: impl Into<PathBuf>,
597 config: meerkat::Config,
598 realm: impl Into<String>,
599 profile: &SelectorProfile,
600 ) -> Self {
601 Self::for_model(store_path, config, realm, &profile.model, profile.provider)
602 }
603
604 pub fn for_model(
608 store_path: impl Into<PathBuf>,
609 config: meerkat::Config,
610 realm: impl Into<String>,
611 model: &str,
612 provider: Provider,
613 ) -> Self {
614 Self {
615 factory: meerkat::AgentFactory::new(store_path.into()),
616 config,
617 realm: realm.into(),
618 identity: SessionLlmIdentity {
619 model: model.to_string(),
620 provider,
621 self_hosted_server_id: None,
622 provider_params: None,
623 auth_binding: None,
626 },
627 cache: Mutex::new(HashMap::new()),
628 }
629 }
630}
631
632#[async_trait]
633impl SelectorHandle for FactorySelectorHandle {
634 async fn client(&self) -> Result<Arc<dyn LlmClient>, SelectorError> {
635 let key = (self.realm.clone(), self.identity.model.clone());
636 if let Some(client) = self
637 .cache
638 .lock()
639 .unwrap_or_else(std::sync::PoisonError::into_inner)
640 .get(&key)
641 {
642 return Ok(client.clone());
643 }
644 let client = self
645 .factory
646 .build_llm_client_for_identity(&self.config, &self.identity)
647 .await
648 .map_err(|err| match err {
649 FactoryError::ProviderAuth(_) | FactoryError::ConnectionTarget(_) => {
650 SelectorError::Auth(err.to_string())
651 }
652 other => SelectorError::Client(other.to_string()),
653 })?;
654 self.cache
655 .lock()
656 .unwrap_or_else(std::sync::PoisonError::into_inner)
657 .insert(key, client.clone());
658 Ok(client)
659 }
660
661 fn invalidate(&self) {
662 self.cache
663 .lock()
664 .unwrap_or_else(std::sync::PoisonError::into_inner)
665 .clear();
666 }
667}
668
669pub struct SelectorStage {
676 profile: SelectorProfile,
677 handle: Arc<dyn SelectorHandle>,
678}
679
680impl SelectorStage {
681 pub fn new(profile: SelectorProfile, handle: Arc<dyn SelectorHandle>) -> Self {
682 Self { profile, handle }
683 }
684
685 pub fn profile(&self) -> &SelectorProfile {
686 &self.profile
687 }
688
689 pub async fn select(
690 &self,
691 manifest: &[RecordMeta],
692 turn_text: &str,
693 suppressed_ids: &HashSet<String>,
694 ) -> Result<Selection, SelectorError> {
695 let client = self.handle.client().await?;
696 match select(manifest, turn_text, suppressed_ids, &self.profile, &*client).await {
697 Err(SelectorError::Auth(first)) => {
698 tracing::warn!(error = %first, "selector auth failure; re-resolving client");
699 self.handle.invalidate();
700 let client = self.handle.client().await?;
701 select(manifest, turn_text, suppressed_ids, &self.profile, &*client).await
702 }
703 other => other,
704 }
705 }
706}
707
708#[derive(Debug, Clone, PartialEq, Eq)]
717pub struct RecordProvenance {
718 pub scope: MemoryScope,
719 pub trust: TrustTier,
720}
721
722#[derive(Debug, Clone, PartialEq, Eq)]
726pub struct AnnotatedRecord {
727 pub record: AgentMemoryRecord,
728 pub provenance: Option<RecordProvenance>,
729}
730
731#[async_trait]
737pub trait SelectedRecordFetch: Send + Sync {
738 async fn fetch_records(
739 &self,
740 scopes: &[MemoryScope],
741 ids: &[String],
742 ) -> Result<Vec<AgentMemoryRecord>, AgentMemoryError>;
743
744 async fn fetch_records_annotated(
749 &self,
750 scopes: &[MemoryScope],
751 ids: &[String],
752 ) -> Result<Vec<AnnotatedRecord>, AgentMemoryError> {
753 Ok(self
754 .fetch_records(scopes, ids)
755 .await?
756 .into_iter()
757 .map(|record| AnnotatedRecord {
758 record,
759 provenance: None,
760 })
761 .collect())
762 }
763}
764
765pub struct SelectorRuntime {
771 pub stage: Arc<SelectorStage>,
772 pub fetch: Arc<dyn SelectedRecordFetch>,
773}
774
775static INSTALLED: LazyLock<RwLock<Option<Arc<SelectorRuntime>>>> =
776 LazyLock::new(|| RwLock::new(None));
777
778pub fn install(runtime: Arc<SelectorRuntime>) {
783 let mut guard = INSTALLED
784 .write()
785 .unwrap_or_else(std::sync::PoisonError::into_inner);
786 if guard.is_some() {
787 tracing::warn!("agent-memory selector re-installed; replacing the existing stage");
788 }
789 *guard = Some(runtime);
790}
791
792pub fn installed() -> Option<Arc<SelectorRuntime>> {
793 INSTALLED
794 .read()
795 .unwrap_or_else(std::sync::PoisonError::into_inner)
796 .clone()
797}
798
799#[derive(Debug, Clone, PartialEq, Eq)]
801pub enum SelectorSpec {
802 Off,
803 Default,
804 Profile(PathBuf),
805}
806
807pub fn spec_from_env() -> Result<SelectorSpec, SelectorError> {
811 match std::env::var(SELECTOR_ENV_VAR) {
812 Err(std::env::VarError::NotPresent) => Ok(SelectorSpec::Off),
813 Err(err) => Err(SelectorError::Profile(format!(
814 "{SELECTOR_ENV_VAR} is not valid unicode: {err}"
815 ))),
816 Ok(value) => parse_spec(&value),
817 }
818}
819
820fn parse_spec(value: &str) -> Result<SelectorSpec, SelectorError> {
821 let value = value.trim();
822 match value {
823 "" | "off" => Ok(SelectorSpec::Off),
824 "default" => Ok(SelectorSpec::Default),
825 other => match other.strip_prefix("profile:") {
826 Some(path) if !path.trim().is_empty() => {
827 Ok(SelectorSpec::Profile(PathBuf::from(path.trim())))
828 }
829 _ => Err(SelectorError::Profile(format!(
830 "invalid {SELECTOR_ENV_VAR} value '{other}' \
831 (expected off | default | profile:<path>)"
832 ))),
833 },
834 }
835}
836
837pub fn profile_for_spec(spec: &SelectorSpec) -> Result<Option<SelectorProfile>, SelectorError> {
839 match spec {
840 SelectorSpec::Off => Ok(None),
841 SelectorSpec::Default => Ok(Some(SelectorProfile::embedded_default())),
842 SelectorSpec::Profile(path) => SelectorProfile::load(path).map(Some),
843 }
844}
845
846#[cfg(test)]
847#[allow(clippy::expect_used, clippy::redundant_clone, clippy::unwrap_used)]
848mod tests {
849 use super::*;
850 use crate::memory::records::MemoryKind;
851 use futures::stream;
852 use std::sync::Mutex as StdMutex;
853
854 fn meta(id: &str, title: &str, description: &str) -> RecordMeta {
855 RecordMeta {
856 id: id.to_string(),
857 kind: MemoryKind::Gotcha,
858 title: title.to_string(),
859 description: description.to_string(),
860 age_days: 3,
861 rank: Some(1),
862 }
863 }
864
865 struct ScriptedLlm {
868 replies: StdMutex<Vec<String>>,
869 prompts: StdMutex<Vec<String>>,
870 provider: Provider,
871 }
872
873 impl ScriptedLlm {
874 fn new(replies: Vec<&str>) -> Self {
875 Self {
876 replies: StdMutex::new(replies.into_iter().map(str::to_string).collect()),
877 prompts: StdMutex::new(Vec::new()),
878 provider: Provider::Anthropic,
879 }
880 }
881
882 fn prompts(&self) -> Vec<String> {
883 self.prompts
884 .lock()
885 .unwrap_or_else(std::sync::PoisonError::into_inner)
886 .clone()
887 }
888 }
889
890 #[async_trait]
891 impl LlmClient for ScriptedLlm {
892 fn stream<'a>(&'a self, request: &'a LlmRequest) -> meerkat_client::types::LlmStream<'a> {
893 let prompt = request
894 .messages
895 .iter()
896 .map(|message| match message {
897 Message::User(user) => user.text_content(),
898 _ => String::new(),
899 })
900 .collect::<Vec<_>>()
901 .join("\n");
902 self.prompts
903 .lock()
904 .unwrap_or_else(std::sync::PoisonError::into_inner)
905 .push(prompt);
906 let reply = {
907 let mut replies = self
908 .replies
909 .lock()
910 .unwrap_or_else(std::sync::PoisonError::into_inner);
911 if replies.is_empty() {
912 String::new()
913 } else {
914 replies.remove(0)
915 }
916 };
917 Box::pin(stream::iter(vec![
918 Ok(LlmEvent::TextDelta {
919 delta: reply,
920 meta: None,
921 }),
922 Ok(LlmEvent::Done {
923 outcome: LlmDoneOutcome::Success {
924 stop_reason: meerkat_core::StopReason::EndTurn,
925 },
926 }),
927 ]))
928 }
929
930 fn provider(&self) -> Provider {
931 self.provider
932 }
933
934 async fn health_check(&self) -> Result<(), LlmError> {
935 Ok(())
936 }
937 }
938
939 fn manifest() -> Vec<RecordMeta> {
940 vec![
941 meta("mem-1", "Cargo wrapper", "When running cargo commands"),
942 meta("mem-2", "Deploy freeze", "When deploying on Fridays"),
943 meta(
944 "mem-3",
945 "Passport location",
946 "When travel documents come up",
947 ),
948 ]
949 }
950
951 #[tokio::test]
952 async fn select_parses_and_orders_selection() -> Result<(), Box<dyn std::error::Error>> {
953 let client = ScriptedLlm::new(vec![
954 r#"{"selected_ids": ["mem-2", "mem-1"], "coverage": "sufficient"}"#,
955 ]);
956 let profile = SelectorProfile::embedded_default();
957 let selection = select(
958 &manifest(),
959 "deploying the gateway",
960 &HashSet::new(),
961 &profile,
962 &client,
963 )
964 .await?;
965 assert_eq!(selection.selected_ids, vec!["mem-2", "mem-1"]);
966 assert_eq!(selection.coverage, Coverage::Sufficient);
967 Ok(())
968 }
969
970 #[tokio::test]
971 async fn select_drops_unknown_and_suppressed_ids() -> Result<(), Box<dyn std::error::Error>> {
972 let client = ScriptedLlm::new(vec![
973 r#"{"selected_ids": ["mem-9", "mem-1", "mem-2", "mem-1"], "coverage": "need_deeper_sweep"}"#,
974 ]);
975 let profile = SelectorProfile::embedded_default();
976 let suppressed: HashSet<String> = ["mem-2".to_string()].into();
977 let selection = select(&manifest(), "cargo build", &suppressed, &profile, &client).await?;
978 assert_eq!(selection.selected_ids, vec!["mem-1"]);
979 assert_eq!(selection.coverage, Coverage::NeedDeeperSweep);
980 Ok(())
981 }
982
983 #[tokio::test]
984 async fn select_repairs_malformed_json_once() -> Result<(), Box<dyn std::error::Error>> {
985 let client = ScriptedLlm::new(vec![
986 "Sure! Here is my selection, hope it helps",
987 r#"{"selected_ids": ["mem-3"], "coverage": "sufficient"}"#,
988 ]);
989 let profile = SelectorProfile::embedded_default();
990 let selection = select(
991 &manifest(),
992 "where is my passport",
993 &HashSet::new(),
994 &profile,
995 &client,
996 )
997 .await?;
998 assert_eq!(selection.selected_ids, vec!["mem-3"]);
999 let prompts = client.prompts();
1000 assert_eq!(prompts.len(), 2, "exactly one repair round-trip");
1001 assert!(prompts[1].contains("ONLY the corrected JSON object"));
1002 Ok(())
1003 }
1004
1005 #[tokio::test]
1006 async fn select_errors_after_failed_repair() {
1007 let client = ScriptedLlm::new(vec!["not json", "still not json"]);
1008 let profile = SelectorProfile::embedded_default();
1009 let result = select(&manifest(), "turn", &HashSet::new(), &profile, &client).await;
1010 assert!(matches!(result, Err(SelectorError::Parse(_))), "{result:?}");
1011 assert_eq!(client.prompts().len(), 2);
1012 }
1013
1014 #[tokio::test]
1015 async fn select_tolerates_fenced_json() -> Result<(), Box<dyn std::error::Error>> {
1016 let client = ScriptedLlm::new(vec![
1017 "```json\n{\"selected_ids\": [\"mem-1\"], \"coverage\": \"sufficient\"}\n```",
1018 ]);
1019 let profile = SelectorProfile::embedded_default();
1020 let selection = select(
1021 &manifest(),
1022 "cargo check",
1023 &HashSet::new(),
1024 &profile,
1025 &client,
1026 )
1027 .await?;
1028 assert_eq!(selection.selected_ids, vec!["mem-1"]);
1029 assert_eq!(client.prompts().len(), 1, "fenced JSON needs no repair");
1030 Ok(())
1031 }
1032
1033 #[tokio::test]
1034 async fn prompt_renders_manifest_suppression_and_turn() -> Result<(), Box<dyn std::error::Error>>
1035 {
1036 let client = ScriptedLlm::new(vec![r#"{"selected_ids": [], "coverage": "sufficient"}"#]);
1037 let profile = SelectorProfile::embedded_default();
1038 let suppressed: HashSet<String> = ["mem-3".to_string()].into();
1039 select(
1040 &manifest(),
1041 "the incoming turn text",
1042 &suppressed,
1043 &profile,
1044 &client,
1045 )
1046 .await?;
1047 let prompt = &client.prompts()[0];
1048 assert!(prompt.contains("- mem-1 [gotcha, saved 3 days ago, rank 1] Cargo wrapper"));
1049 assert!(prompt.contains("— When running cargo commands"), "{prompt}");
1050 assert!(
1051 prompt.contains("- mem-3\n") || prompt.ends_with("- mem-3"),
1052 "{prompt}"
1053 );
1054 assert!(prompt.contains("the incoming turn text"));
1055 assert!(!prompt.contains("{{manifest}}"));
1056 assert!(!prompt.contains("{{turn_text}}"));
1057 assert!(!prompt.contains("{{suppression_list}}"));
1058 Ok(())
1059 }
1060
1061 #[tokio::test]
1062 async fn shuffle_changes_manifest_order_across_calls() -> Result<(), Box<dyn std::error::Error>>
1063 {
1064 let manifest: Vec<RecordMeta> = (0..12)
1067 .map(|i| meta(&format!("mem-{i}"), &format!("Title {i}"), ""))
1068 .collect();
1069 let replies = vec![r#"{"selected_ids": [], "coverage": "sufficient"}"#; 24];
1070 let client = ScriptedLlm::new(replies);
1071 let profile = SelectorProfile::embedded_default();
1072 for _ in 0..24 {
1073 select(&manifest, "turn", &HashSet::new(), &profile, &client).await?;
1074 }
1075 let prompts = client.prompts();
1076 let orders: HashSet<String> = prompts
1077 .iter()
1078 .map(|prompt| {
1079 prompt
1080 .lines()
1081 .filter(|line| line.starts_with("- mem-"))
1082 .collect::<Vec<_>>()
1083 .join("|")
1084 })
1085 .collect();
1086 assert!(
1087 orders.len() > 1,
1088 "manifest order must vary across calls (§8.3 bias guard)"
1089 );
1090 Ok(())
1091 }
1092
1093 #[test]
1094 fn embedded_prompt_matches_calibration_bundle() -> Result<(), Box<dyn std::error::Error>> {
1095 let bundle =
1099 Path::new(env!("CARGO_MANIFEST_DIR")).join("../memory-evals/prompts/selector-v0.md");
1100 if !bundle.is_file() {
1101 return Ok(());
1102 }
1103 let text = std::fs::read_to_string(bundle)?;
1104 assert_eq!(
1105 text, EMBEDDED_PROMPT_V0,
1106 "memory-evals/prompts/selector-v0.md and \
1107 src/memory/selector_prompt_v0.md have drifted"
1108 );
1109 Ok(())
1110 }
1111
1112 #[test]
1113 fn embedded_default_profile_validates_and_names_a_catalog_model() {
1114 let profile = SelectorProfile::embedded_default();
1115 profile.validate().expect("embedded profile must validate");
1116 assert_eq!(
1117 meerkat_models::infer_provider(&profile.model),
1118 Some(profile.provider),
1119 "embedded default model must resolve in the catalog"
1120 );
1121 }
1122
1123 #[test]
1124 fn external_profile_loads_from_evals_layout() -> Result<(), Box<dyn std::error::Error>> {
1125 let path =
1126 Path::new(env!("CARGO_MANIFEST_DIR")).join("../memory-evals/profiles/selector-v0.toml");
1127 if !path.is_file() {
1128 return Ok(());
1129 }
1130 let profile = SelectorProfile::load(&path)?;
1131 assert_eq!(profile.stage, "selector");
1132 assert_eq!(profile.model, SelectorProfile::embedded_default().model);
1133 assert_eq!(profile.prompt_template, EMBEDDED_PROMPT_V0);
1134 assert_eq!(profile.params.working_set_k, 200);
1135 Ok(())
1136 }
1137
1138 #[test]
1139 fn spec_parse_is_fail_loud() {
1140 assert_eq!(parse_spec("off").unwrap(), SelectorSpec::Off);
1141 assert_eq!(parse_spec("").unwrap(), SelectorSpec::Off);
1142 assert_eq!(parse_spec("default").unwrap(), SelectorSpec::Default);
1143 assert_eq!(
1144 parse_spec("profile:/tmp/p.toml").unwrap(),
1145 SelectorSpec::Profile(PathBuf::from("/tmp/p.toml"))
1146 );
1147 assert!(parse_spec("lexical").is_err());
1148 assert!(parse_spec("profile:").is_err());
1149 }
1150
1151 #[test]
1152 fn hard_ceiling_truncation_drops_oldest_unranked_and_names_them() {
1153 let record = |id: &str, rank: Option<u32>, age_days: u64| RecordMeta {
1154 id: id.to_string(),
1155 kind: MemoryKind::Fact,
1156 title: "t".to_string(),
1157 description: String::new(),
1158 age_days,
1159 rank,
1160 };
1161 let manifest = vec![record("a", None, 90), record("b", Some(2), 1)];
1163 let (kept, dropped) = truncate_full_manifest(manifest.clone(), 2);
1164 assert_eq!(
1165 kept.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
1166 vec!["a", "b"]
1167 );
1168 assert!(dropped.is_empty());
1169
1170 let manifest = vec![
1174 record("old-unranked", None, 90),
1175 record("rank-2", Some(2), 50),
1176 record("new-unranked", None, 0),
1177 record("rank-1", Some(1), 70),
1178 record("mid-unranked", None, 30),
1179 ];
1180 let (kept, dropped) = truncate_full_manifest(manifest, 3);
1181 assert_eq!(
1182 kept.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
1183 vec!["rank-1", "rank-2", "new-unranked"]
1184 );
1185 assert_eq!(dropped, vec!["mid-unranked", "old-unranked"]);
1186 }
1187
1188 #[test]
1189 fn chunking_respects_byte_ceiling_and_covers_everything() {
1190 let big_description = "d".repeat(390);
1191 let manifest: Vec<RecordMeta> = (0..600)
1192 .map(|i| meta(&format!("mem-{i}"), "Title", &big_description))
1193 .collect();
1194 let chunks = chunk_manifest(&manifest);
1195 assert!(chunks.len() > 1, "600 fat rows must not fit one chunk");
1196 let total: usize = chunks.iter().map(|chunk| chunk.len()).sum();
1197 assert_eq!(total, manifest.len(), "chunking must not drop rows");
1198 for chunk in &chunks {
1199 let bytes: usize = chunk
1200 .iter()
1201 .map(|meta| render_manifest_row(meta).len() + 1)
1202 .sum();
1203 assert!(
1204 bytes <= FULL_SWEEP_CHUNK_BYTES,
1205 "chunk exceeds the §8.3 per-call ceiling: {bytes}"
1206 );
1207 }
1208 }
1209}