use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::provider::ProviderId;
use crate::runtime::{AgentRuntime, ContextPipeline, RuntimePolicy, RuntimeStore, ToolRuntime};
pub mod loader;
pub mod provider;
pub mod runtime;
pub mod store;
#[cfg(feature = "server")]
pub mod grpc;
#[cfg(feature = "rag")]
pub mod rag;
#[cfg(feature = "queue")]
pub mod queue;
pub use loader::ConfigLoader;
pub use provider::{ProviderConfig, ProviderType};
pub use runtime::{RuntimeConfig, RuntimePolicyConfig};
pub use store::{StoreBackend, StoreConfig};
#[cfg(feature = "server")]
pub use grpc::GrpcConfig;
#[cfg(feature = "rag")]
pub use rag::RagConfig;
#[cfg(feature = "queue")]
pub use queue::{QueueBackend, QueueConfig};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentConfig {
#[serde(default)]
pub runtime: RuntimeConfig,
#[serde(default)]
pub providers: HashMap<ProviderId, ProviderConfig>,
#[serde(default)]
pub stores: StoreConfig,
#[cfg(feature = "rag")]
#[serde(default)]
pub rag: Option<RagConfig>,
#[cfg(feature = "queue")]
#[serde(default)]
pub queue: Option<QueueConfig>,
#[cfg(feature = "server")]
#[serde(default)]
pub grpc: GrpcConfig,
}
impl AgentConfig {
#[must_use]
pub fn builder() -> AgentConfigBuilder {
AgentConfigBuilder::default()
}
pub async fn into_runtime(self) -> Result<AgentRuntime> {
self.into_builder().build_runtime().await
}
#[must_use]
pub fn into_builder(self) -> AgentConfigBuilder {
AgentConfigBuilder::from_config(self)
}
pub fn validate(&self) -> Result<()> {
let stores = &self.stores;
let needs_redis = stores.session_backend == StoreBackend::Redis
|| stores.execution_backend == StoreBackend::Redis
|| stores.run_backend == StoreBackend::Redis
|| stores.artifact_backend == StoreBackend::Redis;
if needs_redis && stores.redis_url.is_none() {
return Err(Error::Config(
"redis_url is required when any store backend is Redis".to_owned(),
));
}
if stores.embedding_backend == StoreBackend::Redis && stores.redis_url.is_none() {
return Err(Error::Config(
"redis_url is required when embedding backend is Redis".to_owned(),
));
}
#[cfg(feature = "rag")]
if let Some(ref rag) = self.rag {
if !self.providers.contains_key(&rag.provider_id) {
return Err(Error::Config(format!(
"RAG provider '{}' is not configured in [providers]",
rag.provider_id
)));
}
}
#[cfg(feature = "queue")]
if let Some(ref queue) = self.queue {
match queue.backend {
QueueBackend::Nats => {
if queue.nats_url.is_none() {
return Err(Error::Config(
"nats_url is required for NATS queue backend".to_owned(),
));
}
}
QueueBackend::RedisStreams => {
if queue.redis_url.is_none() {
return Err(Error::Config(
"redis_url is required for Redis Streams queue backend".to_owned(),
));
}
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct AgentConfigBuilder {
config: AgentConfig,
file_sources: Vec<String>,
env_prefixes: Vec<String>,
}
impl AgentConfigBuilder {
fn from_config(config: AgentConfig) -> Self {
Self {
config,
file_sources: Vec::new(),
env_prefixes: Vec::new(),
}
}
pub fn with_file(mut self, path: impl Into<String>) -> Result<Self> {
let path = path.into();
let file_value: serde_json::Value = loader::load_file(&path)?;
let mut base_value = serde_json::to_value(&self.config)
.map_err(|e| Error::Config(format!("failed to serialize base config: {e}")))?;
loader::merge_json(&mut base_value, file_value);
loader::substitute_json(&mut base_value);
self.config = serde_json::from_value(base_value)
.map_err(|e| Error::Config(format!("failed to deserialize merged config: {e}")))?;
self.file_sources.push(path);
Ok(self)
}
pub fn with_env(mut self, prefix: impl Into<String>) -> Result<Self> {
let prefix = prefix.into();
let env_value: serde_json::Value = loader::load_env(&prefix)?;
let mut base_value = serde_json::to_value(&self.config)
.map_err(|e| Error::Config(format!("failed to serialize base config: {e}")))?;
loader::merge_json(&mut base_value, env_value);
loader::substitute_json(&mut base_value);
self.config = serde_json::from_value(base_value)
.map_err(|e| Error::Config(format!("failed to deserialize merged config: {e}")))?;
self.env_prefixes.push(prefix);
Ok(self)
}
#[must_use]
pub fn with_runtime(mut self, runtime: RuntimeConfig) -> Self {
self.config.runtime = runtime;
self
}
#[must_use]
pub fn with_provider(mut self, id: impl Into<ProviderId>, config: ProviderConfig) -> Self {
self.config.providers.insert(id.into(), config);
self
}
#[must_use]
pub fn with_stores(mut self, stores: StoreConfig) -> Self {
self.config.stores = stores;
self
}
#[cfg(feature = "rag")]
#[must_use]
pub fn with_rag(mut self, rag: RagConfig) -> Self {
self.config.rag = Some(rag);
self
}
#[cfg(feature = "queue")]
#[must_use]
pub fn with_queue(mut self, queue: QueueConfig) -> Self {
self.config.queue = Some(queue);
self
}
pub fn build(mut self) -> Result<AgentConfig> {
let mut value = serde_json::to_value(&self.config).map_err(|e| {
Error::Config(format!(
"failed to serialize config for placeholder substitution: {e}"
))
})?;
loader::substitute_json(&mut value);
self.config = serde_json::from_value(value).map_err(|e| {
Error::Config(format!(
"failed to deserialize config after placeholder substitution: {e}"
))
})?;
self.config.validate()?;
Ok(self.config)
}
pub async fn build_runtime(self) -> Result<AgentRuntime> {
let config = self.build()?;
let policy: RuntimePolicy = config.runtime.policy.into();
#[allow(unused_mut)]
let mut registry = crate::provider::ProviderRegistry::new();
for (id, provider_config) in &config.providers {
let http_config = provider_config.to_http_config(id.clone());
match &provider_config.provider_type {
#[cfg(feature = "openai")]
Some(ProviderType::OpenAi) => {
let chat_adapter = crate::adapt::openai::OpenAiChatAdapter::new(
http_config.clone(),
)
.map_err(|e| {
Error::Config(format!(
"failed to create OpenAI chat adapter for '{id}': {e}"
))
})?;
registry.register_chat(chat_adapter);
let embed_adapter = crate::adapt::openai::OpenAiEmbeddingAdapter::new(
http_config,
)
.map_err(|e| {
Error::Config(format!(
"failed to create OpenAI embedding adapter for '{id}': {e}"
))
})?;
registry.register_embedding(embed_adapter);
tracing::info!(%id, "registered OpenAI chat + embedding provider");
}
#[cfg(feature = "anthropic")]
Some(ProviderType::Anthropic) => {
let chat_adapter =
crate::adapt::anthropic::AnthropicChatAdapter::new(http_config.clone())
.map_err(|e| {
Error::Config(format!(
"failed to create Anthropic chat adapter for '{id}': {e}"
))
})?;
registry.register_chat(chat_adapter);
tracing::info!(%id, "registered Anthropic chat provider");
}
None => {
tracing::info!(%id, base_url = %http_config.base_url, "provider configured without adapter type; manual registration required");
}
#[allow(unreachable_patterns)]
_ => {
tracing::debug!(%id, "provider adapter type not available with current feature flags");
}
}
}
let sessions = build_session_store(&config.stores).await?;
let executions = build_execution_store(&config.stores).await?;
let runs = crate::runtime::memory::MemoryRunStore::new();
let store = std::sync::Arc::new(RuntimeStore::new(sessions, executions, Box::new(runs)));
#[allow(unused_mut)]
let mut context =
ContextPipeline::new().with_max_history(config.runtime.max_history_messages);
#[cfg(feature = "rag")]
if let Some(ref rag_config) = config.rag {
let provider = registry.embedding(&rag_config.provider_id).ok_or_else(|| {
Error::Config(format!(
"RAG embedding provider '{}' not found in registry; register an embedding-capable provider first",
rag_config.provider_id
))
})?;
let embedding_store: std::sync::Arc<dyn crate::store::EmbeddingStore> =
build_embedding_store(&config.stores).await?;
let adapter = crate::rag::RagContextAdapter::new(
provider,
embedding_store,
rag_config.model.clone(),
)
.with_limit(rag_config.limit)
.with_template(rag_config.template.clone())
.with_metadata_field(rag_config.metadata_field.clone());
context.register_arc(std::sync::Arc::new(adapter));
}
let tool_registry = crate::tool::ToolRegistry::new();
let tool_runtime = ToolRuntime::new(tool_registry, policy.clone());
#[allow(unused_mut)]
let mut runtime = AgentRuntime::new(registry, context, tool_runtime, store, policy);
#[cfg(feature = "queue")]
if let Some(ref queue_config) = config.queue {
let publisher = build_event_publisher(queue_config).await?;
runtime = runtime.with_event_publisher(std::sync::Arc::from(publisher));
}
Ok(runtime)
}
}
#[allow(clippy::too_many_lines, clippy::unused_async)]
async fn build_session_store(config: &StoreConfig) -> Result<Box<dyn crate::store::SessionStore>> {
match config.session_backend {
StoreBackend::Memory => Ok(Box::new(crate::store::memory::MemorySessionStore::new())),
StoreBackend::Redis => {
#[cfg(feature = "redis")]
{
let url = config.redis_url.as_deref().ok_or_else(|| {
Error::Config("redis_url is required for Redis session store".to_owned())
})?;
let store = crate::store::redis::RedisSessionStore::new(url).map_err(|e| {
Error::Config(format!("failed to create Redis session store: {e}"))
})?;
Ok(Box::new(store))
}
#[cfg(not(feature = "redis"))]
Err(Error::Config(
"Redis session store requires the 'redis' feature".to_owned(),
))
}
StoreBackend::Sql => {
#[cfg(feature = "sqlx-postgres")]
{
let url = config.sql_url.as_deref().ok_or_else(|| {
Error::Config("sql_url is required for SQL session store".to_owned())
})?;
let pool = sqlx::PgPool::connect(url)
.await
.map_err(|e| Error::Config(format!("failed to connect to PostgreSQL: {e}")))?;
Ok(Box::new(crate::store::sql::SqlSessionStore::new(pool)))
}
#[cfg(all(feature = "sqlx-mysql", not(feature = "sqlx-postgres")))]
{
let url = config.sql_url.as_deref().ok_or_else(|| {
Error::Config("sql_url is required for SQL session store".to_owned())
})?;
let pool = sqlx::MySqlPool::connect(url)
.await
.map_err(|e| Error::Config(format!("failed to connect to MySQL: {e}")))?;
Ok(Box::new(crate::store::sql::SqlSessionStore::new(pool)))
}
#[cfg(all(
feature = "sqlx-sqlite",
not(feature = "sqlx-postgres"),
not(feature = "sqlx-mysql")
))]
{
let url = config.sql_url.as_deref().ok_or_else(|| {
Error::Config("sql_url is required for SQL session store".to_owned())
})?;
let pool = sqlx::SqlitePool::connect(url)
.await
.map_err(|e| Error::Config(format!("failed to connect to SQLite: {e}")))?;
Ok(Box::new(crate::store::sql::SqlSessionStore::new(pool)))
}
#[cfg(not(any(
feature = "sqlx-postgres",
feature = "sqlx-mysql",
feature = "sqlx-sqlite"
)))]
Err(Error::Config(
"SQL session store requires a sqlx-* feature".to_owned(),
))
}
StoreBackend::Mongo => {
#[cfg(feature = "mongodb")]
{
let url = config.mongo_url.as_deref().ok_or_else(|| {
Error::Config("mongo_url is required for MongoDB session store".to_owned())
})?;
let store = crate::store::mongodb::MongodbSessionStore::new(url, "behest")
.await
.map_err(|e| {
Error::Config(format!("failed to create MongoDB session store: {e}"))
})?;
Ok(Box::new(store))
}
#[cfg(not(feature = "mongodb"))]
Err(Error::Config(
"MongoDB session store requires the 'mongodb' feature".to_owned(),
))
}
StoreBackend::Surreal => {
#[cfg(feature = "surrealdb")]
{
let url = config.surreal_url.as_deref().ok_or_else(|| {
Error::Config("surreal_url is required for SurrealDB session store".to_owned())
})?;
let db = surrealdb::engine::any::connect(url)
.await
.map_err(|e| Error::Config(format!("failed to connect to SurrealDB: {e}")))?;
db.use_ns("behest").use_db("behest").await.map_err(|e| {
Error::Config(format!(
"failed to select SurrealDB namespace/database: {e}"
))
})?;
Ok(Box::new(
crate::store::surrealdb::SurrealdbSessionStore::new(db),
))
}
#[cfg(not(feature = "surrealdb"))]
Err(Error::Config(
"SurrealDB session store requires the 'surrealdb' feature".to_owned(),
))
}
}
}
#[allow(clippy::unused_async)]
async fn build_execution_store(
config: &StoreConfig,
) -> Result<Box<dyn crate::store::ExecutionStore>> {
match config.execution_backend {
StoreBackend::Memory => Ok(Box::new(crate::store::memory::MemoryExecutionStore::new())),
StoreBackend::Redis => Err(Error::Config(
"Redis execution store is not supported".to_owned(),
)),
StoreBackend::Sql => {
#[cfg(feature = "sqlx-postgres")]
{
let url = config.sql_url.as_deref().ok_or_else(|| {
Error::Config("sql_url is required for SQL execution store".to_owned())
})?;
let pool = sqlx::PgPool::connect(url)
.await
.map_err(|e| Error::Config(format!("failed to connect to PostgreSQL: {e}")))?;
Ok(Box::new(crate::store::sql::SqlExecutionStore::new(pool)))
}
#[cfg(all(feature = "sqlx-mysql", not(feature = "sqlx-postgres")))]
{
let url = config.sql_url.as_deref().ok_or_else(|| {
Error::Config("sql_url is required for SQL execution store".to_owned())
})?;
let pool = sqlx::MySqlPool::connect(url)
.await
.map_err(|e| Error::Config(format!("failed to connect to MySQL: {e}")))?;
Ok(Box::new(crate::store::sql::SqlExecutionStore::new(pool)))
}
#[cfg(all(
feature = "sqlx-sqlite",
not(feature = "sqlx-postgres"),
not(feature = "sqlx-mysql")
))]
{
let url = config.sql_url.as_deref().ok_or_else(|| {
Error::Config("sql_url is required for SQL execution store".to_owned())
})?;
let pool = sqlx::SqlitePool::connect(url)
.await
.map_err(|e| Error::Config(format!("failed to connect to SQLite: {e}")))?;
Ok(Box::new(crate::store::sql::SqlExecutionStore::new(pool)))
}
#[cfg(not(any(
feature = "sqlx-postgres",
feature = "sqlx-mysql",
feature = "sqlx-sqlite"
)))]
Err(Error::Config(
"SQL execution store requires a sqlx-* feature".to_owned(),
))
}
StoreBackend::Mongo => Err(Error::Config(
"MongoDB execution store is not supported".to_owned(),
)),
StoreBackend::Surreal => Err(Error::Config(
"SurrealDB execution store is not supported".to_owned(),
)),
}
}
#[cfg(feature = "rag")]
async fn build_embedding_store(
config: &StoreConfig,
) -> Result<std::sync::Arc<dyn crate::store::EmbeddingStore>> {
match config.embedding_backend {
StoreBackend::Memory => Ok(std::sync::Arc::new(
crate::store::memory::MemoryEmbeddingStore::new(),
)),
StoreBackend::Sql => {
#[cfg(feature = "sqlx-postgres")]
{
let url = config.sql_url.as_deref().ok_or_else(|| {
Error::Config("sql_url is required for SQL embedding store".to_owned())
})?;
let pool = sqlx::PgPool::connect(url)
.await
.map_err(|e| Error::Config(format!("failed to connect to PostgreSQL: {e}")))?;
Ok(std::sync::Arc::new(
crate::store::sql::SqlEmbeddingStore::new(pool),
))
}
#[cfg(not(feature = "sqlx-postgres"))]
Err(Error::Config(
"SQL embedding store requires the 'sqlx-postgres' feature (pgvector)".to_owned(),
))
}
StoreBackend::Redis | StoreBackend::Mongo | StoreBackend::Surreal => {
Err(Error::Config(format!(
"embedding store backend '{:?}' is not supported via auto-config",
config.embedding_backend
)))
}
}
}
#[cfg(feature = "queue")]
#[allow(clippy::unused_async)]
async fn build_event_publisher(
config: &queue::QueueConfig,
) -> Result<Box<dyn crate::queue::EventPublisher>> {
match config.backend {
queue::QueueBackend::Nats => {
#[cfg(feature = "nats")]
{
let url = config.nats_url.as_deref().ok_or_else(|| {
Error::Config("NATS URL is required for NATS queue backend".to_owned())
})?;
let publisher =
crate::queue::NatsEventPublisher::connect(url, &config.nats_subject)
.await
.map_err(|e| {
Error::Config(format!("failed to connect NATS publisher: {e}"))
})?;
Ok(Box::new(publisher))
}
#[cfg(not(feature = "nats"))]
Err(Error::Config(
"NATS queue backend selected but 'nats' feature is not enabled".to_owned(),
))
}
queue::QueueBackend::RedisStreams => {
#[cfg(feature = "redis")]
{
let url = config.redis_url.as_deref().ok_or_else(|| {
Error::Config(
"Redis URL is required for Redis Streams queue backend".to_owned(),
)
})?;
let publisher =
crate::queue::RedisStreamsPublisher::connect(url, &config.redis_stream_key)
.await
.map_err(|e| {
Error::Config(format!("failed to connect Redis Streams publisher: {e}"))
})?;
Ok(Box::new(publisher))
}
#[cfg(not(feature = "redis"))]
Err(Error::Config(
"Redis Streams queue backend selected but 'redis' feature is not enabled"
.to_owned(),
))
}
}
}
#[allow(dead_code, clippy::too_many_lines)]
fn merge_configs(base: AgentConfig, overlay: AgentConfig) -> AgentConfig {
let mut merged = base;
if overlay.runtime.max_history_messages != 50 {
merged.runtime.max_history_messages = overlay.runtime.max_history_messages;
}
if overlay.runtime.event_channel_capacity != 256 {
merged.runtime.event_channel_capacity = overlay.runtime.event_channel_capacity;
}
if overlay.runtime.policy.max_iterations != 10 {
merged.runtime.policy.max_iterations = overlay.runtime.policy.max_iterations;
}
if overlay.runtime.policy.max_tokens.is_some() {
merged.runtime.policy.max_tokens = overlay.runtime.policy.max_tokens;
}
if overlay.runtime.policy.max_tool_concurrency != 4 {
merged.runtime.policy.max_tool_concurrency = overlay.runtime.policy.max_tool_concurrency;
}
if overlay.runtime.policy.tool_timeout_secs != 30 {
merged.runtime.policy.tool_timeout_secs = overlay.runtime.policy.tool_timeout_secs;
}
if overlay.runtime.policy.provider_timeout_secs != 60 {
merged.runtime.policy.provider_timeout_secs = overlay.runtime.policy.provider_timeout_secs;
}
if !overlay.runtime.policy.continue_on_tool_failure {
merged.runtime.policy.continue_on_tool_failure = false;
}
if !overlay.runtime.policy.retry_on_provider_error {
merged.runtime.policy.retry_on_provider_error = false;
}
if overlay.runtime.policy.max_retries != 2 {
merged.runtime.policy.max_retries = overlay.runtime.policy.max_retries;
}
if overlay.stores.session_backend != StoreBackend::Memory {
merged.stores.session_backend = overlay.stores.session_backend;
}
if overlay.stores.execution_backend != StoreBackend::Memory {
merged.stores.execution_backend = overlay.stores.execution_backend;
}
if overlay.stores.run_backend != StoreBackend::Memory {
merged.stores.run_backend = overlay.stores.run_backend;
}
if overlay.stores.embedding_backend != StoreBackend::Memory {
merged.stores.embedding_backend = overlay.stores.embedding_backend;
}
if overlay.stores.artifact_backend != StoreBackend::Memory {
merged.stores.artifact_backend = overlay.stores.artifact_backend;
}
if overlay.stores.redis_url.is_some() {
merged.stores.redis_url = overlay.stores.redis_url;
}
if overlay.stores.sql_url.is_some() {
merged.stores.sql_url = overlay.stores.sql_url;
}
if overlay.stores.mongo_url.is_some() {
merged.stores.mongo_url = overlay.stores.mongo_url;
}
if overlay.stores.surreal_url.is_some() {
merged.stores.surreal_url = overlay.stores.surreal_url;
}
if overlay.stores.qdrant_url.is_some() {
merged.stores.qdrant_url = overlay.stores.qdrant_url;
}
if !overlay.stores.qdrant_collection.is_empty() {
merged.stores.qdrant_collection = overlay.stores.qdrant_collection;
}
if overlay.stores.qdrant_dimensions != 1536 {
merged.stores.qdrant_dimensions = overlay.stores.qdrant_dimensions;
}
for (id, cfg) in overlay.providers {
let entry = merged.providers.entry(id).or_insert_with(|| cfg.clone());
if cfg.base_url != entry.base_url
&& !cfg.base_url.is_empty()
&& cfg.base_url != "https://api.openai.com/v1"
{
entry.base_url = cfg.base_url;
}
if cfg.api_key.is_some() {
entry.api_key = cfg.api_key;
}
if cfg.provider_type.is_some() {
entry.provider_type = cfg.provider_type;
}
if cfg.model.is_some() {
entry.model = cfg.model;
}
if !cfg.models.is_empty() {
entry.models = cfg.models;
}
if cfg.compaction_model.is_some() {
entry.compaction_model = cfg.compaction_model;
}
if cfg.organization.is_some() {
entry.organization = cfg.organization;
}
if cfg.timeout_secs != 60 {
entry.timeout_secs = cfg.timeout_secs;
}
}
#[cfg(feature = "rag")]
if overlay.rag.is_some() {
merged.rag = overlay.rag;
}
#[cfg(feature = "queue")]
if overlay.queue.is_some() {
merged.queue = overlay.queue;
}
merged
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[cfg(feature = "rag")]
use crate::provider::ModelName;
use secrecy::ExposeSecret;
#[test]
fn default_config_should_have_reasonable_defaults() {
let config = AgentConfig::default();
assert_eq!(config.runtime.policy.max_iterations, 10);
assert_eq!(config.runtime.policy.max_tool_concurrency, 4);
assert_eq!(config.runtime.policy.tool_timeout_secs, 30);
assert_eq!(config.runtime.policy.provider_timeout_secs, 60);
assert_eq!(config.runtime.max_history_messages, 50);
assert_eq!(config.runtime.event_channel_capacity, 256);
assert_eq!(config.stores.session_backend, StoreBackend::Memory);
assert!(config.providers.is_empty());
}
#[test]
fn builder_should_set_runtime_config() {
let runtime = RuntimeConfig {
max_history_messages: 100,
..Default::default()
};
let config = AgentConfig::builder()
.with_runtime(runtime)
.build()
.unwrap();
assert_eq!(config.runtime.max_history_messages, 100);
}
#[test]
fn builder_should_set_provider_config() {
let provider = ProviderConfig::new("https://api.example.com");
let config = AgentConfig::builder()
.with_provider("example", provider)
.build()
.unwrap();
assert_eq!(config.providers.len(), 1);
let p = config
.providers
.get(&crate::provider::ProviderId::new("example"))
.unwrap();
assert_eq!(p.base_url, "https://api.example.com");
}
#[test]
fn provider_config_should_resolve_env_var() {
let provider = ProviderConfig {
api_key: Some("env:HOME".to_owned()),
..ProviderConfig::new("https://api.example.com")
};
let resolved = provider.resolve_api_key();
assert!(resolved.is_some());
}
#[test]
fn provider_config_should_resolve_plain_key() {
let provider = ProviderConfig {
api_key: Some("sk-abc123".to_owned()),
..ProviderConfig::new("https://api.example.com")
};
let resolved = provider.resolve_api_key().unwrap();
assert_eq!(resolved.expose_secret(), "sk-abc123");
}
#[test]
fn provider_config_should_return_none_for_no_key() {
let provider = ProviderConfig::new("https://api.example.com");
assert!(provider.resolve_api_key().is_none());
}
#[test]
fn build_runtime_with_defaults_should_not_panic() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let config = AgentConfig::default();
let runtime = config.into_runtime().await.unwrap();
assert_eq!(runtime.policy().max_iterations, 10);
});
}
#[test]
#[cfg(feature = "rag")]
fn build_runtime_with_rag_should_fail_with_unregistered_provider() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let config = AgentConfig::builder()
.with_rag(RagConfig {
provider_id: crate::provider::ProviderId::new("nonexistent"),
model: ModelName::new("text-embedding-3"),
limit: 3,
template: String::new(),
metadata_field: String::from("text"),
})
.build();
assert!(config.is_err());
});
}
#[test]
fn config_should_serialize_and_deserialize_json() {
let config = AgentConfig::default();
let json = serde_json::to_string(&config).unwrap();
let _: AgentConfig = serde_json::from_str(&json).unwrap();
}
#[test]
fn store_config_defaults_to_memory() {
let config = StoreConfig::default();
assert_eq!(config.session_backend, StoreBackend::Memory);
assert_eq!(config.embedding_backend, StoreBackend::Memory);
assert!(config.redis_url.is_none());
}
#[test]
fn validate_should_reject_redis_store_without_url() {
let config = AgentConfig {
stores: StoreConfig {
session_backend: StoreBackend::Redis,
..Default::default()
},
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
#[cfg(feature = "queue")]
fn validate_should_reject_nats_queue_without_url() {
let config = AgentConfig {
queue: Some(QueueConfig {
backend: QueueBackend::Nats,
nats_url: None,
nats_subject: String::new(),
redis_url: None,
redis_stream_key: String::new(),
}),
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn validate_should_pass_for_memory_only() {
let config = AgentConfig::default();
assert!(config.validate().is_ok());
}
#[test]
fn config_loader_should_load_file() {
use std::io::Write as _;
let toml_content = "[runtime]\nmax_history_messages = 30\nevent_channel_capacity = 128\n";
let mut tmp = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
write!(tmp, "{toml_content}").unwrap();
let config = AgentConfig::builder()
.with_file(tmp.path().display().to_string())
.unwrap()
.build()
.unwrap();
assert_eq!(config.runtime.max_history_messages, 30);
assert_eq!(config.runtime.event_channel_capacity, 128);
assert_eq!(config.runtime.policy.max_iterations, 10);
assert_eq!(config.stores.session_backend, StoreBackend::Memory);
}
#[test]
fn builder_merge_configs_should_override_defaults() {
let overrides = AgentConfig {
runtime: RuntimeConfig {
max_history_messages: 42,
..Default::default()
},
..Default::default()
};
let merged = merge_configs(AgentConfig::default(), overrides);
assert_eq!(merged.runtime.max_history_messages, 42);
assert_eq!(merged.runtime.policy.max_iterations, 10);
assert_eq!(merged.stores.session_backend, StoreBackend::Memory);
}
#[test]
fn test_env_placeholder_substitution() {
let provider = ProviderConfig {
base_url: String::from("${HOME}"),
..ProviderConfig::new("https://api.example.com")
};
let config = AgentConfig::builder()
.with_provider("example", provider)
.build()
.unwrap();
let expected = std::env::var("HOME").unwrap();
assert_eq!(
config
.providers
.get(&crate::provider::ProviderId::new("example"))
.unwrap()
.base_url,
expected
);
}
#[test]
fn test_env_placeholder_substitution_with_default() {
let provider = ProviderConfig {
base_url: String::from("${NONEXISTENT_VAR:-http://default-host}"),
..ProviderConfig::new("https://api.example.com")
};
let config = AgentConfig::builder()
.with_provider("example", provider)
.build()
.unwrap();
assert_eq!(
config
.providers
.get(&crate::provider::ProviderId::new("example"))
.unwrap()
.base_url,
"http://default-host"
);
}
#[test]
fn test_deep_merge_preserves_non_configured_base_values() {
use std::io::Write as _;
let base_runtime = RuntimeConfig {
max_history_messages: 99,
policy: RuntimePolicyConfig {
max_iterations: 88,
..Default::default()
},
..Default::default()
};
let toml_content = "[runtime]\nmax_history_messages = 123\n";
let mut tmp = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
write!(tmp, "{toml_content}").unwrap();
let config = AgentConfig::builder()
.with_runtime(base_runtime)
.with_file(tmp.path().display().to_string())
.unwrap()
.build()
.unwrap();
assert_eq!(config.runtime.max_history_messages, 123); assert_eq!(config.runtime.policy.max_iterations, 88); }
}