From 0028d97c23cb2c850b11b42bd2afa5702c31aa6a Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 7 Apr 2026 10:12:16 -0400 Subject: [PATCH] goose doctor (#8342) Co-authored-by: Douwe Osinga --- crates/goose-cli/src/cli.rs | 5 + crates/goose-cli/src/commands/doctor.rs | 15 ++ crates/goose-cli/src/commands/mod.rs | 1 + crates/goose/src/agents/execute_commands.rs | 5 + crates/goose/src/doctor.rs | 256 ++++++++++++++++++++ crates/goose/src/lib.rs | 1 + crates/goose/src/session/diagnostics.rs | 72 ++++++ crates/goose/src/session/mod.rs | 5 +- 8 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 crates/goose-cli/src/commands/doctor.rs create mode 100644 crates/goose/src/doctor.rs diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 0f9f9898..d43a3425 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -711,6 +711,9 @@ enum Command { verbose: bool, }, + #[command(about = "Check that your Goose setup is working")] + Doctor {}, + /// Manage system prompts and behaviors #[command(about = "Run one of the mcp servers bundled with goose")] Mcp { @@ -1026,6 +1029,7 @@ pub struct InputConfig { fn get_command_name(command: &Option) -> &'static str { match command { Some(Command::Configure {}) => "configure", + Some(Command::Doctor {}) => "doctor", Some(Command::Info { .. }) => "info", Some(Command::Mcp { .. }) => "mcp", Some(Command::Acp { .. }) => "acp", @@ -1755,6 +1759,7 @@ pub async fn cli() -> anyhow::Result<()> { Ok(()) } Some(Command::Configure {}) => handle_configure().await, + Some(Command::Doctor {}) => crate::commands::doctor::handle_doctor().await, Some(Command::Info { verbose }) => handle_info(verbose), Some(Command::Mcp { server }) => handle_mcp_command(server).await, Some(Command::Acp { builtins }) => goose_acp::server::run(builtins).await, diff --git a/crates/goose-cli/src/commands/doctor.rs b/crates/goose-cli/src/commands/doctor.rs new file mode 100644 index 00000000..4db9b7d1 --- /dev/null +++ b/crates/goose-cli/src/commands/doctor.rs @@ -0,0 +1,15 @@ +use anyhow::Result; + +use crate::session::build_session; +use crate::session::SessionBuilderConfig; + +pub async fn handle_doctor() -> Result<()> { + let mut session = build_session(SessionBuilderConfig { + no_session: true, + interactive: true, + ..Default::default() + }) + .await; + + session.interactive(Some("/doctor".to_string())).await +} diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index 6bc33142..6622f4d9 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod configure; +pub mod doctor; pub mod gateway; pub mod info; pub mod project; diff --git a/crates/goose/src/agents/execute_commands.rs b/crates/goose/src/agents/execute_commands.rs index 96b55a0d..b4971385 100644 --- a/crates/goose/src/agents/execute_commands.rs +++ b/crates/goose/src/agents/execute_commands.rs @@ -37,6 +37,10 @@ static COMMANDS: &[CommandDef] = &[ name: "skills", description: "List installed skills and other available sources", }, + CommandDef { + name: "doctor", + description: "Check that your Goose setup is working", + }, ]; pub fn list_commands() -> &'static [CommandDef] { @@ -77,6 +81,7 @@ impl Agent { "compact" => self.handle_compact_command(session_id).await, "clear" => self.handle_clear_command(session_id).await, "skills" => self.handle_skills_command(session_id).await, + "doctor" => Ok(Some(crate::doctor::run(self, session_id).await?)), _ => { self.handle_recipe_command(command, params_str, session_id) .await diff --git a/crates/goose/src/doctor.rs b/crates/goose/src/doctor.rs new file mode 100644 index 00000000..313ff267 --- /dev/null +++ b/crates/goose/src/doctor.rs @@ -0,0 +1,256 @@ +use std::sync::Arc; + +use crate::agents::platform_extensions::developer; +use crate::agents::ExtensionConfig; +use crate::config::Config; +use crate::conversation::message::Message; +use crate::model::ModelConfig; +use crate::providers::base::Provider; +use crate::providers::{self, errors::ProviderError}; +use crate::session::{ + config_path, latest_llm_log_path, latest_server_log_path, read_capped, read_tail, SystemInfo, +}; + +pub async fn run(agent: &crate::agents::Agent, session_id: &str) -> anyhow::Result { + if let Some(msg) = ensure_working_provider(agent, session_id).await? { + return Ok(msg); + } + + ensure_developer_extension(agent, session_id).await; + + let info = SystemInfo::collect(); + let extensions = agent.list_extensions().await; + + let mut prompt = format!( + "I ran /doctor because something seems off. Here's my system info:\n\n\ + {}\n\ + Loaded extensions: {}\n\ + Config file: {}\n", + info.to_text(), + if extensions.is_empty() { + "none".to_string() + } else { + extensions.join(", ") + }, + config_path().display(), + ); + + if let Some(path) = latest_server_log_path() { + if let Some(tail) = read_tail(&path, 50) { + prompt.push_str(&format!("\nRecent server log:\n```\n{}\n```\n", tail)); + } + } + + if let Some(path) = latest_llm_log_path() { + if let Some(content) = read_capped(&path, 10_000) { + prompt.push_str(&format!("\nLast LLM request log:\n```\n{}\n```\n", content)); + } + } + + prompt.push_str( + "\nUse your tools to investigate what might be wrong. \ + Check if common developer tools are available (git, etc.) \ + and report what you find.", + ); + + Ok(Message::user().with_text(prompt)) +} + +async fn ensure_working_provider( + agent: &crate::agents::Agent, + session_id: &str, +) -> anyhow::Result> { + let config = Config::global(); + let mut log: Vec = Vec::new(); + + let provider_name = config.get_goose_provider().ok(); + let model_name = config.get_goose_model().ok(); + + if let (Some(ref pname), Some(ref mname)) = (&provider_name, &model_name) { + log.push(format!("Checking {} / {} ...", pname, mname)); + match try_create_and_test(pname, mname).await { + Ok(_) => { + return Ok(None); + } + Err(e) => { + log.push(format!("❌ {} / {}: {}", pname, mname, describe_error(&e))); + } + } + + log.push(format!("Looking for alternative models on {} ...", pname)); + if let Some(working) = try_other_models(pname, mname, &mut log).await { + let new_model = working.get_model_config().model_name.clone(); + save_and_set(agent, session_id, working).await?; + let preamble = log.join("\n"); + return Ok(Some(Message::assistant().with_text(format!( + "**Goose Doctor**\n\n{}\n\n\ + Your configured model wasn't working, so I switched to \ + **{} / {}**. You can continue chatting now.", + preamble, pname, new_model, + )))); + } + } else { + log.push("No provider/model configured.".to_string()); + } + + log.push("Looking for other configured providers ...".to_string()); + let skip = provider_name.as_deref().unwrap_or(""); + if let Some(working) = try_other_providers(skip, &mut log).await { + let name = working.get_name().to_string(); + let model = working.get_model_config().model_name.clone(); + save_and_set(agent, session_id, working).await?; + let preamble = log.join("\n"); + return Ok(Some(Message::assistant().with_text(format!( + "**Goose Doctor**\n\n{}\n\n\ + Switched to **{} / {}**. You can continue chatting now.", + preamble, name, model, + )))); + } + + let preamble = log.join("\n"); + Ok(Some(Message::assistant().with_text(format!( + "**Goose Doctor**\n\n{}\n\n\ + No working provider found. Run `goose configure` to set one up.", + preamble, + )))) +} + +async fn ensure_developer_extension(agent: &crate::agents::Agent, session_id: &str) { + if agent + .extension_manager + .is_extension_enabled(developer::EXTENSION_NAME) + .await + { + return; + } + let config = ExtensionConfig::Platform { + name: developer::EXTENSION_NAME.to_string(), + description: "Write and edit files, and execute shell commands".to_string(), + display_name: Some("Developer".to_string()), + bundled: None, + available_tools: vec![], + }; + if let Err(e) = agent.add_extension(config, session_id).await { + tracing::warn!("Doctor: failed to load developer extension: {}", e); + } +} + +async fn save_and_set( + agent: &crate::agents::Agent, + session_id: &str, + provider: Arc, +) -> anyhow::Result<()> { + let config = Config::global(); + config.set_goose_provider(provider.get_name()).ok(); + config + .set_goose_model(&provider.get_model_config().model_name) + .ok(); + agent.update_provider(provider, session_id).await +} + +async fn test_provider(provider: &dyn Provider) -> Result<(), ProviderError> { + let messages = vec![Message::user().with_text("Say 'hello' and nothing else.")]; + provider + .complete( + &provider.get_model_config(), + "doctor-check", + "Respond as briefly as possible.", + &messages, + &[], + ) + .await?; + Ok(()) +} + +async fn try_create_and_test( + provider_name: &str, + model_name: &str, +) -> Result, ProviderError> { + let model_config = ModelConfig::new(model_name) + .map_err(|e| ProviderError::ExecutionError(e.to_string()))? + .with_canonical_limits(provider_name); + + let provider = providers::create(provider_name, model_config, vec![]) + .await + .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; + + test_provider(provider.as_ref()).await?; + Ok(provider) +} + +async fn try_other_models( + provider_name: &str, + skip_model: &str, + log: &mut Vec, +) -> Option> { + let entry = providers::get_from_registry(provider_name).await.ok()?; + let temp = entry.create_with_default_model(vec![]).await.ok()?; + let models = temp.fetch_recommended_models().await.ok()?; + + for model in models.iter().filter(|m| m.as_str() != skip_model).take(3) { + log.push(format!(" Trying {} / {} ...", provider_name, model)); + match try_create_and_test(provider_name, model).await { + Ok(p) => { + log.push(format!(" ✓ {} / {} works", provider_name, model)); + return Some(p); + } + Err(e) => log.push(format!(" ✗ {}", describe_error(&e))), + } + } + None +} + +async fn try_other_providers(skip: &str, log: &mut Vec) -> Option> { + for (meta, _) in providers::providers().await { + if meta.name == skip { + continue; + } + let entry = match providers::get_from_registry(&meta.name).await { + Ok(e) => e, + Err(_) => continue, + }; + let provider = match entry.create_with_default_model(vec![]).await { + Ok(p) => p, + Err(_) => continue, + }; + let model_name = provider.get_model_config().model_name.clone(); + log.push(format!(" Trying {} / {} ...", meta.name, model_name)); + match test_provider(provider.as_ref()).await { + Ok(()) => { + log.push(format!(" ✓ {} / {} works", meta.name, model_name)); + return Some(provider); + } + Err(e) => log.push(format!(" ✗ {}", describe_error(&e))), + } + } + None +} + +fn describe_error(e: &ProviderError) -> String { + match e { + ProviderError::Authentication(_) => { + "Authentication failed — check your API key. Run `goose configure` to update it." + .to_string() + } + ProviderError::CreditsExhausted { top_up_url, .. } => { + let mut msg = "Credits exhausted.".to_string(); + if let Some(url) = top_up_url { + msg.push_str(&format!(" Top up at: {}", url)); + } + msg + } + ProviderError::RateLimitExceeded { .. } => { + "Rate limited — wait a moment and try again.".to_string() + } + ProviderError::EndpointNotFound(_) => { + "Model not found — the model name may be wrong for this provider.".to_string() + } + ProviderError::NetworkError(_) => { + "Network error — check your internet connection.".to_string() + } + ProviderError::ServerError(_) => { + "Provider server error — the service may be temporarily down.".to_string() + } + other => format!("{}", other), + } +} diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 694e0391..8e05ebb3 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -12,6 +12,7 @@ pub mod config; pub mod context_mgmt; pub mod conversation; pub mod dictation; +pub mod doctor; pub mod download_manager; pub mod execution; pub mod gateway; diff --git a/crates/goose/src/session/diagnostics.rs b/crates/goose/src/session/diagnostics.rs index 87917fd7..c9e580c7 100644 --- a/crates/goose/src/session/diagnostics.rs +++ b/crates/goose/src/session/diagnostics.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::io::Cursor; use std::io::Write; +use std::path::PathBuf; use utoipa::ToSchema; use zip::write::SimpleFileOptions; use zip::ZipWriter; @@ -70,6 +71,69 @@ pub fn get_system_info() -> SystemInfo { SystemInfo::collect() } +pub fn config_path() -> PathBuf { + Paths::config_dir().join("config.yaml") +} + +pub fn latest_server_log_path() -> Option { + let server_dir = Paths::in_state_dir("logs").join("server"); + let latest_date_dir = latest_entry_by_name(&server_dir)?; + latest_entry_by_name(&latest_date_dir) +} + +pub fn latest_llm_log_path() -> Option { + let path = Paths::in_state_dir("logs").join("llm_request.0.jsonl"); + path.exists().then_some(path) +} + +pub fn read_tail(path: &std::path::Path, max_lines: usize) -> Option { + let content = fs::read_to_string(path).ok()?; + let lines: Vec<&str> = content.lines().collect(); + let start = lines.len().saturating_sub(max_lines); + Some(lines[start..].join("\n")) +} + +pub fn read_capped(path: &std::path::Path, max_bytes: usize) -> Option { + let content = fs::read_to_string(path).ok()?; + if content.len() <= max_bytes { + return Some(content); + } + let half = max_bytes / 2; + let head: String = content + .chars() + .take_while({ + let mut n = 0; + move |c| { + n += c.len_utf8(); + n <= half + } + }) + .collect(); + let tail: String = { + let skip = content.len().saturating_sub(half); + let mut chars = content.chars(); + let mut skipped = 0; + for c in chars.by_ref() { + skipped += c.len_utf8(); + if skipped >= skip { + break; + } + } + chars.collect() + }; + let omitted = content.len() - head.len() - tail.len(); + Some(format!( + "{}\n\n... ({} bytes omitted) ...\n\n{}", + head, omitted, tail, + )) +} + +fn latest_entry_by_name(dir: &std::path::Path) -> Option { + let mut entries: Vec<_> = fs::read_dir(dir).ok()?.filter_map(|e| e.ok()).collect(); + entries.sort_by_key(|e| e.file_name()); + entries.last().map(|e| e.path()) +} + pub async fn generate_diagnostics( session_manager: &SessionManager, session_id: &str, @@ -101,6 +165,14 @@ pub async fn generate_diagnostics( zip.write_all(&fs::read(&path)?)?; } + if let Some(server_log) = latest_server_log_path() { + if let Ok(content) = fs::read(&server_log) { + let name = server_log.file_name().unwrap().to_str().unwrap(); + zip.start_file(format!("logs/server/{}", name), options)?; + zip.write_all(&content)?; + } + } + let session_data = session_manager.export_session(session_id).await?; zip.start_file("session.json", options)?; zip.write_all(session_data.as_bytes())?; diff --git a/crates/goose/src/session/mod.rs b/crates/goose/src/session/mod.rs index 79eee530..5e3534b6 100644 --- a/crates/goose/src/session/mod.rs +++ b/crates/goose/src/session/mod.rs @@ -4,7 +4,10 @@ pub mod extension_data; mod legacy; pub mod session_manager; -pub use diagnostics::{generate_diagnostics, get_system_info, SystemInfo}; +pub use diagnostics::{ + config_path, generate_diagnostics, get_system_info, latest_llm_log_path, + latest_server_log_path, read_capped, read_tail, SystemInfo, +}; pub use extension_data::{EnabledExtensionsState, ExtensionData, ExtensionState, TodoState}; pub use session_manager::{ Session, SessionInsights, SessionManager, SessionType, SessionUpdateBuilder,