- gRPC dev_mode config: disables JWT auth, uses fixed dev identity - Agent prefix (agents.agent_prefix): dev agents use "dev-sol-orchestrator" to avoid colliding with production on shared Mistral accounts - Coding sessions use instructions (system prompt + coding addendum) with mistral-medium-latest for personality adherence - Conversations API: don't send both model + agent_id (422 fix) - GrpcState carries system_prompt + orchestrator_agent_id - Session.end() keeps session active for reuse (not "ended") - User messages posted as m.notice, assistant as m.text (role detection) - History loaded from Matrix room on session resume - Docker Compose local dev stack: OpenSearch 3 + Tuwunel + SearXNG - Dev config: localhost URLs, dev_mode, opensearch-init.sh for ML setup
434 lines
15 KiB
Rust
434 lines
15 KiB
Rust
use serde::Deserialize;
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Config {
|
|
pub matrix: MatrixConfig,
|
|
pub opensearch: OpenSearchConfig,
|
|
pub mistral: MistralConfig,
|
|
pub behavior: BehaviorConfig,
|
|
#[serde(default)]
|
|
pub agents: AgentsConfig,
|
|
#[serde(default)]
|
|
pub services: ServicesConfig,
|
|
#[serde(default)]
|
|
pub vault: VaultConfig,
|
|
#[serde(default)]
|
|
pub grpc: Option<GrpcConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct AgentsConfig {
|
|
/// Model for the orchestrator agent.
|
|
#[serde(default = "default_model")]
|
|
pub orchestrator_model: String,
|
|
/// Model for domain agents.
|
|
#[serde(default = "default_model")]
|
|
pub domain_model: String,
|
|
/// Token threshold for conversation compaction (~90% of model context window).
|
|
#[serde(default = "default_compaction_threshold")]
|
|
pub compaction_threshold: u32,
|
|
/// Whether to use the Conversations API (vs manual message management).
|
|
#[serde(default)]
|
|
pub use_conversations_api: bool,
|
|
/// Model for research micro-agents.
|
|
#[serde(default = "default_research_agent_model")]
|
|
pub research_model: String,
|
|
/// Max tool calls per research micro-agent.
|
|
#[serde(default = "default_research_max_iterations")]
|
|
pub research_max_iterations: usize,
|
|
/// Max parallel agents per research wave.
|
|
#[serde(default = "default_research_max_agents")]
|
|
pub research_max_agents: usize,
|
|
/// Max recursion depth for research agents spawning sub-agents.
|
|
#[serde(default = "default_research_max_depth")]
|
|
pub research_max_depth: usize,
|
|
/// Model for coding agent sessions (sunbeam code).
|
|
#[serde(default = "default_coding_model")]
|
|
pub coding_model: String,
|
|
/// Agent name prefix — set to "dev" in local dev to avoid colliding with production agents.
|
|
#[serde(default)]
|
|
pub agent_prefix: String,
|
|
}
|
|
|
|
impl Default for AgentsConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
orchestrator_model: default_model(),
|
|
domain_model: default_model(),
|
|
compaction_threshold: default_compaction_threshold(),
|
|
use_conversations_api: false,
|
|
research_model: default_research_agent_model(),
|
|
research_max_iterations: default_research_max_iterations(),
|
|
research_max_agents: default_research_max_agents(),
|
|
research_max_depth: default_research_max_depth(),
|
|
coding_model: default_coding_model(),
|
|
agent_prefix: String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct MatrixConfig {
|
|
pub homeserver_url: String,
|
|
pub user_id: String,
|
|
pub state_store_path: String,
|
|
/// Path to the SQLite database for persistent state (conversations, agents).
|
|
/// Should be on a persistent volume in K8s (e.g. Longhorn PVC mounted at /data).
|
|
#[serde(default = "default_db_path")]
|
|
pub db_path: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct OpenSearchConfig {
|
|
pub url: String,
|
|
pub index: String,
|
|
#[serde(default = "default_batch_size")]
|
|
pub batch_size: usize,
|
|
#[serde(default = "default_flush_interval_ms")]
|
|
pub flush_interval_ms: u64,
|
|
#[serde(default = "default_embedding_pipeline")]
|
|
pub embedding_pipeline: String,
|
|
#[serde(default = "default_memory_index")]
|
|
pub memory_index: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct MistralConfig {
|
|
#[serde(default = "default_model")]
|
|
pub default_model: String,
|
|
#[serde(default = "default_evaluation_model")]
|
|
pub evaluation_model: String,
|
|
#[serde(default = "default_research_model")]
|
|
pub research_model: String,
|
|
#[serde(default = "default_max_tool_iterations")]
|
|
pub max_tool_iterations: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct BehaviorConfig {
|
|
#[serde(default = "default_response_delay_min_ms")]
|
|
pub response_delay_min_ms: u64,
|
|
#[serde(default = "default_response_delay_max_ms")]
|
|
pub response_delay_max_ms: u64,
|
|
#[serde(default = "default_spontaneous_delay_min_ms")]
|
|
pub spontaneous_delay_min_ms: u64,
|
|
#[serde(default = "default_spontaneous_delay_max_ms")]
|
|
pub spontaneous_delay_max_ms: u64,
|
|
#[serde(default = "default_spontaneous_threshold")]
|
|
pub spontaneous_threshold: f32,
|
|
#[serde(default = "default_room_context_window")]
|
|
pub room_context_window: usize,
|
|
#[serde(default = "default_dm_context_window")]
|
|
pub dm_context_window: usize,
|
|
#[serde(default = "default_backfill_on_join")]
|
|
pub backfill_on_join: bool,
|
|
#[serde(default = "default_backfill_limit")]
|
|
pub backfill_limit: usize,
|
|
#[serde(default)]
|
|
pub instant_responses: bool,
|
|
#[serde(default = "default_cooldown_after_response_ms")]
|
|
pub cooldown_after_response_ms: u64,
|
|
#[serde(default = "default_evaluation_context_window")]
|
|
pub evaluation_context_window: usize,
|
|
#[serde(default = "default_detect_sol_in_conversation")]
|
|
pub detect_sol_in_conversation: bool,
|
|
#[serde(default)]
|
|
pub evaluation_prompt_active: Option<String>,
|
|
#[serde(default)]
|
|
pub evaluation_prompt_passive: Option<String>,
|
|
#[serde(default = "default_reaction_threshold")]
|
|
pub reaction_threshold: f32,
|
|
#[serde(default = "default_reaction_enabled")]
|
|
pub reaction_enabled: bool,
|
|
#[serde(default = "default_script_timeout_secs")]
|
|
pub script_timeout_secs: u64,
|
|
#[serde(default = "default_script_max_heap_mb")]
|
|
pub script_max_heap_mb: usize,
|
|
#[serde(default)]
|
|
pub script_fetch_allowlist: Vec<String>,
|
|
#[serde(default = "default_memory_extraction_enabled")]
|
|
pub memory_extraction_enabled: bool,
|
|
/// Minimum fraction of a source room's members that must also be in the
|
|
/// requesting room for cross-room search results to be visible.
|
|
/// 0.0 = no restriction, 1.0 = only same room.
|
|
#[serde(default = "default_room_overlap_threshold")]
|
|
pub room_overlap_threshold: f32,
|
|
/// Duration in ms that Sol stays silent after being told to be quiet.
|
|
#[serde(default = "default_silence_duration_ms")]
|
|
pub silence_duration_ms: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Default)]
|
|
pub struct ServicesConfig {
|
|
#[serde(default)]
|
|
pub gitea: Option<GiteaConfig>,
|
|
#[serde(default)]
|
|
pub kratos: Option<KratosConfig>,
|
|
#[serde(default)]
|
|
pub searxng: Option<SearxngConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct GiteaConfig {
|
|
pub url: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct KratosConfig {
|
|
pub admin_url: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct SearxngConfig {
|
|
pub url: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct VaultConfig {
|
|
/// OpenBao/Vault URL. Default: http://openbao.data.svc.cluster.local:8200
|
|
#[serde(default = "default_vault_url")]
|
|
pub url: String,
|
|
/// Kubernetes auth role name. Default: sol-agent
|
|
#[serde(default = "default_vault_role")]
|
|
pub role: String,
|
|
/// KV v2 mount path. Default: secret
|
|
#[serde(default = "default_vault_mount")]
|
|
pub mount: String,
|
|
}
|
|
|
|
impl Default for VaultConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
url: default_vault_url(),
|
|
role: default_vault_role(),
|
|
mount: default_vault_mount(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_vault_url() -> String { "http://openbao.data.svc.cluster.local:8200".into() }
|
|
fn default_vault_role() -> String { "sol-agent".into() }
|
|
fn default_vault_mount() -> String { "secret".into() }
|
|
|
|
fn default_batch_size() -> usize { 50 }
|
|
fn default_flush_interval_ms() -> u64 { 2000 }
|
|
fn default_embedding_pipeline() -> String { "tuwunel_embedding_pipeline".into() }
|
|
fn default_model() -> String { "mistral-medium-latest".into() }
|
|
fn default_evaluation_model() -> String { "ministral-3b-latest".into() }
|
|
fn default_research_model() -> String { "mistral-large-latest".into() }
|
|
fn default_max_tool_iterations() -> usize { 5 }
|
|
fn default_response_delay_min_ms() -> u64 { 100 }
|
|
fn default_response_delay_max_ms() -> u64 { 2300 }
|
|
fn default_spontaneous_delay_min_ms() -> u64 { 15000 }
|
|
fn default_spontaneous_delay_max_ms() -> u64 { 60000 }
|
|
fn default_spontaneous_threshold() -> f32 { 0.85 }
|
|
fn default_cooldown_after_response_ms() -> u64 { 15000 }
|
|
fn default_evaluation_context_window() -> usize { 200 }
|
|
fn default_detect_sol_in_conversation() -> bool { true }
|
|
fn default_reaction_threshold() -> f32 { 0.6 }
|
|
fn default_reaction_enabled() -> bool { true }
|
|
fn default_room_context_window() -> usize { 200 }
|
|
fn default_dm_context_window() -> usize { 200 }
|
|
fn default_backfill_on_join() -> bool { true }
|
|
fn default_backfill_limit() -> usize { 10000 }
|
|
fn default_script_timeout_secs() -> u64 { 5 }
|
|
fn default_script_max_heap_mb() -> usize { 64 }
|
|
fn default_memory_index() -> String { "sol_user_memory".into() }
|
|
fn default_memory_extraction_enabled() -> bool { true }
|
|
fn default_room_overlap_threshold() -> f32 { 0.25 }
|
|
fn default_silence_duration_ms() -> u64 { 1_800_000 } // 30 minutes
|
|
fn default_db_path() -> String { "/data/sol.db".into() }
|
|
fn default_compaction_threshold() -> u32 { 118000 } // ~90% of 131K context window
|
|
fn default_research_agent_model() -> String { "ministral-3b-latest".into() }
|
|
fn default_research_max_iterations() -> usize { 10 }
|
|
fn default_research_max_agents() -> usize { 25 }
|
|
fn default_research_max_depth() -> usize { 4 }
|
|
fn default_coding_model() -> String { "mistral-medium-latest".into() }
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct GrpcConfig {
|
|
/// Address to listen on (default: 0.0.0.0:50051).
|
|
#[serde(default = "default_grpc_addr")]
|
|
pub listen_addr: String,
|
|
/// JWKS URL for JWT validation. Required unless dev_mode is true.
|
|
#[serde(default)]
|
|
pub jwks_url: Option<String>,
|
|
/// Dev mode: disables JWT auth, uses a fixed dev identity.
|
|
#[serde(default)]
|
|
pub dev_mode: bool,
|
|
}
|
|
|
|
fn default_grpc_addr() -> String { "0.0.0.0:50051".into() }
|
|
|
|
impl Config {
|
|
pub fn load(path: &str) -> anyhow::Result<Self> {
|
|
let content = std::fs::read_to_string(path)?;
|
|
let config: Config = toml::from_str(&content)?;
|
|
Ok(config)
|
|
}
|
|
|
|
pub fn from_str(content: &str) -> anyhow::Result<Self> {
|
|
let config: Config = toml::from_str(content)?;
|
|
Ok(config)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const MINIMAL_CONFIG: &str = r#"
|
|
[matrix]
|
|
homeserver_url = "https://chat.sunbeam.pt"
|
|
user_id = "@sol:sunbeam.pt"
|
|
state_store_path = "/data/sol/state"
|
|
|
|
[opensearch]
|
|
url = "http://opensearch:9200"
|
|
index = "sol-archive"
|
|
|
|
[mistral]
|
|
|
|
[behavior]
|
|
"#;
|
|
|
|
const FULL_CONFIG: &str = r#"
|
|
[matrix]
|
|
homeserver_url = "https://chat.sunbeam.pt"
|
|
user_id = "@sol:sunbeam.pt"
|
|
state_store_path = "/data/sol/state"
|
|
|
|
[opensearch]
|
|
url = "http://opensearch:9200"
|
|
index = "sol-archive"
|
|
batch_size = 100
|
|
flush_interval_ms = 5000
|
|
embedding_pipeline = "my_pipeline"
|
|
|
|
[mistral]
|
|
default_model = "mistral-large-latest"
|
|
evaluation_model = "ministral-8b-latest"
|
|
research_model = "mistral-large-latest"
|
|
max_tool_iterations = 10
|
|
|
|
[behavior]
|
|
response_delay_min_ms = 1000
|
|
response_delay_max_ms = 5000
|
|
spontaneous_delay_min_ms = 10000
|
|
spontaneous_delay_max_ms = 30000
|
|
spontaneous_threshold = 0.8
|
|
room_context_window = 50
|
|
dm_context_window = 200
|
|
backfill_on_join = false
|
|
backfill_limit = 5000
|
|
"#;
|
|
|
|
#[test]
|
|
fn test_minimal_config_with_defaults() {
|
|
let config = Config::from_str(MINIMAL_CONFIG).unwrap();
|
|
|
|
assert_eq!(config.matrix.homeserver_url, "https://chat.sunbeam.pt");
|
|
assert_eq!(config.matrix.user_id, "@sol:sunbeam.pt");
|
|
assert_eq!(config.matrix.state_store_path, "/data/sol/state");
|
|
assert_eq!(config.opensearch.url, "http://opensearch:9200");
|
|
assert_eq!(config.opensearch.index, "sol-archive");
|
|
|
|
// Check defaults
|
|
assert_eq!(config.opensearch.batch_size, 50);
|
|
assert_eq!(config.opensearch.flush_interval_ms, 2000);
|
|
assert_eq!(config.opensearch.embedding_pipeline, "tuwunel_embedding_pipeline");
|
|
assert_eq!(config.opensearch.memory_index, "sol_user_memory");
|
|
assert_eq!(config.mistral.default_model, "mistral-medium-latest");
|
|
assert_eq!(config.mistral.evaluation_model, "ministral-3b-latest");
|
|
assert_eq!(config.mistral.research_model, "mistral-large-latest");
|
|
assert_eq!(config.mistral.max_tool_iterations, 5);
|
|
assert_eq!(config.behavior.response_delay_min_ms, 100);
|
|
assert_eq!(config.behavior.response_delay_max_ms, 2300);
|
|
assert_eq!(config.behavior.spontaneous_delay_min_ms, 15000);
|
|
assert_eq!(config.behavior.spontaneous_delay_max_ms, 60000);
|
|
assert!((config.behavior.spontaneous_threshold - 0.85).abs() < f32::EPSILON);
|
|
assert!(!config.behavior.instant_responses);
|
|
assert_eq!(config.behavior.cooldown_after_response_ms, 15000);
|
|
assert_eq!(config.behavior.room_context_window, 200);
|
|
assert_eq!(config.behavior.dm_context_window, 200);
|
|
assert!(config.behavior.backfill_on_join);
|
|
assert_eq!(config.behavior.backfill_limit, 10000);
|
|
assert!(config.behavior.memory_extraction_enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn test_full_config_overrides() {
|
|
let config = Config::from_str(FULL_CONFIG).unwrap();
|
|
|
|
assert_eq!(config.opensearch.batch_size, 100);
|
|
assert_eq!(config.opensearch.flush_interval_ms, 5000);
|
|
assert_eq!(config.opensearch.embedding_pipeline, "my_pipeline");
|
|
assert_eq!(config.mistral.default_model, "mistral-large-latest");
|
|
assert_eq!(config.mistral.evaluation_model, "ministral-8b-latest");
|
|
assert_eq!(config.mistral.max_tool_iterations, 10);
|
|
assert_eq!(config.behavior.response_delay_min_ms, 1000);
|
|
assert_eq!(config.behavior.response_delay_max_ms, 5000);
|
|
assert!((config.behavior.spontaneous_threshold - 0.8).abs() < f32::EPSILON);
|
|
assert_eq!(config.behavior.room_context_window, 50);
|
|
assert_eq!(config.behavior.dm_context_window, 200);
|
|
assert!(!config.behavior.backfill_on_join);
|
|
assert_eq!(config.behavior.backfill_limit, 5000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_missing_required_section_fails() {
|
|
let bad = r#"
|
|
[matrix]
|
|
homeserver_url = "https://chat.sunbeam.pt"
|
|
user_id = "@sol:sunbeam.pt"
|
|
state_store_path = "/data/sol/state"
|
|
"#;
|
|
assert!(Config::from_str(bad).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_services_config_default_is_none() {
|
|
let config = Config::from_str(MINIMAL_CONFIG).unwrap();
|
|
assert!(config.services.gitea.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_services_config_with_kratos() {
|
|
let with_kratos = format!(
|
|
"{}\n[services.kratos]\nadmin_url = \"http://kratos-admin:80\"\n",
|
|
MINIMAL_CONFIG
|
|
);
|
|
let config = Config::from_str(&with_kratos).unwrap();
|
|
let kratos = config.services.kratos.unwrap();
|
|
assert_eq!(kratos.admin_url, "http://kratos-admin:80");
|
|
}
|
|
|
|
#[test]
|
|
fn test_services_config_with_gitea() {
|
|
let with_services = format!(
|
|
"{}\n[services.gitea]\nurl = \"http://gitea:3000\"\n",
|
|
MINIMAL_CONFIG
|
|
);
|
|
let config = Config::from_str(&with_services).unwrap();
|
|
let gitea = config.services.gitea.unwrap();
|
|
assert_eq!(gitea.url, "http://gitea:3000");
|
|
}
|
|
|
|
#[test]
|
|
fn test_missing_required_field_fails() {
|
|
let bad = r#"
|
|
[matrix]
|
|
homeserver_url = "https://chat.sunbeam.pt"
|
|
state_store_path = "/data/sol/state"
|
|
|
|
[opensearch]
|
|
url = "http://opensearch:9200"
|
|
index = "sol-archive"
|
|
|
|
[mistral]
|
|
[behavior]
|
|
"#;
|
|
assert!(Config::from_str(bad).is_err());
|
|
}
|
|
}
|