From cfae86790871e287bfa32affce7512c7555f780a Mon Sep 17 00:00:00 2001 From: jaberjaber23 Date: Sun, 8 Mar 2026 04:04:37 +0300 Subject: [PATCH] batch fixes --- Cargo.lock | 28 ++++++------ crates/openfang-api/src/routes.rs | 17 +++++-- crates/openfang-api/static/js/pages/chat.js | 5 ++- crates/openfang-channels/src/email.rs | 34 ++++++++++++-- crates/openfang-cli/src/main.rs | 17 ++++--- .../src/tui/screens/init_wizard.rs | 2 +- crates/openfang-hands/src/lib.rs | 2 + crates/openfang-hands/src/registry.rs | 45 +++++++++++++++++++ crates/openfang-kernel/src/kernel.rs | 26 +++++++++++ crates/openfang-runtime/src/drivers/gemini.rs | 4 ++ crates/openfang-runtime/src/drivers/mod.rs | 4 +- crates/openfang-runtime/src/drivers/openai.rs | 8 +++- crates/openfang-runtime/src/model_catalog.rs | 40 +++-------------- 13 files changed, 165 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8671e91d..b145143a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3875,7 +3875,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.3.27" +version = "0.3.28" dependencies = [ "async-trait", "axum", @@ -3912,7 +3912,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.3.27" +version = "0.3.28" dependencies = [ "async-trait", "axum", @@ -3944,7 +3944,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.3.27" +version = "0.3.28" dependencies = [ "clap", "clap_complete", @@ -3971,7 +3971,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.3.27" +version = "0.3.28" dependencies = [ "axum", "open", @@ -3997,7 +3997,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.3.27" +version = "0.3.28" dependencies = [ "aes-gcm", "argon2", @@ -4025,7 +4025,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.3.27" +version = "0.3.28" dependencies = [ "chrono", "dashmap", @@ -4042,7 +4042,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.3.27" +version = "0.3.28" dependencies = [ "async-trait", "chrono", @@ -4078,7 +4078,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.3.27" +version = "0.3.28" dependencies = [ "async-trait", "chrono", @@ -4097,7 +4097,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.3.27" +version = "0.3.28" dependencies = [ "chrono", "dirs 6.0.0", @@ -4116,7 +4116,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.3.27" +version = "0.3.28" dependencies = [ "anyhow", "async-trait", @@ -4148,7 +4148,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.3.27" +version = "0.3.28" dependencies = [ "chrono", "hex", @@ -4171,7 +4171,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.3.27" +version = "0.3.28" dependencies = [ "async-trait", "chrono", @@ -4190,7 +4190,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.3.27" +version = "0.3.28" dependencies = [ "async-trait", "chrono", @@ -8818,7 +8818,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.3.27" +version = "0.3.28" [[package]] name = "yoke" diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 510352f8..720b48f8 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -6329,10 +6329,19 @@ pub async fn set_model( } }; match state.kernel.set_agent_model(agent_id, model) { - Ok(()) => ( - StatusCode::OK, - Json(serde_json::json!({"status": "ok", "model": model})), - ), + Ok(()) => { + // Return the resolved provider so frontend can update its state + let provider = state + .kernel + .registry + .get(agent_id) + .map(|e| e.manifest.model.provider.clone()) + .unwrap_or_default(); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "ok", "model": model, "provider": provider})), + ) + } Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("{e}")})), diff --git a/crates/openfang-api/static/js/pages/chat.js b/crates/openfang-api/static/js/pages/chat.js index 054d2252..31b54e14 100644 --- a/crates/openfang-api/static/js/pages/chat.js +++ b/crates/openfang-api/static/js/pages/chat.js @@ -420,9 +420,10 @@ function chatPage() { case '/model': if (self.currentAgent) { if (cmdArgs) { - OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function() { + OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function(resp) { self.currentAgent.model_name = cmdArgs; - self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`', meta: '', tools: [] }); + if (resp && resp.provider) { self.currentAgent.model_provider = resp.provider; } + self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`' + (resp && resp.provider ? ' (provider: `' + resp.provider + '`)' : ''), meta: '', tools: [] }); self.scrollToBottom(); }).catch(function(e) { OpenFangToast.error('Model switch failed: ' + e.message); }); } else { diff --git a/crates/openfang-channels/src/email.rs b/crates/openfang-channels/src/email.rs index 7d7ae2d4..009ba0de 100644 --- a/crates/openfang-channels/src/email.rs +++ b/crates/openfang-channels/src/email.rs @@ -20,6 +20,21 @@ use tokio::sync::{mpsc, watch}; use tracing::{debug, error, info, warn}; use zeroize::Zeroizing; +/// SASL PLAIN authenticator for IMAP servers that reject LOGIN +/// (e.g., Lark/Larksuite which only advertise AUTH=PLAIN). +struct PlainAuthenticator { + username: String, + password: String, +} + +impl imap::Authenticator for PlainAuthenticator { + type Response = String; + fn process(&self, _data: &[u8]) -> Self::Response { + // SASL PLAIN: \0\0 + format!("\x00{}\x00{}", self.username, self.password) + } +} + /// Reply context for email threading (In-Reply-To / Subject continuity). #[derive(Debug, Clone)] struct ReplyCtx { @@ -203,9 +218,22 @@ fn fetch_unseen_emails( let client = imap::connect((host, port), host, &tls) .map_err(|e| format!("IMAP connect failed: {e}"))?; - let mut session = client - .login(username, password) - .map_err(|(e, _)| format!("IMAP login failed: {e}"))?; + // Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark + // that reject LOGIN and only support AUTH=PLAIN (SASL). + let mut session = match client.login(username, password) { + Ok(s) => s, + Err((login_err, client)) => { + let authenticator = PlainAuthenticator { + username: username.to_string(), + password: password.to_string(), + }; + client + .authenticate("PLAIN", &authenticator) + .map_err(|(e, _)| { + format!("IMAP login failed: {login_err}; AUTH=PLAIN also failed: {e}") + })? + } + }; let mut results = Vec::new(); diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index cefa3db4..322a1fd6 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -1287,9 +1287,10 @@ fn launch_desktop_app(_openfang_dir: &std::path::Path) { ui::blank(); if let Some(base) = find_daemon() { let url = format!("{base}/"); - if !open_in_browser(&url) { - ui::hint(&format!("Visit: {url}")); - } + let _ = open_in_browser(&url); + // Always print the URL — browser launch may silently fail + // (e.g., Chromium sandbox EPERM in containers) + ui::hint(&format!("Dashboard: {url}")); } } } @@ -1337,7 +1338,7 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st ( "openrouter", "OPENROUTER_API_KEY", - "openrouter/auto", + "openrouter/anthropic/claude-sonnet-4", "OpenRouter", ), ] @@ -2632,7 +2633,7 @@ decay_rate = 0.05 checks.push(serde_json::json!({"check": "daemon_uptime", "status": "ok", "secs": uptime})); } if let Some(db_status) = body.get("database").and_then(|v| v.as_str()) { - if db_status == "ok" { + if db_status == "connected" || db_status == "ok" { if !json { ui::check_ok("Database connectivity: OK"); } @@ -2954,8 +2955,14 @@ pub(crate) fn open_in_browser(url: &str) -> bool { } #[cfg(target_os = "linux")] { + // Detach from parent to avoid inheriting sandbox restrictions. + // Some Chromium-based browsers fail with EPERM when launched from + // restricted environments (containers, snaps, flatpaks). std::process::Command::new("xdg-open") .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) .spawn() .is_ok() } diff --git a/crates/openfang-cli/src/tui/screens/init_wizard.rs b/crates/openfang-cli/src/tui/screens/init_wizard.rs index 65bc19cd..07f4c2a2 100644 --- a/crates/openfang-cli/src/tui/screens/init_wizard.rs +++ b/crates/openfang-cli/src/tui/screens/init_wizard.rs @@ -72,7 +72,7 @@ const PROVIDERS: &[ProviderInfo] = &[ name: "openrouter", display: "OpenRouter", env_var: "OPENROUTER_API_KEY", - default_model: "openrouter/auto", + default_model: "openrouter/anthropic/claude-sonnet-4", needs_key: true, hint: "", }, diff --git a/crates/openfang-hands/src/lib.rs b/crates/openfang-hands/src/lib.rs index c0a9b53e..1d734a83 100644 --- a/crates/openfang-hands/src/lib.rs +++ b/crates/openfang-hands/src/lib.rs @@ -29,6 +29,8 @@ pub enum HandError { TomlParse(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), + #[error("Config error: {0}")] + Config(String), } pub type HandResult = Result; diff --git a/crates/openfang-hands/src/registry.rs b/crates/openfang-hands/src/registry.rs index b8d9d3b8..70ea582d 100644 --- a/crates/openfang-hands/src/registry.rs +++ b/crates/openfang-hands/src/registry.rs @@ -52,6 +52,51 @@ impl HandRegistry { } } + /// Persist active hand state to disk so it survives restarts. + pub fn persist_state(&self, path: &std::path::Path) -> HandResult<()> { + let entries: Vec = self + .instances + .iter() + .filter(|e| e.status == HandStatus::Active) + .map(|e| { + serde_json::json!({ + "hand_id": e.hand_id, + "config": e.config, + }) + }) + .collect(); + let json = serde_json::to_string_pretty(&entries) + .map_err(|e| HandError::Config(format!("serialize hand state: {e}")))?; + std::fs::write(path, json) + .map_err(|e| HandError::Config(format!("write hand state: {e}")))?; + Ok(()) + } + + /// Load persisted hand state and re-activate hands. + /// Returns list of (hand_id, config) that should be activated. + pub fn load_state(path: &std::path::Path) -> Vec<(String, HashMap)> { + let data = match std::fs::read_to_string(path) { + Ok(d) => d, + Err(_) => return Vec::new(), + }; + let entries: Vec = match serde_json::from_str(&data) { + Ok(e) => e, + Err(e) => { + warn!("Failed to parse hand state file: {e}"); + return Vec::new(); + } + }; + entries + .into_iter() + .filter_map(|e| { + let hand_id = e["hand_id"].as_str()?.to_string(); + let config: HashMap = + serde_json::from_value(e["config"].clone()).unwrap_or_default(); + Some((hand_id, config)) + }) + .collect() + } + /// Load all bundled hand definitions. Returns count of definitions loaded. pub fn load_bundled(&self) -> usize { let bundled = bundled::bundled_hands(); diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 2f033066..d46dbf9f 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -3036,6 +3036,9 @@ impl OpenFangKernel { "Hand activated with agent" ); + // Persist hand state so it survives restarts + self.persist_hand_state(); + // Return instance with agent set Ok(self .hand_registry @@ -3067,9 +3070,19 @@ impl OpenFangKernel { } } } + // Persist hand state so it survives restarts + self.persist_hand_state(); Ok(()) } + /// Persist active hand state to disk. + fn persist_hand_state(&self) { + let state_path = self.config.home_dir.join("hand_state.json"); + if let Err(e) = self.hand_registry.persist_state(&state_path) { + warn!(error = %e, "Failed to persist hand state"); + } + } + /// Pause a hand (marks it paused; agent stays alive but won't receive new work). pub fn pause_hand(&self, instance_id: uuid::Uuid) -> KernelResult<()> { self.hand_registry @@ -3346,6 +3359,19 @@ impl OpenFangKernel { /// Iterates the agent registry and starts background tasks for agents with /// `Continuous`, `Periodic`, or `Proactive` schedules. pub fn start_background_agents(self: &Arc) { + // Restore previously active hands from persisted state + let state_path = self.config.home_dir.join("hand_state.json"); + let saved_hands = openfang_hands::registry::HandRegistry::load_state(&state_path); + if !saved_hands.is_empty() { + info!("Restoring {} persisted hand(s)", saved_hands.len()); + for (hand_id, config) in saved_hands { + match self.activate_hand(&hand_id, config) { + Ok(inst) => info!(hand = %hand_id, instance = %inst.instance_id, "Hand restored"), + Err(e) => warn!(hand = %hand_id, error = %e, "Failed to restore hand"), + } + } + } + let agents = self.registry.list(); let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> = Vec::new(); diff --git a/crates/openfang-runtime/src/drivers/gemini.rs b/crates/openfang-runtime/src/drivers/gemini.rs index f0acd32b..3027e0a9 100644 --- a/crates/openfang-runtime/src/drivers/gemini.rs +++ b/crates/openfang-runtime/src/drivers/gemini.rs @@ -92,6 +92,9 @@ struct GeminiInlineData { struct GeminiFunctionCallData { name: String, args: serde_json::Value, + /// Gemini 2.5+ thinking models return this on functionCall parts. + #[serde(rename = "thoughtSignature", default, skip_serializing_if = "Option::is_none")] + thought_signature: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -202,6 +205,7 @@ fn convert_messages( function_call: GeminiFunctionCallData { name: name.clone(), args: input.clone(), + thought_signature: None, }, }); } diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index fe7b4c40..9285a3aa 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -155,7 +155,7 @@ fn provider_defaults(provider: &str) -> Option { api_key_env: "MOONSHOT_API_KEY", key_required: true, }), - "qwen" | "dashscope" => Some(ProviderDefaults { + "qwen" | "dashscope" | "model_studio" => Some(ProviderDefaults { base_url: QWEN_BASE_URL, api_key_env: "DASHSCOPE_API_KEY", key_required: true, @@ -374,7 +374,7 @@ pub fn detect_available_provider() -> Option<(&'static str, &'static str, &'stat ("gemini", "gemini-2.5-flash", "GEMINI_API_KEY"), ("groq", "llama-3.3-70b-versatile", "GROQ_API_KEY"), ("deepseek", "deepseek-chat", "DEEPSEEK_API_KEY"), - ("openrouter", "openrouter/auto", "OPENROUTER_API_KEY"), + ("openrouter", "openrouter/anthropic/claude-sonnet-4", "OPENROUTER_API_KEY"), ("mistral", "mistral-large-latest", "MISTRAL_API_KEY"), ("together", "meta-llama/Llama-3-70b-chat-hf", "TOGETHER_API_KEY"), ("fireworks", "accounts/fireworks/models/llama-v3p1-70b-instruct", "FIREWORKS_API_KEY"), diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index d8baed7f..b1203927 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -222,7 +222,9 @@ impl LlmDriver for OpenAIDriver { has_tool_results = true; oai_messages.push(OaiMessage { role: "tool".to_string(), - content: Some(OaiMessageContent::Text(content.clone())), + content: Some(OaiMessageContent::Text( + if content.is_empty() { "(empty)".to_string() } else { content.clone() } + )), tool_calls: None, tool_call_id: Some(tool_use_id.clone()), }); @@ -580,7 +582,9 @@ impl LlmDriver for OpenAIDriver { { oai_messages.push(OaiMessage { role: "tool".to_string(), - content: Some(OaiMessageContent::Text(content.clone())), + content: Some(OaiMessageContent::Text( + if content.is_empty() { "(empty)".to_string() } else { content.clone() } + )), tool_calls: None, tool_call_id: Some(tool_use_id.clone()), }); diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 3dfacb6a..1df4eb3c 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -1480,47 +1480,19 @@ fn builtin_models() -> Vec { // OpenRouter (11) // ══════════════════════════════════════════════════════════════ ModelCatalogEntry { - id: "openrouter/auto".into(), - display_name: "OpenRouter Auto".into(), + id: "openrouter/openai/gpt-4o".into(), + display_name: "GPT-4o (OpenRouter)".into(), provider: "openrouter".into(), tier: ModelTier::Smart, - context_window: 200_000, - max_output_tokens: 32_000, - input_cost_per_m: 1.0, - output_cost_per_m: 3.0, + context_window: 128_000, + max_output_tokens: 16_384, + input_cost_per_m: 2.5, + output_cost_per_m: 10.0, supports_tools: true, supports_vision: true, supports_streaming: true, aliases: vec![], }, - ModelCatalogEntry { - id: "openrouter/optimus".into(), - display_name: "OpenRouter Optimus".into(), - provider: "openrouter".into(), - tier: ModelTier::Balanced, - context_window: 200_000, - max_output_tokens: 32_000, - input_cost_per_m: 0.50, - output_cost_per_m: 1.50, - supports_tools: true, - supports_vision: false, - supports_streaming: true, - aliases: vec![], - }, - ModelCatalogEntry { - id: "openrouter/nitro".into(), - display_name: "OpenRouter Nitro".into(), - provider: "openrouter".into(), - tier: ModelTier::Fast, - context_window: 128_000, - max_output_tokens: 16_000, - input_cost_per_m: 0.20, - output_cost_per_m: 0.60, - supports_tools: true, - supports_vision: false, - supports_streaming: true, - aliases: vec![], - }, ModelCatalogEntry { id: "openrouter/anthropic/claude-sonnet-4".into(), display_name: "Claude Sonnet 4 (OpenRouter)".into(),