diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72b9ee1483de..172b02d30e0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 - + - name: Check for file changes uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # pin@v3 id: filter @@ -101,7 +101,7 @@ jobs: just check-openapi-schema desktop-lint: - name: Lint Electron Desktop App + name: Test and Lint Electron Desktop App runs-on: macos-latest needs: changes if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5e9e6d3298f5..4f09015d1585 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -93,7 +93,8 @@ jobs: secrets: WINDOW_SIGNING_ROLE: ${{ secrets.WINDOW_SIGNING_ROLE }} WINDOW_SIGNING_ROLE_TAG: ${{ secrets.WINDOW_SIGNING_ROLE_TAG }} - + WINDOWS_CODESIGN_CERTIFICATE: ${{ secrets.WINDOWS_CODESIGN_CERTIFICATE }} + release: name: Release runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index cf3088070085..40747f447e69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2705,6 +2705,7 @@ dependencies = [ "jsonschema", "nix 0.30.1", "once_cell", + "open", "rand 0.8.5", "regex", "rmcp", @@ -2785,6 +2786,7 @@ dependencies = [ "tree-sitter-javascript", "tree-sitter-kotlin", "tree-sitter-python", + "tree-sitter-ruby", "tree-sitter-rust", "umya-spreadsheet", "url", @@ -3531,6 +3533,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + [[package]] name = "is-terminal" version = "0.4.16" @@ -3542,6 +3553,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -4418,6 +4439,17 @@ version = "11.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" +[[package]] +name = "open" +version = "5.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl" version = "0.10.73" @@ -7223,6 +7255,16 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "tree-sitter-ruby" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0031f687c0772f2dad7b77104c43428611099a1804c81244ada21560f41f0b1" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "tree-sitter-rust" version = "0.21.2" diff --git a/Justfile b/Justfile index 14044e89021e..e83f4a2700cc 100644 --- a/Justfile +++ b/Justfile @@ -143,6 +143,15 @@ run-ui: @echo "Running UI..." cd ui/desktop && npm install && npm run start-gui +run-ui-playwright: + #!/usr/bin/env sh + just release-binary + echo "Running UI with Playwright debugging..." + RUN_DIR="$HOME/goose-runs/$(date +%Y%m%d-%H%M%S)" + mkdir -p "$RUN_DIR" + echo "Using isolated directory: $RUN_DIR" + cd ui/desktop && ENABLE_PLAYWRIGHT=true GOOSE_PATH_ROOT="$RUN_DIR" npm run start-gui + run-ui-only: @echo "Running UI..." cd ui/desktop && npm install && npm run start-gui @@ -463,4 +472,4 @@ build-test-tools: record-mcp-tests: build-test-tools GOOSE_RECORD_MCP=1 cargo test --package goose --test mcp_integration_test - git add crates/goose/tests/mcp_replays/ \ No newline at end of file + git add crates/goose/tests/mcp_replays/ diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 90e5b463b272..83ebb8644a92 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -58,6 +58,7 @@ tokio-util = { version = "0.7.15", features = ["compat"] } is-terminal = "0.4.16" anstream = "0.6.18" url = "2.5.7" +open = "5.3.2" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index cc83fe0271fa..4b2e5e0144fe 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -8,7 +8,7 @@ use crate::commands::bench::agent_generator; use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; use crate::commands::project::{handle_project_default, handle_projects_interactive}; -use crate::commands::recipe::{handle_deeplink, handle_list, handle_validate}; +use crate::commands::recipe::{handle_deeplink, handle_list, handle_open, handle_validate}; // Import the new handlers from commands::schedule use crate::commands::schedule::{ handle_schedule_add, handle_schedule_cron_help, handle_schedule_list, handle_schedule_remove, @@ -283,6 +283,14 @@ enum RecipeCommand { recipe_name: String, }, + /// Open a recipe in Goose Desktop + #[command(about = "Open a recipe in Goose Desktop")] + Open { + /// Recipe name to get recipe file to open + #[arg(help = "recipe name or full path to the recipe file")] + recipe_name: String, + }, + /// List available recipes #[command(about = "List available recipes")] List { @@ -1215,6 +1223,9 @@ pub async fn cli() -> Result<()> { RecipeCommand::Deeplink { recipe_name } => { handle_deeplink(&recipe_name)?; } + RecipeCommand::Open { recipe_name } => { + handle_open(&recipe_name)?; + } RecipeCommand::List { format, verbose } => { handle_list(&format, verbose)?; } diff --git a/crates/goose-cli/src/commands/info.rs b/crates/goose-cli/src/commands/info.rs index e6b22985e509..baf972d76826 100644 --- a/crates/goose-cli/src/commands/info.rs +++ b/crates/goose-cli/src/commands/info.rs @@ -1,6 +1,6 @@ use anyhow::Result; use console::style; -use etcetera::{choose_app_strategy, AppStrategy}; +use goose::config::paths::Paths; use goose::config::Config; use serde_yaml; @@ -9,11 +9,8 @@ fn print_aligned(label: &str, value: &str, width: usize) { } pub fn handle_info(verbose: bool) -> Result<()> { - let data_dir = choose_app_strategy(crate::APP_STRATEGY.clone())?; - let logs_dir = data_dir - .in_state_dir("logs") - .unwrap_or_else(|| data_dir.in_data_dir("logs")); - let sessions_dir = data_dir.in_data_dir("sessions"); + let logs_dir = Paths::in_state_dir("logs"); + let sessions_dir = Paths::in_data_dir("sessions"); // Get paths using a stored reference to the global config let config = Config::global(); diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs index 0e71dcae31c6..a6fd629d7542 100644 --- a/crates/goose-cli/src/commands/recipe.rs +++ b/crates/goose-cli/src/commands/recipe.rs @@ -33,36 +33,75 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> { /// /// # Arguments /// -/// * `file_path` - Path to the recipe file +/// * `recipe_name` - Path to the recipe file /// /// # Returns /// /// Result indicating success or failure pub fn handle_deeplink(recipe_name: &str) -> Result { - // Load the recipe file first to validate it - match load_recipe_for_validation(recipe_name) { - Ok(recipe) => match recipe_deeplink::encode(&recipe) { - Ok(encoded) => { - println!( - "{} Generated deeplink for: {}", - style("✓").green().bold(), - recipe.title - ); - let full_url = format!("goose://recipe?config={}", encoded); - println!("{}", full_url); - Ok(full_url) - } - Err(err) => { - println!( - "{} Failed to encode recipe: {}", - style("✗").red().bold(), - err - ); - Err(anyhow::anyhow!("Failed to encode recipe: {}", err)) + match generate_deeplink(recipe_name) { + Ok((deeplink_url, recipe)) => { + println!( + "{} Generated deeplink for: {}", + style("✓").green().bold(), + recipe.title + ); + println!("{}", deeplink_url); + Ok(deeplink_url) + } + Err(err) => { + println!( + "{} Failed to encode recipe: {}", + style("✗").red().bold(), + err + ); + Err(err) + } + } +} + +/// Opens a recipe in Goose Desktop +/// +/// # Arguments +/// +/// * `recipe_name` - Path to the recipe file +/// +/// # Returns +/// +/// Result indicating success or failure +pub fn handle_open(recipe_name: &str) -> Result<()> { + // Generate the deeplink using the helper function (no printing) + // This reuses all the validation and encoding logic + match generate_deeplink(recipe_name) { + Ok((deeplink_url, recipe)) => { + // Attempt to open the deeplink + match open::that(&deeplink_url) { + Ok(_) => { + println!( + "{} Opened recipe '{}' in Goose Desktop", + style("✓").green().bold(), + recipe.title + ); + Ok(()) + } + Err(err) => { + println!( + "{} Failed to open recipe in Goose Desktop: {}", + style("✗").red().bold(), + err + ); + println!("Generated deeplink: {}", deeplink_url); + println!("You can manually copy and open the URL above, or ensure Goose Desktop is installed."); + Err(anyhow::anyhow!("Failed to open recipe: {}", err)) + } } - }, + } Err(err) => { - println!("{} {}", style("✗").red().bold(), err); + println!( + "{} Failed to encode recipe: {}", + style("✗").red().bold(), + err + ); Err(err) } } @@ -129,6 +168,27 @@ pub fn handle_list(format: &str, verbose: bool) -> Result<()> { Ok(()) } +/// Helper function to generate a deeplink +/// +/// # Arguments +/// +/// * `recipe_name` - Path to the recipe file +/// +/// # Returns +/// +/// Result containing the deeplink URL and recipe +fn generate_deeplink(recipe_name: &str) -> Result<(String, goose::recipe::Recipe)> { + // Load the recipe file first to validate it + let recipe = load_recipe_for_validation(recipe_name)?; + match recipe_deeplink::encode(&recipe) { + Ok(encoded) => { + let full_url = format!("goose://recipe?config={}", encoded); + Ok((full_url, recipe)) + } + Err(err) => Err(anyhow::anyhow!("Failed to encode recipe: {}", err)), + } +} + #[cfg(test)] mod tests { use super::*; @@ -204,6 +264,28 @@ response: assert!(result.is_err()); } + #[test] + fn test_handle_open_recipe() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = + create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT); + + // Test handle_open - should attempt to open but may fail (that's expected in test environment) + // We just want to ensure it doesn't panic and handles the error gracefully + let result = handle_open(&recipe_path); + // The result may be Ok or Err depending on whether the system can open the URL + // In a test environment, it will likely fail to open, but that's fine + // We're mainly testing that the function doesn't panic and processes the recipe correctly + match result { + Ok(_) => { + // Successfully opened (unlikely in test environment) + } + Err(_) => { + // Failed to open (expected in test environment) - this is fine + } + } + } + #[test] fn test_handle_validation_valid_recipe() { let temp_dir = TempDir::new().expect("Failed to create temp directory"); @@ -239,4 +321,30 @@ response: .to_string() .contains("JSON schema validation failed")); } + + #[test] + fn test_generate_deeplink_valid_recipe() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = + create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT); + + let result = generate_deeplink(&recipe_path); + assert!(result.is_ok()); + let (url, recipe) = result.unwrap(); + assert!(url.starts_with("goose://recipe?config=")); + assert_eq!(recipe.title, "Test Recipe with Valid JSON Schema"); + assert_eq!(recipe.description, "A test recipe with valid JSON schema"); + let encoded_part = url.strip_prefix("goose://recipe?config=").unwrap(); + assert!(!encoded_part.is_empty()); + } + + #[test] + fn test_generate_deeplink_invalid_recipe() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = + create_test_recipe_file(&temp_dir, "test_recipe.yaml", INVALID_RECIPE_CONTENT); + + let result = generate_deeplink(&recipe_path); + assert!(result.is_err()); + } } diff --git a/crates/goose-cli/src/lib.rs b/crates/goose-cli/src/lib.rs index b2e882fd9cb4..3996e0796d13 100644 --- a/crates/goose-cli/src/lib.rs +++ b/crates/goose-cli/src/lib.rs @@ -1,5 +1,3 @@ -use etcetera::AppStrategyArgs; -use once_cell::sync::Lazy; pub mod cli; pub mod commands; pub mod logging; @@ -11,9 +9,3 @@ pub mod signal; // Re-export commonly used types pub use session::CliSession; - -pub static APP_STRATEGY: Lazy = Lazy::new(|| AppStrategyArgs { - top_level_domain: "Block".to_string(), - author: "Block".to_string(), - app_name: "goose".to_string(), -}); diff --git a/crates/goose-cli/src/project_tracker.rs b/crates/goose-cli/src/project_tracker.rs index 1b11bbf33631..0cafe7291a5d 100644 --- a/crates/goose-cli/src/project_tracker.rs +++ b/crates/goose-cli/src/project_tracker.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; -use etcetera::{choose_app_strategy, AppStrategy}; +use goose::config::paths::Paths; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; @@ -41,11 +41,7 @@ pub struct ProjectInfoDisplay { impl ProjectTracker { /// Get the path to the projects.json file fn get_projects_file() -> Result { - let projects_file = choose_app_strategy(crate::APP_STRATEGY.clone()) - .context("goose requires a home dir")? - .in_data_dir("projects.json"); - - // Ensure data directory exists + let projects_file = Paths::in_data_dir("projects.json"); if let Some(parent) = projects_file.parent() { if !parent.exists() { fs::create_dir_all(parent)?; diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index cfb2083602fc..cb80d7381fd5 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -25,7 +25,6 @@ use goose::utils::safe_truncate; use anyhow::{Context, Result}; use completion::GooseCompleter; -use etcetera::{choose_app_strategy, AppStrategy}; use goose::agents::extension::{Envs, ExtensionConfig}; use goose::agents::types::RetryConfig; use goose::agents::{Agent, SessionConfig}; @@ -37,6 +36,7 @@ use rmcp::model::PromptMessage; use rmcp::model::ServerNotification; use rmcp::model::{ErrorCode, ErrorData}; +use goose::config::paths::Paths; use goose::conversation::message::{Message, MessageContent}; use rand::{distributions::Alphanumeric, Rng}; use rustyline::EditMode; @@ -413,29 +413,20 @@ impl CliSession { let completer = GooseCompleter::new(self.completion_cache.clone()); editor.set_helper(Some(completer)); - // Create and use a global history file in ~/.config/goose directory - // This allows command history to persist across different chat sessions - // instead of being tied to each individual session's messages - let strategy = - choose_app_strategy(crate::APP_STRATEGY.clone()).expect("goose requires a home dir"); - let config_dir = strategy.config_dir(); - let history_file = config_dir.join("history.txt"); + let history_file = Paths::config_dir().join("history.txt"); - // Ensure config directory exists if let Some(parent) = history_file.parent() { if !parent.exists() { std::fs::create_dir_all(parent)?; } } - // Load history from the global file if history_file.exists() { if let Err(err) = editor.load_history(&history_file) { eprintln!("Warning: Failed to load command history: {}", err); } } - // Helper function to save history after commands let save_history = |editor: &mut rustyline::Editor| { if let Err(err) = editor.save_history(&history_file) { diff --git a/crates/goose-mcp/Cargo.toml b/crates/goose-mcp/Cargo.toml index 4db0e66b0063..2c42e53c2580 100644 --- a/crates/goose-mcp/Cargo.toml +++ b/crates/goose-mcp/Cargo.toml @@ -68,6 +68,7 @@ tree-sitter-go = "0.21" tree-sitter-java = "0.21" tree-sitter-kotlin = "0.3.8" devgen-tree-sitter-swift = "0.21.0" +tree-sitter-ruby = "0.21.0" streaming-iterator = "0.1" rayon = "1.10" libc = "0.2" diff --git a/crates/goose-mcp/src/developer/analyze/graph.rs b/crates/goose-mcp/src/developer/analyze/graph.rs index e2163d022b9a..d87c72fd1d43 100644 --- a/crates/goose-mcp/src/developer/analyze/graph.rs +++ b/crates/goose-mcp/src/developer/analyze/graph.rs @@ -3,6 +3,10 @@ use std::path::PathBuf; use crate::developer::analyze::types::{AnalysisResult, CallChain}; +/// Sentinel value used to represent type references (instantiation, field types, etc.) +/// as callers in the call graph, since they don't have an actual caller function. +const REFERENCE_CALLER: &str = ""; + #[derive(Debug, Clone, Default)] pub struct CallGraph { callers: HashMap>, @@ -60,6 +64,44 @@ impl CallGraph { )); } } + + for reference in &result.references { + use crate::developer::analyze::types::ReferenceType; + + match &reference.ref_type { + ReferenceType::MethodDefinition => { + if let Some(type_name) = &reference.associated_type { + tracing::trace!( + "Linking method {} to type {}", + reference.symbol, + type_name + ); + graph.callees.entry(type_name.clone()).or_default().push(( + file_path.clone(), + reference.line, + reference.symbol.clone(), + )); + } + } + ReferenceType::TypeInstantiation + | ReferenceType::FieldType + | ReferenceType::VariableType + | ReferenceType::ParameterType => { + graph + .callers + .entry(reference.symbol.clone()) + .or_default() + .push(( + file_path.clone(), + reference.line, + REFERENCE_CALLER.to_string(), + )); + } + ReferenceType::Definition | ReferenceType::Call | ReferenceType::Import => { + // These are handled elsewhere or not relevant for type tracking + } + } + } } tracing::trace!( diff --git a/crates/goose-mcp/src/developer/analyze/languages/go.rs b/crates/goose-mcp/src/developer/analyze/languages/go.rs index 4cef6ff4f508..71c7116e7aee 100644 --- a/crates/goose-mcp/src/developer/analyze/languages/go.rs +++ b/crates/goose-mcp/src/developer/analyze/languages/go.rs @@ -3,17 +3,96 @@ pub const ELEMENT_QUERY: &str = r#" (function_declaration name: (identifier) @func) (method_declaration name: (field_identifier) @func) (type_declaration (type_spec name: (type_identifier) @struct)) + (const_declaration (const_spec name: (identifier) @const)) (import_declaration) @import "#; -/// Tree-sitter query for extracting Go function calls +/// Tree-sitter query for extracting Go function calls and identifier references pub const CALL_QUERY: &str = r#" ; Function calls (call_expression function: (identifier) @function.call) - + ; Method calls (call_expression function: (selector_expression field: (field_identifier) @method.call)) + + ; Identifier references in various expression contexts + ; This captures constants/variables used in arguments, comparisons, returns, assignments, etc. + (argument_list (identifier) @identifier.reference) + (binary_expression left: (identifier) @identifier.reference) + (binary_expression right: (identifier) @identifier.reference) + (unary_expression operand: (identifier) @identifier.reference) + (return_statement (expression_list (identifier) @identifier.reference)) + (assignment_statement right: (expression_list (identifier) @identifier.reference)) +"#; + +/// Tree-sitter query for extracting Go struct references and usage patterns +pub const REFERENCE_QUERY: &str = r#" + ; Method receivers - pointer type + (method_declaration + receiver: (parameter_list + (parameter_declaration + type: (pointer_type (type_identifier) @method.receiver)))) + + ; Method receivers - value type + (method_declaration + receiver: (parameter_list + (parameter_declaration + type: (type_identifier) @method.receiver))) + + ; Struct literals - simple + (composite_literal + type: (type_identifier) @struct.literal) + + ; Struct literals - qualified (package.Type) + (composite_literal + type: (qualified_type + name: (type_identifier) @struct.literal)) + + ; Field declarations in structs - simple type + (field_declaration + type: (type_identifier) @field.type) + + ; Field declarations - pointer type + (field_declaration + type: (pointer_type + (type_identifier) @field.type)) + + ; Field declarations - qualified type (package.Type) + (field_declaration + type: (qualified_type + name: (type_identifier) @field.type)) + + ; Field declarations - pointer to qualified type + (field_declaration + type: (pointer_type + (qualified_type + name: (type_identifier) @field.type))) "#; + +/// Find the method name for a method receiver node in Go +/// +/// This walks up the tree to find the method_declaration parent and extracts +/// the method name, used for associating methods with their receiver types. +pub fn find_method_for_receiver( + receiver_node: &tree_sitter::Node, + source: &str, + _ast_recursion_limit: Option, +) -> Option { + let mut current = *receiver_node; + while let Some(parent) = current.parent() { + if parent.kind() == "method_declaration" { + for i in 0..parent.child_count() { + if let Some(child) = parent.child(i) { + if child.kind() == "field_identifier" { + return Some(source[child.byte_range()].to_string()); + } + } + } + } + current = parent; + } + None +} diff --git a/crates/goose-mcp/src/developer/analyze/languages/mod.rs b/crates/goose-mcp/src/developer/analyze/languages/mod.rs index c5303ea9cdd8..db9372ee2423 100644 --- a/crates/goose-mcp/src/developer/analyze/languages/mod.rs +++ b/crates/goose-mcp/src/developer/analyze/languages/mod.rs @@ -1,35 +1,154 @@ +//! Language-specific analysis implementations +//! +//! This module contains language-specific parsing logic and tree-sitter queries +//! for the analyze tool. Each language has its own submodule with query definitions +//! and optional helper functions. +//! +//! ## Adding a New Language +//! +//! To add support for a new language: +//! +//! 1. Create a new file `languages/yourlang.rs` +//! 2. Define `ELEMENT_QUERY` and `CALL_QUERY` constants +//! 3. Optionally define `REFERENCE_QUERY` for advanced type tracking +//! 4. Add `pub mod yourlang;` below +//! 5. Add language configuration to registry in `get_language_info()` +//! +//! ## Optional Features +//! +//! Languages can opt into additional features by implementing: +//! +//! - Reference tracking: Define `REFERENCE_QUERY` to track type instantiation, +//! field types, and method-to-type associations (see Go and Ruby) +//! - Custom function naming: Implement `extract_function_name_for_kind()` for +//! special cases like Swift's init/deinit or Rust's impl blocks +//! - Method receiver lookup: Implement `find_method_for_receiver()` to associate +//! methods with their containing types (see Go and Ruby) + pub mod go; pub mod java; pub mod javascript; pub mod kotlin; pub mod python; +pub mod ruby; pub mod rust; pub mod swift; -/// Get the tree-sitter query for extracting code elements for a language -pub fn get_element_query(language: &str) -> &'static str { - match language { - "python" => python::ELEMENT_QUERY, - "rust" => rust::ELEMENT_QUERY, - "javascript" | "typescript" => javascript::ELEMENT_QUERY, - "go" => go::ELEMENT_QUERY, - "java" => java::ELEMENT_QUERY, - "kotlin" => kotlin::ELEMENT_QUERY, - "swift" => swift::ELEMENT_QUERY, - _ => "", - } +/// Handler for extracting function names from special node kinds +type ExtractFunctionNameHandler = fn(&tree_sitter::Node, &str, &str) -> Option; + +/// Handler for finding method names from receiver nodes +/// Takes: (receiver_node, source, ast_recursion_limit) +type FindMethodForReceiverHandler = fn(&tree_sitter::Node, &str, Option) -> Option; + +/// Language configuration containing all language-specific information +/// +/// This struct serves as a single source of truth for language support. +/// All language-specific queries and handlers are defined here. +#[derive(Copy, Clone)] +pub struct LanguageInfo { + /// Tree-sitter query for extracting code elements (functions, classes, imports) + pub element_query: &'static str, + /// Tree-sitter query for extracting function calls + pub call_query: &'static str, + /// Tree-sitter query for extracting type references (optional) + pub reference_query: &'static str, + /// Node kinds that represent function-like constructs + pub function_node_kinds: &'static [&'static str], + /// Node kinds that represent function name identifiers + pub function_name_kinds: &'static [&'static str], + /// Optional handler for language-specific function name extraction + pub extract_function_name_handler: Option, + /// Optional handler for finding method names from receiver nodes + pub find_method_for_receiver_handler: Option, } -/// Get the tree-sitter query for extracting function calls for a language -pub fn get_call_query(language: &str) -> &'static str { +/// Get language configuration for a given language +/// +/// Returns `Some(LanguageInfo)` if the language is supported, `None` otherwise. +pub fn get_language_info(language: &str) -> Option { match language { - "python" => python::CALL_QUERY, - "rust" => rust::CALL_QUERY, - "javascript" | "typescript" => javascript::CALL_QUERY, - "go" => go::CALL_QUERY, - "java" => java::CALL_QUERY, - "kotlin" => kotlin::CALL_QUERY, - "swift" => swift::CALL_QUERY, - _ => "", + "python" => Some(LanguageInfo { + element_query: python::ELEMENT_QUERY, + call_query: python::CALL_QUERY, + reference_query: "", + function_node_kinds: &["function_definition"], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: None, + find_method_for_receiver_handler: None, + }), + "rust" => Some(LanguageInfo { + element_query: rust::ELEMENT_QUERY, + call_query: rust::CALL_QUERY, + reference_query: "", + function_node_kinds: &["function_item", "impl_item"], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: Some(rust::extract_function_name_for_kind), + find_method_for_receiver_handler: None, + }), + "javascript" | "typescript" => Some(LanguageInfo { + element_query: javascript::ELEMENT_QUERY, + call_query: javascript::CALL_QUERY, + reference_query: "", + function_node_kinds: &[ + "function_declaration", + "method_definition", + "arrow_function", + ], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: None, + find_method_for_receiver_handler: None, + }), + "go" => Some(LanguageInfo { + element_query: go::ELEMENT_QUERY, + call_query: go::CALL_QUERY, + reference_query: go::REFERENCE_QUERY, + function_node_kinds: &["function_declaration", "method_declaration"], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: None, + find_method_for_receiver_handler: Some(go::find_method_for_receiver), + }), + "java" => Some(LanguageInfo { + element_query: java::ELEMENT_QUERY, + call_query: java::CALL_QUERY, + reference_query: "", + function_node_kinds: &["method_declaration", "constructor_declaration"], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: None, + find_method_for_receiver_handler: None, + }), + "kotlin" => Some(LanguageInfo { + element_query: kotlin::ELEMENT_QUERY, + call_query: kotlin::CALL_QUERY, + reference_query: "", + function_node_kinds: &["function_declaration", "class_body"], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: None, + find_method_for_receiver_handler: None, + }), + "swift" => Some(LanguageInfo { + element_query: swift::ELEMENT_QUERY, + call_query: swift::CALL_QUERY, + reference_query: "", + function_node_kinds: &[ + "function_declaration", + "init_declaration", + "deinit_declaration", + "subscript_declaration", + ], + function_name_kinds: &["simple_identifier"], + extract_function_name_handler: Some(swift::extract_function_name_for_kind), + find_method_for_receiver_handler: None, + }), + "ruby" => Some(LanguageInfo { + element_query: ruby::ELEMENT_QUERY, + call_query: ruby::CALL_QUERY, + reference_query: ruby::REFERENCE_QUERY, + function_node_kinds: &["method", "singleton_method"], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: None, + find_method_for_receiver_handler: Some(ruby::find_method_for_receiver), + }), + _ => None, } } diff --git a/crates/goose-mcp/src/developer/analyze/languages/ruby.rs b/crates/goose-mcp/src/developer/analyze/languages/ruby.rs new file mode 100644 index 000000000000..6214e6313596 --- /dev/null +++ b/crates/goose-mcp/src/developer/analyze/languages/ruby.rs @@ -0,0 +1,151 @@ +/// Tree-sitter query for extracting Ruby code elements. +/// +/// This query captures: +/// - Method definitions (def) +/// - Class and module definitions +/// - Constants +/// - Common attr_* declarations (attr_accessor, attr_reader, attr_writer) +/// - Import statements (require, require_relative, load) +pub const ELEMENT_QUERY: &str = r#" + ; Method definitions + (method name: (identifier) @func) + + ; Class and module definitions + (class name: (constant) @class) + (module name: (constant) @class) + + ; Constant assignments + (assignment left: (constant) @const) + + ; Attr declarations as functions + (call method: (identifier) @func (#eq? @func "attr_accessor")) + (call method: (identifier) @func (#eq? @func "attr_reader")) + (call method: (identifier) @func (#eq? @func "attr_writer")) + + ; Require statements + (call method: (identifier) @import (#eq? @import "require")) + (call method: (identifier) @import (#eq? @import "require_relative")) + (call method: (identifier) @import (#eq? @import "load")) +"#; + +/// Tree-sitter query for extracting Ruby function calls. +/// +/// This query captures: +/// - Direct method calls +/// - Method calls with receivers (object.method) +/// - Calls to constants (typically constructors like ClassName.new) +/// - Identifier and constant references in various expression contexts +pub const CALL_QUERY: &str = r#" + ; Method calls + (call method: (identifier) @method.call) + + ; Method calls with receiver + (call receiver: (_) method: (identifier) @method.call) + + ; Calls to constants (typically constructors) + (call receiver: (constant) @function.call) + + ; Identifier and constant references in argument lists + (argument_list (identifier) @identifier.reference) + (argument_list (constant) @identifier.reference) + + ; Binary expressions + (binary left: (identifier) @identifier.reference) + (binary right: (identifier) @identifier.reference) + (binary left: (constant) @identifier.reference) + (binary right: (constant) @identifier.reference) + + ; Assignment expressions + (assignment right: (identifier) @identifier.reference) + (assignment right: (constant) @identifier.reference) +"#; + +/// Tree-sitter query for extracting Ruby type references and usage patterns. +/// +/// This query captures: +/// - Method-to-class associations (instance and class methods) +/// - Class instantiation (ClassName.new) +/// - Type references in various contexts +pub const REFERENCE_QUERY: &str = r#" + ; Instance methods within a class - capture class name, will find method via receiver lookup + (class + name: (constant) @method.receiver + (body_statement (method))) + + ; Class instantiation (ClassName.new) + (call + receiver: (constant) @struct.literal + method: (identifier) @method.name (#eq? @method.name "new")) + + ; Constant references as receivers (type usage) + (call + receiver: (constant) @field.type + method: (identifier)) +"#; + +/// Find the method name for a method receiver node in Ruby +/// +/// For Ruby, the receiver_node is the class constant. This finds methods +/// within that class node, used for associating methods with their classes. +pub fn find_method_for_receiver( + receiver_node: &tree_sitter::Node, + source: &str, + ast_recursion_limit: Option, +) -> Option { + let max_depth = ast_recursion_limit.unwrap_or(10); + + // For Ruby, receiver_node is the class constant + if receiver_node.kind() == "constant" { + let mut current = *receiver_node; + while let Some(parent) = current.parent() { + if parent.kind() == "class" { + return find_first_method_in_class(&parent, source, max_depth); + } + current = parent; + } + } + None +} + +/// Find the first method name within a Ruby class node +fn find_first_method_in_class( + class_node: &tree_sitter::Node, + source: &str, + max_depth: usize, +) -> Option { + for i in 0..class_node.child_count() { + if let Some(child) = class_node.child(i) { + if child.kind() == "body_statement" { + return find_method_in_body_with_depth(&child, source, 0, max_depth); + } + } + } + None +} + +/// Recursively find a method within a body_statement node with depth limit +fn find_method_in_body_with_depth( + node: &tree_sitter::Node, + source: &str, + depth: usize, + max_depth: usize, +) -> Option { + if depth >= max_depth { + return None; + } + + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + if child.kind() == "method" { + for j in 0..child.child_count() { + if let Some(name_node) = child.child(j) { + if name_node.kind() == "identifier" { + return Some(source[name_node.byte_range()].to_string()); + } + } + } + } + } + } + None +} diff --git a/crates/goose-mcp/src/developer/analyze/languages/rust.rs b/crates/goose-mcp/src/developer/analyze/languages/rust.rs index 1a40674486c3..31c46cfd8f6d 100644 --- a/crates/goose-mcp/src/developer/analyze/languages/rust.rs +++ b/crates/goose-mcp/src/developer/analyze/languages/rust.rs @@ -26,3 +26,25 @@ pub const CALL_QUERY: &str = r#" (macro_invocation macro: (identifier) @macro.call) "#; + +/// Extract function name for Rust-specific node kinds +/// +/// Rust has special cases like impl_item blocks that should be +/// formatted as "impl TypeName" instead of extracting a simple name. +pub fn extract_function_name_for_kind( + node: &tree_sitter::Node, + source: &str, + kind: &str, +) -> Option { + if kind == "impl_item" { + // For impl blocks, find the type being implemented + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + if child.kind() == "type_identifier" { + return Some(format!("impl {}", &source[child.byte_range()])); + } + } + } + } + None +} diff --git a/crates/goose-mcp/src/developer/analyze/languages/swift.rs b/crates/goose-mcp/src/developer/analyze/languages/swift.rs index bbf24239bcf5..dd8ca8136a16 100644 --- a/crates/goose-mcp/src/developer/analyze/languages/swift.rs +++ b/crates/goose-mcp/src/developer/analyze/languages/swift.rs @@ -54,3 +54,19 @@ pub const CALL_QUERY: &str = r#" (call_expression (navigation_expression) @function.call) "#; + +/// Extract function name for Swift-specific node kinds +/// +/// Swift has special cases like init_declaration and deinit_declaration +/// that should return fixed names instead of extracting from children. +pub fn extract_function_name_for_kind( + _node: &tree_sitter::Node, + _source: &str, + kind: &str, +) -> Option { + match kind { + "init_declaration" => Some("init".to_string()), + "deinit_declaration" => Some("deinit".to_string()), + _ => None, + } +} diff --git a/crates/goose-mcp/src/developer/analyze/mod.rs b/crates/goose-mcp/src/developer/analyze/mod.rs index 5c36f0b318da..a6dccd7affbe 100644 --- a/crates/goose-mcp/src/developer/analyze/mod.rs +++ b/crates/goose-mcp/src/developer/analyze/mod.rs @@ -53,7 +53,6 @@ impl Default for CodeAnalyzer { } impl CodeAnalyzer { - /// Create a new code analyzer pub fn new() -> Self { tracing::debug!("Initializing CodeAnalyzer"); Self { @@ -62,7 +61,6 @@ impl CodeAnalyzer { } } - /// Main analyze entry point pub fn analyze( &self, params: AnalyzeParams, @@ -83,16 +81,15 @@ impl CodeAnalyzer { AnalysisMode::Focused => self.analyze_focused(&path, ¶ms, &traverser)?, AnalysisMode::Semantic => { if path.is_file() { - let result = self.analyze_file(&path, &mode)?; + let result = self.analyze_file(&path, &mode, ¶ms)?; Formatter::format_analysis_result(&path, &result, &mode) } else { - // Semantic mode on directory - analyze all files self.analyze_directory(&path, ¶ms, &traverser, &mode)? } } AnalysisMode::Structure => { if path.is_file() { - let result = self.analyze_file(&path, &mode)?; + let result = self.analyze_file(&path, &mode, ¶ms)?; Formatter::format_analysis_result(&path, &result, &mode) } else { self.analyze_directory(&path, ¶ms, &traverser, &mode)? @@ -107,7 +104,6 @@ impl CodeAnalyzer { } } - // Check output size and warn if too large (unless force flag is set) const OUTPUT_LIMIT: usize = 1000; if !params.force { let line_count = output.lines().count(); @@ -142,14 +138,11 @@ impl CodeAnalyzer { Ok(CallToolResult::success(Formatter::format_results(output))) } - /// Determine the analysis mode based on parameters and path fn determine_mode(&self, params: &AnalyzeParams, path: &Path) -> AnalysisMode { - // If focus is specified, use focused mode if params.focus.is_some() { return AnalysisMode::Focused; } - // Otherwise, use semantic for files, structure for directories if path.is_file() { AnalysisMode::Semantic } else { @@ -157,11 +150,14 @@ impl CodeAnalyzer { } } - /// Analyze a single file - fn analyze_file(&self, path: &Path, mode: &AnalysisMode) -> Result { + fn analyze_file( + &self, + path: &Path, + mode: &AnalysisMode, + params: &AnalyzeParams, + ) -> Result { tracing::debug!("Analyzing file {:?} in {:?} mode", path, mode); - // Check cache first let metadata = std::fs::metadata(path).map_err(|e| { tracing::error!("Failed to get file metadata for {:?}: {}", path, e); ErrorData::new( @@ -183,61 +179,56 @@ impl CodeAnalyzer { ) })?; - // Check cache if let Some(cached) = self.cache.get(&path.to_path_buf(), modified) { tracing::trace!("Using cached result for {:?}", path); return Ok(cached); } - // Read file content - handle binary files gracefully let content = match std::fs::read_to_string(path) { Ok(content) => content, Err(e) => { - // Binary or non-UTF-8 file, skip parsing tracing::trace!("Skipping binary/non-UTF-8 file {:?}: {}", path, e); return Ok(AnalysisResult::empty(0)); } }; - // Count lines let line_count = content.lines().count(); - // Get language let language = lang::get_language_identifier(path); if language.is_empty() { tracing::trace!("Unsupported file type: {:?}", path); - // Unsupported language, return empty result return Ok(AnalysisResult::empty(line_count)); } // Check if we support this language for parsing - let supported = matches!( - language, - "python" | "rust" | "javascript" | "typescript" | "go" | "java" | "kotlin" | "swift" - ); + // A language is supported if it has query definitions + let language_supported = languages::get_language_info(language) + .map(|info| !info.element_query.is_empty()) + .unwrap_or(false); - if !supported { + if !language_supported { tracing::trace!("Language {} not supported for parsing", language); return Ok(AnalysisResult::empty(line_count)); } - // Parse the file let tree = self.parser_manager.parse(&content, language)?; - // Extract information based on mode let depth = mode.as_str(); - let mut result = ElementExtractor::extract_with_depth(&tree, &content, language, depth)?; + let mut result = ElementExtractor::extract_with_depth( + &tree, + &content, + language, + depth, + params.ast_recursion_limit, + )?; - // Add line count to the result result.line_count = line_count; - // Cache the result self.cache.put(path.to_path_buf(), modified, result.clone()); Ok(result) } - /// Analyze a directory fn analyze_directory( &self, path: &Path, @@ -249,12 +240,10 @@ impl CodeAnalyzer { let mode = *mode; - // Collect directory results with parallel processing let results = traverser.collect_directory_results(path, params.max_depth, |file_path| { - self.analyze_file(file_path, &mode) + self.analyze_file(file_path, &mode, params) })?; - // Format based on mode Ok(Formatter::format_directory_structure( path, &results, @@ -262,14 +251,12 @@ impl CodeAnalyzer { )) } - /// Focused mode analysis - track a symbol across files fn analyze_focused( &self, path: &Path, params: &AnalyzeParams, traverser: &FileTraverser<'_>, ) -> Result { - // Focused mode requires focus parameter let focus_symbol = params.focus.as_ref().ok_or_else(|| { ErrorData::new( ErrorCode::INVALID_PARAMS, @@ -281,7 +268,6 @@ impl CodeAnalyzer { tracing::info!("Running focused analysis for symbol '{}'", focus_symbol); - // Step 1: Collect all files to analyze let files_to_analyze = if path.is_file() { vec![path.to_path_buf()] } else { @@ -293,21 +279,18 @@ impl CodeAnalyzer { files_to_analyze.len() ); - // Step 2: Analyze all files and collect results using parallel processing use rayon::prelude::*; let all_results: Result, _> = files_to_analyze .par_iter() .map(|file_path| { - self.analyze_file(file_path, &AnalysisMode::Semantic) + self.analyze_file(file_path, &AnalysisMode::Semantic, params) .map(|result| (file_path.clone(), result)) }) .collect(); let all_results = all_results?; - // Step 3: Build the call graph let graph = CallGraph::build_from_results(&all_results); - // Step 4: Find call chains based on follow_depth let incoming_chains = if params.follow_depth > 0 { graph.find_incoming_chains(focus_symbol, params.follow_depth) } else { @@ -320,14 +303,12 @@ impl CodeAnalyzer { vec![] }; - // Step 5: Get definitions from graph let definitions = graph .definitions .get(focus_symbol) .cloned() .unwrap_or_default(); - // Step 6: Format the output let focus_data = FocusedAnalysisData { focus_symbol, follow_depth: params.follow_depth, diff --git a/crates/goose-mcp/src/developer/analyze/parser.rs b/crates/goose-mcp/src/developer/analyze/parser.rs index e44de3cbc991..9edb360ab9e9 100644 --- a/crates/goose-mcp/src/developer/analyze/parser.rs +++ b/crates/goose-mcp/src/developer/analyze/parser.rs @@ -9,7 +9,6 @@ use crate::developer::analyze::types::{ ReferenceType, }; -/// Manages tree-sitter parsers for different languages #[derive(Clone)] pub struct ParserManager { parsers: Arc>>>>, @@ -23,7 +22,6 @@ impl ParserManager { } } - /// Get or create a parser for the specified language pub fn get_or_create_parser(&self, language: &str) -> Result>, ErrorData> { let mut cache = lock_or_recover(&self.parsers, |c| c.clear()); @@ -42,6 +40,7 @@ impl ParserManager { "java" => tree_sitter_java::language(), "kotlin" => tree_sitter_kotlin::language(), "swift" => devgen_tree_sitter_swift::language(), + "ruby" => tree_sitter_ruby::language(), _ => { tracing::warn!("Unsupported language: {}", language); return Err(ErrorData::new( @@ -66,10 +65,8 @@ impl ParserManager { Ok(parser_arc) } - /// Parse source code and return the syntax tree pub fn parse(&self, content: &str, language: &str) -> Result { let parser_arc = self.get_or_create_parser(language)?; - // Parser doesn't have a clear() method, so we just continue with it let mut parser = lock_or_recover(&parser_arc, |_| {}); parser.parse(content, None).ok_or_else(|| { @@ -89,66 +86,94 @@ impl Default for ParserManager { } } -/// Extract code elements from a parsed tree pub struct ElementExtractor; impl ElementExtractor { - /// Extract code elements with optional semantic analysis + fn find_child_by_kind<'a>( + node: &'a tree_sitter::Node, + kinds: &[&str], + ) -> Option> { + (0..node.child_count()) + .filter_map(|i| node.child(i)) + .find(|child| kinds.contains(&child.kind())) + } + + fn extract_text_from_child( + node: &tree_sitter::Node, + source: &str, + kinds: &[&str], + ) -> Option { + Self::find_child_by_kind(node, kinds).map(|child| source[child.byte_range()].to_string()) + } + pub fn extract_with_depth( tree: &Tree, source: &str, language: &str, depth: &str, + ast_recursion_limit: Option, ) -> Result { + use crate::developer::analyze::languages; + tracing::trace!( "Extracting elements from {} code with depth {}", language, depth ); - // First get the structural analysis let mut result = Self::extract_elements(tree, source, language)?; - // For structure mode, clear the detailed vectors but keep the counts if depth == "structure" { result.functions.clear(); result.classes.clear(); result.imports.clear(); } else if depth == "semantic" { - // For semantic mode, also extract calls let calls = Self::extract_calls(tree, source, language)?; result.calls = calls; - // Also populate references from the calls for call in &result.calls { result.references.push(ReferenceInfo { symbol: call.callee_name.clone(), ref_type: ReferenceType::Call, line: call.line, context: call.context.clone(), + associated_type: None, }); } + + // Languages can opt-in to advanced reference tracking by providing a REFERENCE_QUERY + // in their language definition. This enables tracking of: + // - Type instantiation (struct literals, object creation) + // - Field/variable/parameter type references + // - Method-to-type associations + if let Some(info) = languages::get_language_info(language) { + if !info.reference_query.is_empty() { + let references = + Self::extract_references(tree, source, language, ast_recursion_limit)?; + result.references.extend(references); + } + } } Ok(result) } - /// Extract basic code elements (functions, classes, imports) pub fn extract_elements( tree: &Tree, source: &str, language: &str, ) -> Result { - // Get language-specific query - let query_str = Self::get_element_query(language); - if query_str.is_empty() { - return Ok(Self::empty_analysis_result()); - } + use crate::developer::analyze::languages; + + let info = match languages::get_language_info(language) { + Some(info) if !info.element_query.is_empty() => info, + _ => return Ok(Self::empty_analysis_result()), + }; + + let query_str = info.element_query; - // Parse and process the query let (functions, classes, imports) = Self::process_element_query(tree, source, query_str)?; - // Detect main function let main_line = functions.iter().find(|f| f.name == "main").map(|f| f.line); Ok(AnalysisResult { @@ -165,23 +190,6 @@ impl ElementExtractor { }) } - /// Get language-specific query for elements - fn get_element_query(language: &str) -> &'static str { - use crate::developer::analyze::languages; - - match language { - "python" => languages::python::ELEMENT_QUERY, - "rust" => languages::rust::ELEMENT_QUERY, - "javascript" | "typescript" => languages::javascript::ELEMENT_QUERY, - "go" => languages::go::ELEMENT_QUERY, - "java" => languages::java::ELEMENT_QUERY, - "kotlin" => languages::kotlin::ELEMENT_QUERY, - "swift" => languages::swift::ELEMENT_QUERY, - _ => "", - } - } - - /// Process element query and extract functions, classes, imports fn process_element_query( tree: &Tree, source: &str, @@ -212,7 +220,7 @@ impl ElementExtractor { let line = source[..node.start_byte()].lines().count() + 1; match query.capture_names()[capture.index as usize] { - "func" => { + "func" | "const" => { functions.push(FunctionInfo { name: text.to_string(), line, @@ -244,37 +252,22 @@ impl ElementExtractor { Ok((functions, classes, imports)) } - /// Get language-specific query for finding function calls - fn get_call_query(language: &str) -> &'static str { - use crate::developer::analyze::languages; - - match language { - "python" => languages::python::CALL_QUERY, - "rust" => languages::rust::CALL_QUERY, - "javascript" | "typescript" => languages::javascript::CALL_QUERY, - "go" => languages::go::CALL_QUERY, - "java" => languages::java::CALL_QUERY, - "kotlin" => languages::kotlin::CALL_QUERY, - "swift" => languages::swift::CALL_QUERY, - _ => "", - } - } - - /// Extract function calls from the parse tree fn extract_calls( tree: &Tree, source: &str, language: &str, ) -> Result, ErrorData> { + use crate::developer::analyze::languages; use tree_sitter::{Query, QueryCursor}; let mut calls = Vec::new(); - // Get language-specific call query - let query_str = Self::get_call_query(language); - if query_str.is_empty() { - return Ok(calls); // No call query for this language - } + let info = match languages::get_language_info(language) { + Some(info) if !info.call_query.is_empty() => info, + _ => return Ok(calls), + }; + + let query_str = info.call_query; let query = Query::new(&tree.language(), query_str).map_err(|e| { tracing::error!("Failed to create call query: {}", e); @@ -294,7 +287,6 @@ impl ElementExtractor { let text = &source[node.byte_range()]; let start_pos = node.start_position(); - // Get the line of code for context let line_start = source[..node.start_byte()] .rfind('\n') .map(|i| i + 1) @@ -305,13 +297,15 @@ impl ElementExtractor { .unwrap_or(source.len()); let context = source[line_start..line_end].trim().to_string(); - // Find the containing function let caller_name = Self::find_containing_function(&node, source, language); - // Add the call based on capture name match query.capture_names()[capture.index as usize] { - "function.call" | "method.call" | "scoped.call" | "macro.call" - | "constructor.call" => { + "function.call" + | "method.call" + | "scoped.call" + | "macro.call" + | "constructor.call" + | "identifier.reference" => { calls.push(CallInfo { caller_name, callee_name: text.to_string(), @@ -329,82 +323,151 @@ impl ElementExtractor { Ok(calls) } - /// Find which function contains a given node + fn extract_references( + tree: &Tree, + source: &str, + language: &str, + ast_recursion_limit: Option, + ) -> Result, ErrorData> { + use crate::developer::analyze::languages; + use tree_sitter::{Query, QueryCursor}; + + let mut references = Vec::new(); + + let info = match languages::get_language_info(language) { + Some(info) if !info.reference_query.is_empty() => info, + _ => return Ok(references), + }; + + let query_str = info.reference_query; + + let query = Query::new(&tree.language(), query_str).map_err(|e| { + tracing::error!("Failed to create reference query: {}", e); + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + format!("Failed to create reference query: {}", e), + None, + ) + })?; + + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes()); + + for match_ in matches.by_ref() { + for capture in match_.captures { + let node = capture.node; + let text = &source[node.byte_range()]; + let start_pos = node.start_position(); + + let line_start = source[..node.start_byte()] + .rfind('\n') + .map(|i| i + 1) + .unwrap_or(0); + let line_end = source[node.end_byte()..] + .find('\n') + .map(|i| node.end_byte() + i) + .unwrap_or(source.len()); + let context = source[line_start..line_end].trim().to_string(); + + let capture_name = query.capture_names()[capture.index as usize]; + + let (ref_type, symbol, associated_type) = match capture_name { + "method.receiver" => { + let method_name = Self::find_method_name_for_receiver( + &node, + source, + language, + ast_recursion_limit, + ); + if let Some(method_name) = method_name { + ( + ReferenceType::MethodDefinition, + method_name, + Some(text.to_string()), + ) + } else { + continue; + } + } + "struct.literal" => (ReferenceType::TypeInstantiation, text.to_string(), None), + "field.type" => (ReferenceType::FieldType, text.to_string(), None), + "param.type" => (ReferenceType::ParameterType, text.to_string(), None), + "var.type" | "shortvar.type" => { + (ReferenceType::VariableType, text.to_string(), None) + } + "type.assertion" | "type.conversion" => { + (ReferenceType::Call, text.to_string(), None) + } + _ => continue, + }; + + references.push(ReferenceInfo { + symbol, + ref_type, + line: start_pos.row + 1, + context, + associated_type, + }); + } + } + + tracing::trace!("Extracted {} struct references", references.len()); + Ok(references) + } + + fn find_method_name_for_receiver( + receiver_node: &tree_sitter::Node, + source: &str, + language: &str, + ast_recursion_limit: Option, + ) -> Option { + use crate::developer::analyze::languages; + + languages::get_language_info(language) + .and_then(|info| info.find_method_for_receiver_handler) + .and_then(|handler| handler(receiver_node, source, ast_recursion_limit)) + } + fn find_containing_function( node: &tree_sitter::Node, source: &str, language: &str, ) -> Option { + use crate::developer::analyze::languages; + + let info = languages::get_language_info(language)?; + let mut current = *node; - // Walk up the tree to find a function definition while let Some(parent) = current.parent() { let kind = parent.kind(); - // Check for function-like nodes based on language - let is_function = match language { - "python" => kind == "function_definition", - "rust" => kind == "function_item" || kind == "impl_item", - "javascript" | "typescript" => { - kind == "function_declaration" - || kind == "method_definition" - || kind == "arrow_function" - } - "go" => kind == "function_declaration" || kind == "method_declaration", - "java" => kind == "method_declaration" || kind == "constructor_declaration", - "kotlin" => kind == "function_declaration" || kind == "class_body", - "swift" => { - kind == "function_declaration" - || kind == "init_declaration" - || kind == "deinit_declaration" - || kind == "subscript_declaration" + // Check if this is a function-like node + if info.function_node_kinds.contains(&kind) { + // Two-step extraction process: + // 1. Try language-specific extraction for special cases (e.g., Rust impl blocks, Swift init/deinit) + // 2. Fall back to generic extraction using standard identifier node kinds + // This pattern allows languages to override default behavior when needed + if let Some(handler) = info.extract_function_name_handler { + if let Some(name) = handler(&parent, source, kind) { + return Some(name); + } } - _ => false, - }; - - if is_function { - // Try to extract the function name - for i in 0..parent.child_count() { - if let Some(child) = parent.child(i) { - // Look for identifier nodes that represent the function name - if child.kind() == "identifier" - || child.kind() == "field_identifier" - || child.kind() == "property_identifier" - || (language == "swift" && child.kind() == "simple_identifier") - { - // For Python, skip the first identifier if it's 'def' - if language == "python" && i == 0 { - continue; - } - // For Swift init/deinit, use special names - if language == "swift" { - if kind == "init_declaration" { - return Some("init".to_string()); - } else if kind == "deinit_declaration" { - return Some("deinit".to_string()); - } - } - return Some(source[child.byte_range()].to_string()); - } - // For Rust impl blocks, look for the type - if language == "rust" - && kind == "impl_item" - && child.kind() == "type_identifier" - { - return Some(format!("impl {}", &source[child.byte_range()])); - } - } + // Standard extraction: find first child matching expected identifier kinds + if let Some(name) = + Self::extract_text_from_child(&parent, source, info.function_name_kinds) + { + return Some(name); } } current = parent; } - None // No containing function found (module-level call) + None } - /// Create an empty analysis result fn empty_analysis_result() -> AnalysisResult { AnalysisResult { functions: vec![], diff --git a/crates/goose-mcp/src/developer/analyze/tests/go_test.rs b/crates/goose-mcp/src/developer/analyze/tests/go_test.rs new file mode 100644 index 000000000000..2d7fc4881a60 --- /dev/null +++ b/crates/goose-mcp/src/developer/analyze/tests/go_test.rs @@ -0,0 +1,115 @@ +use crate::developer::analyze::graph::CallGraph; +use crate::developer::analyze::parser::{ElementExtractor, ParserManager}; +use crate::developer::analyze::types::{AnalysisResult, ReferenceType}; +use std::collections::HashSet; +use std::path::PathBuf; + +fn parse_and_extract(code: &str) -> AnalysisResult { + let manager = ParserManager::new(); + let tree = manager.parse(code, "go").unwrap(); + ElementExtractor::extract_with_depth(&tree, code, "go", "semantic", None).unwrap() +} + +fn build_test_graph(files: Vec<(&str, &str)>) -> CallGraph { + let manager = ParserManager::new(); + let results: Vec<_> = files + .iter() + .map(|(path, code)| { + let tree = manager.parse(code, "go").unwrap(); + let result = + ElementExtractor::extract_with_depth(&tree, code, "go", "semantic", None).unwrap(); + (PathBuf::from(*path), result) + }) + .collect(); + CallGraph::build_from_results(&results) +} + +#[test] +fn test_go_struct_and_method_tracking() { + let code = r#" +package main + +import "myapp/pkg/service" + +type Config struct { + Host string + Port int +} + +type Handler struct { + Cfg *Config + Svc *service.Widget +} + +func (h *Handler) Start() error { + return nil +} + +func (h *Handler) Stop() error { + return nil +} + +func main() { + cfg := Config{Host: "localhost", Port: 8080} + handler := Handler{Cfg: &cfg} + _ = handler.Start() +} +"#; + + let result = parse_and_extract(code); + let graph = build_test_graph(vec![("test.go", code)]); + + assert_eq!(result.class_count, 2); + let struct_names: HashSet<_> = result.classes.iter().map(|c| c.name.as_str()).collect(); + assert!(struct_names.contains("Config")); + assert!(struct_names.contains("Handler")); + + assert_eq!(result.function_count, 3); + let method_names: HashSet<_> = result.functions.iter().map(|f| f.name.as_str()).collect(); + assert!(method_names.contains("Start")); + assert!(method_names.contains("Stop")); + assert!(method_names.contains("main")); + + let handler_methods: Vec<_> = result + .references + .iter() + .filter(|r| { + r.ref_type == ReferenceType::MethodDefinition + && r.associated_type.as_deref() == Some("Handler") + }) + .collect(); + assert!( + handler_methods.len() >= 2, + "Expected at least 2 methods on Handler, found {}", + handler_methods.len() + ); + + let field_type_refs: Vec<_> = result + .references + .iter() + .filter(|r| r.ref_type == ReferenceType::FieldType) + .collect(); + assert!( + !field_type_refs.is_empty(), + "Expected to find field type references" + ); + + let config_literals: Vec<_> = result + .references + .iter() + .filter(|r| r.symbol == "Config" && r.ref_type == ReferenceType::TypeInstantiation) + .collect(); + assert!( + !config_literals.is_empty(), + "Expected to find Config struct literals" + ); + + let incoming = graph.find_incoming_chains("Handler", 1); + assert!( + !incoming.is_empty(), + "Expected to find incoming references to Handler" + ); + + let outgoing = graph.find_outgoing_chains("Handler", 1); + assert!(!outgoing.is_empty(), "Expected to find methods on Handler"); +} diff --git a/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs index 464424f3cd37..4db4a23dc6b6 100644 --- a/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs +++ b/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs @@ -17,6 +17,7 @@ fn test_analyze_python_file() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -45,6 +46,7 @@ fn test_analyze_directory() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -81,6 +83,7 @@ fn test_focused_analysis() { focus: Some("helper".to_string()), follow_depth: 1, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -109,6 +112,7 @@ fn test_analyze_with_cache() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -140,6 +144,7 @@ fn test_analyze_unsupported_file() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -158,6 +163,7 @@ fn test_analyze_nonexistent_path() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -182,6 +188,7 @@ fn test_focused_without_symbol() { focus: Some("nonexistent_symbol".to_string()), follow_depth: 1, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -219,6 +226,7 @@ fn test_nested_directory_analysis() { focus: None, follow_depth: 2, max_depth: 3, // Increase max_depth to ensure we reach nested files + ast_recursion_limit: None, force: false, }; diff --git a/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs index a2c1409a6b54..5df17ddb79b3 100644 --- a/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs +++ b/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs @@ -34,6 +34,7 @@ fn test_large_output_warning() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, // Should trigger warning }; @@ -82,6 +83,7 @@ fn test_force_flag_bypasses_warning() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: true, // Should bypass warning }; @@ -119,6 +121,7 @@ fn test_small_output_no_warning() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, // Shouldn't matter for small output }; diff --git a/crates/goose-mcp/src/developer/analyze/tests/mod.rs b/crates/goose-mcp/src/developer/analyze/tests/mod.rs index efc42587736c..f74ec3ba37ec 100644 --- a/crates/goose-mcp/src/developer/analyze/tests/mod.rs +++ b/crates/goose-mcp/src/developer/analyze/tests/mod.rs @@ -3,8 +3,10 @@ pub mod cache_tests; pub mod fixtures; pub mod formatter_tests; +pub mod go_test; pub mod graph_tests; pub mod integration_tests; pub mod large_output_tests; pub mod parser_tests; +pub mod ruby_test; pub mod traversal_tests; diff --git a/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs index ae5e7a752f54..0b93dec6aa0f 100644 --- a/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs +++ b/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs @@ -118,7 +118,7 @@ def func2(): let tree = manager.parse(content, "python").unwrap(); let result = - ElementExtractor::extract_with_depth(&tree, content, "python", "structure").unwrap(); + ElementExtractor::extract_with_depth(&tree, content, "python", "structure", None).unwrap(); // In structure mode, detailed vectors should be empty but counts preserved assert_eq!(result.function_count, 2); @@ -139,7 +139,7 @@ def func2(): let tree = manager.parse(content, "python").unwrap(); let result = - ElementExtractor::extract_with_depth(&tree, content, "python", "semantic").unwrap(); + ElementExtractor::extract_with_depth(&tree, content, "python", "semantic", None).unwrap(); // In semantic mode, should have both elements and calls assert_eq!(result.function_count, 2); @@ -226,3 +226,80 @@ fun helper() { assert!(result.import_count > 0); // import statements assert!(result.main_line.is_some()); } + +#[test] +fn test_language_registry() { + use crate::developer::analyze::languages; + + let supported = vec![ + "python", + "rust", + "javascript", + "typescript", + "go", + "java", + "kotlin", + "swift", + "ruby", + ]; + + for lang in supported { + let info = languages::get_language_info(lang); + assert!(info.is_some(), "Language {} should be supported", lang); + + let info = info.unwrap(); + assert!( + !info.element_query.is_empty(), + "{} missing element_query", + lang + ); + assert!(!info.call_query.is_empty(), "{} missing call_query", lang); + assert!( + !info.function_node_kinds.is_empty(), + "{} missing function_node_kinds", + lang + ); + assert!( + !info.function_name_kinds.is_empty(), + "{} missing function_name_kinds", + lang + ); + } + + let js = languages::get_language_info("javascript").unwrap(); + let ts = languages::get_language_info("typescript").unwrap(); + assert_eq!( + js.element_query, ts.element_query, + "JS/TS should share config" + ); + + let go = languages::get_language_info("go").unwrap(); + assert!( + !go.reference_query.is_empty(), + "Go should have reference tracking" + ); + assert!(go.find_method_for_receiver_handler.is_some()); + + let ruby = languages::get_language_info("ruby").unwrap(); + assert!( + !ruby.reference_query.is_empty(), + "Ruby should have reference tracking" + ); + assert!(ruby.find_method_for_receiver_handler.is_some()); + + let rust = languages::get_language_info("rust").unwrap(); + assert!( + rust.extract_function_name_handler.is_some(), + "Rust should have custom handler" + ); + + let swift = languages::get_language_info("swift").unwrap(); + assert!( + swift.extract_function_name_handler.is_some(), + "Swift should have custom handler" + ); + + assert!(languages::get_language_info("unsupported").is_none()); + assert!(languages::get_language_info("").is_none()); + assert!(languages::get_language_info("C++").is_none()); +} diff --git a/crates/goose-mcp/src/developer/analyze/tests/ruby_test.rs b/crates/goose-mcp/src/developer/analyze/tests/ruby_test.rs new file mode 100644 index 000000000000..5f6616604738 --- /dev/null +++ b/crates/goose-mcp/src/developer/analyze/tests/ruby_test.rs @@ -0,0 +1,259 @@ +#[cfg(test)] +mod ruby_tests { + use crate::developer::analyze::graph::CallGraph; + use crate::developer::analyze::parser::{ElementExtractor, ParserManager}; + use crate::developer::analyze::types::ReferenceType; + use std::collections::HashSet; + use std::path::PathBuf; + + #[test] + fn test_ruby_basic_parsing() { + let parser = ParserManager::new(); + let source = r#" +require 'json' + +class MyClass + attr_accessor :name + + def initialize(name) + @name = name + end + + def greet + puts "Hello" + end +end +"#; + + let tree = parser.parse(source, "ruby").unwrap(); + let result = ElementExtractor::extract_elements(&tree, source, "ruby").unwrap(); + + assert_eq!(result.class_count, 1); + assert!(result.classes.iter().any(|c| c.name == "MyClass")); + + assert!(result.function_count > 0); + assert!(result.functions.iter().any(|f| f.name == "initialize")); + assert!(result.functions.iter().any(|f| f.name == "greet")); + + assert!(result.import_count > 0); + } + + #[test] + fn test_ruby_attr_methods() { + let parser = ParserManager::new(); + let source = r#" +class Person + attr_reader :age + attr_writer :status + attr_accessor :name +end +"#; + + let tree = parser.parse(source, "ruby").unwrap(); + let result = ElementExtractor::extract_elements(&tree, source, "ruby").unwrap(); + + assert!( + result.function_count >= 3, + "Expected at least 3 functions from attr_* declarations, got {}", + result.function_count + ); + } + + #[test] + fn test_ruby_require_patterns() { + let parser = ParserManager::new(); + let source = r#" +require 'json' +require_relative 'lib/helper' +"#; + + let tree = parser.parse(source, "ruby").unwrap(); + let result = ElementExtractor::extract_elements(&tree, source, "ruby").unwrap(); + + assert_eq!( + result.import_count, 2, + "Should find both require and require_relative" + ); + } + + #[test] + fn test_ruby_method_calls() { + let parser = ParserManager::new(); + let source = r#" +class Example + def test_method + puts "Hello" + JSON.parse("{}") + object.method_call + end +end +"#; + + let tree = parser.parse(source, "ruby").unwrap(); + let result = + ElementExtractor::extract_with_depth(&tree, source, "ruby", "semantic", None).unwrap(); + + assert!(!result.calls.is_empty(), "Should find method calls"); + assert!(result.calls.iter().any(|c| c.callee_name == "puts")); + } + + #[test] + fn test_ruby_reference_tracking() { + let parser = ParserManager::new(); + let source = r#" +class User + attr_accessor :name + + def initialize(name) + @name = name + end + + def greet + puts "Hello, #{@name}" + end +end + +class Post + STATUS_DRAFT = "draft" + STATUS_PUBLISHED = "published" + + def initialize(title) + @title = title + @status = STATUS_DRAFT + end + + def publish + @status = STATUS_PUBLISHED + notify_users(@status) + end +end + +def main + user = User.new("Alice") + post = Post.new("My Title") + post.publish +end +"#; + + let tree = parser.parse(source, "ruby").unwrap(); + let result = + ElementExtractor::extract_with_depth(&tree, source, "ruby", "semantic", None).unwrap(); + + assert_eq!(result.class_count, 2); + let class_names: HashSet<_> = result.classes.iter().map(|c| c.name.as_str()).collect(); + assert!(class_names.contains("User")); + assert!(class_names.contains("Post")); + + assert!(result.function_count > 0); + let method_names: HashSet<_> = result.functions.iter().map(|f| f.name.as_str()).collect(); + assert!(method_names.contains("initialize")); + assert!(method_names.contains("greet")); + assert!(method_names.contains("publish")); + + let constant_refs: Vec<_> = result + .references + .iter() + .filter(|r| r.symbol == "STATUS_DRAFT" || r.symbol == "STATUS_PUBLISHED") + .collect(); + assert!( + !constant_refs.is_empty(), + "Expected to find constant references" + ); + + let instantiations: Vec<_> = result + .references + .iter() + .filter(|r| r.ref_type == ReferenceType::TypeInstantiation) + .collect(); + assert!( + instantiations.len() >= 2, + "Expected at least 2 class instantiations (User.new, Post.new)" + ); + let instantiated_types: HashSet<_> = + instantiations.iter().map(|r| r.symbol.as_str()).collect(); + assert!(instantiated_types.contains("User")); + assert!(instantiated_types.contains("Post")); + + let constant_usages: Vec<_> = result + .references + .iter() + .filter(|r| r.symbol == "STATUS_DRAFT" || r.symbol == "STATUS_PUBLISHED") + .collect(); + assert!( + !constant_usages.is_empty(), + "Expected to find STATUS_* constant usages" + ); + } + + #[test] + fn test_ruby_call_chains() { + let parser = ParserManager::new(); + + let file1 = r#" +class User + def initialize(name) + @name = name + end + + def display + format_output(@name) + end + + def format_output(text) + "User: #{text}" + end +end +"#; + + let file2 = r#" +require_relative 'user' + +def create_user(name) + User.new(name) +end + +def show_user(name) + user = create_user(name) + user.display +end +"#; + + let tree1 = parser.parse(file1, "ruby").unwrap(); + let result1 = + ElementExtractor::extract_with_depth(&tree1, file1, "ruby", "semantic", None).unwrap(); + + let tree2 = parser.parse(file2, "ruby").unwrap(); + let result2 = + ElementExtractor::extract_with_depth(&tree2, file2, "ruby", "semantic", None).unwrap(); + + let results = vec![ + (PathBuf::from("user.rb"), result1), + (PathBuf::from("main.rb"), result2), + ]; + let graph = CallGraph::build_from_results(&results); + + let incoming_user = graph.find_incoming_chains("User", 1); + assert!( + !incoming_user.is_empty(), + "Expected incoming references to User class" + ); + + let outgoing_display = graph.find_outgoing_chains("display", 1); + assert!( + !outgoing_display.is_empty(), + "Expected display to call format_output" + ); + + let outgoing_create = graph.find_outgoing_chains("create_user", 2); + assert!( + !outgoing_create.is_empty(), + "Expected create_user to have call chains" + ); + + let incoming_create = graph.find_incoming_chains("create_user", 1); + assert!( + !incoming_create.is_empty(), + "Expected show_user to call create_user" + ); + } +} diff --git a/crates/goose-mcp/src/developer/analyze/types.rs b/crates/goose-mcp/src/developer/analyze/types.rs index d2c172e80599..68175814f824 100644 --- a/crates/goose-mcp/src/developer/analyze/types.rs +++ b/crates/goose-mcp/src/developer/analyze/types.rs @@ -2,13 +2,10 @@ use rmcp::schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -/// Parameters for the analyze tool #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct AnalyzeParams { - /// Absolute path. Step 1: Directory for overview. Step 2: File for details. Step 3: Directory with focus param for call graphs pub path: String, - /// Symbol name for call graph analysis (Step 3). Requires directory path with broad enough scope to capture all relevant symbol references pub focus: Option, /// Call graph depth. 0=where defined, 1=direct callers/callees, 2+=transitive chains @@ -19,6 +16,10 @@ pub struct AnalyzeParams { #[serde(default = "default_max_depth")] pub max_depth: u32, + /// Maximum depth for recursive AST traversal (prevents stack overflow in deeply nested code) + #[serde(default)] + pub ast_recursion_limit: Option, + /// Allow large outputs without warning (default: false) #[serde(default)] pub force: bool, @@ -64,11 +65,11 @@ pub struct ClassInfo { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CallInfo { - pub caller_name: Option, // Function containing this call - pub callee_name: String, // Function being called + pub caller_name: Option, + pub callee_name: String, pub line: usize, pub column: usize, - pub context: String, // Line of code containing the call + pub context: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -77,14 +78,29 @@ pub struct ReferenceInfo { pub ref_type: ReferenceType, pub line: usize, pub context: String, + /// For method definitions, this stores the type the method belongs to + /// For type usage, this is None + pub associated_type: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ReferenceType { + /// Type/class/struct definition Definition, + /// Method or function definition on a type (use associated_type to link to type) + MethodDefinition, + /// Function call or method call Call, + /// Type instantiation (e.g., struct literal, class constructor) + TypeInstantiation, + /// Type used in field declaration + FieldType, + /// Type used in variable declaration + VariableType, + /// Type used in function/method parameter + ParameterType, + /// Import statement Import, - Assignment, } // Entry type for directory results - cleaner than overloading AnalysisResult diff --git a/crates/goose-mcp/src/developer/shell.rs b/crates/goose-mcp/src/developer/shell.rs index b007fd8bb722..8f0912cf31fa 100644 --- a/crates/goose-mcp/src/developer/shell.rs +++ b/crates/goose-mcp/src/developer/shell.rs @@ -1,6 +1,6 @@ -use goose::config::get_config_dir; use std::{env, ffi::OsString, process::Stdio}; +use goose::config::paths::Paths; #[cfg(unix)] #[allow(unused_imports)] // False positive: trait is used for process_group method use std::os::unix::process::CommandExt; @@ -30,7 +30,7 @@ impl Default for ShellConfig { // Configure environment based on shell type let envs = if shell_name == "bash" { - let bash_env = get_config_dir().join(".bash_env").into_os_string(); + let bash_env = Paths::config_dir().join(".bash_env").into_os_string(); vec![(OsString::from("BASH_ENV"), bash_env)] } else { vec![] diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index cd429b378f9e..0bf758bd4111 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -5,9 +5,8 @@ use axum::{ routing::{delete, get, post}, Json, Router, }; -use etcetera::{choose_app_strategy, AppStrategy}; +use goose::config::paths::Paths; use goose::config::ExtensionEntry; -use goose::config::APP_STRATEGY; use goose::config::{Config, ConfigError}; use goose::model::ModelConfig; use goose::providers::base::ProviderMetadata; @@ -565,11 +564,7 @@ pub async fn upsert_permissions( ) )] pub async fn backup_config() -> Result, StatusCode> { - let config_dir = choose_app_strategy(APP_STRATEGY.clone()) - .expect("goose requires a home dir") - .config_dir(); - - let config_path = config_dir.join("config.yaml"); + let config_path = Paths::config_dir().join("config.yaml"); if config_path.exists() { let file_name = config_path @@ -630,11 +625,7 @@ pub async fn recover_config() -> Result, StatusCode> { ) )] pub async fn validate_config() -> Result, StatusCode> { - let config_dir = choose_app_strategy(APP_STRATEGY.clone()) - .expect("goose requires a home dir") - .config_dir(); - - let config_path = config_dir.join("config.yaml"); + let config_path = Paths::config_dir().join("config.yaml"); if !config_path.exists() { return Ok(Json("Config file does not exist".to_string())); diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index c8651e6a9433..d5533aa80ecf 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -23,6 +23,7 @@ use crate::agents::recipe_tools::dynamic_task_tools::{ use crate::agents::retry::{RetryManager, RetryResult}; use crate::agents::router_tools::ROUTER_LLM_SEARCH_TOOL_NAME; use crate::agents::sub_recipe_manager::SubRecipeManager; +use crate::agents::subagent_execution_tool::lib::ExecutionMode; use crate::agents::subagent_execution_tool::subagent_execute_task_tool::{ self, SUBAGENT_EXECUTE_TASK_TOOL_NAME, }; @@ -297,6 +298,7 @@ impl Agent { permission_check_result: &PermissionCheckResult, message_tool_response: Arc>, cancel_token: Option, + session: Option, ) -> Result> { let mut tool_futures: Vec<(String, ToolStream)> = Vec::new(); @@ -304,7 +306,12 @@ impl Agent { for request in &permission_check_result.approved { if let Ok(tool_call) = request.tool_call.clone() { let (req_id, tool_result) = self - .dispatch_tool_call(tool_call, request.id.clone(), cancel_token.clone()) + .dispatch_tool_call( + tool_call, + request.id.clone(), + cancel_token.clone(), + session.clone(), + ) .await; tool_futures.push(( @@ -384,6 +391,7 @@ impl Agent { tool_call: CallToolRequestParam, request_id: String, cancellation_token: Option, + session: Option, ) -> (String, Result) { if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME { let arguments = tool_call @@ -451,16 +459,89 @@ impl Agent { .dispatch_sub_recipe_tool_call(&tool_call.name, arguments, &self.tasks_manager) .await } else if tool_call.name == SUBAGENT_EXECUTE_TASK_TOOL_NAME { - let provider = self.provider().await.ok(); - let arguments = tool_call - .arguments - .clone() - .map(Value::Object) - .unwrap_or(Value::Object(serde_json::Map::new())); + let provider = match self.provider().await { + Ok(p) => p, + Err(_) => { + return ( + request_id, + Err(ErrorData::new( + ErrorCode::INTERNAL_ERROR, + "Provider is required".to_string(), + None, + )), + ); + } + }; + let session = match session.as_ref() { + Some(s) => s, + None => { + return ( + request_id, + Err(ErrorData::new( + ErrorCode::INTERNAL_ERROR, + "Session is required".to_string(), + None, + )), + ); + } + }; + let parent_session_id = session.id.to_string(); + let parent_working_dir = session.working_dir.clone(); + + let task_config = TaskConfig::new( + provider, + parent_session_id, + parent_working_dir, + get_enabled_extensions(), + ); + + let arguments = match tool_call.arguments.clone() { + Some(args) => Value::Object(args), + None => { + return ( + request_id, + Err(ErrorData::new( + ErrorCode::INVALID_PARAMS, + "Tool call arguments are required".to_string(), + None, + )), + ); + } + }; + let task_ids: Vec = match arguments.get("task_ids") { + Some(v) => match serde_json::from_value(v.clone()) { + Ok(ids) => ids, + Err(_) => { + return ( + request_id, + Err(ErrorData::new( + ErrorCode::INVALID_PARAMS, + "Invalid task_ids format".to_string(), + None, + )), + ); + } + }, + None => { + return ( + request_id, + Err(ErrorData::new( + ErrorCode::INVALID_PARAMS, + "task_ids parameter is required".to_string(), + None, + )), + ); + } + }; + + let execution_mode = arguments + .get("execution_mode") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .unwrap_or(ExecutionMode::Sequential); - let task_config = TaskConfig::new(provider); subagent_execute_task_tool::run_tasks( - arguments, + task_ids, + execution_mode, task_config, &self.tasks_manager, cancellation_token, @@ -1162,6 +1243,7 @@ impl Agent { &permission_check_result, message_tool_response.clone(), cancel_token.clone(), + session.clone(), ).await?; let tool_futures_arc = Arc::new(Mutex::new(tool_futures)); @@ -1172,6 +1254,7 @@ impl Agent { tool_futures_arc.clone(), message_tool_response.clone(), cancel_token.clone(), + session.clone(), &inspection_results, ); diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index b5cf270bf912..39bf903a3579 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -16,7 +16,6 @@ mod router_tool_selector; mod router_tools; mod schedule_tool; pub mod sub_recipe_manager; -pub mod subagent; pub mod subagent_execution_tool; pub mod subagent_handler; mod subagent_task_config; @@ -30,6 +29,5 @@ pub use agent::{Agent, AgentEvent}; pub use extension::ExtensionConfig; pub use extension_manager::ExtensionManager; pub use prompt_manager::PromptManager; -pub use subagent::{SubAgent, SubAgentProgress, SubAgentStatus}; pub use subagent_task_config::TaskConfig; pub use types::{FrontendTool, RetryConfig, SessionConfig, SuccessCheck}; diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 641e65a0341c..c4b7b34d1b20 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -84,48 +84,6 @@ impl Agent { Ok((tools, toolshim_tools, system_prompt)) } - /// Generate a response from the LLM provider - /// Handles toolshim transformations if needed - pub(crate) async fn generate_response_from_provider( - provider: Arc, - system_prompt: &str, - messages: &[Message], - tools: &[Tool], - toolshim_tools: &[Tool], - ) -> Result<(Message, ProviderUsage), ProviderError> { - let config = provider.get_model_config(); - - // Convert tool messages to text if toolshim is enabled - let messages_for_provider = if config.toolshim { - convert_tool_messages_to_text(messages) - } else { - Conversation::new_unvalidated(messages.to_vec()) - }; - - // Call the provider to get a response - let (mut response, mut usage) = provider - .complete(system_prompt, messages_for_provider.messages(), tools) - .await?; - - // Ensure we have token counts, estimating if necessary - usage - .ensure_tokens( - system_prompt, - messages_for_provider.messages(), - &response, - tools, - ) - .await?; - - crate::providers::base::set_current_model(&usage.model); - - if config.toolshim { - response = toolshim_postprocess(response, toolshim_tools).await?; - } - - Ok((response, usage)) - } - /// Stream a response from the LLM provider. /// Handles toolshim transformations if needed pub(crate) async fn stream_response_from_provider( diff --git a/crates/goose/src/agents/subagent.rs b/crates/goose/src/agents/subagent.rs deleted file mode 100644 index 7daac47255d8..000000000000 --- a/crates/goose/src/agents/subagent.rs +++ /dev/null @@ -1,334 +0,0 @@ -use crate::agents::subagent_task_config::DEFAULT_SUBAGENT_MAX_TURNS; -use crate::{ - agents::{extension_manager::ExtensionManager, Agent, TaskConfig}, - config::get_all_extensions, - prompt_template::render_global_file, - providers::errors::ProviderError, -}; -use anyhow::anyhow; -use chrono::{DateTime, Utc}; -use rmcp::model::Tool; -use rmcp::model::{ErrorCode, ErrorData}; -use serde::{Deserialize, Serialize}; -// use serde_json::{self}; -use crate::conversation::message::{Message, MessageContent, ToolRequest}; -use crate::conversation::Conversation; -use std::{collections::HashMap, sync::Arc}; -use tokio::sync::{Mutex, RwLock}; -use tokio_util::sync::CancellationToken; -use tracing::{debug, error, instrument}; - -/// Status of a subagent -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum SubAgentStatus { - Ready, // Ready to process messages - Processing, // Currently working on a task - Completed(String), // Task completed (with optional message for success/error) - Terminated, // Manually terminated -} - -/// Progress information for a subagent -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SubAgentProgress { - pub subagent_id: String, - pub status: SubAgentStatus, - pub message: String, - pub turn: usize, - pub max_turns: Option, - pub timestamp: DateTime, -} - -/// A specialized agent that can handle specific tasks independently -pub struct SubAgent { - pub id: String, - pub conversation: Arc>, - pub status: Arc>, - pub config: TaskConfig, - pub turn_count: Arc>, - pub created_at: DateTime, - pub extension_manager: Arc>, -} - -impl SubAgent { - /// Create a new subagent with the given configuration and provider - #[instrument(skip(task_config))] - pub async fn new(task_config: TaskConfig) -> Result, anyhow::Error> { - debug!("Creating new subagent with id: {}", task_config.id); - - // Create a new extension manager for this subagent - let extension_manager = ExtensionManager::new(); - - // Determine which extensions to add: - // 1. If task_config.extensions is Some(vec), use those specific extensions - // 2. If task_config.extensions is None, use all enabled extensions (backward compatibility) - - let extensions_to_add = if let Some(ref extensions) = task_config.extensions { - // Use the explicitly specified extensions - extensions.clone() - } else { - // Default behavior: use all enabled extensions - get_all_extensions() - .into_iter() - .filter(|ext| ext.enabled) - .map(|ext| ext.config) - .collect() - }; - - // Add the determined extensions to the subagent's extension manager - for extension in extensions_to_add { - if let Err(e) = extension_manager.add_extension(extension).await { - debug!("Failed to add extension to subagent: {}", e); - // Continue with other extensions even if one fails - } - } - - let subagent = Arc::new(SubAgent { - id: task_config.id.clone(), - conversation: Arc::new(Mutex::new(Conversation::new_unvalidated(Vec::new()))), - status: Arc::new(RwLock::new(SubAgentStatus::Ready)), - config: task_config, - turn_count: Arc::new(Mutex::new(0)), - created_at: Utc::now(), - extension_manager: Arc::new(RwLock::new(extension_manager)), - }); - - debug!("Subagent {} created successfully", subagent.id); - Ok(subagent) - } - - /// Update the status of the subagent - async fn set_status(&self, status: SubAgentStatus) { - // Update the status first, then release the lock - { - let mut current_status = self.status.write().await; - *current_status = status.clone(); - } // Write lock is released here! - } - - /// Process a message and generate a response using the subagent's provider - #[instrument(skip(self, message))] - pub async fn reply_subagent( - &self, - message: String, - task_config: TaskConfig, - ) -> Result { - debug!("Processing message for subagent {}", self.id); - - // Get provider from task config - let provider = self - .config - .provider - .as_ref() - .ok_or_else(|| anyhow!("No provider configured for subagent"))?; - - // Set status to processing - self.set_status(SubAgentStatus::Processing).await; - - // Add user message to conversation - let user_message = Message::user().with_text(message.clone()); - { - let mut conversation = self.conversation.lock().await; - conversation.push(user_message.clone()); - } - - // Get the current conversation for context - let mut messages = { - let conversation = self.conversation.lock().await; - conversation.clone() - }; - - // Get tools from the subagent's own extension manager - let tools: Vec = self - .extension_manager - .read() - .await - .get_prefixed_tools(None) - .await - .unwrap_or_default(); - - let toolshim_tools: Vec = vec![]; - - // Build system prompt using the template - let system_prompt = self.build_system_prompt(&tools).await?; - - // Generate response from provider with loop for tool processing (max_turns iterations) - let mut loop_count = 0; - let max_turns = self.config.max_turns.unwrap_or(DEFAULT_SUBAGENT_MAX_TURNS); - let mut last_error: Option = None; - - // Generate response from provider - loop { - loop_count += 1; - - match Agent::generate_response_from_provider( - Arc::clone(provider), - &system_prompt, - messages.messages(), - &tools, - &toolshim_tools, - ) - .await - { - Ok((response, _usage)) => { - // Process any tool calls in the response - let tool_requests: Vec = response - .content - .iter() - .filter_map(|content| { - if let MessageContent::ToolRequest(req) = content { - Some(req.clone()) - } else { - None - } - }) - .collect(); - - // If there are no tool requests, we're done - if tool_requests.is_empty() || loop_count >= max_turns { - self.add_message(response.clone()).await; - messages.push(response.clone()); - - // Set status back to ready - self.set_status(SubAgentStatus::Completed("Completed!".to_string())) - .await; - break; - } - - // Add the assistant message with tool calls to the conversation - messages.push(response.clone()); - - // Process each tool request and create user response messages - for request in &tool_requests { - if let Ok(tool_call) = &request.tool_call { - // Handle platform tools or dispatch to extension manager - let tool_result = match self - .extension_manager - .read() - .await - .dispatch_tool_call(tool_call.clone(), CancellationToken::default()) - .await - { - Ok(result) => result.result.await, - Err(e) => Err(ErrorData::new( - ErrorCode::INTERNAL_ERROR, - e.to_string(), - None, - )), - }; - - match tool_result { - Ok(result) => { - // Create a user message with the tool response - let tool_response_message = Message::user() - .with_tool_response(request.id.clone(), Ok(result.clone())); - messages.push(tool_response_message); - } - Err(e) => { - // Create a user message with the tool error - let tool_error_message = Message::user().with_tool_response( - request.id.clone(), - Err(ErrorData::new( - ErrorCode::INTERNAL_ERROR, - e.to_string(), - None, - )), - ); - messages.push(tool_error_message); - } - } - } - } - - // Continue the loop to get the next response from the provider - } - Err(ProviderError::ContextLengthExceeded(_)) => { - self.set_status(SubAgentStatus::Completed( - "Context length exceeded".to_string(), - )) - .await; - last_error = Some(anyhow::anyhow!("Context length exceeded")); - break; - } - Err(ProviderError::RateLimitExceeded { .. }) => { - self.set_status(SubAgentStatus::Completed("Rate limit exceeded".to_string())) - .await; - last_error = Some(anyhow::anyhow!("Rate limit exceeded")); - break; - } - Err(e) => { - self.set_status(SubAgentStatus::Completed(format!("Error: {}", e))) - .await; - error!("Error: {}", e); - last_error = Some(anyhow::anyhow!("Provider error: {}", e)); - break; - } - } - } - - // Handle error cases or return the last message - if let Some(error) = last_error { - Err(error) - } else { - Ok(messages) - } - } - - /// Add a message to the conversation (for tracking agent responses) - async fn add_message(&self, message: Message) { - let mut conversation = self.conversation.lock().await; - conversation.push(message); - } - - /// Build the system prompt for the subagent using the template - async fn build_system_prompt(&self, available_tools: &[Tool]) -> Result { - let mut context = HashMap::new(); - - // Add basic context - context.insert( - "current_date_time", - serde_json::Value::String(Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string()), - ); - context.insert("subagent_id", serde_json::Value::String(self.id.clone())); - - // Add max turns if configured - if let Some(max_turns) = self.config.max_turns { - context.insert( - "max_turns", - serde_json::Value::Number(serde_json::Number::from(max_turns)), - ); - } - - // Add available tools with descriptions for better context - let tools_with_descriptions: Vec = available_tools - .iter() - .map(|t| { - if let Some(description) = &t.description { - format!("{}: {}", t.name, description) - } else { - t.name.to_string() - } - }) - .collect(); - - context.insert( - "available_tools", - serde_json::Value::String(if tools_with_descriptions.is_empty() { - "None".to_string() - } else { - tools_with_descriptions.join(", ") - }), - ); - - // Add tool count for context - context.insert( - "tool_count", - serde_json::Value::Number(serde_json::Number::from(available_tools.len())), - ); - - // Render the subagent system prompt template - let system_prompt = render_global_file("subagent_system.md", &context) - .map_err(|e| anyhow!("Failed to render subagent system prompt: {}", e))?; - - Ok(system_prompt) - } -} diff --git a/crates/goose/src/agents/subagent_execution_tool/lib/mod.rs b/crates/goose/src/agents/subagent_execution_tool/lib/mod.rs index 695d8ddb97ab..48947c1438a8 100644 --- a/crates/goose/src/agents/subagent_execution_tool/lib/mod.rs +++ b/crates/goose/src/agents/subagent_execution_tool/lib/mod.rs @@ -12,21 +12,13 @@ use tokio::sync::mpsc::Sender; use tokio_util::sync::CancellationToken; pub async fn execute_tasks( - input: Value, + task_ids: Vec, execution_mode: ExecutionMode, notifier: Sender, task_config: TaskConfig, tasks_manager: &TasksManager, cancellation_token: Option, ) -> Result { - let task_ids: Vec = serde_json::from_value( - input - .get("task_ids") - .ok_or("Missing task_ids field")? - .clone(), - ) - .map_err(|e| format!("Failed to parse task_ids: {}", e))?; - let tasks = tasks_manager.get_tasks(&task_ids).await?; let task_count = tasks.len(); diff --git a/crates/goose/src/agents/subagent_execution_tool/subagent_execute_task_tool.rs b/crates/goose/src/agents/subagent_execution_tool/subagent_execute_task_tool.rs index 474912f0c416..b70f71d3f205 100644 --- a/crates/goose/src/agents/subagent_execution_tool/subagent_execute_task_tool.rs +++ b/crates/goose/src/agents/subagent_execution_tool/subagent_execute_task_tool.rs @@ -1,14 +1,12 @@ use std::borrow::Cow; -use rmcp::model::{Content, ErrorCode, ErrorData, ServerNotification, Tool, ToolAnnotations}; -use serde_json::Value; - use crate::agents::subagent_task_config::TaskConfig; use crate::agents::{ subagent_execution_tool::lib::execute_tasks, subagent_execution_tool::task_types::ExecutionMode, subagent_execution_tool::tasks_manager::TasksManager, tool_execution::ToolCallResult, }; +use rmcp::model::{Content, ErrorCode, ErrorData, ServerNotification, Tool, ToolAnnotations}; use rmcp::object; use tokio::sync::mpsc; use tokio_stream; @@ -62,7 +60,8 @@ pub fn create_subagent_execute_task_tool() -> Tool { } pub async fn run_tasks( - execute_data: Value, + task_ids: Vec, + execution_mode: ExecutionMode, task_config: TaskConfig, tasks_manager: &TasksManager, cancellation_token: Option, @@ -71,14 +70,8 @@ pub async fn run_tasks( let tasks_manager_clone = tasks_manager.clone(); let result_future = async move { - let execute_data_clone = execute_data.clone(); - let execution_mode = execute_data_clone - .get("execution_mode") - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - .unwrap_or_default(); - match execute_tasks( - execute_data, + task_ids, execution_mode, notification_tx, task_config, diff --git a/crates/goose/src/agents/subagent_execution_tool/tasks.rs b/crates/goose/src/agents/subagent_execution_tool/tasks.rs index 96d9f25d0f33..ccb9fdb717cd 100644 --- a/crates/goose/src/agents/subagent_execution_tool/tasks.rs +++ b/crates/goose/src/agents/subagent_execution_tool/tasks.rs @@ -74,7 +74,7 @@ async fn handle_inline_recipe_task( mut task_config: TaskConfig, cancellation_token: CancellationToken, ) -> Result { - use crate::agents::subagent_handler::run_complete_subagent_task_with_options; + use crate::agents::subagent_handler::run_complete_subagent_task; use crate::recipe::Recipe; let recipe_value = task @@ -91,14 +91,23 @@ async fn handle_inline_recipe_task( .and_then(|v| v.as_bool()) .unwrap_or(false); - task_config.extensions = recipe.extensions.clone(); + if let Some(exts) = recipe.extensions { + if !exts.is_empty() { + task_config.extensions = exts.clone(); + } + } let instruction = recipe .instructions .or(recipe.prompt) .ok_or_else(|| "No instructions or prompt in recipe".to_string())?; + let result = tokio::select! { - result = run_complete_subagent_task_with_options(instruction, task_config, return_last_only) => result, + result = run_complete_subagent_task( + instruction, + task_config, + return_last_only, + ) => result, _ = cancellation_token.cancelled() => { return Err("Task cancelled".to_string()); } diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index c2cb724a8eb0..83f4cfb054fe 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -1,35 +1,30 @@ -use crate::agents::subagent::SubAgent; -use crate::agents::subagent_task_config::TaskConfig; -use anyhow::Result; +use crate::{ + agents::{subagent_task_config::TaskConfig, AgentEvent, SessionConfig}, + conversation::{message::Message, Conversation}, + execution::manager::AgentManager, + session::SessionManager, +}; +use anyhow::{anyhow, Result}; +use futures::future::BoxFuture; +use futures::StreamExt; use rmcp::model::{ErrorCode, ErrorData}; - -/// Standalone function to run a complete subagent task -pub async fn run_complete_subagent_task( - text_instruction: String, - task_config: TaskConfig, -) -> Result { - run_complete_subagent_task_with_options(text_instruction, task_config, false).await -} +use tracing::debug; /// Standalone function to run a complete subagent task with output options -pub async fn run_complete_subagent_task_with_options( +pub async fn run_complete_subagent_task( text_instruction: String, task_config: TaskConfig, return_last_only: bool, ) -> Result { - // Create the subagent with the parent agent's provider - let subagent = SubAgent::new(task_config.clone()).await.map_err(|e| { - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to create subagent: {}", e), - None, - ) - })?; - - // Execute the subagent task - let messages = subagent - .reply_subagent(text_instruction, task_config) - .await?; + let messages = get_agent_messages(text_instruction, task_config) + .await + .map_err(|e| { + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + format!("Failed to execute task: {}", e), + None, + ) + })?; // Extract text content based on return_last_only flag let response_text = if return_last_only { @@ -94,3 +89,73 @@ pub async fn run_complete_subagent_task_with_options( // Return the result Ok(response_text) } + +fn get_agent_messages( + text_instruction: String, + task_config: TaskConfig, +) -> BoxFuture<'static, Result> { + Box::pin(async move { + let agent_manager = AgentManager::instance() + .await + .map_err(|e| anyhow!("Failed to create AgentManager: {}", e))?; + let parent_session_id = task_config.parent_session_id; + let working_dir = task_config.parent_working_dir; + let session = SessionManager::create_session( + working_dir.clone(), + format!("Subagent task for: {}", parent_session_id), + ) + .await + .map_err(|e| anyhow!("Failed to create a session for sub agent: {}", e))?; + + let agent = agent_manager + .get_or_create_agent(session.id.clone()) + .await + .map_err(|e| anyhow!("Failed to get sub agent session file path: {}", e))?; + agent + .update_provider(task_config.provider) + .await + .map_err(|e| anyhow!("Failed to set provider on sub agent: {}", e))?; + + for extension in task_config.extensions { + if let Err(e) = agent.add_extension(extension.clone()).await { + debug!( + "Failed to add extension '{}' to subagent: {}", + extension.name(), + e + ); + } + } + + let mut session_messages = + Conversation::new_unvalidated( + vec![Message::user().with_text(text_instruction.clone())], + ); + let session_config = SessionConfig { + id: session.id, + working_dir, + schedule_id: None, + execution_mode: None, + max_turns: task_config.max_turns.map(|v| v as u32), + retry_config: None, + }; + + let mut stream = agent + .reply(session_messages.clone(), Some(session_config), None) + .await + .map_err(|e| anyhow!("Failed to get reply from agent: {}", e))?; + while let Some(message_result) = stream.next().await { + match message_result { + Ok(AgentEvent::Message(msg)) => session_messages.push(msg), + Ok(AgentEvent::McpNotification(_)) + | Ok(AgentEvent::ModelChange { .. }) + | Ok(AgentEvent::HistoryReplaced(_)) => {} // Handle informational events + Err(e) => { + tracing::error!("Error receiving message from subagent: {}", e); + break; + } + } + } + + Ok(session_messages) + }) +} diff --git a/crates/goose/src/agents/subagent_task_config.rs b/crates/goose/src/agents/subagent_task_config.rs index 5cdcf76b0228..3cafea2b8351 100644 --- a/crates/goose/src/agents/subagent_task_config.rs +++ b/crates/goose/src/agents/subagent_task_config.rs @@ -1,8 +1,9 @@ +use crate::agents::ExtensionConfig; use crate::providers::base::Provider; use std::env; use std::fmt; +use std::path::PathBuf; use std::sync::Arc; -use uuid::Uuid; /// Default maximum number of turns for task execution pub const DEFAULT_SUBAGENT_MAX_TURNS: usize = 25; @@ -13,17 +14,19 @@ pub const GOOSE_SUBAGENT_MAX_TURNS_ENV_VAR: &str = "GOOSE_SUBAGENT_MAX_TURNS"; /// Configuration for task execution with all necessary dependencies #[derive(Clone)] pub struct TaskConfig { - pub id: String, - pub provider: Option>, + pub provider: Arc, + pub parent_session_id: String, + pub parent_working_dir: PathBuf, + pub extensions: Vec, pub max_turns: Option, - pub extensions: Option>, } impl fmt::Debug for TaskConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("TaskConfig") - .field("id", &self.id) .field("provider", &"") + .field("parent_session_id", &self.parent_session_id) + .field("parent_working_dir", &self.parent_working_dir) .field("max_turns", &self.max_turns) .field("extensions", &self.extensions) .finish() @@ -32,22 +35,23 @@ impl fmt::Debug for TaskConfig { impl TaskConfig { /// Create a new TaskConfig with all required dependencies - pub fn new(provider: Option>) -> Self { + pub fn new( + provider: Arc, + parent_session_id: String, + parent_working_dir: PathBuf, + extensions: Vec, + ) -> Self { Self { - id: Uuid::new_v4().to_string(), provider, + parent_session_id, + parent_working_dir, + extensions, max_turns: Some( env::var(GOOSE_SUBAGENT_MAX_TURNS_ENV_VAR) .ok() .and_then(|val| val.parse::().ok()) .unwrap_or(DEFAULT_SUBAGENT_MAX_TURNS), ), - extensions: None, } } - - /// Get a reference to the provider - pub fn provider(&self) -> Option<&Arc> { - self.provider.as_ref() - } } diff --git a/crates/goose/src/agents/tool_execution.rs b/crates/goose/src/agents/tool_execution.rs index 4869d124f994..3cc7c5d08a94 100644 --- a/crates/goose/src/agents/tool_execution.rs +++ b/crates/goose/src/agents/tool_execution.rs @@ -29,7 +29,7 @@ impl From>> for ToolCallResult { } use super::agent::{tool_stream, ToolStream}; -use crate::agents::Agent; +use crate::agents::{Agent, SessionConfig}; use crate::conversation::message::{Message, ToolRequest}; use crate::tool_inspection::get_security_finding_id_from_results; @@ -53,6 +53,7 @@ impl Agent { tool_futures: Arc>>, message_tool_response: Arc>, cancellation_token: Option, + session: Option, inspection_results: &'a [crate::tool_inspection::InspectionResult], ) -> BoxStream<'a, anyhow::Result> { try_stream! { @@ -90,7 +91,7 @@ impl Agent { } if confirmation.permission == Permission::AllowOnce || confirmation.permission == Permission::AlwaysAllow { - let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone(), cancellation_token.clone()).await; + let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone(), cancellation_token.clone(), session.clone()).await; let mut futures = tool_futures.lock().await; futures.push((req_id, match tool_result { diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index ac25ba5c5d5a..3da5fbf95bcd 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -1,7 +1,7 @@ -use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; +use crate::config::paths::Paths; use fs2::FileExt; use keyring::Entry; -use once_cell::sync::{Lazy, OnceCell}; +use once_cell::sync::OnceCell; use serde::Deserialize; use serde_json::Value; use std::collections::HashMap; @@ -11,12 +11,6 @@ use std::io::Write; use std::path::{Path, PathBuf}; use thiserror::Error; -pub static APP_STRATEGY: Lazy = Lazy::new(|| AppStrategyArgs { - top_level_domain: "Block".to_string(), - author: "Block".to_string(), - app_name: "goose".to_string(), -}); - const KEYRING_SERVICE: &str = "goose"; const KEYRING_USERNAME: &str = "secrets"; @@ -116,18 +110,9 @@ enum SecretStorage { // Global instance static GLOBAL_CONFIG: OnceCell = OnceCell::new(); -pub fn get_config_dir() -> PathBuf { - choose_app_strategy(APP_STRATEGY.clone()) - .expect("goose requires a home dir") - .config_dir() -} - impl Default for Config { fn default() -> Self { - // choose_app_strategy().config_dir() - // - macOS/Linux: ~/.config/goose/ - // - Windows: ~\AppData\Roaming\Block\goose\config\ - let config_dir = get_config_dir(); + let config_dir = Paths::config_dir(); std::fs::create_dir_all(&config_dir).expect("Failed to create config directory"); diff --git a/crates/goose/src/config/custom_providers.rs b/crates/goose/src/config/custom_providers.rs index 3110771a185e..e486f39f4d24 100644 --- a/crates/goose/src/config/custom_providers.rs +++ b/crates/goose/src/config/custom_providers.rs @@ -1,20 +1,17 @@ -use crate::config::{Config, APP_STRATEGY}; +use crate::config::paths::Paths; +use crate::config::Config; use crate::model::ModelConfig; use crate::providers::anthropic::AnthropicProvider; use crate::providers::base::ModelInfo; use crate::providers::ollama::OllamaProvider; use crate::providers::openai::OpenAiProvider; use anyhow::Result; -use etcetera::{choose_app_strategy, AppStrategy}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; pub fn custom_providers_dir() -> std::path::PathBuf { - choose_app_strategy(APP_STRATEGY.clone()) - .expect("goose requires a home dir") - .config_dir() - .join("custom_providers") + Paths::config_dir().join("custom_providers") } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/goose/src/config/mod.rs b/crates/goose/src/config/mod.rs index c80204205888..bdb4d7678000 100644 --- a/crates/goose/src/config/mod.rs +++ b/crates/goose/src/config/mod.rs @@ -2,12 +2,13 @@ pub mod base; pub mod custom_providers; mod experiments; pub mod extensions; +pub mod paths; pub mod permission; pub mod signup_openrouter; pub mod signup_tetrate; pub use crate::agents::ExtensionConfig; -pub use base::{get_config_dir, Config, ConfigError, APP_STRATEGY}; +pub use base::{Config, ConfigError}; pub use custom_providers::CustomProviderConfig; pub use experiments::ExperimentManager; pub use extensions::{ diff --git a/crates/goose/src/config/paths.rs b/crates/goose/src/config/paths.rs new file mode 100644 index 000000000000..839f8f1b6598 --- /dev/null +++ b/crates/goose/src/config/paths.rs @@ -0,0 +1,60 @@ +use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; +use std::path::PathBuf; + +pub struct Paths; + +impl Paths { + fn get_dir(dir_type: DirType) -> PathBuf { + if let Ok(test_root) = std::env::var("GOOSE_PATH_ROOT") { + let base = PathBuf::from(test_root); + match dir_type { + DirType::Config => base.join("config"), + DirType::Data => base.join("data"), + DirType::State => base.join("state"), + } + } else { + let strategy = choose_app_strategy(AppStrategyArgs { + top_level_domain: "Block".to_string(), + author: "Block".to_string(), + app_name: "goose".to_string(), + }) + .expect("goose requires a home dir"); + + match dir_type { + DirType::Config => strategy.config_dir(), + DirType::Data => strategy.data_dir(), + DirType::State => strategy.state_dir().unwrap_or(strategy.data_dir()), + } + } + } + + pub fn config_dir() -> PathBuf { + Self::get_dir(DirType::Config) + } + + pub fn data_dir() -> PathBuf { + Self::get_dir(DirType::Data) + } + + pub fn state_dir() -> PathBuf { + Self::get_dir(DirType::State) + } + + pub fn in_state_dir(subpath: &str) -> PathBuf { + Self::state_dir().join(subpath) + } + + pub fn in_config_dir(subpath: &str) -> PathBuf { + Self::config_dir().join(subpath) + } + + pub fn in_data_dir(subpath: &str) -> PathBuf { + Self::data_dir().join(subpath) + } +} + +enum DirType { + Config, + Data, + State, +} diff --git a/crates/goose/src/config/permission.rs b/crates/goose/src/config/permission.rs index 417cd56d1297..5c184a3e9765 100644 --- a/crates/goose/src/config/permission.rs +++ b/crates/goose/src/config/permission.rs @@ -1,5 +1,4 @@ -use super::APP_STRATEGY; -use etcetera::{choose_app_strategy, AppStrategy}; +use crate::config::paths::Paths; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; @@ -37,14 +36,7 @@ const SMART_APPROVE_PERMISSION: &str = "smart_approve"; /// Implements the default constructor for `PermissionManager`. impl Default for PermissionManager { fn default() -> Self { - // Choose the app strategy and determine the config directory - let config_dir = choose_app_strategy(APP_STRATEGY.clone()) - .expect("goose requires a home dir") - .config_dir(); - - // Ensure the configuration directory exists - std::fs::create_dir_all(&config_dir).expect("Failed to create config directory"); - let config_path = config_dir.join("permission.yaml"); + let config_path = Paths::config_dir().join("permission.yaml"); // Load the existing configuration file or create an empty map if the file doesn't exist let permission_map = if config_path.exists() { diff --git a/crates/goose/src/execution/manager.rs b/crates/goose/src/execution/manager.rs index 0596f8c6cdc3..76ece5c90710 100644 --- a/crates/goose/src/execution/manager.rs +++ b/crates/goose/src/execution/manager.rs @@ -1,12 +1,11 @@ use crate::agents::extension::PlatformExtensionContext; use crate::agents::Agent; -use crate::config::APP_STRATEGY; +use crate::config::paths::Paths; use crate::model::ModelConfig; use crate::providers::create; use crate::scheduler_factory::SchedulerFactory; use crate::scheduler_trait::SchedulerTrait; use anyhow::Result; -use etcetera::{choose_app_strategy, AppStrategy}; use lru::LruCache; use std::num::NonZeroUsize; use std::sync::Arc; @@ -37,10 +36,7 @@ impl AgentManager { // Private constructor - prevents direct instantiation in production async fn new(max_sessions: Option) -> Result { - // Construct scheduler with the standard goose-server path - let schedule_file_path = choose_app_strategy(APP_STRATEGY.clone())? - .data_dir() - .join("schedule.json"); + let schedule_file_path = Paths::data_dir().join("schedule.json"); let scheduler = SchedulerFactory::create(schedule_file_path).await?; diff --git a/crates/goose/src/logging.rs b/crates/goose/src/logging.rs index 90aa977e08c8..a6742d3d1e8f 100644 --- a/crates/goose/src/logging.rs +++ b/crates/goose/src/logging.rs @@ -1,10 +1,8 @@ +use crate::config::paths::Paths; use anyhow::{Context, Result}; -use etcetera::{choose_app_strategy, AppStrategy}; use std::fs; use std::path::PathBuf; -use crate::config::APP_STRATEGY; - /// Returns the directory where log files should be stored for a specific component. /// Creates the directory structure if it doesn't exist. /// @@ -12,17 +10,8 @@ use crate::config::APP_STRATEGY; /// /// * `component` - The component name (e.g., "cli", "server", "debug") /// * `use_date_subdir` - Whether to create a date-based subdirectory -/// -/// # Returns -/// -/// The path to the log directory for the specified component pub fn get_log_directory(component: &str, use_date_subdir: bool) -> Result { - let home_dir = - choose_app_strategy(APP_STRATEGY.clone()).context("HOME environment variable not set")?; - - let base_log_dir = home_dir - .in_state_dir("logs") - .unwrap_or_else(|| home_dir.in_data_dir("logs")); + let base_log_dir = Paths::in_state_dir("logs"); let component_dir = base_log_dir.join(component); diff --git a/crates/goose/src/permission/permission_store.rs b/crates/goose/src/permission/permission_store.rs index d8d7cf3e6425..4dee06ff8a0c 100644 --- a/crates/goose/src/permission/permission_store.rs +++ b/crates/goose/src/permission/permission_store.rs @@ -1,8 +1,8 @@ +use crate::config::paths::Paths; use crate::conversation::message::ToolRequest; use anyhow::Result; use blake3::Hasher; use chrono::Utc; -use etcetera::{choose_app_strategy, AppStrategy}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Duration; @@ -35,14 +35,10 @@ impl Default for ToolPermissionStore { impl ToolPermissionStore { pub fn new() -> Self { - let permissions_dir = choose_app_strategy(crate::config::APP_STRATEGY.clone()) - .map(|strategy| strategy.config_dir()) - .unwrap_or_else(|_| PathBuf::from(".config/goose")); - Self { permissions: HashMap::new(), version: 1, - permissions_dir, + permissions_dir: Paths::config_dir().join("permissions"), } } diff --git a/crates/goose/src/providers/githubcopilot.rs b/crates/goose/src/providers/githubcopilot.rs index a533a5407ab1..3a4a4c55508c 100644 --- a/crates/goose/src/providers/githubcopilot.rs +++ b/crates/goose/src/providers/githubcopilot.rs @@ -1,8 +1,8 @@ +use crate::config::paths::Paths; use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use axum::http; use chrono::{DateTime, Utc}; -use etcetera::{choose_app_strategy, AppStrategy}; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -81,9 +81,7 @@ struct DiskCache { impl DiskCache { fn new() -> Self { - let cache_path = choose_app_strategy(crate::config::APP_STRATEGY.clone()) - .expect("goose requires a home dir") - .in_config_dir("githubcopilot/info.json"); + let cache_path = Paths::in_config_dir("githubcopilot/info.json"); Self { cache_path } } diff --git a/crates/goose/src/providers/oauth.rs b/crates/goose/src/providers/oauth.rs index 50c6ee0908b7..c0be3544bd12 100644 --- a/crates/goose/src/providers/oauth.rs +++ b/crates/goose/src/providers/oauth.rs @@ -1,8 +1,8 @@ +use crate::config::paths::Paths; use anyhow::Result; use axum::{extract::Query, response::Html, routing::get, Router}; use base64::Engine; use chrono::{DateTime, Utc}; -use etcetera::{choose_app_strategy, AppStrategy}; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -38,12 +38,7 @@ struct TokenCache { } fn get_base_path() -> PathBuf { - // choose_app_strategy().config_dir() - // - macOS/Linux: ~/.config/goose/databricks/oauth - // - Windows: ~\AppData\Roaming\Block\goose\config\databricks\oauth\ - choose_app_strategy(crate::config::APP_STRATEGY.clone()) - .expect("goose requires a home dir") - .in_config_dir("databricks/oauth") + Paths::in_config_dir("databricks/oauth") } impl TokenCache { diff --git a/crates/goose/src/recipe/local_recipes.rs b/crates/goose/src/recipe/local_recipes.rs index ca7725da9df3..b26a592884c8 100644 --- a/crates/goose/src/recipe/local_recipes.rs +++ b/crates/goose/src/recipe/local_recipes.rs @@ -1,10 +1,9 @@ use anyhow::{anyhow, Result}; -use etcetera::{choose_app_strategy, AppStrategy}; use std::env; use std::fs; use std::path::{Path, PathBuf}; -use crate::config::APP_STRATEGY; +use crate::config::paths::Paths; use crate::recipe::read_recipe_file_content::{read_recipe_file, RecipeFile}; use crate::recipe::Recipe; use crate::recipe::RECIPE_FILE_EXTENSIONS; @@ -14,10 +13,7 @@ const GOOSE_RECIPE_PATH_ENV_VAR: &str = "GOOSE_RECIPE_PATH"; pub fn get_recipe_library_dir(is_global: bool) -> PathBuf { if is_global { - choose_app_strategy(APP_STRATEGY.clone()) - .expect("goose requires a home dir") - .config_dir() - .join("recipes") + Paths::config_dir().join("recipes") } else { std::env::current_dir().unwrap().join(".goose/recipes") } diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 503c86584ada..93b696c820b2 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -7,14 +7,14 @@ use std::sync::Arc; use anyhow::{anyhow, Result}; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use etcetera::{choose_app_strategy, AppStrategy}; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use tokio_cron_scheduler::{job::JobId, Job, JobScheduler as TokioJobScheduler}; use crate::agents::AgentEvent; use crate::agents::{Agent, SessionConfig}; -use crate::config::{self, Config}; +use crate::config::paths::Paths; +use crate::config::Config; use crate::conversation::message::Message; use crate::conversation::Conversation; use crate::providers::base::Provider as GooseProvider; // Alias to avoid conflict in test section @@ -63,18 +63,13 @@ pub fn normalize_cron_expression(src: &str) -> String { } pub fn get_default_scheduler_storage_path() -> Result { - let strategy = choose_app_strategy(config::APP_STRATEGY.clone()) - .map_err(|e| io::Error::new(io::ErrorKind::NotFound, e.to_string()))?; - let data_dir = strategy.data_dir(); + let data_dir = Paths::data_dir(); fs::create_dir_all(&data_dir)?; Ok(data_dir.join("schedules.json")) } pub fn get_default_scheduled_recipes_dir() -> Result { - let strategy = choose_app_strategy(config::APP_STRATEGY.clone()).map_err(|e| { - SchedulerError::StorageError(io::Error::new(io::ErrorKind::NotFound, e.to_string())) - })?; - let data_dir = strategy.data_dir(); + let data_dir = Paths::data_dir(); let recipes_dir = data_dir.join("scheduled_recipes"); fs::create_dir_all(&recipes_dir).map_err(SchedulerError::StorageError)?; tracing::debug!( diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 6b8f362c2423..1a846af5471c 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -1,4 +1,4 @@ -use crate::config::APP_STRATEGY; +use crate::config::paths::Paths; use crate::conversation::message::Message; use crate::conversation::Conversation; use crate::providers::base::{Provider, MSG_COUNT_FOR_SESSION_NAME_GENERATION}; @@ -6,7 +6,6 @@ use crate::recipe::Recipe; use crate::session::extension_data::ExtensionData; use anyhow::Result; use chrono::{DateTime, Utc}; -use etcetera::{choose_app_strategy, AppStrategy}; use rmcp::model::Role; use serde::{Deserialize, Serialize}; use sqlx::sqlite::SqliteConnectOptions; @@ -241,16 +240,13 @@ pub struct SessionStorage { } pub fn ensure_session_dir() -> Result { - let data_dir = choose_app_strategy(APP_STRATEGY.clone()) - .expect("goose requires a home dir") - .data_dir() - .join("sessions"); + let session_dir = Paths::data_dir().join("sessions"); - if !data_dir.exists() { - fs::create_dir_all(&data_dir)?; + if !session_dir.exists() { + fs::create_dir_all(&session_dir)?; } - Ok(data_dir) + Ok(session_dir) } fn role_to_string(role: &Role) -> &'static str { diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index aac33feab5d0..1c366024427c 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -630,7 +630,7 @@ mod final_output_tool_tests { }; let (_, result) = agent - .dispatch_tool_call(tool_call, "request_id".to_string(), None) + .dispatch_tool_call(tool_call, "request_id".to_string(), None, None) .await; assert!(result.is_ok(), "Tool call should succeed"); diff --git a/crates/goose/tests/private_tests.rs b/crates/goose/tests/private_tests.rs index acd89881ebf7..8ac97aa15048 100644 --- a/crates/goose/tests/private_tests.rs +++ b/crates/goose/tests/private_tests.rs @@ -817,7 +817,7 @@ async fn test_schedule_tool_dispatch() { }; let (request_id, result) = agent - .dispatch_tool_call(tool_call, "test_dispatch".to_string(), None) + .dispatch_tool_call(tool_call, "test_dispatch".to_string(), None, None) .await; assert_eq!(request_id, "test_dispatch"); assert!(result.is_ok()); diff --git a/documentation/docs/guides/interactive-chat/index.md b/documentation/docs/guides/interactive-chat/index.mdx similarity index 81% rename from documentation/docs/guides/interactive-chat/index.md rename to documentation/docs/guides/interactive-chat/index.mdx index 97cd81656b25..607655596e9c 100644 --- a/documentation/docs/guides/interactive-chat/index.md +++ b/documentation/docs/guides/interactive-chat/index.mdx @@ -6,8 +6,9 @@ description: Transform text-based responses into graphical components and intera import Card from '@site/src/components/Card'; import styles from '@site/src/components/Card/styles.module.css'; +import VideoCarousel from '@site/src/components/VideoCarousel'; -

Rich Interactive Chat

+

Rich Interactive Chat with MCP-UI

Goose Desktop supports extensions that transform text-only responses into graphical, interactive experiences. Instead of reading through lists and descriptions, you can click, explore, and interact with UI components directly in your conversations.

@@ -63,4 +64,21 @@ import styles from '@site/src/components/Card/styles.module.css'; link="/blog/2025/09/08/turn-any-mcp-server-mcp-ui-compatible" /> + + +
+

🎥 More Videos

+ +
\ No newline at end of file diff --git a/documentation/docs/guides/subagents.md b/documentation/docs/guides/subagents.md index 59863e9b49a5..068884c3e2db 100644 --- a/documentation/docs/guides/subagents.md +++ b/documentation/docs/guides/subagents.md @@ -6,6 +6,18 @@ sidebar_label: Subagents Subagents are independent instances that execute tasks while keeping your main conversation clean and focused. They bring process isolation and context preservation by offloading work to separate instances. Think of them as temporary assistants that handle specific jobs without cluttering your chat with tool execution details. +
+ Subagents Walkthrough + +
+ ## How to Use Subagents To use subagents, ask Goose to delegate tasks using natural language. Goose automatically decides when to spawn subagents and handles their lifecycle. You can: diff --git a/documentation/docs/tutorials/lead-worker.md b/documentation/docs/tutorials/lead-worker.md index 310084e8c1c3..080f2b9dd09e 100644 --- a/documentation/docs/tutorials/lead-worker.md +++ b/documentation/docs/tutorials/lead-worker.md @@ -9,6 +9,18 @@ import TabItem from '@theme/TabItem'; Goose supports a lead/worker model configuration that lets you pair two different AI models - one that's great at thinking and another that's fast at doing. This setup tackles a major pain point: premium models are powerful but expensive, while cheaper models are faster but can struggle with complex tasks. With lead/worker mode, you get the best of both worlds. +
+ Lead/Worker Mode Walkthrough + +
+ The lead/worker model is a smart hand-off system. The "lead" model (think: GPT-4 or Claude Opus) kicks things off, handling the early planning and big picture reasoning. Once the direction is set, Goose hands the task over to the "worker" model (like GPT-4o-mini or Claude Sonnet) to carry out the steps. If things go sideways (e.g. the worker model gets confused or keeps making mistakes), Goose notices and automatically pulls the lead model back in to recover. Once things are back on track, the worker takes over again. diff --git a/documentation/src/pages/extensions/detail.tsx b/documentation/src/pages/extensions/detail.tsx index a736b963d492..734be4d947de 100644 --- a/documentation/src/pages/extensions/detail.tsx +++ b/documentation/src/pages/extensions/detail.tsx @@ -167,6 +167,34 @@ const getDocumentationPath = (serverId: string): string => { )} + {server.headers && server.headers.length > 0 && ( +
+

+ Request Headers +

+
+ {server.headers.map((header) => ( +
+
+ {header.name} +
+
+ {header.description} +
+ {header.required && ( + + Required + + )} +
+ ))} +
+
+ )} +
{githubStars !== null && ( /.config/gcp-oauth.keys.json", + "required": true + }, + { + "name": "GDRIVE_CREDENTIALS_PATH", + "description": "/Users//.config/.gdrive-server-credentials.json", + "required": true + } + ] + }, + { + "name": "Developer", + "command": "developer", + "is_builtin": true, + "environmentVariables": [] + }, + { + "name": "Memory", + "command": "memory", + "is_builtin": true, + "environmentVariables": [] + } + ] +} \ No newline at end of file diff --git a/documentation/src/types/server.ts b/documentation/src/types/server.ts index 3cca632f0058..068944bc6855 100644 --- a/documentation/src/types/server.ts +++ b/documentation/src/types/server.ts @@ -14,4 +14,9 @@ export interface MCPServer { description: string; required: boolean; }[]; + headers?: { + name: string; + description: string; + required: boolean; + }[]; } \ No newline at end of file diff --git a/documentation/src/utils/install-links.ts b/documentation/src/utils/install-links.ts index cb7799394139..a70467748513 100644 --- a/documentation/src/utils/install-links.ts +++ b/documentation/src/utils/install-links.ts @@ -25,6 +25,11 @@ export function getGooseInstallLink(server: MCPServer): string { .map( (env) => `env=${encodeURIComponent(`${env.name}=${env.description}`)}` ), + ...(server.headers || []) + .filter((header) => header.required) + .map( + (header) => `header=${encodeURIComponent(`${header.name}=${header.description}`)}` + ), ].join("&"); return `goose://extension?${queryParams}`; diff --git a/documentation/static/servers.json b/documentation/static/servers.json index 9874bee926a3..693619536fed 100644 --- a/documentation/static/servers.json +++ b/documentation/static/servers.json @@ -285,13 +285,14 @@ "description": "GitHub repository management and operations", "url": "https://api.githubcopilot.com/mcp/", "link": "https://github.com/github/github-mcp-server", - "installation_notes": "This is a remote extension. Configure using the endpoint URL in Goose settings.", + "installation_notes": "This is a remote extension that requires a GitHub Personal Access Token as a Request Header e.g Authorization: Bearer .", "is_builtin": false, "endorsed": true, - "environmentVariables": [ + "environmentVariables": [], + "headers": [ { - "name": "GITHUB_PERSONAL_ACCESS_TOKEN", - "description": "Required environment variable", + "name": "Authorization", + "description": "Bearer ", "required": true } ], diff --git a/download_cli.sh b/download_cli.sh index 8f9e4a49a706..610ca081fc9b 100755 --- a/download_cli.sh +++ b/download_cli.sh @@ -36,6 +36,20 @@ if ! command -v tar >/dev/null 2>&1 && ! command -v unzip >/dev/null 2>&1; then exit 1 fi +# Check for required extraction tools based on detected OS +if [ "$OS" = "windows" ]; then + # Windows uses PowerShell's built-in Expand-Archive - check if PowerShell is available + if ! command -v powershell.exe >/dev/null 2>&1 && ! command -v pwsh >/dev/null 2>&1; then + echo "Warning: PowerShell is recommended to extract Windows packages but was not found." + echo "Falling back to unzip if available." + fi +else + if ! command -v tar >/dev/null 2>&1; then + echo "Error: 'tar' is required to extract packages for $OS. Please install tar and try again." + exit 1 + fi +fi + # --- 2) Variables --- REPO="block/goose" @@ -58,12 +72,33 @@ else fi # --- 3) Detect OS/Architecture --- -OS=$(uname -s | tr '[:upper:]' '[:lower:]') +# Better OS detection for Windows environments +if [[ "${WINDIR:-}" ]] || [[ "${windir:-}" ]] || [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]]; then + OS="windows" +elif [[ -f "/proc/version" ]] && grep -q "Microsoft\|WSL" /proc/version 2>/dev/null; then + # WSL detection + OS="windows" +elif [[ "$PWD" =~ ^/mnt/[a-zA-Z]/ ]]; then + # WSL mount point detection (like /mnt/c/) + OS="windows" +elif [[ "$OSTYPE" == "darwin"* ]]; then + OS="darwin" +elif command -v powershell.exe >/dev/null 2>&1 || command -v cmd.exe >/dev/null 2>&1; then + # Check if Windows executables are available (another Windows indicator) + OS="windows" +elif [[ "$PWD" =~ ^/[a-zA-Z]/ ]] && [[ -d "/c" || -d "/d" || -d "/e" ]]; then + # Check for Windows-style mount points (like in Git Bash) + OS="windows" +else + # Fallback to uname for other systems + OS=$(uname -s | tr '[:upper:]' '[:lower:]') +fi + ARCH=$(uname -m) # Handle Windows environments (MSYS2, Git Bash, Cygwin, WSL) case "$OS" in - linux|darwin) ;; + linux|darwin|windows) ;; mingw*|msys*|cygwin*) OS="windows" ;; @@ -87,6 +122,16 @@ case "$ARCH" in ;; esac +# Debug output (safely handle undefined variables) +echo "WINDIR: ${WINDIR:-}" +echo "OSTYPE: $OSTYPE" +echo "uname -s: $(uname -s)" +echo "uname -m: $(uname -m)" +echo "PWD: $PWD" + +# Output the detected OS +echo "Detected OS: $OS with ARCH $ARCH" + # Build the filename and URL for the stable release if [ "$OS" = "darwin" ]; then FILE="goose-$ARCH-apple-darwin.tar.bz2" diff --git a/ui/desktop/src/components/settings/extensions/deeplink.ts b/ui/desktop/src/components/settings/extensions/deeplink.ts index 8620acedbe4c..1ea574e27631 100644 --- a/ui/desktop/src/components/settings/extensions/deeplink.ts +++ b/ui/desktop/src/components/settings/extensions/deeplink.ts @@ -43,7 +43,6 @@ function getStdioConfig( const envList = parsedUrl.searchParams.getAll('env'); - // Create the extension config const config: ExtensionConfig = { name: name, type: 'stdio', @@ -147,6 +146,7 @@ export async function addExtensionFromDeepLink( const parsedTimeout = parsedUrl.searchParams.get('timeout'); const timeout = parsedTimeout ? parseInt(parsedTimeout, 10) : DEFAULT_EXTENSION_TIMEOUT; const description = parsedUrl.searchParams.get('description'); + const installation_notes = parsedUrl.searchParams.get('installation_notes'); const cmd = parsedUrl.searchParams.get('cmd'); const remoteUrl = parsedUrl.searchParams.get('url'); @@ -177,12 +177,17 @@ export async function addExtensionFromDeepLink( ) : undefined; - const config = remoteUrl + const baseConfig = remoteUrl ? transportType === 'streamable_http' ? getStreamableHttpConfig(remoteUrl, name, description || '', timeout, headers, envs) : getSseConfig(remoteUrl, name, description || '', timeout) : getStdioConfig(cmd!, parsedUrl, name, description || '', timeout); + const config = { + ...baseConfig, + ...(installation_notes ? { installation_notes } : {}), + }; + // Check if extension requires env vars or headers and go to settings if so const hasEnvVars = config.envs && Object.keys(config.envs).length > 0; const hasHeaders = diff --git a/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx index 788cd08d3ae4..44b26345f843 100644 --- a/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx +++ b/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx @@ -12,7 +12,7 @@ import { ExtensionFormData } from '../utils'; import EnvVarsSection from './EnvVarsSection'; import HeadersSection from './HeadersSection'; import ExtensionConfigFields from './ExtensionConfigFields'; -import { PlusIcon, Edit, Trash2, AlertTriangle } from 'lucide-react'; +import { PlusIcon, Edit, Trash2, AlertTriangle, Info } from 'lucide-react'; import ExtensionInfoFields from './ExtensionInfoFields'; import ExtensionTimeoutField from './ExtensionTimeoutField'; import { upsertConfig } from '../../../../api'; @@ -329,6 +329,20 @@ export default function ExtensionModal({
) : (
+ {formData.installation_notes && ( +
+
+ +
+

+ Installation Notes +

+

{formData.installation_notes}

+
+
+
+ )} + {/* Form Fields */} {/* Name and Type */} )['installation_notes'] as + | string + | undefined, }; } diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index b9a18f59bdfc..5696017435dd 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -151,6 +151,11 @@ async function ensureTempDirExists(): Promise { if (started) app.quit(); +if (process.env.ENABLE_PLAYWRIGHT) { + console.log('[Main] Enabling Playwright remote debugging on port 9222'); + app.commandLine.appendSwitch('remote-debugging-port', '9222'); +} + // In development mode, force registration as the default protocol client // In production, register normally if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { @@ -174,9 +179,10 @@ if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { app.setAsDefaultProtocolClient('goose'); } -// Only apply single instance lock on Windows where it's needed for deep links +// Apply single instance lock on Windows and Linux where it's needed for deep links +// macOS uses the 'open-url' event instead let gotTheLock = true; -if (process.platform === 'win32') { +if (process.platform !== 'darwin') { gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { @@ -226,7 +232,7 @@ if (process.platform === 'win32') { }); } - // Handle protocol URLs on Windows startup + // Handle protocol URLs on Windows and Linux startup const protocolUrl = process.argv.find((arg) => arg.startsWith('goose://')); if (protocolUrl) { app.whenReady().then(() => { @@ -518,10 +524,9 @@ const createChat = async ( const settings = loadSettings(); updateSchedulingEngineEnvironment(settings.schedulingEngine); - // Start new Goosed process for regular windows - // Pass through scheduling engine environment variables const envVars = { GOOSE_SCHEDULER_TYPE: process.env.GOOSE_SCHEDULER_TYPE, + GOOSE_PATH_ROOT: process.env.GOOSE_PATH_ROOT, }; const [newPort, newWorkingDir, newGoosedProcess] = await startGoosed( app, @@ -1678,7 +1683,6 @@ async function appMain() { // Ensure Windows shims are available before any MCP processes are spawned await ensureWinShims(); - // Register update IPC handlers once (but don't setup auto-updater yet) registerUpdateIpcHandlers(); // Handle microphone permission requests