use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use crate::config::AgentConfig;
use crate::error::ConfigError;
use crate::tenant::TenantContext;
pub trait ConfigProvider: Send + Sync {
fn agent_config<'a>(
&'a self,
tenant: &'a TenantContext,
agent_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<AgentConfig, ConfigError>> + Send + 'a>>;
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct CacheKey {
tenant_id: String,
env_id: String,
agent_id: String,
}
pub struct CachingConfigProvider<P: ConfigProvider> {
inner: P,
ttl: Duration,
cache: RwLock<HashMap<CacheKey, (Instant, AgentConfig)>>,
}
impl<P: ConfigProvider> CachingConfigProvider<P> {
pub fn new(inner: P) -> Self {
Self::with_ttl(inner, Duration::from_secs(60))
}
pub fn with_ttl(inner: P, ttl: Duration) -> Self {
Self {
inner,
ttl,
cache: RwLock::new(HashMap::new()),
}
}
}
impl<P: ConfigProvider> ConfigProvider for CachingConfigProvider<P> {
fn agent_config<'a>(
&'a self,
tenant: &'a TenantContext,
agent_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<AgentConfig, ConfigError>> + Send + 'a>> {
Box::pin(async move {
let key = CacheKey {
tenant_id: tenant.tenant_id.clone(),
env_id: tenant.env_id.clone(),
agent_id: agent_id.to_string(),
};
{
let cache = self.cache.read().await;
if let Some((stored_at, cfg)) = cache.get(&key)
&& stored_at.elapsed() < self.ttl
{
return Ok(cfg.clone());
}
}
let fresh = self.inner.agent_config(tenant, agent_id).await?;
let mut cache = self.cache.write().await;
cache.insert(key, (Instant::now(), fresh.clone()));
Ok(fresh)
})
}
}
pub struct InMemoryConfigProvider {
entries: HashMap<(String, String, String), AgentConfig>,
}
impl InMemoryConfigProvider {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
pub fn insert(&mut self, tenant: &TenantContext, agent_id: &str, cfg: AgentConfig) {
self.entries.insert(
(
tenant.tenant_id.clone(),
tenant.env_id.clone(),
agent_id.to_string(),
),
cfg,
);
}
}
impl Default for InMemoryConfigProvider {
fn default() -> Self {
Self::new()
}
}
impl ConfigProvider for InMemoryConfigProvider {
fn agent_config<'a>(
&'a self,
tenant: &'a TenantContext,
agent_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<AgentConfig, ConfigError>> + Send + 'a>> {
let key = (
tenant.tenant_id.clone(),
tenant.env_id.clone(),
agent_id.to_string(),
);
let entry = self.entries.get(&key).cloned();
let agent_id_owned = agent_id.to_string();
Box::pin(async move { entry.ok_or(ConfigError::AgentNotFound(agent_id_owned)) })
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::config::{AgentLimits, LlmProviderRef};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
fn sample_cfg() -> AgentConfig {
AgentConfig {
agent_id: "a-1".into(),
system_prompt: "sys".into(),
tools: vec![],
guardrails: vec![],
llm: LlmProviderRef {
provider: "openai".into(),
model: "gpt-4o-mini".into(),
credential_ref: None,
},
limits: AgentLimits::default(),
memory: None,
knowledge: None,
}
}
struct CountingProvider {
calls: AtomicUsize,
cfg: AgentConfig,
}
impl ConfigProvider for CountingProvider {
fn agent_config<'a>(
&'a self,
_tenant: &'a TenantContext,
_agent_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<AgentConfig, ConfigError>> + Send + 'a>> {
self.calls.fetch_add(1, Ordering::SeqCst);
let cfg = self.cfg.clone();
Box::pin(async move { Ok(cfg) })
}
}
struct Wrapper(Arc<CountingProvider>);
impl ConfigProvider for Wrapper {
fn agent_config<'a>(
&'a self,
tenant: &'a TenantContext,
agent_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<AgentConfig, ConfigError>> + Send + 'a>> {
self.0.agent_config(tenant, agent_id)
}
}
#[tokio::test]
async fn caching_provider_hits_inner_once_within_ttl() {
let inner = Arc::new(CountingProvider {
calls: AtomicUsize::new(0),
cfg: sample_cfg(),
});
let cache = CachingConfigProvider::new(Wrapper(inner.clone()));
let tc = TenantContext::new("acme", "prod");
let _ = cache.agent_config(&tc, "a-1").await.unwrap();
let _ = cache.agent_config(&tc, "a-1").await.unwrap();
let _ = cache.agent_config(&tc, "a-1").await.unwrap();
assert_eq!(inner.calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn caching_provider_expires_after_ttl() {
let inner = Arc::new(CountingProvider {
calls: AtomicUsize::new(0),
cfg: sample_cfg(),
});
let cache =
CachingConfigProvider::with_ttl(Wrapper(inner.clone()), Duration::from_millis(50));
let tc = TenantContext::new("acme", "prod");
let _ = cache.agent_config(&tc, "a-1").await.unwrap();
tokio::time::sleep(Duration::from_millis(80)).await;
let _ = cache.agent_config(&tc, "a-1").await.unwrap();
assert_eq!(inner.calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn in_memory_provider_returns_not_found_for_missing_agent() {
let p = InMemoryConfigProvider::new();
let tc = TenantContext::new("acme", "prod");
let result = p.agent_config(&tc, "missing").await;
assert!(matches!(result, Err(ConfigError::AgentNotFound(_))));
}
}