diff --git a/Cargo.toml b/Cargo.toml index 9e3894af..6456c21a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.4.0" +version = "0.4.1" edition = "2021" license = "Apache-2.0 OR MIT" repository = "https://github.com/RightNow-AI/openfang" diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 68e72867..a132f6d1 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -7007,8 +7007,8 @@ pub async fn set_provider_key( "\n[default_model]\nprovider = \"{}\"\nmodel = \"{}\"\napi_key_env = \"{}\"\n", name, model_id, env_var ); + backup_config(&config_path); if let Ok(existing) = std::fs::read_to_string(&config_path) { - // Remove existing [default_model] section if present, then append let cleaned = remove_toml_section(&existing, "default_model"); let _ = std::fs::write(&config_path, format!("{}\n{}", cleaned.trim(), update_toml)); } else { @@ -10839,6 +10839,12 @@ pub async fn auth_check( } /// Remove a `[section]` and its contents from a TOML string. +#[allow(dead_code)] +fn backup_config(config_path: &std::path::Path) { + let backup = config_path.with_extension("toml.bak"); + let _ = std::fs::copy(config_path, backup); +} + fn remove_toml_section(content: &str, section: &str) -> String { let header = format!("[{}]", section); let mut result = String::new(); diff --git a/crates/openfang-api/static/js/pages/agents.js b/crates/openfang-api/static/js/pages/agents.js index 21f506f1..5a3c7df4 100644 --- a/crates/openfang-api/static/js/pages/agents.js +++ b/crates/openfang-api/static/js/pages/agents.js @@ -393,7 +393,7 @@ function agentsPage() { }, // ── Multi-step wizard navigation ── - openSpawnWizard() { + async openSpawnWizard() { this.showSpawnModal = true; this.spawnStep = 1; this.spawnMode = 'wizard'; @@ -401,8 +401,18 @@ function agentsPage() { this.selectedPreset = ''; this.soulContent = ''; this.spawnForm.name = ''; + this.spawnForm.provider = 'groq'; + this.spawnForm.model = 'llama-3.3-70b-versatile'; this.spawnForm.systemPrompt = 'You are a helpful assistant.'; this.spawnForm.profile = 'full'; + try { + var res = await fetch('/api/status'); + if (res.ok) { + var status = await res.json(); + if (status.default_provider) this.spawnForm.provider = status.default_provider; + if (status.default_model) this.spawnForm.model = status.default_model; + } + } catch(e) { /* keep hardcoded defaults */ } }, nextStep() { diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 60b29562..390b367f 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -780,7 +780,7 @@ async fn dispatch_message( send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await; } warn!("Agent error for {agent_id}: {e}"); - let err_msg = format!("Agent error: {e}"); + let err_msg = sanitize_agent_error(&e.to_string()); send_response( adapter, &message.sender, @@ -803,6 +803,76 @@ async fn dispatch_message( } } +fn sanitize_agent_error(raw: &str) -> String { + let lower = raw.to_lowercase(); + + if lower.contains("rate limit") + || lower.contains("rate_limit") + || lower.contains("429") + || lower.contains("too many requests") + || lower.contains("resource_exhausted") + { + return "Rate limit reached, please try again later.".to_string(); + } + + if lower.contains("authentication") + || lower.contains("unauthorized") + || lower.contains("invalid api key") + || lower.contains("invalid x-goog-api-key") + || lower.contains("incorrect api key") + || lower.contains("permission denied") + || lower.contains("billing") + || lower.contains("quota exceeded") + { + return "Service temporarily unavailable.".to_string(); + } + + if lower.contains("context length") + || lower.contains("token limit") + || lower.contains("too many tokens") + || lower.contains("maximum context") + || lower.contains("max_tokens") + || lower.contains("context window") + { + return "Message too long, try a shorter request.".to_string(); + } + + if lower.contains("overloaded") + || lower.contains("503") + || lower.contains("502") + || lower.contains("server error") + || lower.contains("internal error") + { + return "The AI service is temporarily overloaded, please try again shortly.".to_string(); + } + + if lower.contains("timeout") || lower.contains("timed out") || lower.contains("deadline") { + return "Request timed out, please try again.".to_string(); + } + + if lower.contains("model not found") || lower.contains("model_not_found") { + return "The requested model is currently unavailable.".to_string(); + } + + let cleaned = raw + .strip_prefix("LLM driver error: ") + .or_else(|| raw.strip_prefix("Agent error: ")) + .unwrap_or(raw); + + if let Some(first_sentence_end) = cleaned.find(". ") { + let first = &cleaned[..=first_sentence_end]; + if first.len() < cleaned.len() / 2 { + return format!("Agent error: {first}"); + } + } + + if cleaned.contains('{') || cleaned.len() > 200 { + return "Something went wrong processing your request. Please try again.".to_string(); + } + + format!("Agent error: {cleaned}") +} + /// Detect image format from the first few magic bytes. /// /// Returns `Some("image/...")` for JPEG, PNG, GIF, and WebP. @@ -1012,7 +1082,7 @@ async fn dispatch_with_blocks( send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await; } warn!("Agent error for {agent_id}: {e}"); - let err_msg = format!("Agent error: {e}"); + let err_msg = sanitize_agent_error(&e.to_string()); send_response( adapter, &message.sender, diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index 6ab71db4..7bfa88ac 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -794,11 +794,36 @@ enum SystemCommands { }, } +fn config_log_level() -> String { + let config_path = if let Ok(home) = std::env::var("OPENFANG_HOME") { + std::path::PathBuf::from(home).join("config.toml") + } else { + dirs::home_dir() + .unwrap_or_else(std::env::temp_dir) + .join(".openfang") + .join("config.toml") + }; + if let Ok(content) = std::fs::read_to_string(config_path) { + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("log_level") { + if let Some(val) = trimmed.split('=').nth(1) { + let level = val.trim().trim_matches('"').trim_matches('\''); + if !level.is_empty() { + return level.to_string(); + } + } + } + } + } + "info".to_string() +} + fn init_tracing_stderr() { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())), ) .init(); } @@ -824,7 +849,7 @@ fn init_tracing_file() { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())), ) .with_writer(std::sync::Mutex::new(file)) .with_ansi(false) @@ -4691,6 +4716,8 @@ fn cmd_config_set(key: &str, value: &str) { std::process::exit(1); }); + let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak")); + std::fs::write(&config_path, &serialized).unwrap_or_else(|e| { ui::error(&format!("Failed to write config: {e}")); std::process::exit(1); @@ -4757,6 +4784,8 @@ fn cmd_config_unset(key: &str) { std::process::exit(1); }); + let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak")); + std::fs::write(&config_path, &serialized).unwrap_or_else(|e| { ui::error(&format!("Failed to write config: {e}")); std::process::exit(1); diff --git a/crates/openfang-hands/src/bundled.rs b/crates/openfang-hands/src/bundled.rs index b54b9feb..bce5af2d 100644 --- a/crates/openfang-hands/src/bundled.rs +++ b/crates/openfang-hands/src/bundled.rs @@ -1,6 +1,6 @@ //! Compile-time embedded Hand definitions. -use crate::{HandDefinition, HandError}; +use crate::{parse_hand_toml, HandDefinition, HandError}; /// Returns all bundled hand definitions as (id, HAND.toml content, SKILL.md content). pub fn bundled_hands() -> Vec<(&'static str, &'static str, &'static str)> { @@ -55,7 +55,7 @@ pub fn parse_bundled( skill_content: &str, ) -> Result { let mut def: HandDefinition = - toml::from_str(toml_content).map_err(|e| HandError::TomlParse(e.to_string()))?; + parse_hand_toml(toml_content).map_err(|e| HandError::TomlParse(e.to_string()))?; if !skill_content.is_empty() { def.skill_content = Some(skill_content.to_string()); } diff --git a/crates/openfang-hands/src/lib.rs b/crates/openfang-hands/src/lib.rs index f2d3e339..a96dfd44 100644 --- a/crates/openfang-hands/src/lib.rs +++ b/crates/openfang-hands/src/lib.rs @@ -306,6 +306,20 @@ fn default_temperature() -> f32 { 0.7 } +#[derive(Deserialize)] +struct HandTomlWrapper { + hand: HandDefinition, +} + +/// Parse HAND.toml content, supporting both flat format and `[hand]` table format. +pub fn parse_hand_toml(content: &str) -> Result { + if let Ok(def) = toml::from_str::(content) { + return Ok(def); + } + let wrapper: HandTomlWrapper = toml::from_str(content)?; + Ok(wrapper.hand) +} + /// Complete Hand definition — parsed from HAND.toml. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HandDefinition { @@ -798,4 +812,50 @@ metrics = [] assert!(install.macos.is_none()); assert!(install.windows.is_none()); } + + #[test] + fn parse_hand_toml_flat_format() { + let toml_str = r#" +id = "test" +name = "Test Hand" +description = "A test hand" +category = "content" +tools = ["shell_exec"] + +[agent] +name = "test-hand" +description = "Test agent" +system_prompt = "You are a test agent." + +[dashboard] +metrics = [] +"#; + let def = parse_hand_toml(toml_str).unwrap(); + assert_eq!(def.id, "test"); + assert_eq!(def.name, "Test Hand"); + } + + #[test] + fn parse_hand_toml_wrapped_format() { + let toml_str = r#" +[hand] +id = "test" +name = "Test Hand" +description = "A test hand" +category = "content" +tools = ["shell_exec"] + +[hand.agent] +name = "test-hand" +description = "Test agent" +system_prompt = "You are a test agent." + +[hand.dashboard] +metrics = [] +"#; + let def = parse_hand_toml(toml_str).unwrap(); + assert_eq!(def.id, "test"); + assert_eq!(def.name, "Test Hand"); + assert_eq!(def.agent.name, "test-hand"); + } } diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 99f5de91..4ecf3587 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -1602,7 +1602,7 @@ impl OpenFangKernel { label: None, }); - // Check if auto-compaction is needed: message-count OR token-count trigger + // Check if auto-compaction is needed: message-count OR token-count OR quota-headroom trigger let needs_compact = { use openfang_runtime::compactor::{ estimate_token_count, needs_compaction as check_compact, @@ -1624,7 +1624,23 @@ impl OpenFangKernel { "Token-based compaction triggered (messages below threshold but tokens above)" ); } - by_messages || by_tokens + let by_quota = if let Some(headroom) = self.scheduler.token_headroom(agent_id) { + let threshold = (headroom as f64 * 0.8) as u64; + if estimated as u64 > threshold && session.messages.len() > 4 { + info!( + agent_id = %agent_id, + estimated_tokens = estimated, + quota_headroom = headroom, + "Quota-headroom compaction triggered (session would consume >80% of remaining quota)" + ); + true + } else { + false + } + } else { + false + }; + by_messages || by_tokens || by_quota }; let tools = self.available_tools(agent_id); @@ -2094,6 +2110,42 @@ impl OpenFangKernel { label: None, }); + // Pre-emptive compaction: compact before LLM call if session is large or quota headroom is low + { + use openfang_runtime::compactor::{ + estimate_token_count, needs_compaction as check_compact, + needs_compaction_by_tokens, CompactionConfig, + }; + let config = CompactionConfig::default(); + let by_messages = check_compact(&session, &config); + let estimated = estimate_token_count( + &session.messages, + Some(&entry.manifest.model.system_prompt), + None, + ); + let by_tokens = needs_compaction_by_tokens(estimated, &config); + let by_quota = if let Some(headroom) = self.scheduler.token_headroom(agent_id) { + let threshold = (headroom as f64 * 0.8) as u64; + estimated as u64 > threshold && session.messages.len() > 4 + } else { + false + }; + if by_messages || by_tokens || by_quota { + info!(agent_id = %agent_id, messages = session.messages.len(), estimated_tokens = estimated, "Pre-emptive compaction before LLM call"); + match self.compact_agent_session(agent_id).await { + Ok(msg) => { + info!(agent_id = %agent_id, "{msg}"); + if let Ok(Some(reloaded)) = self.memory.get_session(session.id) { + session = reloaded; + } + } + Err(e) => { + warn!(agent_id = %agent_id, "Pre-emptive compaction failed: {e}"); + } + } + } + } + let messages_before = session.messages.len(); let tools = self.available_tools(agent_id); diff --git a/crates/openfang-kernel/src/scheduler.rs b/crates/openfang-kernel/src/scheduler.rs index b0fb5245..c75fb869 100644 --- a/crates/openfang-kernel/src/scheduler.rs +++ b/crates/openfang-kernel/src/scheduler.rs @@ -130,6 +130,19 @@ impl AgentScheduler { .get(&agent_id) .map(|t| (t.total_tokens, t.tool_calls)) } + + /// Returns remaining token headroom before quota is hit. + /// Returns `None` if no token quota is configured (unlimited). + pub fn token_headroom(&self, agent_id: AgentId) -> Option { + let quota = self.quotas.get(&agent_id)?; + if quota.max_llm_tokens_per_hour == 0 { + return None; + } + let mut tracker = self.usage.get_mut(&agent_id)?; + tracker.reset_if_expired(); + let used = tracker.total_tokens; + Some(quota.max_llm_tokens_per_hour.saturating_sub(used)) + } } impl Default for AgentScheduler { diff --git a/crates/openfang-runtime/src/prompt_builder.rs b/crates/openfang-runtime/src/prompt_builder.rs index e0a8bd2a..4e984490 100644 --- a/crates/openfang-runtime/src/prompt_builder.rs +++ b/crates/openfang-runtime/src/prompt_builder.rs @@ -275,12 +275,18 @@ pub fn build_canonical_context_message(ctx: &PromptContext) -> Option { /// /// Also used by `agent_loop.rs` to append recalled memories after DB lookup. pub fn build_memory_section(memories: &[(String, String)]) -> String { - let mut out = String::from( - "## Memory\n\ - - When the user asks about something from a previous conversation, use memory_recall first.\n\ - - Store important preferences, decisions, and context with memory_store for future use.", - ); - if !memories.is_empty() { + let mut out = String::from("## Memory\n"); + if memories.is_empty() { + out.push_str( + "- When the user asks about something from a previous conversation, use memory_recall first.\n\ + - Store important preferences, decisions, and context with memory_store for future use.", + ); + } else { + out.push_str( + "- Use the recalled memories below to inform your responses.\n\ + - Only call memory_recall if you need information not already shown here.\n\ + - Store important preferences, decisions, and context with memory_store for future use.", + ); out.push_str("\n\nRecalled memories:\n"); for (key, content) in memories.iter().take(5) { let capped = cap_str(content, 500); @@ -728,7 +734,7 @@ mod tests { fn test_memory_section_empty() { let section = build_memory_section(&[]); assert!(section.contains("## Memory")); - assert!(section.contains("memory_recall")); + assert!(section.contains("use memory_recall first")); assert!(!section.contains("Recalled memories")); } @@ -742,6 +748,8 @@ mod tests { assert!(section.contains("Recalled memories")); assert!(section.contains("[pref] User likes dark mode")); assert!(section.contains("[ctx] Working on Rust project")); + assert!(section.contains("Use the recalled memories below")); + assert!(!section.contains("use memory_recall first")); } #[test] diff --git a/crates/openfang-types/src/error.rs b/crates/openfang-types/src/error.rs index beadcf0c..4f6be014 100644 --- a/crates/openfang-types/src/error.rs +++ b/crates/openfang-types/src/error.rs @@ -72,7 +72,7 @@ pub enum OpenFangError { Serialization(String), /// The agent loop exceeded the maximum iteration count. - #[error("Max iterations exceeded: {0}")] + #[error("Max iterations exceeded ({0}). Configure a higher limit in agent.toml under [autonomous] max_iterations")] MaxIterationsExceeded(u32), /// The kernel is shutting down.