use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use meerkat_client::{FactoryError, LlmClient};
use meerkat_core::{Provider, SessionLlmIdentity};
use crate::identity_first::agent_memory::{
AgentMemoryError, AgentMemoryRecord, compact_whitespace,
};
use crate::memory::records::{MemoryScope, RecordMeta, TrustTier};
#[derive(Debug)]
pub enum ModelClientError {
Auth(String),
Client(String),
}
impl std::fmt::Display for ModelClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Auth(msg) => write!(f, "model client auth error: {msg}"),
Self::Client(msg) => write!(f, "model client error: {msg}"),
}
}
}
impl std::error::Error for ModelClientError {}
#[async_trait]
pub trait ModelClientHandle: Send + Sync {
async fn client(&self) -> Result<Arc<dyn LlmClient>, ModelClientError>;
fn invalidate(&self);
}
pub struct FactoryModelClientHandle {
factory: meerkat::AgentFactory,
config: meerkat::Config,
realm: String,
identity: SessionLlmIdentity,
cache: Mutex<HashMap<(String, String), Arc<dyn LlmClient>>>,
}
impl FactoryModelClientHandle {
pub fn for_model(
store_path: impl Into<PathBuf>,
config: meerkat::Config,
realm: impl Into<String>,
model: &str,
provider: Provider,
) -> Self {
Self {
factory: meerkat::AgentFactory::new(store_path.into()),
config,
realm: realm.into(),
identity: SessionLlmIdentity {
model: model.to_string(),
provider,
self_hosted_server_id: None,
provider_params: None,
auth_binding: None,
},
cache: Mutex::new(HashMap::new()),
}
}
}
#[async_trait]
impl ModelClientHandle for FactoryModelClientHandle {
async fn client(&self) -> Result<Arc<dyn LlmClient>, ModelClientError> {
let key = (self.realm.clone(), self.identity.model.clone());
if let Some(client) = self
.cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&key)
{
return Ok(client.clone());
}
let client = self
.factory
.build_llm_client_for_identity(&self.config, &self.identity)
.await
.map_err(|err| match err {
FactoryError::ProviderAuth(_) | FactoryError::ConnectionTarget(_) => {
ModelClientError::Auth(err.to_string())
}
other => ModelClientError::Client(other.to_string()),
})?;
self.cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(key, client.clone());
Ok(client)
}
fn invalidate(&self) {
self.cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear();
}
}
pub(crate) fn render_manifest_row(meta: &RecordMeta) -> String {
let rank = match meta.rank {
Some(rank) => format!("rank {rank}"),
None => "unranked".to_string(),
};
let mut row = format!(
"- {} [{}, {}, {}] {}",
meta.id,
meta.kind.as_str(),
age_phrase(meta.age_days),
rank,
compact_whitespace(&meta.title),
);
let description = compact_whitespace(&meta.description);
if !description.is_empty() {
row.push_str(" — ");
row.push_str(&description);
}
row
}
fn age_phrase(age_days: u64) -> String {
match age_days {
0 => "saved today".to_string(),
1 => "saved 1 day ago".to_string(),
n => format!("saved {n} days ago"),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordProvenance {
pub scope: MemoryScope,
pub trust: TrustTier,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnnotatedRecord {
pub record: AgentMemoryRecord,
pub provenance: Option<RecordProvenance>,
}
#[async_trait]
pub trait SelectedRecordFetch: Send + Sync {
async fn fetch_records(
&self,
scopes: &[MemoryScope],
ids: &[String],
) -> Result<Vec<AgentMemoryRecord>, AgentMemoryError>;
async fn fetch_records_annotated(
&self,
scopes: &[MemoryScope],
ids: &[String],
) -> Result<Vec<AnnotatedRecord>, AgentMemoryError> {
Ok(self
.fetch_records(scopes, ids)
.await?
.into_iter()
.map(|record| AnnotatedRecord {
record,
provenance: None,
})
.collect())
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::memory::records::MemoryKind;
fn meta(id: &str, title: &str, description: &str, age_days: u64) -> RecordMeta {
RecordMeta {
id: id.to_string(),
kind: MemoryKind::Gotcha,
title: title.to_string(),
description: description.to_string(),
age_days,
rank: Some(1),
}
}
#[test]
fn manifest_row_renders_id_kind_age_rank_title_and_description() {
let row = render_manifest_row(&meta("mem-1", "Cargo wrapper", "When running cargo", 3));
assert!(
row.starts_with("- mem-1 [gotcha, saved 3 days ago, rank 1] Cargo wrapper"),
"{row}"
);
assert!(row.contains("When running cargo"), "{row}");
}
#[test]
fn manifest_row_omits_the_description_separator_when_empty() {
let row = render_manifest_row(&meta("mem-2", "Titled only", " ", 0));
assert!(row.ends_with("Titled only"), "{row}");
}
#[test]
fn manifest_row_age_phrases_singular_and_plural() {
assert!(render_manifest_row(&meta("a", "t", "", 0)).contains("saved today"));
assert!(render_manifest_row(&meta("b", "t", "", 1)).contains("saved 1 day ago"));
assert!(render_manifest_row(&meta("c", "t", "", 9)).contains("saved 9 days ago"));
}
#[test]
fn manifest_row_marks_unranked_records() {
let mut unranked = meta("mem-3", "No rank", "", 2);
unranked.rank = None;
assert!(
render_manifest_row(&unranked).contains("unranked"),
"{}",
render_manifest_row(&unranked)
);
}
}