batch fixes

This commit is contained in:
jaberjaber23
2026-03-08 04:04:37 +03:00
parent 6857e3cf06
commit cfae867908
13 changed files with 165 additions and 67 deletions
Generated
+14 -14
View File
@@ -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"
+13 -4
View File
@@ -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}")})),
+3 -2
View File
@@ -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 {
+31 -3
View File
@@ -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<username>\0<password>
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();
+12 -5
View File
@@ -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()
}
@@ -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: "",
},
+2
View File
@@ -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<T> = Result<T, HandError>;
+45
View File
@@ -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<serde_json::Value> = 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<String, serde_json::Value>)> {
let data = match std::fs::read_to_string(path) {
Ok(d) => d,
Err(_) => return Vec::new(),
};
let entries: Vec<serde_json::Value> = 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<String, serde_json::Value> =
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();
+26
View File
@@ -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<Self>) {
// 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();
@@ -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<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -202,6 +205,7 @@ fn convert_messages(
function_call: GeminiFunctionCallData {
name: name.clone(),
args: input.clone(),
thought_signature: None,
},
});
}
+2 -2
View File
@@ -155,7 +155,7 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
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"),
@@ -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()),
});
+6 -34
View File
@@ -1480,47 +1480,19 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
// 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(),