1439 lines
42 KiB
Rust
1439 lines
42 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use chrono_tz::Tz;
|
|
use regex::Regex;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::env;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::str::FromStr;
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct Config {
|
|
pub providers: HashMap<String, ProviderConfig>,
|
|
pub models: HashMap<String, ModelConfig>,
|
|
pub agents: HashMap<String, AgentConfig>,
|
|
#[serde(default)]
|
|
pub time: TimeConfig,
|
|
#[serde(default)]
|
|
pub gateway: GatewayConfig,
|
|
#[serde(default)]
|
|
pub scheduler: SchedulerConfig,
|
|
#[serde(default)]
|
|
pub client: ClientConfig,
|
|
#[serde(default)]
|
|
pub channels: HashMap<String, FeishuChannelConfig>,
|
|
#[serde(default)]
|
|
pub skills: SkillsConfig,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct TimeConfig {
|
|
#[serde(default = "default_timezone")]
|
|
pub timezone: String,
|
|
}
|
|
|
|
impl TimeConfig {
|
|
pub fn parse_timezone(&self) -> Result<Tz, ConfigError> {
|
|
self.timezone.parse::<Tz>().map_err(|_| {
|
|
ConfigError::InvalidTimezone(format!(
|
|
"unsupported timezone '{}', expected an IANA timezone like 'Asia/Shanghai'",
|
|
self.timezone
|
|
))
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Default for TimeConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
timezone: default_timezone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct SkillsConfig {
|
|
#[serde(default = "default_skills_enabled")]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_skills_sources")]
|
|
pub sources: Vec<String>,
|
|
#[serde(default = "default_skills_max_index_chars")]
|
|
pub max_index_chars: usize,
|
|
#[serde(default = "default_skills_max_listed")]
|
|
pub max_listed_skills: usize,
|
|
}
|
|
|
|
fn default_skills_enabled() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_skills_sources() -> Vec<String> {
|
|
vec!["project".to_string(), "user".to_string()]
|
|
}
|
|
|
|
fn default_skills_max_index_chars() -> usize {
|
|
4_000
|
|
}
|
|
|
|
fn default_skills_max_listed() -> usize {
|
|
32
|
|
}
|
|
|
|
impl Default for SkillsConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: default_skills_enabled(),
|
|
sources: default_skills_sources(),
|
|
max_index_chars: default_skills_max_index_chars(),
|
|
max_listed_skills: default_skills_max_listed(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct FeishuChannelConfig {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
pub app_id: String,
|
|
pub app_secret: String,
|
|
#[serde(default = "default_allow_from")]
|
|
pub allow_from: Vec<String>,
|
|
#[serde(default)]
|
|
pub agent: String,
|
|
#[serde(default = "default_media_dir")]
|
|
pub media_dir: String,
|
|
/// Emoji type for message reactions (e.g. "THUMBSUP", "OK", "EYES").
|
|
#[serde(default = "default_reaction_emoji")]
|
|
pub reaction_emoji: String,
|
|
#[serde(default = "default_channel_max_message_chars")]
|
|
pub max_message_chars: usize,
|
|
#[serde(default = "default_channel_reply_context_max_chars")]
|
|
pub reply_context_max_chars: usize,
|
|
}
|
|
|
|
fn default_allow_from() -> Vec<String> {
|
|
vec!["*".to_string()]
|
|
}
|
|
|
|
fn default_media_dir() -> String {
|
|
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
|
|
home.join(".picobot/media/feishu").to_string_lossy().to_string()
|
|
}
|
|
|
|
fn default_reaction_emoji() -> String {
|
|
"Typing".to_string()
|
|
}
|
|
|
|
fn default_channel_max_message_chars() -> usize {
|
|
20_000
|
|
}
|
|
|
|
fn default_channel_reply_context_max_chars() -> usize {
|
|
20_000
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct ProviderConfig {
|
|
#[serde(rename = "type")]
|
|
pub provider_type: String,
|
|
pub base_url: String,
|
|
pub api_key: String,
|
|
#[serde(default)]
|
|
pub extra_headers: HashMap<String, String>,
|
|
#[serde(default = "default_llm_timeout_secs")]
|
|
pub llm_timeout_secs: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct ModelConfig {
|
|
pub model_id: String,
|
|
#[serde(default)]
|
|
pub temperature: Option<f32>,
|
|
#[serde(default)]
|
|
pub max_tokens: Option<u32>,
|
|
#[serde(flatten)]
|
|
pub extra: HashMap<String, serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct AgentConfig {
|
|
pub provider: String,
|
|
pub model: String,
|
|
#[serde(default = "default_max_tool_iterations")]
|
|
pub max_tool_iterations: usize,
|
|
#[serde(default = "default_tool_result_max_chars")]
|
|
pub tool_result_max_chars: usize,
|
|
#[serde(default = "default_context_tool_result_trim_chars")]
|
|
pub context_tool_result_trim_chars: usize,
|
|
}
|
|
|
|
fn default_max_tool_iterations() -> usize {
|
|
100
|
|
}
|
|
|
|
fn default_tool_result_max_chars() -> usize {
|
|
20_000
|
|
}
|
|
|
|
fn default_context_tool_result_trim_chars() -> usize {
|
|
2_000
|
|
}
|
|
|
|
fn default_llm_timeout_secs() -> u64 {
|
|
120
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct GatewayConfig {
|
|
#[serde(default = "default_gateway_host")]
|
|
pub host: String,
|
|
#[serde(default = "default_gateway_port")]
|
|
pub port: u16,
|
|
#[serde(default)]
|
|
pub show_tool_results: bool,
|
|
#[serde(default, rename = "session_ttl_hours")]
|
|
pub session_ttl_hours: Option<u64>,
|
|
#[serde(default = "default_agent_prompt_reinject_every", rename = "agent_prompt_reinject_every")]
|
|
pub agent_prompt_reinject_every: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct ClientConfig {
|
|
#[serde(default = "default_gateway_url")]
|
|
pub gateway_url: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct SchedulerConfig {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_scheduler_tick_resolution_ms")]
|
|
pub tick_resolution_ms: u64,
|
|
#[serde(default = "default_scheduler_worker_queue_capacity")]
|
|
pub worker_queue_capacity: usize,
|
|
#[serde(default)]
|
|
pub misfire_policy: SchedulerMisfirePolicy,
|
|
#[serde(default)]
|
|
pub jobs: Vec<SchedulerJobConfig>,
|
|
}
|
|
|
|
pub const BUILTIN_MEMORY_MAINTENANCE_JOB_ID: &str = "builtin.memory_maintenance_daily";
|
|
|
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SchedulerMisfirePolicy {
|
|
CatchUp,
|
|
#[default]
|
|
Skip,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct SchedulerJobConfig {
|
|
pub id: String,
|
|
#[serde(default = "default_scheduler_job_enabled")]
|
|
pub enabled: bool,
|
|
pub kind: SchedulerJobKind,
|
|
#[serde(default)]
|
|
pub schedule: Option<SchedulerSchedule>,
|
|
#[serde(default)]
|
|
pub startup_delay_secs: u64,
|
|
#[serde(default)]
|
|
pub interval_secs: u64,
|
|
#[serde(default)]
|
|
pub target: SchedulerJobTarget,
|
|
#[serde(default)]
|
|
pub payload: serde_json::Value,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SchedulerJobKind {
|
|
InternalEvent,
|
|
OutboundMessage,
|
|
AgentTask,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
|
pub struct SchedulerJobTarget {
|
|
#[serde(default)]
|
|
pub channel: Option<String>,
|
|
#[serde(default)]
|
|
pub chat_id: Option<String>,
|
|
#[serde(default)]
|
|
pub reply_to: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum SchedulerSchedule {
|
|
Delay {
|
|
seconds: u64,
|
|
},
|
|
Interval {
|
|
seconds: u64,
|
|
#[serde(default)]
|
|
startup_delay_secs: u64,
|
|
},
|
|
At {
|
|
timestamp: String,
|
|
},
|
|
Cron {
|
|
expression: String,
|
|
},
|
|
}
|
|
|
|
impl SchedulerJobConfig {
|
|
pub fn resolved_schedule(&self) -> Result<SchedulerSchedule, ConfigError> {
|
|
if let Some(schedule) = &self.schedule {
|
|
schedule.validate(&self.id)?;
|
|
return Ok(schedule.normalized_for_storage());
|
|
}
|
|
|
|
if self.interval_secs > 0 {
|
|
return Ok(SchedulerSchedule::Interval {
|
|
seconds: self.interval_secs,
|
|
startup_delay_secs: self.startup_delay_secs,
|
|
});
|
|
}
|
|
|
|
Err(ConfigError::InvalidSchedulerJob(format!(
|
|
"scheduler job '{}' requires schedule or interval_secs",
|
|
self.id
|
|
)))
|
|
}
|
|
}
|
|
|
|
impl SchedulerConfig {
|
|
pub fn builtin_jobs(time: &TimeConfig) -> Vec<SchedulerJobConfig> {
|
|
vec![SchedulerJobConfig {
|
|
id: BUILTIN_MEMORY_MAINTENANCE_JOB_ID.to_string(),
|
|
enabled: true,
|
|
kind: SchedulerJobKind::InternalEvent,
|
|
schedule: Some(SchedulerSchedule::Cron {
|
|
expression: "0 3 * * *".to_string(),
|
|
}),
|
|
startup_delay_secs: 0,
|
|
interval_secs: 0,
|
|
target: SchedulerJobTarget::default(),
|
|
payload: serde_json::json!({
|
|
"event": "memory_maintenance",
|
|
"time_zone": time.timezone,
|
|
"local_time": "03:00"
|
|
}),
|
|
}]
|
|
}
|
|
|
|
pub fn effective_jobs(&self, time: &TimeConfig) -> Vec<SchedulerJobConfig> {
|
|
let mut jobs = Self::builtin_jobs(time);
|
|
|
|
for configured in &self.jobs {
|
|
if let Some(existing) = jobs.iter_mut().find(|job| job.id == configured.id) {
|
|
*existing = configured.clone();
|
|
} else {
|
|
jobs.push(configured.clone());
|
|
}
|
|
}
|
|
|
|
jobs
|
|
}
|
|
}
|
|
|
|
impl SchedulerSchedule {
|
|
pub fn validate(&self, job_id: &str) -> Result<(), ConfigError> {
|
|
match self {
|
|
SchedulerSchedule::Delay { seconds } => {
|
|
if *seconds == 0 {
|
|
return Err(ConfigError::InvalidSchedulerJob(format!(
|
|
"scheduler job '{}' delay.seconds must be greater than 0",
|
|
job_id
|
|
)));
|
|
}
|
|
}
|
|
SchedulerSchedule::Interval { seconds, .. } => {
|
|
if *seconds == 0 {
|
|
return Err(ConfigError::InvalidSchedulerJob(format!(
|
|
"scheduler job '{}' interval.seconds must be greater than 0",
|
|
job_id
|
|
)));
|
|
}
|
|
}
|
|
SchedulerSchedule::At { timestamp } => {
|
|
DateTime::parse_from_rfc3339(timestamp).map_err(|err| {
|
|
ConfigError::InvalidSchedulerJob(format!(
|
|
"scheduler job '{}' invalid at.timestamp '{}': {}",
|
|
job_id, timestamp, err
|
|
))
|
|
})?;
|
|
}
|
|
SchedulerSchedule::Cron { expression } => {
|
|
parse_scheduler_cron(expression).map_err(|err| {
|
|
ConfigError::InvalidSchedulerJob(format!(
|
|
"scheduler job '{}' invalid cron.expression '{}': {}",
|
|
job_id, expression, err
|
|
))
|
|
})?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn is_one_shot(&self) -> bool {
|
|
matches!(self, SchedulerSchedule::Delay { .. } | SchedulerSchedule::At { .. })
|
|
}
|
|
|
|
pub fn normalized_for_storage(&self) -> Self {
|
|
match self {
|
|
SchedulerSchedule::At { timestamp } => {
|
|
let parsed = DateTime::parse_from_rfc3339(timestamp)
|
|
.map(|value| value.with_timezone(&Utc).to_rfc3339())
|
|
.unwrap_or_else(|_| timestamp.clone());
|
|
SchedulerSchedule::At { timestamp: parsed }
|
|
}
|
|
other => other.clone(),
|
|
}
|
|
}
|
|
|
|
pub fn display(&self) -> String {
|
|
match self {
|
|
SchedulerSchedule::Delay { seconds } => format!("delay:{}s", seconds),
|
|
SchedulerSchedule::Interval {
|
|
seconds,
|
|
startup_delay_secs,
|
|
} => format!("interval:{}s:start_delay:{}s", seconds, startup_delay_secs),
|
|
SchedulerSchedule::At { timestamp } => format!("at:{}", timestamp),
|
|
SchedulerSchedule::Cron { expression } => format!("cron:{}", expression),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_scheduler_cron(expression: &str) -> Result<cron::Schedule, cron::error::Error> {
|
|
let normalized = normalize_cron_expression(expression);
|
|
cron::Schedule::from_str(&normalized)
|
|
}
|
|
|
|
fn normalize_cron_expression(expression: &str) -> String {
|
|
let parts: Vec<&str> = expression.split_whitespace().collect();
|
|
if parts.len() == 5 {
|
|
format!("0 {}", expression.trim())
|
|
} else {
|
|
expression.trim().to_string()
|
|
}
|
|
}
|
|
|
|
fn default_scheduler_tick_resolution_ms() -> u64 {
|
|
1_000
|
|
}
|
|
|
|
fn default_scheduler_worker_queue_capacity() -> usize {
|
|
64
|
|
}
|
|
|
|
fn default_scheduler_job_enabled() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_gateway_host() -> String {
|
|
"127.0.0.1".to_string()
|
|
}
|
|
|
|
fn default_gateway_port() -> u16 {
|
|
19876
|
|
}
|
|
|
|
fn default_gateway_url() -> String {
|
|
"ws://127.0.0.1:19876/ws".to_string()
|
|
}
|
|
|
|
fn default_timezone() -> String {
|
|
detect_system_timezone().unwrap_or_else(default_beijing_timezone)
|
|
}
|
|
|
|
fn detect_system_timezone() -> Option<String> {
|
|
let detected = iana_time_zone::get_timezone().ok()?;
|
|
if detected.parse::<Tz>().is_ok() {
|
|
Some(detected)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn default_beijing_timezone() -> String {
|
|
"Asia/Shanghai".to_string()
|
|
}
|
|
|
|
fn default_agent_prompt_reinject_every() -> u64 {
|
|
100
|
|
}
|
|
|
|
impl Default for GatewayConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
host: default_gateway_host(),
|
|
port: default_gateway_port(),
|
|
show_tool_results: false,
|
|
session_ttl_hours: None,
|
|
agent_prompt_reinject_every: default_agent_prompt_reinject_every(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for ClientConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
gateway_url: default_gateway_url(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for SchedulerConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
tick_resolution_ms: default_scheduler_tick_resolution_ms(),
|
|
worker_queue_capacity: default_scheduler_worker_queue_capacity(),
|
|
misfire_policy: SchedulerMisfirePolicy::default(),
|
|
jobs: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct LLMProviderConfig {
|
|
pub provider_type: String,
|
|
pub name: String,
|
|
pub base_url: String,
|
|
pub api_key: String,
|
|
pub extra_headers: HashMap<String, String>,
|
|
pub llm_timeout_secs: u64,
|
|
pub model_id: String,
|
|
pub temperature: Option<f32>,
|
|
pub max_tokens: Option<u32>,
|
|
pub model_extra: HashMap<String, serde_json::Value>,
|
|
pub max_tool_iterations: usize,
|
|
pub tool_result_max_chars: usize,
|
|
pub context_tool_result_trim_chars: usize,
|
|
}
|
|
|
|
impl LLMProviderConfig {
|
|
pub fn context_window_tokens(&self) -> usize {
|
|
self.max_tokens
|
|
.map(|value| value as usize)
|
|
.unwrap_or(128_000)
|
|
}
|
|
|
|
pub fn context_summary_char_budget(&self) -> usize {
|
|
const SUMMARY_RATIO: f64 = 0.1;
|
|
const CHARS_PER_TOKEN: f64 = 2.5;
|
|
const MIN_SUMMARY_CHARS: usize = 1_500;
|
|
const MAX_SUMMARY_CHARS: usize = 50_000;
|
|
|
|
((self.context_window_tokens() as f64 * SUMMARY_RATIO * CHARS_PER_TOKEN) as usize)
|
|
.clamp(MIN_SUMMARY_CHARS, MAX_SUMMARY_CHARS)
|
|
}
|
|
}
|
|
|
|
fn get_default_config_path() -> PathBuf {
|
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
|
home.join(".picobot").join("config.json")
|
|
}
|
|
|
|
impl Config {
|
|
pub fn load(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
|
Self::load_from(Path::new(path))
|
|
}
|
|
|
|
pub fn load_default() -> Result<Self, Box<dyn std::error::Error>> {
|
|
let path = get_default_config_path();
|
|
Self::load_from(&path)
|
|
}
|
|
|
|
fn load_from(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
|
load_env_file()?;
|
|
let content = if path.exists() {
|
|
tracing::info!(path = %path.display(), "Config loaded");
|
|
fs::read_to_string(path)?
|
|
} else {
|
|
// Fallback to current directory
|
|
let fallback = Path::new("config.json");
|
|
if fallback.exists() {
|
|
tracing::info!(path = %fallback.display(), "Config loaded from fallback path");
|
|
fs::read_to_string(fallback)?
|
|
} else {
|
|
return Err(Box::new(ConfigError::ConfigNotFound(
|
|
path.to_string_lossy().to_string(),
|
|
)));
|
|
}
|
|
};
|
|
let content = resolve_env_placeholders(&content);
|
|
let config: Config = serde_json::from_str(&content)?;
|
|
config.time.parse_timezone()?;
|
|
Ok(config)
|
|
}
|
|
|
|
pub fn get_provider_config(&self, agent_name: &str) -> Result<LLMProviderConfig, ConfigError> {
|
|
let agent = self.agents.get(agent_name)
|
|
.ok_or(ConfigError::AgentNotFound(agent_name.to_string()))?;
|
|
|
|
let provider = self.providers.get(&agent.provider)
|
|
.ok_or(ConfigError::ProviderNotFound(agent.provider.clone()))?;
|
|
|
|
let model = self.models.get(&agent.model)
|
|
.ok_or(ConfigError::ModelNotFound(agent.model.clone()))?;
|
|
|
|
Ok(LLMProviderConfig {
|
|
provider_type: provider.provider_type.clone(),
|
|
name: agent.provider.clone(),
|
|
base_url: provider.base_url.clone(),
|
|
api_key: provider.api_key.clone(),
|
|
extra_headers: provider.extra_headers.clone(),
|
|
llm_timeout_secs: provider.llm_timeout_secs,
|
|
model_id: model.model_id.clone(),
|
|
temperature: model.temperature,
|
|
max_tokens: model.max_tokens,
|
|
model_extra: model.extra.clone(),
|
|
max_tool_iterations: agent.max_tool_iterations,
|
|
tool_result_max_chars: agent.tool_result_max_chars,
|
|
context_tool_result_trim_chars: agent.context_tool_result_trim_chars,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ConfigError {
|
|
ConfigNotFound(String),
|
|
AgentNotFound(String),
|
|
ProviderNotFound(String),
|
|
ModelNotFound(String),
|
|
InvalidSchedulerJob(String),
|
|
InvalidTimezone(String),
|
|
}
|
|
|
|
impl std::fmt::Display for ConfigError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
ConfigError::ConfigNotFound(path) => write!(f, "Config file not found: {}. Use CONFIG_PATH env var or place config in ~/.picobot/config.json", path),
|
|
ConfigError::AgentNotFound(name) => write!(f, "Agent not found: {}", name),
|
|
ConfigError::ProviderNotFound(name) => write!(f, "Provider not found: {}", name),
|
|
ConfigError::ModelNotFound(name) => write!(f, "Model not found: {}", name),
|
|
ConfigError::InvalidSchedulerJob(message) => write!(f, "Invalid scheduler job: {}", message),
|
|
ConfigError::InvalidTimezone(message) => write!(f, "Invalid timezone: {}", message),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ConfigError {}
|
|
|
|
fn load_env_file() -> Result<(), Box<dyn std::error::Error>> {
|
|
let env_path = Path::new(".env");
|
|
if env_path.exists() {
|
|
let content = fs::read_to_string(env_path)?;
|
|
for line in content.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
if let Some((key, value)) = line.split_once('=') {
|
|
let key = key.trim();
|
|
let value = value.trim().trim_matches('"').trim_matches('\'');
|
|
if !value.is_empty() {
|
|
// SAFETY: Setting environment variables for the current process
|
|
// is safe as we're only modifying our own process state
|
|
unsafe { env::set_var(key, value) };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_env_placeholders(content: &str) -> String {
|
|
let re = Regex::new(r"<([A-Z_]+)>").expect("invalid regex");
|
|
re.replace_all(content, |caps: ®ex::Captures| {
|
|
let var_name = &caps[1];
|
|
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
|
|
}).to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn write_test_config() -> tempfile::NamedTempFile {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {}
|
|
},
|
|
"volcengine": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/volc",
|
|
"api_key": "test-key-2",
|
|
"extra_headers": {}
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus",
|
|
"temperature": 0.0
|
|
},
|
|
"doubao-seed-2-0-lite-260215": {
|
|
"model_id": "doubao-seed-2-0-lite-260215"
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus"
|
|
}
|
|
},
|
|
"gateway": {
|
|
"host": "0.0.0.0",
|
|
"port": 19876,
|
|
"agent_prompt_reinject_every": 120
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
file
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_load() {
|
|
let file = write_test_config();
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
|
|
// Check providers
|
|
assert!(config.providers.contains_key("volcengine"));
|
|
assert!(config.providers.contains_key("aliyun"));
|
|
|
|
// Check models
|
|
assert!(config.models.contains_key("doubao-seed-2-0-lite-260215"));
|
|
assert!(config.models.contains_key("qwen-plus"));
|
|
|
|
// Check agents
|
|
assert!(config.agents.contains_key("default"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_provider_config() {
|
|
let file = write_test_config();
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
let provider_config = config.get_provider_config("default").unwrap();
|
|
|
|
assert_eq!(provider_config.provider_type, "openai");
|
|
assert_eq!(provider_config.name, "aliyun");
|
|
assert_eq!(provider_config.model_id, "qwen-plus");
|
|
assert_eq!(provider_config.temperature, Some(0.0));
|
|
assert_eq!(provider_config.max_tokens, None);
|
|
assert_eq!(provider_config.llm_timeout_secs, 120);
|
|
assert_eq!(provider_config.tool_result_max_chars, 20_000);
|
|
assert_eq!(provider_config.context_tool_result_trim_chars, 2_000);
|
|
assert_eq!(provider_config.context_summary_char_budget(), 32_000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_provider_config_loads_custom_llm_timeout() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {},
|
|
"llm_timeout_secs": 400
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus"
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
let provider_config = config.get_provider_config("default").unwrap();
|
|
|
|
assert_eq!(provider_config.llm_timeout_secs, 400);
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_gateway_config() {
|
|
let file = write_test_config();
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
assert_eq!(config.gateway.host, "0.0.0.0");
|
|
assert_eq!(config.gateway.port, 19876);
|
|
assert!(!config.gateway.show_tool_results);
|
|
assert_eq!(config.gateway.agent_prompt_reinject_every, 120);
|
|
}
|
|
|
|
#[test]
|
|
fn test_gateway_config_defaults_agent_prompt_reinject_every() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {}
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus"
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
assert!(!config.gateway.show_tool_results);
|
|
assert_eq!(config.gateway.agent_prompt_reinject_every, 100);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_loads_configured_timezone() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {}
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus"
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus"
|
|
}
|
|
},
|
|
"time": {
|
|
"timezone": "Asia/Shanghai"
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
assert_eq!(config.time.timezone, "Asia/Shanghai");
|
|
assert_eq!(config.time.parse_timezone().unwrap(), chrono_tz::Asia::Shanghai);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_rejects_invalid_timezone() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {}
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus"
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus"
|
|
}
|
|
},
|
|
"time": {
|
|
"timezone": "Mars/Base"
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let error = Config::load(file.path().to_str().unwrap()).unwrap_err();
|
|
assert!(error.to_string().contains("Invalid timezone"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_gateway_config_can_enable_tool_results() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {}
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus"
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus"
|
|
}
|
|
},
|
|
"gateway": {
|
|
"show_tool_results": true
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
assert!(config.gateway.show_tool_results);
|
|
}
|
|
|
|
#[test]
|
|
fn test_agent_config_defaults_max_tool_iterations_to_100() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {}
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus"
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
assert_eq!(config.agents["default"].max_tool_iterations, 100);
|
|
assert_eq!(config.agents["default"].tool_result_max_chars, 20_000);
|
|
assert_eq!(config.agents["default"].context_tool_result_trim_chars, 2_000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_agent_config_loads_custom_truncation_limits() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {}
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus"
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus",
|
|
"tool_result_max_chars": 1234,
|
|
"context_tool_result_trim_chars": 3456
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
let agent = &config.agents["default"];
|
|
let provider_config = config.get_provider_config("default").unwrap();
|
|
|
|
assert_eq!(agent.tool_result_max_chars, 1234);
|
|
assert_eq!(agent.context_tool_result_trim_chars, 3456);
|
|
assert_eq!(provider_config.tool_result_max_chars, 1234);
|
|
assert_eq!(provider_config.context_tool_result_trim_chars, 3456);
|
|
assert_eq!(provider_config.context_summary_char_budget(), 32_000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_provider_config_summary_budget_scales_with_model_max_tokens() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {}
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus",
|
|
"max_tokens": 4096
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
let provider_config = config.get_provider_config("default").unwrap();
|
|
|
|
assert_eq!(provider_config.context_window_tokens(), 4096);
|
|
assert_eq!(provider_config.context_summary_char_budget(), 1_500);
|
|
}
|
|
|
|
#[test]
|
|
fn test_feishu_channel_config_defaults_truncation_limits() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {}
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus"
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus"
|
|
}
|
|
},
|
|
"channels": {
|
|
"feishu": {
|
|
"enabled": true,
|
|
"app_id": "app-id",
|
|
"app_secret": "secret"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
let feishu = &config.channels["feishu"];
|
|
assert_eq!(feishu.max_message_chars, 20_000);
|
|
assert_eq!(feishu.reply_context_max_chars, 20_000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_feishu_channel_config_loads_custom_truncation_limits() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {}
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus"
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus"
|
|
}
|
|
},
|
|
"channels": {
|
|
"feishu": {
|
|
"enabled": true,
|
|
"app_id": "app-id",
|
|
"app_secret": "secret",
|
|
"max_message_chars": 3456,
|
|
"reply_context_max_chars": 4567
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
let feishu = &config.channels["feishu"];
|
|
assert_eq!(feishu.max_message_chars, 3456);
|
|
assert_eq!(feishu.reply_context_max_chars, 4567);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scheduler_config_defaults() {
|
|
let file = write_test_config();
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
|
|
assert!(config.scheduler.enabled);
|
|
assert_eq!(config.scheduler.tick_resolution_ms, 1_000);
|
|
assert_eq!(config.scheduler.worker_queue_capacity, 64);
|
|
assert_eq!(config.scheduler.misfire_policy, SchedulerMisfirePolicy::Skip);
|
|
assert!(config.scheduler.jobs.is_empty());
|
|
|
|
let effective_jobs = config.scheduler.effective_jobs(&config.time);
|
|
assert_eq!(effective_jobs.len(), 1);
|
|
assert_eq!(effective_jobs[0].id, BUILTIN_MEMORY_MAINTENANCE_JOB_ID);
|
|
assert_eq!(effective_jobs[0].kind, SchedulerJobKind::InternalEvent);
|
|
assert_eq!(
|
|
effective_jobs[0].resolved_schedule().unwrap(),
|
|
SchedulerSchedule::Cron {
|
|
expression: "0 3 * * *".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scheduler_effective_jobs_allows_builtin_override() {
|
|
let mut scheduler = SchedulerConfig::default();
|
|
scheduler.jobs.push(SchedulerJobConfig {
|
|
id: BUILTIN_MEMORY_MAINTENANCE_JOB_ID.to_string(),
|
|
enabled: false,
|
|
kind: SchedulerJobKind::InternalEvent,
|
|
schedule: Some(SchedulerSchedule::Cron {
|
|
expression: "15 2 * * *".to_string(),
|
|
}),
|
|
startup_delay_secs: 0,
|
|
interval_secs: 0,
|
|
target: SchedulerJobTarget::default(),
|
|
payload: serde_json::json!({
|
|
"event": "memory_maintenance",
|
|
"time_zone": "UTC",
|
|
"local_time": "02:15"
|
|
}),
|
|
});
|
|
scheduler.jobs.push(SchedulerJobConfig {
|
|
id: "custom.reminder".to_string(),
|
|
enabled: true,
|
|
kind: SchedulerJobKind::InternalEvent,
|
|
schedule: Some(SchedulerSchedule::Delay { seconds: 30 }),
|
|
startup_delay_secs: 0,
|
|
interval_secs: 0,
|
|
target: SchedulerJobTarget::default(),
|
|
payload: serde_json::json!({"event": "custom"}),
|
|
});
|
|
|
|
let effective_jobs = scheduler.effective_jobs(&TimeConfig {
|
|
timezone: "Asia/Shanghai".to_string(),
|
|
});
|
|
assert_eq!(effective_jobs.len(), 2);
|
|
assert_eq!(effective_jobs[0].id, BUILTIN_MEMORY_MAINTENANCE_JOB_ID);
|
|
assert!(!effective_jobs[0].enabled);
|
|
assert_eq!(
|
|
effective_jobs[0].resolved_schedule().unwrap(),
|
|
SchedulerSchedule::Cron {
|
|
expression: "15 2 * * *".to_string(),
|
|
}
|
|
);
|
|
assert_eq!(effective_jobs[1].id, "custom.reminder");
|
|
}
|
|
|
|
#[test]
|
|
fn test_scheduler_config_loads_interval_compat_jobs() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {}
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus"
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus"
|
|
}
|
|
},
|
|
"scheduler": {
|
|
"enabled": true,
|
|
"tick_resolution_ms": 500,
|
|
"worker_queue_capacity": 8,
|
|
"misfire_policy": "catch_up",
|
|
"jobs": [
|
|
{
|
|
"id": "heartbeat.reminder",
|
|
"kind": "outbound_message",
|
|
"interval_secs": 60,
|
|
"startup_delay_secs": 5,
|
|
"target": {
|
|
"channel": "feishu",
|
|
"chat_id": "oc_demo"
|
|
},
|
|
"payload": {
|
|
"content": "heartbeat"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
assert!(config.scheduler.enabled);
|
|
assert_eq!(config.scheduler.tick_resolution_ms, 500);
|
|
assert_eq!(config.scheduler.worker_queue_capacity, 8);
|
|
assert_eq!(config.scheduler.misfire_policy, SchedulerMisfirePolicy::CatchUp);
|
|
assert_eq!(config.scheduler.jobs.len(), 1);
|
|
|
|
let job = &config.scheduler.jobs[0];
|
|
assert_eq!(job.id, "heartbeat.reminder");
|
|
assert!(job.enabled);
|
|
assert_eq!(job.kind, SchedulerJobKind::OutboundMessage);
|
|
assert_eq!(job.interval_secs, 60);
|
|
assert_eq!(job.startup_delay_secs, 5);
|
|
assert_eq!(job.target.channel.as_deref(), Some("feishu"));
|
|
assert_eq!(job.target.chat_id.as_deref(), Some("oc_demo"));
|
|
assert_eq!(job.payload.get("content").and_then(|value| value.as_str()), Some("heartbeat"));
|
|
assert_eq!(job.resolved_schedule().unwrap(), SchedulerSchedule::Interval {
|
|
seconds: 60,
|
|
startup_delay_secs: 5,
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_scheduler_config_loads_schedule_variants() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {}
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus"
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus"
|
|
}
|
|
},
|
|
"scheduler": {
|
|
"enabled": true,
|
|
"jobs": [
|
|
{
|
|
"id": "delay.job",
|
|
"kind": "internal_event",
|
|
"schedule": {
|
|
"type": "delay",
|
|
"seconds": 30
|
|
}
|
|
},
|
|
{
|
|
"id": "at.job",
|
|
"kind": "outbound_message",
|
|
"schedule": {
|
|
"type": "at",
|
|
"timestamp": "2026-04-23T09:00:00Z"
|
|
},
|
|
"target": {
|
|
"channel": "feishu",
|
|
"chat_id": "oc_demo"
|
|
},
|
|
"payload": {
|
|
"content": "at run"
|
|
}
|
|
},
|
|
{
|
|
"id": "cron.job",
|
|
"kind": "internal_event",
|
|
"schedule": {
|
|
"type": "cron",
|
|
"expression": "0 9 * * *"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
assert_eq!(config.scheduler.jobs.len(), 3);
|
|
assert_eq!(
|
|
config.scheduler.jobs[0].resolved_schedule().unwrap(),
|
|
SchedulerSchedule::Delay { seconds: 30 }
|
|
);
|
|
assert_eq!(config.scheduler.jobs[0].kind, SchedulerJobKind::InternalEvent);
|
|
assert_eq!(
|
|
config.scheduler.jobs[1].resolved_schedule().unwrap(),
|
|
SchedulerSchedule::At {
|
|
timestamp: "2026-04-23T09:00:00+00:00".to_string(),
|
|
}
|
|
);
|
|
assert_eq!(config.scheduler.jobs[1].kind, SchedulerJobKind::OutboundMessage);
|
|
assert_eq!(
|
|
config.scheduler.jobs[2].resolved_schedule().unwrap(),
|
|
SchedulerSchedule::Cron {
|
|
expression: "0 9 * * *".to_string(),
|
|
}
|
|
);
|
|
assert_eq!(config.scheduler.jobs[2].kind, SchedulerJobKind::InternalEvent);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scheduler_config_loads_agent_task_job() {
|
|
let file = tempfile::NamedTempFile::new().unwrap();
|
|
std::fs::write(
|
|
file.path(),
|
|
r#"{
|
|
"providers": {
|
|
"aliyun": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": "test-key",
|
|
"extra_headers": {}
|
|
}
|
|
},
|
|
"models": {
|
|
"qwen-plus": {
|
|
"model_id": "qwen-plus"
|
|
}
|
|
},
|
|
"agents": {
|
|
"default": {
|
|
"provider": "aliyun",
|
|
"model": "qwen-plus"
|
|
}
|
|
},
|
|
"scheduler": {
|
|
"enabled": true,
|
|
"jobs": [
|
|
{
|
|
"id": "agent.daily_summary",
|
|
"kind": "agent_task",
|
|
"schedule": {
|
|
"type": "cron",
|
|
"expression": "0 9 * * *"
|
|
},
|
|
"target": {
|
|
"channel": "feishu",
|
|
"chat_id": "oc_demo"
|
|
},
|
|
"payload": {
|
|
"prompt": "请总结今天待办"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = Config::load(file.path().to_str().unwrap()).unwrap();
|
|
let job = &config.scheduler.jobs[0];
|
|
assert_eq!(job.kind, SchedulerJobKind::AgentTask);
|
|
assert_eq!(job.target.channel.as_deref(), Some("feishu"));
|
|
assert_eq!(job.target.chat_id.as_deref(), Some("oc_demo"));
|
|
assert_eq!(job.payload.get("prompt").and_then(|value| value.as_str()), Some("请总结今天待办"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_scheduler_schedule_validation_rejects_invalid_values() {
|
|
assert!(SchedulerSchedule::Delay { seconds: 0 }
|
|
.validate("delay.job")
|
|
.is_err());
|
|
assert!(SchedulerSchedule::Interval {
|
|
seconds: 0,
|
|
startup_delay_secs: 0,
|
|
}
|
|
.validate("interval.job")
|
|
.is_err());
|
|
assert!(SchedulerSchedule::At {
|
|
timestamp: "bad timestamp".to_string(),
|
|
}
|
|
.validate("at.job")
|
|
.is_err());
|
|
assert!(SchedulerSchedule::Cron {
|
|
expression: "bad cron".to_string(),
|
|
}
|
|
.validate("cron.job")
|
|
.is_err());
|
|
}
|
|
}
|