1069 lines
32 KiB
Rust
1069 lines
32 KiB
Rust
use regex::Regex;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::env;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Get the user configuration directory (~/.picobot)
|
|
pub fn get_user_config_dir() -> PathBuf {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join(".picobot")
|
|
}
|
|
|
|
/// Get the default workspace directory (~/.picobot/workspace)
|
|
pub fn get_default_workspace_dir() -> PathBuf {
|
|
get_user_config_dir().join("workspace")
|
|
}
|
|
|
|
/// Expand ~ in path to user home directory
|
|
pub fn expand_path(path: &str) -> PathBuf {
|
|
if let Some(path) = path.strip_prefix("~/") {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join(path)
|
|
} else {
|
|
PathBuf::from(path)
|
|
}
|
|
}
|
|
|
|
/// Ensure workspace directory exists, create if needed
|
|
pub fn ensure_workspace_dir(path: &Path) -> Result<PathBuf, std::io::Error> {
|
|
if !path.exists() {
|
|
tracing::info!("Creating workspace directory: {}", path.display());
|
|
fs::create_dir_all(path)?;
|
|
}
|
|
// Return canonical path
|
|
path.canonicalize().or_else(|_| Ok(path.to_path_buf()))
|
|
}
|
|
|
|
#[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 gateway: GatewayConfig,
|
|
#[serde(default)]
|
|
pub client: ClientConfig,
|
|
#[serde(default)]
|
|
pub channels: HashMap<String, FeishuChannelConfig>,
|
|
#[serde(default)]
|
|
pub memory: MemoryConfig,
|
|
#[serde(default = "default_workspace_dir")]
|
|
pub workspace_dir: String,
|
|
#[serde(default)]
|
|
pub mcp: McpConfig,
|
|
#[serde(default)]
|
|
pub browser: BrowserConfig,
|
|
}
|
|
|
|
fn default_workspace_dir() -> String {
|
|
get_default_workspace_dir().to_string_lossy().to_string()
|
|
}
|
|
|
|
#[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>,
|
|
/// Require an explicit bot @mention before accepting group-chat messages.
|
|
#[serde(default = "default_true")]
|
|
pub require_mention: bool,
|
|
#[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,
|
|
/// Edit one card with latest Turn snapshots instead of sending only the final result.
|
|
#[serde(default)]
|
|
pub live_updates: bool,
|
|
#[serde(default = "default_feishu_live_update_interval_ms")]
|
|
pub live_update_interval_ms: u64,
|
|
#[serde(default = "default_feishu_max_image_bytes")]
|
|
pub max_image_bytes: u64,
|
|
#[serde(default = "default_feishu_max_file_bytes")]
|
|
pub max_file_bytes: u64,
|
|
#[serde(default = "default_feishu_media_dir_max_bytes")]
|
|
pub media_dir_max_bytes: u64,
|
|
#[serde(default = "default_feishu_request_timeout_secs")]
|
|
pub request_timeout_secs: u64,
|
|
}
|
|
|
|
fn default_allow_from() -> Vec<String> {
|
|
vec!["*".to_string()]
|
|
}
|
|
|
|
fn default_media_dir() -> String {
|
|
get_user_config_dir()
|
|
.join("media/feishu")
|
|
.to_string_lossy()
|
|
.to_string()
|
|
}
|
|
|
|
fn default_reaction_emoji() -> String {
|
|
"Typing".to_string()
|
|
}
|
|
|
|
fn default_feishu_live_update_interval_ms() -> u64 {
|
|
500
|
|
}
|
|
|
|
fn default_feishu_max_image_bytes() -> u64 {
|
|
10 * 1024 * 1024
|
|
}
|
|
|
|
fn default_feishu_max_file_bytes() -> u64 {
|
|
25 * 1024 * 1024
|
|
}
|
|
|
|
fn default_feishu_media_dir_max_bytes() -> u64 {
|
|
512 * 1024 * 1024
|
|
}
|
|
|
|
fn default_feishu_request_timeout_secs() -> u64 {
|
|
30
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
#[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(default = "default_input_type")]
|
|
pub input_type: Vec<String>,
|
|
#[serde(flatten)]
|
|
pub extra: HashMap<String, serde_json::Value>,
|
|
}
|
|
|
|
fn default_input_type() -> Vec<String> {
|
|
vec!["text".to_string()]
|
|
}
|
|
|
|
#[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_token_limit")]
|
|
pub token_limit: usize,
|
|
}
|
|
|
|
fn default_max_tool_iterations() -> usize {
|
|
99
|
|
}
|
|
|
|
fn default_token_limit() -> usize {
|
|
128_000
|
|
}
|
|
|
|
#[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 = "default_require_pairing")]
|
|
pub require_pairing: bool,
|
|
#[serde(default, rename = "session_ttl_hours")]
|
|
pub session_ttl_hours: Option<u64>,
|
|
#[serde(default, rename = "cleanup_interval_minutes")]
|
|
pub cleanup_interval_minutes: Option<u64>,
|
|
#[serde(default, rename = "session_db_path")]
|
|
pub session_db_path: Option<String>,
|
|
#[serde(default, rename = "max_concurrent_background_tasks")]
|
|
pub max_concurrent_background_tasks: usize,
|
|
#[serde(default)]
|
|
pub scheduler: Option<SchedulerConfig>,
|
|
#[serde(default)]
|
|
pub file_transfer: FileTransferConfig,
|
|
}
|
|
|
|
impl Default for GatewayConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
host: default_gateway_host(),
|
|
port: default_gateway_port(),
|
|
require_pairing: default_require_pairing(),
|
|
session_ttl_hours: None,
|
|
cleanup_interval_minutes: None,
|
|
session_db_path: None,
|
|
max_concurrent_background_tasks: 10,
|
|
scheduler: None,
|
|
file_transfer: FileTransferConfig::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FileTransferConfig {
|
|
#[serde(default = "default_file_transfer_enabled")]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_upload_dir")]
|
|
pub upload_dir: String,
|
|
#[serde(default = "default_max_file_bytes")]
|
|
pub max_file_bytes: u64,
|
|
#[serde(default = "default_max_files_per_message")]
|
|
pub max_files_per_message: usize,
|
|
#[serde(default = "default_max_message_bytes")]
|
|
pub max_message_bytes: u64,
|
|
#[serde(default = "default_pending_ttl_seconds")]
|
|
pub pending_ttl_seconds: u64,
|
|
}
|
|
|
|
impl Default for FileTransferConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: default_file_transfer_enabled(),
|
|
upload_dir: default_upload_dir(),
|
|
max_file_bytes: default_max_file_bytes(),
|
|
max_files_per_message: default_max_files_per_message(),
|
|
max_message_bytes: default_max_message_bytes(),
|
|
pending_ttl_seconds: default_pending_ttl_seconds(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_file_transfer_enabled() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_upload_dir() -> String {
|
|
get_user_config_dir()
|
|
.join("media/cli_chat")
|
|
.to_string_lossy()
|
|
.to_string()
|
|
}
|
|
|
|
fn default_max_file_bytes() -> u64 {
|
|
25 * 1024 * 1024
|
|
}
|
|
|
|
fn default_max_files_per_message() -> usize {
|
|
8
|
|
}
|
|
|
|
fn default_max_message_bytes() -> u64 {
|
|
64 * 1024 * 1024
|
|
}
|
|
|
|
fn default_pending_ttl_seconds() -> u64 {
|
|
60 * 60
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SchedulerConfig {
|
|
/// Whether the scheduler is enabled
|
|
#[serde(default = "default_scheduler_enabled")]
|
|
pub enabled: bool,
|
|
/// Poll interval in seconds (how often to check for due jobs)
|
|
#[serde(default = "default_poll_interval_secs")]
|
|
pub poll_interval_secs: u64,
|
|
/// Maximum concurrent job executions.
|
|
#[serde(default = "default_max_concurrent")]
|
|
pub max_concurrent: usize,
|
|
/// Hard timeout for one scheduled execution.
|
|
#[serde(default = "default_execution_timeout_secs")]
|
|
pub execution_timeout_secs: u64,
|
|
}
|
|
|
|
fn default_scheduler_enabled() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_poll_interval_secs() -> u64 {
|
|
60
|
|
}
|
|
|
|
fn default_max_concurrent() -> usize {
|
|
1
|
|
}
|
|
|
|
fn default_execution_timeout_secs() -> u64 {
|
|
900
|
|
}
|
|
|
|
impl Default for SchedulerConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
poll_interval_secs: 60,
|
|
max_concurrent: 1,
|
|
execution_timeout_secs: 900,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct ClientConfig {
|
|
#[serde(default = "default_gateway_url")]
|
|
pub gateway_url: String,
|
|
}
|
|
|
|
fn default_gateway_host() -> String {
|
|
"127.0.0.1".to_string()
|
|
}
|
|
|
|
fn default_gateway_port() -> u16 {
|
|
19876
|
|
}
|
|
|
|
fn default_require_pairing() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_gateway_url() -> String {
|
|
"ws://127.0.0.1:19876/ws".to_string()
|
|
}
|
|
|
|
impl Default for ClientConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
gateway_url: default_gateway_url(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct MemoryConfig {
|
|
/// Provider name for consolidation LLM calls (key in `providers`).
|
|
/// If not set, falls back to the main agent's provider.
|
|
#[serde(default)]
|
|
pub consolidation_provider: Option<String>,
|
|
/// Model name for consolidation LLM calls (key in `models`).
|
|
/// If not set, falls back to the main agent's model.
|
|
#[serde(default)]
|
|
pub consolidation_model: Option<String>,
|
|
/// Max knowledge entries injected into system prompt per turn.
|
|
#[serde(default = "default_recall_limit")]
|
|
pub recall_limit: usize,
|
|
/// Idle minutes before triggering consolidation (for async channels).
|
|
#[serde(default = "default_idle_consolidation_minutes")]
|
|
pub idle_consolidation_minutes: u64,
|
|
/// Days before timeline entries are auto-cleaned.
|
|
#[serde(default = "default_timeline_retention_days")]
|
|
pub timeline_retention_days: u64,
|
|
/// Consecutive consolidation failures before degrading to raw archive.
|
|
#[serde(default = "default_max_failures_before_degrade")]
|
|
pub max_failures_before_degrade: usize,
|
|
}
|
|
|
|
impl Default for MemoryConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
consolidation_provider: None,
|
|
consolidation_model: None,
|
|
recall_limit: 5,
|
|
idle_consolidation_minutes: 10,
|
|
timeline_retention_days: 90,
|
|
max_failures_before_degrade: 3,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MemoryConfig {
|
|
/// Resolve consolidation provider name, falling back to the main agent's provider.
|
|
pub fn resolve_consolidation_provider(&self, default: &str) -> String {
|
|
self.consolidation_provider
|
|
.clone()
|
|
.unwrap_or_else(|| default.to_string())
|
|
}
|
|
|
|
/// Resolve consolidation model name, falling back to the main agent's model.
|
|
pub fn resolve_consolidation_model(&self, default: &str) -> String {
|
|
self.consolidation_model
|
|
.clone()
|
|
.unwrap_or_else(|| default.to_string())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct McpConfig {
|
|
#[serde(default)]
|
|
pub servers: Vec<McpServerConfig>,
|
|
#[serde(default = "default_mcp_tool_timeout_secs")]
|
|
pub tool_timeout_secs: u64,
|
|
}
|
|
|
|
impl Default for McpConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
servers: Vec::new(),
|
|
tool_timeout_secs: default_mcp_tool_timeout_secs(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct McpServerConfig {
|
|
pub name: String,
|
|
#[serde(default = "default_mcp_transport")]
|
|
pub transport: McpTransport,
|
|
#[serde(default)]
|
|
pub command: Option<String>,
|
|
#[serde(default)]
|
|
pub args: Vec<String>,
|
|
#[serde(default)]
|
|
pub env: HashMap<String, String>,
|
|
#[serde(default)]
|
|
pub url: Option<String>,
|
|
#[serde(default)]
|
|
pub headers: HashMap<String, String>,
|
|
#[serde(default)]
|
|
pub tool_timeout_secs: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum McpTransport {
|
|
Stdio,
|
|
Sse,
|
|
#[serde(alias = "streamable-http")]
|
|
StreamableHttp,
|
|
}
|
|
|
|
fn default_mcp_transport() -> McpTransport {
|
|
McpTransport::Stdio
|
|
}
|
|
|
|
fn default_mcp_tool_timeout_secs() -> u64 {
|
|
180
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct BrowserConfig {
|
|
#[serde(default = "default_true")]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_agent_browser_command")]
|
|
pub command: String,
|
|
#[serde(default = "default_true")]
|
|
pub headless: bool,
|
|
#[serde(default)]
|
|
pub browser_executable_path: Option<String>,
|
|
#[serde(default = "default_browser_max_sessions")]
|
|
pub max_sessions: usize,
|
|
#[serde(default = "default_browser_idle_timeout_secs")]
|
|
pub idle_timeout_secs: u64,
|
|
#[serde(default = "default_browser_command_timeout_secs")]
|
|
pub command_timeout_secs: u64,
|
|
#[serde(default = "default_browser_max_output_chars")]
|
|
pub max_output_chars: usize,
|
|
#[serde(default = "default_true")]
|
|
pub content_boundaries: bool,
|
|
#[serde(default)]
|
|
pub allowed_domains: Vec<String>,
|
|
#[serde(default)]
|
|
pub allow_private_hosts: bool,
|
|
#[serde(default = "default_browser_artifact_dir")]
|
|
pub artifact_dir: String,
|
|
#[serde(default)]
|
|
pub persistence: BrowserPersistenceConfig,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct BrowserPersistenceConfig {
|
|
#[serde(default = "default_browser_profile_dir")]
|
|
pub profile_dir: String,
|
|
}
|
|
|
|
fn default_agent_browser_command() -> String {
|
|
"agent-browser".to_string()
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_browser_max_sessions() -> usize {
|
|
4
|
|
}
|
|
|
|
fn default_browser_idle_timeout_secs() -> u64 {
|
|
15 * 60
|
|
}
|
|
|
|
fn default_browser_command_timeout_secs() -> u64 {
|
|
120
|
|
}
|
|
|
|
fn default_browser_max_output_chars() -> usize {
|
|
50_000
|
|
}
|
|
|
|
fn default_browser_artifact_dir() -> String {
|
|
get_user_config_dir()
|
|
.join("media/browser")
|
|
.to_string_lossy()
|
|
.to_string()
|
|
}
|
|
|
|
fn default_browser_profile_dir() -> String {
|
|
get_user_config_dir()
|
|
.join("browser/profiles")
|
|
.to_string_lossy()
|
|
.to_string()
|
|
}
|
|
|
|
impl Default for BrowserPersistenceConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
profile_dir: default_browser_profile_dir(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for BrowserConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
command: default_agent_browser_command(),
|
|
headless: true,
|
|
browser_executable_path: None,
|
|
max_sessions: default_browser_max_sessions(),
|
|
idle_timeout_secs: default_browser_idle_timeout_secs(),
|
|
command_timeout_secs: default_browser_command_timeout_secs(),
|
|
max_output_chars: default_browser_max_output_chars(),
|
|
content_boundaries: true,
|
|
allowed_domains: Vec::new(),
|
|
allow_private_hosts: false,
|
|
artifact_dir: default_browser_artifact_dir(),
|
|
persistence: BrowserPersistenceConfig::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_recall_limit() -> usize {
|
|
5
|
|
}
|
|
fn default_idle_consolidation_minutes() -> u64 {
|
|
10
|
|
}
|
|
fn default_timeline_retention_days() -> u64 {
|
|
90
|
|
}
|
|
fn default_max_failures_before_degrade() -> usize {
|
|
3
|
|
}
|
|
|
|
#[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 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 token_limit: usize,
|
|
pub workspace_dir: PathBuf,
|
|
pub input_types: Vec<String>,
|
|
pub price_input_per_million: Option<f64>,
|
|
pub price_output_per_million: Option<f64>,
|
|
}
|
|
|
|
impl LLMProviderConfig {
|
|
pub fn cost_of(&self, prompt_tokens: u32, completion_tokens: u32) -> Option<f64> {
|
|
match (self.price_input_per_million, self.price_output_per_million) {
|
|
(Some(pi), Some(po)) => Some(
|
|
prompt_tokens as f64 / 1e6 * pi + completion_tokens as f64 / 1e6 * po,
|
|
),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn get_default_config_path() -> PathBuf {
|
|
get_user_config_dir().join("config.json")
|
|
}
|
|
|
|
/// Resolve the config file that `load_default` will read. This must be called
|
|
/// before Gateway changes its working directory so the fallback remains stable.
|
|
pub fn resolve_default_config_path() -> PathBuf {
|
|
let primary = get_default_config_path();
|
|
if primary.exists() {
|
|
primary
|
|
} else {
|
|
env::current_dir()
|
|
.unwrap_or_else(|_| PathBuf::from("."))
|
|
.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 = resolve_default_config_path();
|
|
Self::load_from(&path)
|
|
}
|
|
|
|
pub(crate) fn load_from(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
|
let process_env = collect_process_env();
|
|
Self::load_from_with_process_env(path, &process_env, true, None)
|
|
}
|
|
|
|
/// Reload configuration without mutating the process environment. The
|
|
/// supplied environment must be the process environment captured before
|
|
/// startup `.env` layers were installed, preserving the documented
|
|
/// precedence while keeping runtime reload thread-safe.
|
|
pub(crate) fn load_for_reload(
|
|
path: &Path,
|
|
startup_process_env: &HashMap<String, String>,
|
|
startup_cwd: &Path,
|
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
|
Self::load_from_with_process_env(path, startup_process_env, false, Some(startup_cwd))
|
|
}
|
|
|
|
pub(crate) fn startup_process_env() -> HashMap<String, String> {
|
|
collect_process_env()
|
|
}
|
|
|
|
fn load_from_with_process_env(
|
|
path: &Path,
|
|
process_env: &HashMap<String, String>,
|
|
apply_to_process: bool,
|
|
workspace_base: Option<&Path>,
|
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
|
let config_path = if path.exists() {
|
|
path.to_path_buf()
|
|
} else {
|
|
let fallback = env::current_dir()
|
|
.unwrap_or_else(|_| PathBuf::from("."))
|
|
.join("config.json");
|
|
if fallback.exists() {
|
|
fallback
|
|
} else {
|
|
return Err(Box::new(ConfigError::ConfigNotFound(
|
|
path.to_string_lossy().to_string(),
|
|
)));
|
|
}
|
|
};
|
|
|
|
let content = fs::read_to_string(&config_path)?;
|
|
let config_env_path = config_path
|
|
.parent()
|
|
.unwrap_or_else(|| Path::new("."))
|
|
.join(".env");
|
|
let config_env = read_env_file(&config_env_path)?;
|
|
|
|
// The config-directory layer selects the workspace. Loading the workspace
|
|
// layer first would be circular because its location comes from config.json.
|
|
let initial_env = merge_env_layers(&config_env, &HashMap::new(), process_env);
|
|
let initial_content = resolve_env_placeholders(&content, &initial_env);
|
|
let initial_config: Config = serde_json::from_str(&initial_content)?;
|
|
let mut workspace_path = expand_path(&initial_config.workspace_dir);
|
|
if workspace_path.is_relative()
|
|
&& let Some(base) = workspace_base
|
|
{
|
|
workspace_path = base.join(workspace_path);
|
|
}
|
|
let workspace_env_path = workspace_path.join(".env");
|
|
let workspace_env = read_env_file(&workspace_env_path)?;
|
|
|
|
let effective_env = merge_env_layers(&config_env, &workspace_env, process_env);
|
|
let resolved_content = resolve_env_placeholders(&content, &effective_env);
|
|
let config: Config = serde_json::from_str(&resolved_content)?;
|
|
if config.workspace_dir != initial_config.workspace_dir {
|
|
return Err(format!(
|
|
"workspace .env cannot change workspace_dir (selected {}, resolved {})",
|
|
initial_config.workspace_dir, config.workspace_dir
|
|
)
|
|
.into());
|
|
}
|
|
|
|
if apply_to_process {
|
|
apply_env_layers(&config_env, &workspace_env, process_env);
|
|
}
|
|
tracing::info!(
|
|
path = %config_path.display(),
|
|
config_env = %config_env_path.display(),
|
|
workspace_env = %workspace_env_path.display(),
|
|
"Config and layered environment loaded"
|
|
);
|
|
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(),
|
|
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,
|
|
token_limit: agent.token_limit,
|
|
workspace_dir: expand_path(&self.workspace_dir),
|
|
input_types: model.input_type.clone(),
|
|
price_input_per_million: None,
|
|
price_output_per_million: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ConfigError {
|
|
ConfigNotFound(String),
|
|
AgentNotFound(String),
|
|
ProviderNotFound(String),
|
|
ModelNotFound(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),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ConfigError {}
|
|
|
|
fn read_env_file(path: &Path) -> Result<HashMap<String, String>, Box<dyn std::error::Error>> {
|
|
let mut values = HashMap::new();
|
|
if !path.exists() {
|
|
return Ok(values);
|
|
}
|
|
|
|
let content = fs::read_to_string(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 !key.is_empty() && !value.is_empty() {
|
|
values.insert(key.to_string(), value.to_string());
|
|
}
|
|
}
|
|
}
|
|
Ok(values)
|
|
}
|
|
|
|
fn collect_process_env() -> HashMap<String, String> {
|
|
env::vars_os()
|
|
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
|
|
.collect()
|
|
}
|
|
|
|
fn merge_env_layers(
|
|
config_env: &HashMap<String, String>,
|
|
workspace_env: &HashMap<String, String>,
|
|
process_env: &HashMap<String, String>,
|
|
) -> HashMap<String, String> {
|
|
let mut merged = config_env.clone();
|
|
merged.extend(workspace_env.clone());
|
|
merged.extend(process_env.clone());
|
|
merged
|
|
}
|
|
|
|
fn apply_env_layers(
|
|
config_env: &HashMap<String, String>,
|
|
workspace_env: &HashMap<String, String>,
|
|
process_env: &HashMap<String, String>,
|
|
) {
|
|
for (key, value) in config_env.iter().chain(workspace_env) {
|
|
if !process_env.contains_key(key) {
|
|
// SAFETY: Config loading happens during single-threaded startup before
|
|
// Gateway background tasks are spawned. Existing process values are
|
|
// never modified, and the workspace layer intentionally overwrites the
|
|
// lower-priority config-directory layer.
|
|
unsafe { env::set_var(key, value) };
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resolve_env_placeholders(content: &str, values: &HashMap<String, String>) -> String {
|
|
let re = Regex::new(r"<([A-Z_]+)>").expect("invalid regex");
|
|
re.replace_all(content, |caps: ®ex::Captures| {
|
|
let var_name = &caps[1];
|
|
values
|
|
.get(var_name)
|
|
.cloned()
|
|
.unwrap_or_else(|| caps[0].to_string())
|
|
})
|
|
.to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::ffi::OsString;
|
|
use std::sync::Mutex;
|
|
|
|
struct TestConfig {
|
|
_dir: tempfile::TempDir,
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl TestConfig {
|
|
fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
}
|
|
|
|
fn write_test_config() -> TestConfig {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let path = dir.path().join("config.json");
|
|
let workspace = dir.path().join("workspace");
|
|
let mut content: serde_json::Value = serde_json::from_str(
|
|
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
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
content["workspace_dir"] = serde_json::json!(workspace);
|
|
std::fs::write(&path, serde_json::to_vec_pretty(&content).unwrap()).unwrap();
|
|
TestConfig { _dir: dir, path }
|
|
}
|
|
|
|
static ENV_TEST_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
struct EnvRestore(Vec<(&'static str, Option<OsString>)>);
|
|
|
|
impl Drop for EnvRestore {
|
|
fn drop(&mut self) {
|
|
for (key, value) in self.0.drain(..) {
|
|
if let Some(value) = value {
|
|
// SAFETY: The test serializes mutations of these unique keys.
|
|
unsafe { env::set_var(key, value) };
|
|
} else {
|
|
// SAFETY: The test serializes mutations of these unique keys.
|
|
unsafe { env::remove_var(key) };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn layered_env_uses_config_then_workspace_then_process_precedence() {
|
|
const VALUE: &str = "PICOBOT_ENV_LAYERING_VALUE";
|
|
const SYSTEM: &str = "PICOBOT_ENV_LAYERING_SYSTEM";
|
|
const CONFIG_ONLY: &str = "PICOBOT_ENV_LAYERING_CONFIG_ONLY";
|
|
|
|
let _lock = ENV_TEST_LOCK.lock().unwrap();
|
|
let _restore = EnvRestore(
|
|
[VALUE, SYSTEM, CONFIG_ONLY]
|
|
.into_iter()
|
|
.map(|key| (key, env::var_os(key)))
|
|
.collect(),
|
|
);
|
|
// SAFETY: Config loading is the code under test and these unique keys are
|
|
// protected by ENV_TEST_LOCK for the duration of the test.
|
|
unsafe {
|
|
env::remove_var(VALUE);
|
|
env::set_var(SYSTEM, "process");
|
|
env::remove_var(CONFIG_ONLY);
|
|
}
|
|
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let config_dir = dir.path().join("config");
|
|
let workspace_dir = dir.path().join("workspace");
|
|
fs::create_dir_all(&config_dir).unwrap();
|
|
fs::create_dir_all(&workspace_dir).unwrap();
|
|
fs::write(
|
|
config_dir.join(".env"),
|
|
format!("{VALUE}=config\n{SYSTEM}=config\n{CONFIG_ONLY}=config-only\n"),
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
workspace_dir.join(".env"),
|
|
format!("{VALUE}=workspace\n{SYSTEM}=workspace\n"),
|
|
)
|
|
.unwrap();
|
|
|
|
let config_path = config_dir.join("config.json");
|
|
let config_json = serde_json::json!({
|
|
"providers": {
|
|
"default": {
|
|
"type": "openai",
|
|
"base_url": "https://example.invalid/v1",
|
|
"api_key": format!("<{VALUE}>|<{SYSTEM}>|<{CONFIG_ONLY}>")
|
|
}
|
|
},
|
|
"models": { "default": { "model_id": "test" } },
|
|
"agents": { "default": { "provider": "default", "model": "default" } },
|
|
"workspace_dir": workspace_dir
|
|
});
|
|
fs::write(
|
|
&config_path,
|
|
serde_json::to_vec_pretty(&config_json).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let config = Config::load(config_path.to_str().unwrap()).unwrap();
|
|
|
|
assert_eq!(
|
|
config.providers["default"].api_key,
|
|
"workspace|process|config-only"
|
|
);
|
|
assert_eq!(env::var(VALUE).unwrap(), "workspace");
|
|
assert_eq!(env::var(SYSTEM).unwrap(), "process");
|
|
assert_eq!(env::var(CONFIG_ONLY).unwrap(), "config-only");
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
|
|
#[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.require_pairing);
|
|
assert!(config.gateway.file_transfer.enabled);
|
|
assert_eq!(
|
|
config.gateway.file_transfer.max_file_bytes,
|
|
25 * 1024 * 1024
|
|
);
|
|
assert!(config.browser.enabled);
|
|
let browser: BrowserConfig = serde_json::from_str("{}").unwrap();
|
|
assert!(browser.enabled);
|
|
assert!(
|
|
browser
|
|
.persistence
|
|
.profile_dir
|
|
.ends_with("browser/profiles")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn browser_persistence_config_is_strict_and_explicit() {
|
|
let browser: BrowserConfig = serde_json::from_str(
|
|
r#"{
|
|
"persistence": {
|
|
"profile_dir": "/tmp/picobot-browser-profiles"
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
browser.persistence.profile_dir,
|
|
"/tmp/picobot-browser-profiles"
|
|
);
|
|
assert!(
|
|
serde_json::from_str::<BrowserConfig>(
|
|
r#"{"persistence":{"profile_dir":"/tmp/profiles","unknown":1}}"#
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_config_path_is_stable_across_working_directory_changes() {
|
|
assert!(resolve_default_config_path().is_absolute());
|
|
}
|
|
}
|