mod cache;
pub mod injection;
mod run_manager;
use std::sync::Arc;
use std::time::Duration;
use serde_json::Value;
use cache::LessonCache;
use injection::{extract_query, inject_context_openai};
use run_manager::RunManagerInner;
#[derive(Clone, Debug)]
pub struct LearnConfig {
pub api_key: String,
pub endpoint: String,
pub agent_id: String,
pub user_id: String,
pub session_id: Option<String>,
pub inject_lessons: bool,
pub injection_position: String,
pub max_token_budget: u32,
pub entry_types: Vec<String>,
pub context_sections: Vec<String>,
pub lane: String,
pub auto_reflect: bool,
pub reflect_after_n_calls: Option<u64>,
pub cache_ttl_seconds: f64,
pub cache_max_entries: usize,
pub fail_open: bool,
pub context_fetch_timeout: f64,
pub include_mental_models: bool,
}
impl Default for LearnConfig {
fn default() -> Self {
Self {
api_key: String::new(),
endpoint: "http://127.0.0.1:3000".to_string(),
agent_id: "auto".to_string(),
user_id: String::new(),
session_id: None,
inject_lessons: true,
injection_position: "system".to_string(),
max_token_budget: 2048,
entry_types: Vec::new(),
context_sections: Vec::new(),
lane: String::new(),
auto_reflect: true,
reflect_after_n_calls: None,
cache_ttl_seconds: 30.0,
cache_max_entries: 100,
fail_open: true,
context_fetch_timeout: 5.0,
include_mental_models: true,
}
}
}
impl LearnConfig {
pub fn from_env() -> Self {
let api_key = std::env::var("MUBIT_API_KEY").unwrap_or_default();
let endpoint = std::env::var("MUBIT_ENDPOINT")
.unwrap_or_else(|_| "http://127.0.0.1:3000".to_string());
let mut cfg = Self {
api_key,
endpoint: endpoint.trim_end_matches('/').to_string(),
..Default::default()
};
if let Ok(t) = std::env::var("MUBIT_LEARN_CONTEXT_TIMEOUT") {
if let Ok(secs) = t.parse::<f64>() {
cfg.context_fetch_timeout = secs;
}
}
cfg
}
pub fn api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = key.into();
self
}
pub fn endpoint(mut self, ep: impl Into<String>) -> Self {
self.endpoint = ep.into().trim_end_matches('/').to_string();
self
}
pub fn agent_id(mut self, id: impl Into<String>) -> Self {
self.agent_id = id.into();
self
}
pub fn user_id(mut self, id: impl Into<String>) -> Self {
self.user_id = id.into();
self
}
pub fn session_id(mut self, id: impl Into<String>) -> Self {
self.session_id = Some(id.into());
self
}
pub fn inject_lessons(mut self, val: bool) -> Self {
self.inject_lessons = val;
self
}
pub fn injection_position(mut self, pos: impl Into<String>) -> Self {
self.injection_position = pos.into();
self
}
pub fn max_token_budget(mut self, budget: u32) -> Self {
self.max_token_budget = budget;
self
}
pub fn entry_types(mut self, types: Vec<String>) -> Self {
self.entry_types = types;
self
}
pub fn context_sections(mut self, sections: Vec<String>) -> Self {
self.context_sections = sections;
self
}
pub fn lane(mut self, lane: impl Into<String>) -> Self {
self.lane = lane.into();
self
}
pub fn auto_reflect(mut self, val: bool) -> Self {
self.auto_reflect = val;
self
}
pub fn reflect_after_n_calls(mut self, n: u64) -> Self {
self.reflect_after_n_calls = Some(n);
self
}
pub fn cache_ttl_seconds(mut self, ttl: f64) -> Self {
self.cache_ttl_seconds = ttl;
self
}
pub fn fail_open(mut self, val: bool) -> Self {
self.fail_open = val;
self
}
pub fn context_fetch_timeout(mut self, secs: f64) -> Self {
self.context_fetch_timeout = secs;
self
}
}
pub struct LearnSession {
inner: Arc<RunManagerInner>,
cache: Arc<LessonCache>,
}
impl LearnSession {
pub async fn new(config: LearnConfig) -> Self {
let ttl = Duration::from_secs_f64(config.cache_ttl_seconds);
let max_entries = config.cache_max_entries;
Self {
inner: Arc::new(RunManagerInner::new(config)),
cache: Arc::new(LessonCache::new(ttl, max_entries)),
}
}
pub fn session_id(&self) -> &str {
&self.inner.session_id
}
pub fn call_count(&self) -> u64 {
self.inner.call_count()
}
pub async fn enrich_messages(&self, messages: &[Value]) -> Vec<Value> {
if !self.inner.config.inject_lessons {
return messages.to_vec();
}
let query = extract_query(messages, 200);
if query.is_empty() {
return messages.to_vec();
}
let (context_block, ids) =
if let Some((cached, cached_ids)) = self.cache.get(&self.inner.session_id, &query).await {
(cached, cached_ids)
} else {
match self.inner.get_context_with_ids_http(&query).await {
Ok((block, ids)) => {
self.cache
.set(&self.inner.session_id, &query, block.clone(), ids.clone())
.await;
(block, ids)
}
Err(_) if self.inner.config.fail_open => (String::new(), Vec::new()),
Err(e) => {
eprintln!("mubit.learn: context retrieval failed: {e}");
(String::new(), Vec::new())
}
}
};
self.inner.set_recalled_ids(ids);
if context_block.is_empty() {
messages.to_vec()
} else {
inject_context_openai(
messages,
&context_block,
&self.inner.config.injection_position,
)
}
}
pub async fn record(&self, _response_text: &str, _model: &str, _latency_ms: f64) {
self.inner.increment();
}
pub async fn feedback(&self, signal: f32) {
self.feedback_entries(signal, None, false).await;
}
pub async fn feedback_entries(
&self,
signal: f32,
entry_ids: Option<Vec<String>>,
verified_in_production: bool,
) {
let ids: Vec<String> = entry_ids
.unwrap_or_else(|| self.inner.last_recalled_ids())
.into_iter()
.filter(|s| !s.is_empty())
.collect();
if ids.is_empty() {
return;
}
let (outcome, signal) = normalized_outcome(signal);
let _ = self
.inner
.record_outcome_http(outcome, signal, &ids, verified_in_production)
.await;
}
pub fn last_recalled_ids(&self) -> Vec<String> {
self.inner.last_recalled_ids()
}
pub async fn end(&self) {
self.inner.end().await;
}
}
pub(crate) fn normalized_outcome(signal: f32) -> (&'static str, f32) {
let s = signal.clamp(-1.0, 1.0);
(if s >= 0.0 { "success" } else { "failure" }, s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_default_context_timeout() {
assert_eq!(LearnConfig::default().context_fetch_timeout, 5.0);
}
#[test]
fn config_builder_sets_context_timeout() {
assert_eq!(
LearnConfig::default().context_fetch_timeout(3.0).context_fetch_timeout,
3.0
);
}
#[test]
fn normalized_outcome_clamps_and_labels() {
assert_eq!(normalized_outcome(2.5), ("success", 1.0));
assert_eq!(normalized_outcome(0.0), ("success", 0.0));
assert_eq!(normalized_outcome(-0.4), ("failure", -0.4));
assert_eq!(normalized_outcome(-9.0), ("failure", -1.0));
}
#[test]
fn injection_wraps_block_and_extracts_query() {
let msgs = vec![
serde_json::json!({"role": "system", "content": "You are helpful."}),
serde_json::json!({"role": "user", "content": "what timeout?"}),
];
let out = inject_context_openai(&msgs, "lesson A", "system");
let joined = serde_json::to_string(&out).unwrap();
assert!(joined.contains("<memory_context>"));
assert!(joined.contains("You are helpful."));
assert_eq!(extract_query(&msgs, 200), "what timeout?");
}
#[tokio::test]
async fn feedback_is_noop_without_recalled_ids() {
let session =
LearnSession::new(LearnConfig::default().session_id("t").auto_reflect(false)).await;
session.feedback(1.0).await; assert!(session.last_recalled_ids().is_empty());
}
}