diff --git a/Cargo.lock b/Cargo.lock index 9c79d496..136d843a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3866,7 +3866,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.3.6" +version = "0.3.7" dependencies = [ "async-trait", "axum", @@ -3902,7 +3902,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.3.6" +version = "0.3.7" dependencies = [ "async-trait", "axum", @@ -3933,7 +3933,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.3.6" +version = "0.3.7" dependencies = [ "clap", "clap_complete", @@ -3960,7 +3960,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.3.6" +version = "0.3.7" dependencies = [ "axum", "open", @@ -3986,7 +3986,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.3.6" +version = "0.3.7" dependencies = [ "aes-gcm", "argon2", @@ -4014,7 +4014,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.3.6" +version = "0.3.7" dependencies = [ "chrono", "dashmap", @@ -4031,7 +4031,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.3.6" +version = "0.3.7" dependencies = [ "async-trait", "chrono", @@ -4067,7 +4067,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.3.6" +version = "0.3.7" dependencies = [ "async-trait", "chrono", @@ -4086,7 +4086,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.3.6" +version = "0.3.7" dependencies = [ "chrono", "dirs 6.0.0", @@ -4105,7 +4105,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.3.6" +version = "0.3.7" dependencies = [ "anyhow", "async-trait", @@ -4137,7 +4137,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.3.6" +version = "0.3.7" dependencies = [ "chrono", "hex", @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.3.6" +version = "0.3.7" dependencies = [ "async-trait", "chrono", @@ -4179,7 +4179,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.3.6" +version = "0.3.7" dependencies = [ "async-trait", "chrono", @@ -8791,7 +8791,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.3.6" +version = "0.3.7" [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index 867d5bb9..7ce9cc59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.3.7" +version = "0.3.8" edition = "2021" license = "Apache-2.0 OR MIT" repository = "https://github.com/RightNow-AI/openfang" diff --git a/crates/openfang-api/src/ws.rs b/crates/openfang-api/src/ws.rs index f24c70be..412fec46 100644 --- a/crates/openfang-api/src/ws.rs +++ b/crates/openfang-api/src/ws.rs @@ -796,7 +796,12 @@ async fn handle_command( } else { match state.kernel.set_agent_model(agent_id, args) { Ok(()) => { - serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")}) + let msg = if let Some(entry) = state.kernel.registry.get(agent_id) { + format!("Model switched to: {} (provider: {})", entry.manifest.model.model, entry.manifest.model.provider) + } else { + format!("Model switched to: {args}") + }; + serde_json::json!({"type": "command_result", "command": cmd, "message": msg}) } Err(e) => { serde_json::json!({"type": "error", "content": format!("Model switch failed: {e}")}) diff --git a/crates/openfang-api/tests/api_integration_test.rs b/crates/openfang-api/tests/api_integration_test.rs index e7b0bdab..cef9cd90 100644 --- a/crates/openfang-api/tests/api_integration_test.rs +++ b/crates/openfang-api/tests/api_integration_test.rs @@ -222,10 +222,10 @@ async fn test_status_endpoint() { assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.unwrap(); assert_eq!(body["status"], "running"); - assert_eq!(body["agent_count"], 0); + assert_eq!(body["agent_count"], 1); // default assistant auto-spawned assert!(body["uptime_seconds"].is_number()); assert_eq!(body["default_provider"], "ollama"); - assert_eq!(body["agents"].as_array().unwrap().len(), 0); + assert_eq!(body["agents"].as_array().unwrap().len(), 1); } #[tokio::test] @@ -247,7 +247,7 @@ async fn test_spawn_list_kill_agent() { let agent_id = body["agent_id"].as_str().unwrap().to_string(); assert!(!agent_id.is_empty()); - // --- List (1 agent) --- + // --- List (2 agents: default assistant + test-agent) --- let resp = client .get(format!("{}/api/agents", server.base_url)) .send() @@ -255,10 +255,10 @@ async fn test_spawn_list_kill_agent() { .unwrap(); assert_eq!(resp.status(), 200); let agents: Vec = resp.json().await.unwrap(); - assert_eq!(agents.len(), 1); - assert_eq!(agents[0]["name"], "test-agent"); - assert_eq!(agents[0]["id"], agent_id); - assert_eq!(agents[0]["model_provider"], "ollama"); + assert_eq!(agents.len(), 2); + let test_agent = agents.iter().find(|a| a["name"] == "test-agent").unwrap(); + assert_eq!(test_agent["id"], agent_id); + assert_eq!(test_agent["model_provider"], "ollama"); // --- Kill --- let resp = client @@ -270,7 +270,7 @@ async fn test_spawn_list_kill_agent() { let body: serde_json::Value = resp.json().await.unwrap(); assert_eq!(body["status"], "killed"); - // --- List (empty) --- + // --- List (only default assistant remains) --- let resp = client .get(format!("{}/api/agents", server.base_url)) .send() @@ -278,7 +278,8 @@ async fn test_spawn_list_kill_agent() { .unwrap(); assert_eq!(resp.status(), 200); let agents: Vec = resp.json().await.unwrap(); - assert_eq!(agents.len(), 0); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0]["name"], "assistant"); } #[tokio::test] @@ -617,14 +618,14 @@ memory_write = ["self.*"] ids.push(body["agent_id"].as_str().unwrap().to_string()); } - // List should show 3 + // List should show 4 (3 spawned + default assistant) let resp = client .get(format!("{}/api/agents", server.base_url)) .send() .await .unwrap(); let agents: Vec = resp.json().await.unwrap(); - assert_eq!(agents.len(), 3); + assert_eq!(agents.len(), 4); // Status should agree let resp = client @@ -633,7 +634,7 @@ memory_write = ["self.*"] .await .unwrap(); let status: serde_json::Value = resp.json().await.unwrap(); - assert_eq!(status["agent_count"], 3); + assert_eq!(status["agent_count"], 4); // Kill one let resp = client @@ -643,14 +644,14 @@ memory_write = ["self.*"] .unwrap(); assert_eq!(resp.status(), 200); - // List should show 2 + // List should show 3 (2 spawned + default assistant) let resp = client .get(format!("{}/api/agents", server.base_url)) .send() .await .unwrap(); let agents: Vec = resp.json().await.unwrap(); - assert_eq!(agents.len(), 2); + assert_eq!(agents.len(), 3); // Kill the rest for id in [&ids[0], &ids[2]] { @@ -661,14 +662,14 @@ memory_write = ["self.*"] .unwrap(); } - // List should be empty + // List should have only default assistant let resp = client .get(format!("{}/api/agents", server.base_url)) .send() .await .unwrap(); let agents: Vec = resp.json().await.unwrap(); - assert_eq!(agents.len(), 0); + assert_eq!(agents.len(), 1); } // --------------------------------------------------------------------------- diff --git a/crates/openfang-api/tests/load_test.rs b/crates/openfang-api/tests/load_test.rs index 0a74931b..27ebbf46 100644 --- a/crates/openfang-api/tests/load_test.rs +++ b/crates/openfang-api/tests/load_test.rs @@ -541,7 +541,7 @@ async fn load_spawn_kill_cycle() { .await .unwrap(); let remaining = agents.as_array().map(|a| a.len()).unwrap_or(0); - assert_eq!(remaining, 0, "All agents should be killed"); + assert_eq!(remaining, 1, "Only default assistant should remain"); } /// Test: Prometheus metrics endpoint under sustained load. diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 9d940f15..4d3c282b 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -1054,6 +1054,33 @@ impl OpenFangKernel { } } + // If no agents exist (fresh install), spawn a default assistant + if kernel.registry.list().is_empty() { + info!("No agents found — spawning default assistant"); + let dm = &kernel.config.default_model; + let manifest = AgentManifest { + name: "assistant".to_string(), + description: "General-purpose assistant".to_string(), + model: openfang_types::agent::ModelConfig { + provider: dm.provider.clone(), + model: dm.model.clone(), + system_prompt: "You are a helpful AI assistant.".to_string(), + api_key_env: if dm.api_key_env.is_empty() { + None + } else { + Some(dm.api_key_env.clone()) + }, + base_url: dm.base_url.clone(), + ..Default::default() + }, + ..Default::default() + }; + match kernel.spawn_agent(manifest) { + Ok(id) => info!(id = %id, "Default assistant spawned"), + Err(e) => warn!("Failed to spawn default assistant: {e}"), + } + } + // Validate routing configs against model catalog for entry in kernel.registry.list() { if let Some(ref routing_config) = entry.manifest.routing { @@ -2089,7 +2116,16 @@ impl OpenFangKernel { routed_model = %routed_model, "Model routing applied" ); - manifest.model.model = routed_model; + manifest.model.model = routed_model.clone(); + // Also update provider if the routed model belongs to a different provider + if let Ok(cat) = self.model_catalog.read() { + if let Some(entry) = cat.find_model(&routed_model) { + if entry.provider != manifest.model.provider { + info!(old = %manifest.model.provider, new = %entry.provider, "Model routing changed provider"); + manifest.model.provider = entry.provider.clone(); + } + } + } } let driver = self.resolve_driver(&manifest)?; @@ -2923,6 +2959,13 @@ impl OpenFangKernel { ); } + // If an agent with this hand's name already exists, remove it first + let existing = self.registry.list().into_iter().find(|e| e.name == def.agent.name); + if let Some(old) = existing { + info!(agent = %old.name, id = %old.id, "Removing existing hand agent for reactivation"); + let _ = self.kill_agent(old.id); + } + // Spawn the agent let agent_id = self.spawn_agent(manifest)?; diff --git a/crates/openfang-kernel/src/metering.rs b/crates/openfang-kernel/src/metering.rs index aa4f66ad..71549e6b 100644 --- a/crates/openfang-kernel/src/metering.rs +++ b/crates/openfang-kernel/src/metering.rs @@ -429,7 +429,7 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) { } // ── xAI / Grok ────────────────────────────────────────────── - if model.contains("grok-4.1") { + if model.contains("grok-4-1") { return (0.20, 0.50); } if model.contains("grok-4") { diff --git a/crates/openfang-kernel/tests/wasm_agent_integration_test.rs b/crates/openfang-kernel/tests/wasm_agent_integration_test.rs index 8531fc7f..26ec5212 100644 --- a/crates/openfang-kernel/tests/wasm_agent_integration_test.rs +++ b/crates/openfang-kernel/tests/wasm_agent_integration_test.rs @@ -349,9 +349,9 @@ async fn test_multiple_wasm_agents() { assert_eq!(hello_result.response, "hello from wasm"); assert!(echo_result.response.contains("test data")); - // Verify agent list shows both + // Verify agent list shows both + default assistant let agents = kernel.registry.list(); - assert_eq!(agents.len(), 2); + assert_eq!(agents.len(), 3); kernel.shutdown(); } @@ -391,9 +391,9 @@ memory_write = ["self.*"] let llm_manifest: AgentManifest = toml::from_str(llm_toml).unwrap(); let llm_id = kernel.spawn_agent(llm_manifest).unwrap(); - // Verify both agents exist + // Verify both agents exist + default assistant let agents = kernel.registry.list(); - assert_eq!(agents.len(), 2); + assert_eq!(agents.len(), 3); // WASM agent should work let result = kernel.send_message(wasm_id, "hello").await.unwrap(); @@ -404,7 +404,7 @@ memory_write = ["self.*"] // Kill WASM agent kernel.kill_agent(wasm_id).unwrap(); - assert_eq!(kernel.registry.list().len(), 1); + assert_eq!(kernel.registry.list().len(), 2); kernel.shutdown(); } diff --git a/crates/openfang-runtime/src/drivers/gemini.rs b/crates/openfang-runtime/src/drivers/gemini.rs index 5d58b7e6..f0acd32b 100644 --- a/crates/openfang-runtime/src/drivers/gemini.rs +++ b/crates/openfang-runtime/src/drivers/gemini.rs @@ -305,32 +305,44 @@ fn convert_response(resp: GeminiResponse) -> Result { - if !text.is_empty() { - content.push(ContentBlock::Text { text }); + match candidate.content { + Some(gemini_content) => { + for part in gemini_content.parts { + match part { + GeminiPart::Text { text } => { + if !text.is_empty() { + content.push(ContentBlock::Text { text }); + } + } + GeminiPart::FunctionCall { function_call } => { + let id = format!("call_{}", uuid::Uuid::new_v4().simple()); + content.push(ContentBlock::ToolUse { + id: id.clone(), + name: function_call.name.clone(), + input: function_call.args.clone(), + }); + tool_calls.push(ToolCall { + id, + name: function_call.name, + input: function_call.args, + }); + } + GeminiPart::InlineData { .. } | GeminiPart::FunctionResponse { .. } => { + // Shouldn't normally appear in responses, ignore } } - GeminiPart::FunctionCall { function_call } => { - let id = format!("call_{}", uuid::Uuid::new_v4().simple()); - content.push(ContentBlock::ToolUse { - id: id.clone(), - name: function_call.name.clone(), - input: function_call.args.clone(), - }); - tool_calls.push(ToolCall { - id, - name: function_call.name, - input: function_call.args, - }); - } - GeminiPart::InlineData { .. } | GeminiPart::FunctionResponse { .. } => { - // Shouldn't normally appear in responses, ignore - } } } + None => { + let reason = candidate + .finish_reason + .as_deref() + .unwrap_or("unknown"); + warn!(finish_reason = %reason, "Gemini returned candidate with no content"); + return Err(LlmError::Parse(format!( + "Gemini returned empty response (finish_reason: {reason})" + ))); + } } // Gemini uses "STOP" for both end-of-turn and function calls, diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index fc99d54b..e7da568d 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -648,8 +648,8 @@ fn builtin_aliases() -> HashMap { ("gpt5", "gpt-5.2"), ("gpt5-mini", "gpt-5-mini"), ("flash", "gemini-2.5-flash"), - ("gemini-flash", "gemini-2.5-flash"), - ("gemini-pro", "gemini-3.1-pro"), + ("gemini-pro", "gemini-3.1-pro-preview"), + ("gemini-flash", "gemini-3-flash-preview"), ("deepseek", "deepseek-chat"), ("llama", "llama-3.3-70b-versatile"), ("llama-70b", "llama-3.3-70b-versatile"), @@ -663,10 +663,11 @@ fn builtin_aliases() -> HashMap { ("mistral-nemo", "open-mistral-nemo"), ("pixtral", "pixtral-large-latest"), // xAI aliases - ("grok", "grok-4"), + ("grok", "grok-4-0709"), + ("grok-4", "grok-4-0709"), ("grok-mini", "grok-2-mini"), ("grok3", "grok-3"), - ("grok-fast", "grok-4.1-fast"), + ("grok-fast", "grok-4-1-fast-reasoning"), // Perplexity alias ("sonar", "sonar-pro"), // AI21 aliases @@ -1039,8 +1040,8 @@ fn builtin_models() -> Vec { // Google Gemini (10) // ══════════════════════════════════════════════════════════════ ModelCatalogEntry { - id: "gemini-3.1-pro".into(), - display_name: "Gemini 3.1 Pro".into(), + id: "gemini-3.1-pro-preview".into(), + display_name: "Gemini 3.1 Pro Preview".into(), provider: "gemini".into(), tier: ModelTier::Frontier, context_window: 1_048_576, @@ -1052,16 +1053,29 @@ fn builtin_models() -> Vec { supports_streaming: true, aliases: vec!["gemini-pro".into()], }, - // gemini-3-flash removed: model doesn't exist. Use gemini-2.5-flash instead. ModelCatalogEntry { - id: "gemini-3-deep-think".into(), - display_name: "Gemini 3 Deep Think".into(), + id: "gemini-3-flash-preview".into(), + display_name: "Gemini 3 Flash Preview".into(), provider: "gemini".into(), - tier: ModelTier::Frontier, + tier: ModelTier::Smart, context_window: 1_048_576, max_output_tokens: 65_536, - input_cost_per_m: 2.50, - output_cost_per_m: 15.0, + input_cost_per_m: 0.15, + output_cost_per_m: 0.60, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec!["gemini-flash".into()], + }, + ModelCatalogEntry { + id: "gemini-3.1-flash-lite-preview".into(), + display_name: "Gemini 3.1 Flash Lite Preview".into(), + provider: "gemini".into(), + tier: ModelTier::Fast, + context_window: 1_048_576, + max_output_tokens: 8_192, + input_cost_per_m: 0.04, + output_cost_per_m: 0.15, supports_tools: true, supports_vision: true, supports_streaming: true, @@ -2174,10 +2188,10 @@ fn builtin_models() -> Vec { aliases: vec![], }, // ══════════════════════════════════════════════════════════════ - // xAI (6) + // xAI (9) // ══════════════════════════════════════════════════════════════ ModelCatalogEntry { - id: "grok-4".into(), + id: "grok-4-0709".into(), display_name: "Grok 4".into(), provider: "xai".into(), tier: ModelTier::Frontier, @@ -2188,11 +2202,39 @@ fn builtin_models() -> Vec { supports_tools: true, supports_vision: true, supports_streaming: true, - aliases: vec!["grok".into()], + aliases: vec!["grok".into(), "grok-4".into()], }, ModelCatalogEntry { - id: "grok-4.1-fast".into(), - display_name: "Grok 4.1 Fast".into(), + id: "grok-4-fast-reasoning".into(), + display_name: "Grok 4 Fast Reasoning".into(), + provider: "xai".into(), + tier: ModelTier::Smart, + context_window: 256_000, + max_output_tokens: 32_768, + input_cost_per_m: 1.0, + output_cost_per_m: 5.0, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "grok-4-fast-non-reasoning".into(), + display_name: "Grok 4 Fast Non-Reasoning".into(), + provider: "xai".into(), + tier: ModelTier::Smart, + context_window: 256_000, + max_output_tokens: 32_768, + input_cost_per_m: 1.0, + output_cost_per_m: 5.0, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "grok-4-1-fast-reasoning".into(), + display_name: "Grok 4.1 Fast Reasoning".into(), provider: "xai".into(), tier: ModelTier::Fast, context_window: 2_000_000, @@ -2204,6 +2246,20 @@ fn builtin_models() -> Vec { supports_streaming: true, aliases: vec!["grok-fast".into()], }, + ModelCatalogEntry { + id: "grok-4-1-fast-non-reasoning".into(), + display_name: "Grok 4.1 Fast Non-Reasoning".into(), + provider: "xai".into(), + tier: ModelTier::Fast, + context_window: 2_000_000, + max_output_tokens: 32_768, + input_cost_per_m: 0.20, + output_cost_per_m: 0.50, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, ModelCatalogEntry { id: "grok-3".into(), display_name: "Grok 3".into(), @@ -3020,7 +3076,7 @@ mod tests { assert!(aliases.len() >= 20); assert_eq!(aliases.get("sonnet").unwrap(), "claude-sonnet-4-6"); // New aliases - assert_eq!(aliases.get("grok").unwrap(), "grok-4"); + assert_eq!(aliases.get("grok").unwrap(), "grok-4-0709"); assert_eq!(aliases.get("jamba").unwrap(), "jamba-1.5-large"); } @@ -3028,7 +3084,7 @@ mod tests { fn test_find_grok_by_alias() { let catalog = ModelCatalog::new(); let entry = catalog.find_model("grok").unwrap(); - assert_eq!(entry.id, "grok-4"); + assert_eq!(entry.id, "grok-4-0709"); assert_eq!(entry.provider, "xai"); } @@ -3049,9 +3105,12 @@ mod tests { fn test_xai_models() { let catalog = ModelCatalog::new(); let xai = catalog.models_by_provider("xai"); - assert_eq!(xai.len(), 6); - assert!(xai.iter().any(|m| m.id == "grok-4")); - assert!(xai.iter().any(|m| m.id == "grok-4.1-fast")); + assert_eq!(xai.len(), 9); + assert!(xai.iter().any(|m| m.id == "grok-4-0709")); + assert!(xai.iter().any(|m| m.id == "grok-4-fast-reasoning")); + assert!(xai.iter().any(|m| m.id == "grok-4-fast-non-reasoning")); + assert!(xai.iter().any(|m| m.id == "grok-4-1-fast-reasoning")); + assert!(xai.iter().any(|m| m.id == "grok-4-1-fast-non-reasoning")); assert!(xai.iter().any(|m| m.id == "grok-3")); assert!(xai.iter().any(|m| m.id == "grok-3-mini")); assert!(xai.iter().any(|m| m.id == "grok-2"));