use std::sync::Arc;
use greentic_aw_runtime::{AgentRuntime, LongTermMemory};
use greentic_dw_embedding::EmbeddingProvider;
use greentic_dw_embedding_openai_compatible::{
OpenAiCompatibleEmbeddingConfig, OpenAiCompatibleEmbeddingProvider,
};
use greentic_dw_llm::LlmProvider;
use greentic_dw_llm_openai_compatible::{OpenAiCompatibleConfig, OpenAiCompatibleProvider};
use greentic_dw_memory_chronicle::{
ChronicleBackend, ChronicleLongTermMemory, ChronicleMemoryConfig, DEFAULT_FALKOR_GRAPH,
DEFAULT_NEO4J_DATABASE,
};
use greentic_types::{EnvId, TenantCtx, TenantId};
const ENV_BACKEND: &str = "GREENTIC_CHRONICLE_BACKEND";
const ENV_NEO4J_URI: &str = "GREENTIC_CHRONICLE_NEO4J_URI";
const ENV_NEO4J_USER: &str = "GREENTIC_CHRONICLE_NEO4J_USER";
const ENV_NEO4J_PASSWORD: &str = "GREENTIC_CHRONICLE_NEO4J_PASSWORD";
const ENV_NEO4J_DATABASE: &str = "GREENTIC_CHRONICLE_NEO4J_DATABASE";
const ENV_FALKOR_URL: &str = "GREENTIC_CHRONICLE_FALKOR_URL";
const ENV_FALKOR_GRAPH: &str = "GREENTIC_CHRONICLE_FALKOR_GRAPH";
const ENV_SURREAL_PATH: &str = "GREENTIC_CHRONICLE_SURREAL_PATH";
const DEFAULT_BACKEND: &str = "surreal-embedded";
const DEFAULT_SURREAL_PATH: &str = "/var/lib/greentic/chronicle";
const ENV_LLM_BASE_URL: &str = "GREENTIC_CHRONICLE_LLM_BASE_URL";
const ENV_LLM_API_KEY: &str = "GREENTIC_CHRONICLE_LLM_API_KEY";
const ENV_LLM_MODEL: &str = "GREENTIC_CHRONICLE_LLM_MODEL";
const ENV_EMBED_BASE_URL: &str = "GREENTIC_CHRONICLE_EMBED_BASE_URL";
const ENV_EMBED_API_KEY: &str = "GREENTIC_CHRONICLE_EMBED_API_KEY";
const ENV_EMBED_MODEL: &str = "GREENTIC_CHRONICLE_EMBED_MODEL";
const ENV_EMBED_DIM: &str = "GREENTIC_CHRONICLE_EMBED_DIM";
const DEFAULT_TIMEOUT_MS: u64 = 60_000;
const DEFAULT_EMBEDDING_DIM: usize = 1024;
pub async fn attach(runtime: AgentRuntime) -> AgentRuntime {
let Some(llm) = build_llm() else {
tracing::debug!("long-term memory: LLM endpoint env unset/invalid; long-term disabled");
return runtime;
};
let Some((embedder, embedding_dim)) = build_embedder() else {
tracing::debug!(
"long-term memory: embedding endpoint env unset/invalid; long-term disabled"
);
return runtime;
};
let Some(backend) = select_backend() else {
return runtime;
};
let mut config = ChronicleMemoryConfig::with_backend(backend);
config.embedding_dim = Some(embedding_dim);
let backend_kind = config.backend.kind();
match ChronicleLongTermMemory::connect_with_dw_providers(
config,
llm,
embedder,
operator_tenant(),
)
.await
{
Ok(memory) => {
tracing::info!(
backend = %backend_kind,
"long-term memory: Chronicle attached (provider-neutral DW LLM + embeddings)"
);
runtime.with_long_term_memory(Arc::new(memory) as Arc<dyn LongTermMemory>)
}
Err(err) => {
tracing::warn!(
backend = %backend_kind,
error = %err,
"long-term memory: Chronicle connect failed; long-term disabled"
);
runtime
}
}
}
fn select_backend() -> Option<ChronicleBackend> {
let kind = std::env::var(ENV_BACKEND).unwrap_or_else(|_| DEFAULT_BACKEND.to_string());
match kind.as_str() {
"neo4j" => {
let (Ok(uri), Ok(user), Ok(password)) = (
std::env::var(ENV_NEO4J_URI),
std::env::var(ENV_NEO4J_USER),
std::env::var(ENV_NEO4J_PASSWORD),
) else {
tracing::warn!(
"long-term memory: backend=neo4j but GREENTIC_CHRONICLE_NEO4J_URI/USER/PASSWORD \
incomplete; long-term disabled"
);
return None;
};
let database = std::env::var(ENV_NEO4J_DATABASE)
.unwrap_or_else(|_| DEFAULT_NEO4J_DATABASE.to_string());
Some(ChronicleBackend::Neo4j {
uri,
user,
password,
database,
})
}
"falkor" => {
let Ok(connection) = std::env::var(ENV_FALKOR_URL) else {
tracing::warn!(
"long-term memory: backend=falkor but GREENTIC_CHRONICLE_FALKOR_URL unset; \
long-term disabled"
);
return None;
};
let graph = std::env::var(ENV_FALKOR_GRAPH)
.unwrap_or_else(|_| DEFAULT_FALKOR_GRAPH.to_string());
Some(ChronicleBackend::Falkor { connection, graph })
}
"surreal-embedded" => {
let path = std::env::var(ENV_SURREAL_PATH)
.unwrap_or_else(|_| DEFAULT_SURREAL_PATH.to_string());
Some(ChronicleBackend::surreal_embedded(path))
}
"surreal-memory" => {
tracing::warn!(
"long-term memory: backend=surreal-memory is EPHEMERAL — recalled facts are lost \
on restart"
);
Some(ChronicleBackend::surreal_memory())
}
other => {
tracing::warn!(
backend = %other,
"long-term memory: unknown GREENTIC_CHRONICLE_BACKEND \
(expected neo4j|falkor|surreal-embedded|surreal-memory); long-term disabled"
);
None
}
}
}
fn build_llm() -> Option<Arc<dyn LlmProvider>> {
let base_url = std::env::var(ENV_LLM_BASE_URL).ok()?;
let api_key = std::env::var(ENV_LLM_API_KEY).ok()?;
let model = std::env::var(ENV_LLM_MODEL).ok()?;
let mut cfg = OpenAiCompatibleConfig::new(base_url, model, DEFAULT_TIMEOUT_MS);
cfg.api_key_secret = Some(api_key);
match OpenAiCompatibleProvider::new(cfg) {
Ok(provider) => Some(Arc::new(provider)),
Err(err) => {
tracing::warn!(error = %err, "long-term memory: LLM provider config invalid");
None
}
}
}
fn build_embedder() -> Option<(Arc<dyn EmbeddingProvider>, usize)> {
let base_url = std::env::var(ENV_EMBED_BASE_URL).ok()?;
let api_key = std::env::var(ENV_EMBED_API_KEY).ok()?;
let model = std::env::var(ENV_EMBED_MODEL).ok()?;
let embedding_dim = std::env::var(ENV_EMBED_DIM)
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(DEFAULT_EMBEDDING_DIM);
let mut cfg =
OpenAiCompatibleEmbeddingConfig::new(api_key, base_url, model, DEFAULT_TIMEOUT_MS);
cfg.embedding_dim = embedding_dim;
match OpenAiCompatibleEmbeddingProvider::new(cfg) {
Ok(provider) => Some((Arc::new(provider), embedding_dim)),
Err(err) => {
tracing::warn!(error = %err, "long-term memory: embedding provider config invalid");
None
}
}
}
fn operator_tenant() -> TenantCtx {
let env = EnvId::try_from("operator").unwrap_or_else(|_| {
EnvId::try_from("dev").expect("the literal env id \"dev\" is always valid")
});
let tenant = TenantId::try_from("chronicle")
.expect("the literal tenant id \"chronicle\" is always valid");
TenantCtx::new(env, tenant)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[allow(unsafe_code)]
fn set(key: &str, val: &str) {
unsafe { std::env::set_var(key, val) };
}
#[allow(unsafe_code)]
fn unset(key: &str) {
unsafe { std::env::remove_var(key) };
}
fn clear_backend_env() {
for key in [
ENV_BACKEND,
ENV_NEO4J_URI,
ENV_NEO4J_USER,
ENV_NEO4J_PASSWORD,
ENV_NEO4J_DATABASE,
ENV_FALKOR_URL,
ENV_FALKOR_GRAPH,
ENV_SURREAL_PATH,
] {
unset(key);
}
}
#[test]
#[serial]
fn defaults_to_embedded_surreal_on_disk() {
clear_backend_env();
match select_backend() {
Some(ChronicleBackend::SurrealEmbedded { path }) => {
assert_eq!(path, DEFAULT_SURREAL_PATH);
}
other => panic!("expected default SurrealEmbedded, got {other:?}"),
}
clear_backend_env();
}
#[test]
#[serial]
fn surreal_embedded_honours_custom_path() {
clear_backend_env();
set(ENV_BACKEND, "surreal-embedded");
set(ENV_SURREAL_PATH, "/data/chronicle");
match select_backend() {
Some(ChronicleBackend::SurrealEmbedded { path }) => {
assert_eq!(path, "/data/chronicle");
}
other => panic!("expected SurrealEmbedded, got {other:?}"),
}
clear_backend_env();
}
#[test]
#[serial]
fn surreal_memory_selected() {
clear_backend_env();
set(ENV_BACKEND, "surreal-memory");
assert_eq!(select_backend().map(|b| b.kind()), Some("surreal-memory"));
clear_backend_env();
}
#[test]
#[serial]
fn neo4j_reads_all_connection_fields() {
clear_backend_env();
set(ENV_BACKEND, "neo4j");
set(ENV_NEO4J_URI, "bolt://db:7687");
set(ENV_NEO4J_USER, "neo");
set(ENV_NEO4J_PASSWORD, "secret");
set(ENV_NEO4J_DATABASE, "graph-db");
match select_backend() {
Some(ChronicleBackend::Neo4j {
uri,
user,
password,
database,
}) => {
assert_eq!(uri, "bolt://db:7687");
assert_eq!(user, "neo");
assert_eq!(password, "secret");
assert_eq!(database, "graph-db");
}
other => panic!("expected Neo4j, got {other:?}"),
}
clear_backend_env();
}
#[test]
#[serial]
fn neo4j_defaults_database_when_unset() {
clear_backend_env();
set(ENV_BACKEND, "neo4j");
set(ENV_NEO4J_URI, "bolt://db:7687");
set(ENV_NEO4J_USER, "neo");
set(ENV_NEO4J_PASSWORD, "secret");
match select_backend() {
Some(ChronicleBackend::Neo4j { database, .. }) => {
assert_eq!(database, DEFAULT_NEO4J_DATABASE);
}
other => panic!("expected Neo4j, got {other:?}"),
}
clear_backend_env();
}
#[test]
#[serial]
fn neo4j_missing_credentials_disables() {
clear_backend_env();
set(ENV_BACKEND, "neo4j");
set(ENV_NEO4J_URI, "bolt://db:7687");
assert!(select_backend().is_none());
clear_backend_env();
}
#[test]
#[serial]
fn falkor_reads_connection_and_graph() {
clear_backend_env();
set(ENV_BACKEND, "falkor");
set(ENV_FALKOR_URL, "redis://falkor:6379");
set(ENV_FALKOR_GRAPH, "episodes");
match select_backend() {
Some(ChronicleBackend::Falkor { connection, graph }) => {
assert_eq!(connection, "redis://falkor:6379");
assert_eq!(graph, "episodes");
}
other => panic!("expected Falkor, got {other:?}"),
}
clear_backend_env();
}
#[test]
#[serial]
fn falkor_defaults_graph_when_unset() {
clear_backend_env();
set(ENV_BACKEND, "falkor");
set(ENV_FALKOR_URL, "redis://falkor:6379");
match select_backend() {
Some(ChronicleBackend::Falkor { graph, .. }) => {
assert_eq!(graph, DEFAULT_FALKOR_GRAPH);
}
other => panic!("expected Falkor, got {other:?}"),
}
clear_backend_env();
}
#[test]
#[serial]
fn falkor_missing_url_disables() {
clear_backend_env();
set(ENV_BACKEND, "falkor");
assert!(select_backend().is_none());
clear_backend_env();
}
#[test]
#[serial]
fn unknown_backend_disables() {
clear_backend_env();
set(ENV_BACKEND, "cassandra");
assert!(select_backend().is_none());
clear_backend_env();
}
}