goose doctor (#8342)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-04-07 10:12:16 -04:00
committed by GitHub
parent c4aa7f56df
commit 0028d97c23
8 changed files with 359 additions and 1 deletions
+5
View File
@@ -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<Command>) -> &'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,
+15
View File
@@ -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
}
+1
View File
@@ -1,4 +1,5 @@
pub mod configure;
pub mod doctor;
pub mod gateway;
pub mod info;
pub mod project;
@@ -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
+256
View File
@@ -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<Message> {
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<Option<Message>> {
let config = Config::global();
let mut log: Vec<String> = 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<dyn Provider>,
) -> 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<Arc<dyn Provider>, 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<String>,
) -> Option<Arc<dyn Provider>> {
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<String>) -> Option<Arc<dyn Provider>> {
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),
}
}
+1
View File
@@ -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;
+72
View File
@@ -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<PathBuf> {
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<PathBuf> {
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<String> {
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<String> {
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<PathBuf> {
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())?;
+4 -1
View File
@@ -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,