diff --git a/Cargo.lock b/Cargo.lock index 02e37b73..cf77fe7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3866,7 +3866,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.2.8" +version = "0.2.9" dependencies = [ "async-trait", "axum", @@ -3902,7 +3902,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.2.8" +version = "0.2.9" dependencies = [ "async-trait", "axum", @@ -3933,7 +3933,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.2.8" +version = "0.2.9" dependencies = [ "clap", "clap_complete", @@ -3960,7 +3960,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.2.8" +version = "0.2.9" dependencies = [ "axum", "open", @@ -3986,7 +3986,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.2.8" +version = "0.2.9" dependencies = [ "aes-gcm", "argon2", @@ -4014,7 +4014,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.2.8" +version = "0.2.9" dependencies = [ "chrono", "dashmap", @@ -4031,7 +4031,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.2.8" +version = "0.2.9" dependencies = [ "async-trait", "chrono", @@ -4067,7 +4067,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.2.8" +version = "0.2.9" dependencies = [ "async-trait", "chrono", @@ -4086,7 +4086,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.2.8" +version = "0.2.9" dependencies = [ "chrono", "dirs 6.0.0", @@ -4105,7 +4105,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.2.8" +version = "0.2.9" dependencies = [ "anyhow", "async-trait", @@ -4137,7 +4137,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.2.8" +version = "0.2.9" dependencies = [ "chrono", "hex", @@ -4159,7 +4159,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.2.8" +version = "0.2.9" dependencies = [ "async-trait", "chrono", @@ -4178,7 +4178,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.2.8" +version = "0.2.9" dependencies = [ "async-trait", "chrono", @@ -8790,7 +8790,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.2.8" +version = "0.2.9" [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index 113fa747..4ba3dcdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.2.9" +version = "0.3.0" edition = "2021" license = "Apache-2.0 OR MIT" repository = "https://github.com/RightNow-AI/openfang" diff --git a/crates/openfang-api/src/middleware.rs b/crates/openfang-api/src/middleware.rs index 56385217..c953a788 100644 --- a/crates/openfang-api/src/middleware.rs +++ b/crates/openfang-api/src/middleware.rs @@ -82,6 +82,10 @@ pub async fn auth( // Public endpoints that don't require auth (dashboard needs these) let path = request.uri().path(); if path == "/" + || path == "/logo.png" + || path == "/favicon.ico" + || path == "/.well-known/agent.json" + || path.starts_with("/a2a/") || path == "/api/health" || path == "/api/health/detail" || path == "/api/status" diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index b10cf184..bb6edb4b 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -1123,7 +1123,7 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[ ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("DISCORD_BOT_TOKEN"), required: true, placeholder: "MTIz...", advanced: false }, ChannelField { key: "allowed_guilds", label: "Allowed Guild IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123456789, 987654321", advanced: true }, ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, - ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "33280", advanced: true }, + ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "37376", advanced: true }, ], setup_steps: &["Go to discord.com/developers/applications", "Create a bot and copy the token", "Paste it below"], config_template: "[channels.discord]\nbot_token_env = \"DISCORD_BOT_TOKEN\"", @@ -3022,13 +3022,18 @@ pub async fn clawhub_install( ) } Err(e) => { - let status = if e.to_string().contains("SecurityBlocked") { + let msg = format!("{e}"); + let status = if msg.contains("SecurityBlocked") { StatusCode::FORBIDDEN + } else if msg.contains("429") || msg.contains("rate limit") { + StatusCode::TOO_MANY_REQUESTS + } else if msg.contains("Network error") || msg.contains("returned 4") || msg.contains("returned 5") { + StatusCode::BAD_GATEWAY } else { StatusCode::INTERNAL_SERVER_ERROR }; - tracing::warn!("ClawHub install failed: {e}"); - (status, Json(serde_json::json!({"error": format!("{e}")}))) + tracing::warn!("ClawHub install failed: {msg}"); + (status, Json(serde_json::json!({"error": msg}))) } } } @@ -6987,6 +6992,29 @@ pub async fn create_schedule( } let agent_id_str = req["agent_id"].as_str().unwrap_or("").to_string(); + if agent_id_str.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing required field: agent_id"})), + ); + } + // Validate agent exists (UUID or name lookup) + let agent_exists = if let Ok(aid) = agent_id_str.parse::() { + state.kernel.registry.get(aid).is_some() + } else { + state + .kernel + .registry + .list() + .iter() + .any(|a| a.name == agent_id_str) + }; + if !agent_exists { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Agent not found: {agent_id_str}")})), + ); + } let message = req["message"].as_str().unwrap_or("").to_string(); let enabled = req.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true); @@ -7160,10 +7188,14 @@ pub async fn run_schedule( .unwrap_or("Scheduled task triggered manually."); let name = schedule["name"].as_str().unwrap_or("(unnamed)"); - // Find the target agent + // Find the target agent — require explicit agent_id, no silent fallback let target_agent = if !agent_id_str.is_empty() { if let Ok(aid) = agent_id_str.parse::() { - Some(aid) + if state.kernel.registry.get(aid).is_some() { + Some(aid) + } else { + None + } } else { state .kernel @@ -7174,7 +7206,7 @@ pub async fn run_schedule( .map(|a| a.id) } } else { - state.kernel.registry.list().first().map(|a| a.id) + None }; let target_agent = match target_agent { diff --git a/crates/openfang-channels/src/discord.rs b/crates/openfang-channels/src/discord.rs index 465b2cd9..696b677c 100644 --- a/crates/openfang-channels/src/discord.rs +++ b/crates/openfang-channels/src/discord.rs @@ -684,7 +684,7 @@ mod tests { #[test] fn test_discord_adapter_creation() { - let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], 33280); + let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], 37376); assert_eq!(adapter.name(), "discord"); assert_eq!(adapter.channel_type(), ChannelType::Discord); } diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index 2ad21b7d..e254849a 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -405,6 +405,26 @@ enum HandCommands { /// Hand ID. id: String, }, + /// Check dependency status for a hand. + CheckDeps { + /// Hand ID. + id: String, + }, + /// Install missing dependencies for a hand. + InstallDeps { + /// Hand ID. + id: String, + }, + /// Pause a running hand instance. + Pause { + /// Instance ID (from `hand active`). + id: String, + }, + /// Resume a paused hand instance. + Resume { + /// Instance ID (from `hand active`). + id: String, + }, } #[derive(Subcommand)] @@ -612,6 +632,9 @@ enum CronCommands { spec: String, /// Prompt to send when the job fires. prompt: String, + /// Optional job name (auto-generated if omitted). + #[arg(long)] + name: Option, }, /// Delete a scheduled job. Delete { @@ -890,6 +913,10 @@ fn main() { HandCommands::Activate { id } => cmd_hand_activate(&id), HandCommands::Deactivate { id } => cmd_hand_deactivate(&id), HandCommands::Info { id } => cmd_hand_info(&id), + HandCommands::CheckDeps { id } => cmd_hand_check_deps(&id), + HandCommands::InstallDeps { id } => cmd_hand_install_deps(&id), + HandCommands::Pause { id } => cmd_hand_pause(&id), + HandCommands::Resume { id } => cmd_hand_resume(&id), }, Some(Commands::Config(sub)) => match sub { ConfigCommands::Show => cmd_config_show(), @@ -940,7 +967,8 @@ fn main() { agent, spec, prompt, - } => cmd_cron_create(&agent, &spec, &prompt), + name, + } => cmd_cron_create(&agent, &spec, &prompt, name.as_deref()), CronCommands::Delete { id } => cmd_cron_delete(&id), CronCommands::Enable { id } => cmd_cron_toggle(&id, true), CronCommands::Disable { id } => cmd_cron_toggle(&id, false), @@ -3952,6 +3980,87 @@ fn cmd_hand_info(id: &str) { ); } +fn cmd_hand_check_deps(id: &str) { + let base = require_daemon("hand check-deps"); + let client = daemon_client(); + let body = daemon_json( + client + .post(format!("{base}/api/hands/{id}/check-deps")) + .send(), + ); + if body.get("error").is_some() { + ui::error(&format!( + "Failed: {}", + body["error"].as_str().unwrap_or("?") + )); + } else { + println!( + "{}", + serde_json::to_string_pretty(&body).unwrap_or_default() + ); + } +} + +fn cmd_hand_install_deps(id: &str) { + let base = require_daemon("hand install-deps"); + let client = daemon_client(); + let body = daemon_json( + client + .post(format!("{base}/api/hands/{id}/install-deps")) + .send(), + ); + if body.get("error").is_some() { + ui::error(&format!( + "Failed: {}", + body["error"].as_str().unwrap_or("?") + )); + } else { + ui::success(&format!("Dependencies installed for hand '{id}'.")); + if let Some(results) = body.get("results") { + println!( + "{}", + serde_json::to_string_pretty(results).unwrap_or_default() + ); + } + } +} + +fn cmd_hand_pause(id: &str) { + let base = require_daemon("hand pause"); + let client = daemon_client(); + let body = daemon_json( + client + .post(format!("{base}/api/hands/instances/{id}/pause")) + .send(), + ); + if body.get("error").is_some() { + ui::error(&format!( + "Failed: {}", + body["error"].as_str().unwrap_or("?") + )); + } else { + ui::success(&format!("Hand instance '{id}' paused.")); + } +} + +fn cmd_hand_resume(id: &str) { + let base = require_daemon("hand resume"); + let client = daemon_client(); + let body = daemon_json( + client + .post(format!("{base}/api/hands/instances/{id}/resume")) + .send(), + ); + if body.get("error").is_some() { + ui::error(&format!( + "Failed: {}", + body["error"].as_str().unwrap_or("?") + )); + } else { + ui::success(&format!("Hand instance '{id}' resumed.")); + } +} + // --------------------------------------------------------------------------- // Provider / API key helpers // --------------------------------------------------------------------------- @@ -5149,16 +5258,40 @@ fn cmd_cron_list(json: bool) { } } -fn cmd_cron_create(agent: &str, spec: &str, prompt: &str) { +fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option<&str>) { let base = require_daemon("cron create"); let client = daemon_client(); + + // Use explicit name if provided, otherwise derive from agent + prompt + let name = if let Some(n) = explicit_name { + n.to_string() + } else { + let short_prompt: String = prompt + .split_whitespace() + .take(4) + .collect::>() + .join("-") + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') + .take(64) + .collect(); + format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt }) + }; + let body = daemon_json( client .post(format!("{base}/api/cron/jobs")) .json(&serde_json::json!({ "agent_id": agent, - "cron_expr": spec, - "prompt": prompt, + "name": name, + "schedule": { + "kind": "cron", + "expr": spec + }, + "action": { + "kind": "agent_turn", + "message": prompt + } })) .send(), ); diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 7ca31ea0..ce49b4f2 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -532,7 +532,11 @@ impl OpenFangKernel { let driver_config = DriverConfig { provider: config.default_model.provider.clone(), api_key: std::env::var(&config.default_model.api_key_env).ok(), - base_url: config.default_model.base_url.clone(), + base_url: config + .default_model + .base_url + .clone() + .or_else(|| config.provider_urls.get(&config.default_model.provider).cloned()), }; let primary_driver = drivers::create_driver(&driver_config) .map_err(|e| KernelError::BootFailed(format!("LLM driver init failed: {e}")))?; @@ -548,7 +552,10 @@ impl OpenFangKernel { } else { std::env::var(&fb.api_key_env).ok() }, - base_url: fb.base_url.clone(), + base_url: fb + .base_url + .clone() + .or_else(|| config.provider_urls.get(&fb.provider).cloned()), }; match drivers::create_driver(&fb_config) { Ok(d) => { @@ -3698,10 +3705,14 @@ impl OpenFangKernel { let base_url = if has_custom_url { manifest.model.base_url.clone() } else if agent_provider == default_provider { - self.config.default_model.base_url.clone() + self.config + .default_model + .base_url + .clone() + .or_else(|| self.config.provider_urls.get(agent_provider.as_str()).cloned()) } else { - // Let create_driver() use the target provider's default base URL - None + // Check provider_urls before falling back to hardcoded defaults + self.config.provider_urls.get(agent_provider.as_str()).cloned() }; let driver_config = DriverConfig { @@ -3727,7 +3738,10 @@ impl OpenFangKernel { .api_key_env .as_ref() .and_then(|env| std::env::var(env).ok()), - base_url: fb.base_url.clone(), + base_url: fb + .base_url + .clone() + .or_else(|| self.config.provider_urls.get(&fb.provider).cloned()), }; match drivers::create_driver(&config) { Ok(d) => chain.push((d, fb.model.clone())), diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index b7455109..f44f9844 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -33,7 +33,12 @@ impl OpenAIDriver { struct OaiRequest { model: String, messages: Vec, - max_tokens: u32, + /// Classic token limit field (used by most models). + #[serde(skip_serializing_if = "Option::is_none")] + max_tokens: Option, + /// New token limit field required by GPT-5 and o-series reasoning models. + #[serde(skip_serializing_if = "Option::is_none")] + max_completion_tokens: Option, temperature: f32, #[serde(skip_serializing_if = "Vec::is_empty")] tools: Vec, @@ -43,6 +48,16 @@ struct OaiRequest { stream: bool, } +/// Returns true if a model uses `max_completion_tokens` instead of `max_tokens`. +fn uses_completion_tokens(model: &str) -> bool { + let m = model.to_lowercase(); + m.starts_with("gpt-5") + || m.starts_with("gpt5") + || m.starts_with("o1") + || m.starts_with("o3") + || m.starts_with("o4") +} + #[derive(Debug, Serialize)] struct OaiMessage { role: String, @@ -276,10 +291,16 @@ impl LlmDriver for OpenAIDriver { Some(serde_json::json!("auto")) }; + let (mt, mct) = if uses_completion_tokens(&request.model) { + (None, Some(request.max_tokens)) + } else { + (Some(request.max_tokens), None) + }; let mut oai_request = OaiRequest { model: request.model.clone(), messages: oai_messages, - max_tokens: request.max_tokens, + max_tokens: mt, + max_completion_tokens: mct, temperature: request.temperature, tools: oai_tools, tool_choice, @@ -339,16 +360,31 @@ impl LlmDriver for OpenAIDriver { } } + // GPT-5 / o-series: switch from max_tokens to max_completion_tokens + if status == 400 + && body.contains("max_tokens") + && (body.contains("unsupported_parameter") + || body.contains("max_completion_tokens")) + && oai_request.max_tokens.is_some() + && attempt < max_retries + { + let val = oai_request.max_tokens.unwrap(); + warn!(model = %oai_request.model, "Switching to max_completion_tokens for this model"); + oai_request.max_tokens = None; + oai_request.max_completion_tokens = Some(val); + continue; + } + // Auto-cap max_tokens when model rejects our value (e.g. Groq Maverick limit 8192) if status == 400 && body.contains("max_tokens") && attempt < max_retries { - // Extract the limit from error: "must be less than or equal to `8192`" - let cap = extract_max_tokens_limit(&body).unwrap_or(oai_request.max_tokens / 2); - warn!( - old = oai_request.max_tokens, - new = cap, - "Auto-capping max_tokens to model limit" - ); - oai_request.max_tokens = cap; + let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096); + let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2); + warn!(old = current, new = cap, "Auto-capping max_tokens to model limit"); + if oai_request.max_completion_tokens.is_some() { + oai_request.max_completion_tokens = Some(cap); + } else { + oai_request.max_tokens = Some(cap); + } continue; } @@ -555,10 +591,16 @@ impl LlmDriver for OpenAIDriver { Some(serde_json::json!("auto")) }; + let (mt, mct) = if uses_completion_tokens(&request.model) { + (None, Some(request.max_tokens)) + } else { + (Some(request.max_tokens), None) + }; let mut oai_request = OaiRequest { model: request.model.clone(), messages: oai_messages, - max_tokens: request.max_tokens, + max_tokens: mt, + max_completion_tokens: mct, temperature: request.temperature, tools: oai_tools, tool_choice, @@ -620,15 +662,31 @@ impl LlmDriver for OpenAIDriver { } } + // GPT-5 / o-series: switch from max_tokens to max_completion_tokens + if status == 400 + && body.contains("max_tokens") + && (body.contains("unsupported_parameter") + || body.contains("max_completion_tokens")) + && oai_request.max_tokens.is_some() + && attempt < max_retries + { + let val = oai_request.max_tokens.unwrap(); + warn!(model = %oai_request.model, "Switching to max_completion_tokens for this model (stream)"); + oai_request.max_tokens = None; + oai_request.max_completion_tokens = Some(val); + continue; + } + // Auto-cap max_tokens when model rejects our value if status == 400 && body.contains("max_tokens") && attempt < max_retries { - let cap = extract_max_tokens_limit(&body).unwrap_or(oai_request.max_tokens / 2); - warn!( - old = oai_request.max_tokens, - new = cap, - "Auto-capping max_tokens (stream)" - ); - oai_request.max_tokens = cap; + let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096); + let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2); + warn!(old = current, new = cap, "Auto-capping max_tokens (stream)"); + if oai_request.max_completion_tokens.is_some() { + oai_request.max_completion_tokens = Some(cap); + } else { + oai_request.max_tokens = Some(cap); + } continue; } diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 6fb7ed13..3e8b50e7 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1556,7 +1556,7 @@ pub struct DiscordConfig { pub allowed_guilds: Vec, /// Default agent name to route messages to. pub default_agent: Option, - /// Gateway intents bitmask (default: 33280 = GUILD_MESSAGES | MESSAGE_CONTENT). + /// Gateway intents bitmask (default: 37376 = GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT). pub intents: u64, /// Per-channel behavior overrides. #[serde(default)] @@ -1569,7 +1569,7 @@ impl Default for DiscordConfig { bot_token_env: "DISCORD_BOT_TOKEN".to_string(), allowed_guilds: vec![], default_agent: None, - intents: 33280, + intents: 37376, overrides: ChannelOverrides::default(), } } @@ -3226,7 +3226,7 @@ mod tests { let dc = DiscordConfig::default(); assert_eq!(dc.bot_token_env, "DISCORD_BOT_TOKEN"); assert!(dc.allowed_guilds.is_empty()); - assert_eq!(dc.intents, 33280); + assert_eq!(dc.intents, 37376); } #[test]