use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use crate::learn::LearnConfig;
pub(crate) struct RunManagerInner {
pub session_id: String,
pub config: LearnConfig,
call_count: AtomicU64,
ended: AtomicBool,
last_recalled_ids: Mutex<Vec<String>>,
http_client: reqwest::Client,
}
impl RunManagerInner {
pub fn new(config: LearnConfig) -> Self {
let session_id = config.session_id.clone().unwrap_or_else(|| {
uuid::Uuid::new_v4()
.to_string()
.replace('-', "")
.chars()
.take(16)
.collect()
});
Self {
session_id,
config,
call_count: AtomicU64::new(0),
ended: AtomicBool::new(false),
last_recalled_ids: Mutex::new(Vec::new()),
http_client: reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_default(),
}
}
pub fn set_recalled_ids(&self, ids: Vec<String>) {
if let Ok(mut guard) = self.last_recalled_ids.lock() {
*guard = ids.into_iter().filter(|s| !s.is_empty()).collect();
}
}
pub fn last_recalled_ids(&self) -> Vec<String> {
self.last_recalled_ids
.lock()
.map(|g| g.clone())
.unwrap_or_default()
}
pub fn increment(&self) {
let count = self.call_count.fetch_add(1, Ordering::Relaxed) + 1;
if let Some(n) = self.config.reflect_after_n_calls {
if n > 0 && count % n == 0 {
self.background_reflect();
}
}
}
pub fn call_count(&self) -> u64 {
self.call_count.load(Ordering::Relaxed)
}
pub async fn end(&self) {
if self.ended.swap(true, Ordering::SeqCst) {
return;
}
if self.config.auto_reflect {
let _ = self.do_reflect().await;
}
}
pub fn background_reflect(&self) {
let endpoint = self.config.endpoint.clone();
let api_key = self.config.api_key.clone();
let session_id = self.session_id.clone();
let client = self.http_client.clone();
tokio::spawn(async move {
let _ = Self::reflect_http(&client, &endpoint, &api_key, &session_id).await;
});
}
async fn do_reflect(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Self::reflect_http(
&self.http_client,
&self.config.endpoint,
&self.config.api_key,
&self.session_id,
)
.await
}
async fn reflect_http(
client: &reqwest::Client,
endpoint: &str,
api_key: &str,
session_id: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if api_key.is_empty() {
return Ok(());
}
let url = format!("{}/v2/control/reflect", endpoint.trim_end_matches('/'));
client
.post(&url)
.bearer_auth(api_key)
.timeout(Duration::from_secs(10))
.json(&serde_json::json!({"run_id": session_id}))
.send()
.await?;
Ok(())
}
pub async fn get_context_with_ids_http(
&self,
query: &str,
) -> Result<(String, Vec<String>), Box<dyn std::error::Error + Send + Sync>> {
if self.config.api_key.is_empty() {
return Ok((String::new(), Vec::new()));
}
let url = format!(
"{}/v2/control/context",
self.config.endpoint.trim_end_matches('/')
);
let mut payload = serde_json::json!({
"run_id": self.session_id,
"query": query,
"format": "structured",
"max_token_budget": self.config.max_token_budget,
});
if !self.config.entry_types.is_empty() {
payload["entry_types"] = serde_json::json!(self.config.entry_types);
}
if !self.config.context_sections.is_empty() {
payload["sections"] = serde_json::json!(self.config.context_sections);
}
let resp = self
.http_client
.post(&url)
.bearer_auth(&self.config.api_key)
.timeout(Duration::from_secs_f64(self.config.context_fetch_timeout.max(0.1)))
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
return Ok((String::new(), Vec::new()));
}
let body: serde_json::Value = resp.json().await?;
let block = body
.get("context_block")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let ids = body
.get("sources")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|s| s.get("id").and_then(|i| i.as_str()).map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok((block, ids))
}
pub async fn record_outcome_http(
&self,
outcome: &str,
signal: f32,
entry_ids: &[String],
verified_in_production: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if self.config.api_key.is_empty() {
return Ok(());
}
let url = format!(
"{}/v2/control/outcome",
self.config.endpoint.trim_end_matches('/')
);
let payload = outcome_payload(
&self.session_id,
&self.config.agent_id,
outcome,
signal,
entry_ids,
verified_in_production,
);
self.http_client
.post(&url)
.bearer_auth(&self.config.api_key)
.timeout(Duration::from_secs(8))
.json(&payload)
.send()
.await?;
Ok(())
}
}
pub(crate) fn outcome_payload(
session_id: &str,
agent_id: &str,
outcome: &str,
signal: f32,
entry_ids: &[String],
verified_in_production: bool,
) -> serde_json::Value {
let mut payload = serde_json::json!({
"run_id": session_id,
"reference_id": "",
"outcome": outcome,
"signal": signal,
"agent_id": agent_id,
});
if !entry_ids.is_empty() {
payload["entry_ids"] = serde_json::json!(entry_ids);
}
if verified_in_production {
payload["verified_in_production"] = serde_json::json!(true);
}
payload
}
#[cfg(test)]
mod tests {
use super::*;
use crate::learn::LearnConfig;
#[test]
fn recalled_ids_are_stashed_and_filtered() {
let inner = RunManagerInner::new(LearnConfig::default().session_id("s"));
assert!(inner.last_recalled_ids().is_empty());
inner.set_recalled_ids(vec!["a".into(), "".into(), "b".into()]);
assert_eq!(inner.last_recalled_ids(), vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn outcome_payload_shape() {
let p = outcome_payload("run1", "ag", "success", 1.0, &["e1".into(), "e2".into()], true);
assert_eq!(p["run_id"], "run1");
assert_eq!(p["reference_id"], "");
assert_eq!(p["outcome"], "success");
assert_eq!(p["signal"], 1.0);
assert_eq!(p["agent_id"], "ag");
assert_eq!(p["entry_ids"], serde_json::json!(["e1", "e2"]));
assert_eq!(p["verified_in_production"], true);
}
#[test]
fn outcome_payload_omits_optional_fields() {
let p = outcome_payload("run1", "ag", "failure", -0.5, &[], false);
assert!(p.get("entry_ids").is_none());
assert!(p.get("verified_in_production").is_none());
assert_eq!(p["outcome"], "failure");
}
}