diff --git a/Dockerfile b/Dockerfile index 16993a4..e228114 100644 --- a/Dockerfile +++ b/Dockerfile @@ -94,9 +94,9 @@ COPY --from=permissions /zeroclaw-data /zeroclaw-data # Environment setup ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace ENV HOME=/zeroclaw-data -# Defaults for prod (OpenRouter) +# Default provider (model is set in config.toml, not here, +# so config file edits are not silently overridden) ENV PROVIDER="openrouter" -ENV ZEROCLAW_MODEL="anthropic/claude-sonnet-4-20250514" ENV ZEROCLAW_GATEWAY_PORT=3000 # API_KEY must be provided at runtime! diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 13d2ae0..fcaedf9 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -489,7 +489,7 @@ pub async fn run( let model_name = model_override .as_deref() .or(config.default_model.as_deref()) - .unwrap_or("anthropic/claude-sonnet-4-20250514"); + .unwrap_or("anthropic/claude-sonnet-4"); let provider: Box = providers::create_routed_provider( provider_name, diff --git a/src/channels/discord.rs b/src/channels/discord.rs index 3f5a450..27d2582 100644 --- a/src/channels/discord.rs +++ b/src/channels/discord.rs @@ -10,16 +10,18 @@ pub struct DiscordChannel { bot_token: String, guild_id: Option, allowed_users: Vec, + listen_to_bots: bool, client: reqwest::Client, typing_handle: std::sync::Mutex>>, } impl DiscordChannel { - pub fn new(bot_token: String, guild_id: Option, allowed_users: Vec) -> Self { + pub fn new(bot_token: String, guild_id: Option, allowed_users: Vec, listen_to_bots: bool) -> Self { Self { bot_token, guild_id, allowed_users, + listen_to_bots, client: reqwest::Client::new(), typing_handle: std::sync::Mutex::new(None), } @@ -309,8 +311,8 @@ impl Channel for DiscordChannel { continue; } - // Skip bot messages - if d.get("author").and_then(|a| a.get("bot")).and_then(serde_json::Value::as_bool).unwrap_or(false) { + // Skip bot messages (unless listen_to_bots is enabled) + if !self.listen_to_bots && d.get("author").and_then(|a| a.get("bot")).and_then(serde_json::Value::as_bool).unwrap_or(false) { continue; } @@ -411,7 +413,7 @@ mod tests { #[test] fn discord_channel_name() { - let ch = DiscordChannel::new("fake".into(), None, vec![]); + let ch = DiscordChannel::new("fake".into(), None, vec![], false); assert_eq!(ch.name(), "discord"); } @@ -432,21 +434,21 @@ mod tests { #[test] fn empty_allowlist_denies_everyone() { - let ch = DiscordChannel::new("fake".into(), None, vec![]); + let ch = DiscordChannel::new("fake".into(), None, vec![], false); assert!(!ch.is_user_allowed("12345")); assert!(!ch.is_user_allowed("anyone")); } #[test] fn wildcard_allows_everyone() { - let ch = DiscordChannel::new("fake".into(), None, vec!["*".into()]); + let ch = DiscordChannel::new("fake".into(), None, vec!["*".into()], false); assert!(ch.is_user_allowed("12345")); assert!(ch.is_user_allowed("anyone")); } #[test] fn specific_allowlist_filters() { - let ch = DiscordChannel::new("fake".into(), None, vec!["111".into(), "222".into()]); + let ch = DiscordChannel::new("fake".into(), None, vec!["111".into(), "222".into()], false); assert!(ch.is_user_allowed("111")); assert!(ch.is_user_allowed("222")); assert!(!ch.is_user_allowed("333")); @@ -455,7 +457,7 @@ mod tests { #[test] fn allowlist_is_exact_match_not_substring() { - let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()]); + let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()], false); assert!(!ch.is_user_allowed("1111")); assert!(!ch.is_user_allowed("11")); assert!(!ch.is_user_allowed("0111")); @@ -463,20 +465,20 @@ mod tests { #[test] fn allowlist_empty_string_user_id() { - let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()]); + let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()], false); assert!(!ch.is_user_allowed("")); } #[test] fn allowlist_with_wildcard_and_specific() { - let ch = DiscordChannel::new("fake".into(), None, vec!["111".into(), "*".into()]); + let ch = DiscordChannel::new("fake".into(), None, vec!["111".into(), "*".into()], false); assert!(ch.is_user_allowed("111")); assert!(ch.is_user_allowed("anyone_else")); } #[test] fn allowlist_case_sensitive() { - let ch = DiscordChannel::new("fake".into(), None, vec!["ABC".into()]); + let ch = DiscordChannel::new("fake".into(), None, vec!["ABC".into()], false); assert!(ch.is_user_allowed("ABC")); assert!(!ch.is_user_allowed("abc")); assert!(!ch.is_user_allowed("Abc")); @@ -651,14 +653,14 @@ mod tests { #[test] fn typing_handle_starts_as_none() { - let ch = DiscordChannel::new("fake".into(), None, vec![]); + let ch = DiscordChannel::new("fake".into(), None, vec![], false); let guard = ch.typing_handle.lock().unwrap(); assert!(guard.is_none()); } #[tokio::test] async fn start_typing_sets_handle() { - let ch = DiscordChannel::new("fake".into(), None, vec![]); + let ch = DiscordChannel::new("fake".into(), None, vec![], false); let _ = ch.start_typing("123456").await; let guard = ch.typing_handle.lock().unwrap(); assert!(guard.is_some()); @@ -666,7 +668,7 @@ mod tests { #[tokio::test] async fn stop_typing_clears_handle() { - let ch = DiscordChannel::new("fake".into(), None, vec![]); + let ch = DiscordChannel::new("fake".into(), None, vec![], false); let _ = ch.start_typing("123456").await; let _ = ch.stop_typing("123456").await; let guard = ch.typing_handle.lock().unwrap(); @@ -675,14 +677,14 @@ mod tests { #[tokio::test] async fn stop_typing_is_idempotent() { - let ch = DiscordChannel::new("fake".into(), None, vec![]); + let ch = DiscordChannel::new("fake".into(), None, vec![], false); assert!(ch.stop_typing("123456").await.is_ok()); assert!(ch.stop_typing("123456").await.is_ok()); } #[tokio::test] async fn start_typing_replaces_existing_task() { - let ch = DiscordChannel::new("fake".into(), None, vec![]); + let ch = DiscordChannel::new("fake".into(), None, vec![], false); let _ = ch.start_typing("111").await; let _ = ch.start_typing("222").await; let guard = ch.typing_handle.lock().unwrap(); diff --git a/src/channels/mod.rs b/src/channels/mod.rs index a828f53..ad095d0 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -544,6 +544,7 @@ pub async fn doctor_channels(config: Config) -> Result<()> { dc.bot_token.clone(), dc.guild_id.clone(), dc.allowed_users.clone(), + dc.listen_to_bots, )), )); } @@ -671,7 +672,7 @@ pub async fn start_channels(config: Config) -> Result<()> { let model = config .default_model .clone() - .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); + .unwrap_or_else(|| "anthropic/claude-sonnet-4".into()); let temperature = config.default_temperature; let mem: Arc = Arc::from(memory::create_memory( &config.memory, @@ -752,6 +753,7 @@ pub async fn start_channels(config: Config) -> Result<()> { dc.bot_token.clone(), dc.guild_id.clone(), dc.allowed_users.clone(), + dc.listen_to_bots, ))); } diff --git a/src/config/schema.rs b/src/config/schema.rs index 2ec474b..da00e7c 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -774,6 +774,10 @@ pub struct DiscordConfig { pub guild_id: Option, #[serde(default)] pub allowed_users: Vec, + /// When true, process messages from other bots (not just humans). + /// The bot still ignores its own messages to prevent feedback loops. + #[serde(default)] + pub listen_to_bots: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -887,7 +891,7 @@ impl Default for Config { config_path: zeroclaw_dir.join("config.toml"), api_key: None, default_provider: Some("openrouter".to_string()), - default_model: Some("anthropic/claude-sonnet-4-20250514".to_string()), + default_model: Some("anthropic/claude-sonnet-4".to_string()), default_temperature: 0.7, observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 6941208..11de562 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -198,7 +198,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { let model = config .default_model .clone() - .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); + .unwrap_or_else(|| "anthropic/claude-sonnet-4".into()); let temperature = config.default_temperature; let mem: Arc = Arc::from(memory::create_memory( &config.memory, diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 5b66e17..2baae7d 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -406,7 +406,7 @@ fn default_model_for_provider(provider: &str) -> String { "groq" => "llama-3.3-70b-versatile".into(), "deepseek" => "deepseek-chat".into(), "gemini" | "google" | "google-gemini" => "gemini-2.0-flash".into(), - _ => "anthropic/claude-sonnet-4-20250514".into(), + _ => "anthropic/claude-sonnet-4".into(), } } @@ -689,7 +689,7 @@ fn setup_provider() -> Result<(String, String, String)> { let models: Vec<(&str, &str)> = match provider_name { "openrouter" => vec![ ( - "anthropic/claude-sonnet-4-20250514", + "anthropic/claude-sonnet-4", "Claude Sonnet 4 (balanced, recommended)", ), ( @@ -1378,6 +1378,7 @@ fn setup_channels() -> Result { bot_token: token, guild_id: if guild.is_empty() { None } else { Some(guild) }, allowed_users, + listen_to_bots: false, }); } 2 => { diff --git a/src/providers/router.rs b/src/providers/router.rs index 2fec083..4ee36f3 100644 --- a/src/providers/router.rs +++ b/src/providers/router.rs @@ -14,7 +14,7 @@ pub struct Route { /// based on a task hint encoded in the model parameter. /// /// The model parameter can be: -/// - A regular model name (e.g. "anthropic/claude-sonnet-4-20250514") → uses default provider +/// - A regular model name (e.g. "anthropic/claude-sonnet-4") → uses default provider /// - A hint-prefixed string (e.g. "hint:reasoning") → resolves via route table /// /// This wraps multiple pre-created providers and selects the right one per request.