diff --git a/clippy-baselines/too_many_lines.txt b/clippy-baselines/too_many_lines.txt index ae7ca874e58c..47ada3c2b352 100644 --- a/clippy-baselines/too_many_lines.txt +++ b/clippy-baselines/too_many_lines.txt @@ -23,3 +23,4 @@ crates/goose/src/providers/formats/google.rs::format_messages crates/goose/src/providers/formats/openai.rs::format_messages crates/goose/src/providers/formats/openai.rs::response_to_streaming_message crates/goose/src/providers/snowflake.rs::post +crates/goose-bench/src/eval_suites/core/developer/simple_repo_clone_test.rs::run diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 788456446afa..3fac45eff77c 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -587,6 +587,8 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { ) .await; + agent_ptr.ensure_subagent_extension(&session_id).await; + // Determine editor mode let edit_mode = config .get_param::("EDIT_MODE") diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 89b26c66c518..2d844ce6b18a 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -285,7 +285,7 @@ fn render_tool_request(req: &ToolRequest, theme: Theme, debug: bool) { "developer__text_editor" => render_text_editor_request(call, debug), "developer__shell" => render_shell_request(call, debug), "code_execution__execute_code" => render_execute_code_request(call, debug), - "subagent" => render_subagent_request(call, debug), + "subagent__delegate" => render_subagent_request(call, debug), "todo__write" => render_todo_request(call, debug), _ => render_default_request(call, debug), }, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 5e54a7b6d12e..789fa9ea17d4 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -426,7 +426,9 @@ async fn update_from_session( .await { Ok(Some(recipe)) => { - if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { + if let Some(prompt) = + apply_recipe_to_agent(&agent, &recipe, &payload.session_id, true).await + { update_prompt = prompt; } } @@ -700,7 +702,8 @@ async fn restart_agent_internal( .await { Ok(Some(recipe)) => { - if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { + if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, session_id, true).await + { update_prompt = prompt; } } diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs index 46c8c3d5f605..68cf978cef1e 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -160,6 +160,7 @@ pub async fn build_recipe_with_parameter_values( pub async fn apply_recipe_to_agent( agent: &Arc, recipe: &Recipe, + session_id: &str, include_final_output_tool: bool, ) -> Option { agent @@ -170,6 +171,8 @@ pub async fn apply_recipe_to_agent( ) .await; + agent.ensure_subagent_extension(session_id).await; + recipe.instructions.as_ref().map(|instructions| { let mut context: HashMap<&str, Value> = HashMap::new(); context.insert("recipe_instructions", Value::String(instructions.clone())); diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 148c132bf730..4e5f26651cdd 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -246,7 +246,7 @@ async fn update_session_user_recipe_values( message: format!("Failed to get agent: {}", status), status, })?; - if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, false).await { + if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, &session_id, false).await { agent.extend_system_prompt(prompt).await; } Ok(Json(UpdateSessionUserRecipeValuesResponse { recipe })) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index eb303d8417ad..97919a758419 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use super::final_output_tool::FinalOutputTool; use super::platform_tools; -use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; +use super::tool_execution::{DeferredToolCall, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; use crate::action_required_manager::ActionRequiredManager; use crate::agents::extension::{ExtensionConfig, ExtensionResult, ToolInfo}; use crate::agents::extension_manager::{get_parameter_names, normalize, ExtensionManager}; @@ -19,13 +19,10 @@ use crate::agents::final_output_tool::{FINAL_OUTPUT_CONTINUATION_MESSAGE, FINAL_ use crate::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME; use crate::agents::prompt_manager::PromptManager; use crate::agents::retry::{RetryManager, RetryResult}; -use crate::agents::subagent_task_config::TaskConfig; -use crate::agents::subagent_tool::{ - create_subagent_tool, handle_subagent_tool, SUBAGENT_TOOL_NAME, -}; +use crate::agents::subagent_client; use crate::agents::types::{FrontendTool, SessionConfig, SharedProvider, ToolResultReceiver}; use crate::config::permission::PermissionManager; -use crate::config::{get_enabled_extensions, Config, GooseMode}; +use crate::config::{get_enabled_extensions, is_extension_enabled, Config, GooseMode}; use crate::context_mgmt::{ check_if_compaction_needed, compact_messages, DEFAULT_COMPACTION_THRESHOLD, }; @@ -116,7 +113,7 @@ pub struct Agent { pub config: AgentConfig, pub extension_manager: Arc, - pub(super) sub_recipes: Mutex>, + pub(super) sub_recipes: Arc>>, pub(super) final_output_tool: Arc>>, pub(super) frontend_tools: Mutex>, pub(super) frontend_instructions: Mutex>, @@ -197,11 +194,18 @@ impl Agent { let session_manager = Arc::clone(&config.session_manager); let permission_manager = Arc::clone(&config.permission_manager); + let goose_mode = config.goose_mode; + let sub_recipes = Arc::new(tokio::sync::RwLock::new(HashMap::new())); Self { provider: provider.clone(), config, - extension_manager: Arc::new(ExtensionManager::new(provider.clone(), session_manager)), - sub_recipes: Mutex::new(HashMap::new()), + extension_manager: Arc::new(ExtensionManager::new( + provider.clone(), + session_manager, + Some(sub_recipes.clone()), + goose_mode, + )), + sub_recipes, final_output_tool: Arc::new(Mutex::new(None)), frontend_tools: Mutex::new(HashMap::new()), frontend_instructions: Mutex::new(None), @@ -340,10 +344,10 @@ impl Agent { async fn handle_approved_and_denied_tools( &self, + session_id: &str, permission_check_result: &PermissionCheckResult, request_to_response_map: &HashMap>>, cancel_token: Option, - session: &Session, ) -> Result> { let mut tool_futures: Vec<(String, ToolStream)> = Vec::new(); @@ -352,10 +356,10 @@ impl Agent { if let Ok(tool_call) = request.tool_call.clone() { let (req_id, tool_result) = self .dispatch_tool_call( + session_id, tool_call, request.id.clone(), cancel_token.clone(), - session, ) .await; @@ -427,8 +431,12 @@ impl Agent { self.extend_system_prompt(final_output_system_prompt).await; } + pub fn sub_recipes(&self) -> Arc>> { + self.sub_recipes.clone() + } + pub async fn add_sub_recipes(&self, sub_recipes_to_add: Vec) { - let mut sub_recipes = self.sub_recipes.lock().await; + let mut sub_recipes = self.sub_recipes.write().await; for sr in sub_recipes_to_add { sub_recipes.insert(sr.name.clone(), sr); } @@ -440,8 +448,8 @@ impl Agent { response: Option, include_final_output: bool, ) { - if let Some(sub_recipes) = sub_recipes { - self.add_sub_recipes(sub_recipes).await; + if let Some(ref sub_recipes) = sub_recipes { + self.add_sub_recipes(sub_recipes.clone()).await; } if include_final_output { @@ -451,27 +459,45 @@ impl Agent { } } + pub async fn ensure_subagent_extension(&self, session_id: &str) { + if !self.subagents_enabled(session_id).await { + return; + } + + if self + .extension_manager + .is_extension_enabled(subagent_client::EXTENSION_NAME) + .await + { + return; + } + + if let Err(e) = self + .extension_manager + .add_extension_with_working_dir( + ExtensionConfig::Platform { + name: subagent_client::EXTENSION_NAME.to_string(), + description: "Delegate tasks to independent subagents".to_string(), + bundled: Some(true), + available_tools: vec![], + }, + None, + ) + .await + { + warn!("Failed to enable subagent extension: {}", e); + } + } + /// Dispatch a single tool call to the appropriate client - #[instrument(skip(self, tool_call, request_id), fields(input, output))] + #[instrument(skip(self, session_id, tool_call, request_id), fields(input, output))] pub async fn dispatch_tool_call( &self, + session_id: &str, tool_call: CallToolRequestParams, request_id: String, cancellation_token: Option, - session: &Session, - ) -> (String, Result) { - // Prevent subagents from creating other subagents - if session.session_type == SessionType::SubAgent && tool_call.name == SUBAGENT_TOOL_NAME { - return ( - request_id, - Err(ErrorData::new( - ErrorCode::INVALID_REQUEST, - "Subagents cannot create other subagents".to_string(), - None, - )), - ); - } - + ) -> (String, Result) { if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME { let arguments = tool_call .arguments @@ -486,7 +512,7 @@ impl Agent { is_error: Some(false), meta: None, }); - return (request_id, Ok(ToolCallResult::from(wrapped_result))); + return (request_id, Ok(DeferredToolCall::from(wrapped_result))); } if tool_call.name == FINAL_OUTPUT_TOOL_NAME { @@ -506,53 +532,18 @@ impl Agent { } debug!("WAITING_TOOL_START: {}", tool_call.name); - let result: ToolCallResult = if tool_call.name == SUBAGENT_TOOL_NAME { - 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 extensions = self.get_extension_configs().await; - let task_config = - TaskConfig::new(provider, &session.id, &session.working_dir, extensions); - let sub_recipes = self.sub_recipes.lock().await.clone(); - - let arguments = tool_call - .arguments - .clone() - .map(Value::Object) - .unwrap_or(Value::Object(serde_json::Map::new())); - - handle_subagent_tool( - &self.config, - arguments, - task_config, - sub_recipes, - session.working_dir.clone(), - cancellation_token, - ) - } else if self.is_frontend_tool(&tool_call.name).await { - // For frontend tools, return an error indicating we need frontend execution - ToolCallResult::from(Err(ErrorData::new( + // Note: SUBAGENT_TOOL_NAME is now handled as a platform extension, not here + let result: DeferredToolCall = if self.is_frontend_tool(&tool_call.name).await { + DeferredToolCall::from(Err(ErrorData::new( ErrorCode::INTERNAL_ERROR, "Frontend tool execution required".to_string(), None, ))) } else { - // Clone the result to ensure no references to extension_manager are returned let result = self .extension_manager .dispatch_tool_call( - &session.id, + session_id, tool_call.clone(), cancellation_token.unwrap_or_default(), ) @@ -566,7 +557,7 @@ impl Agent { let error_data = e.downcast::().unwrap_or_else(|e| { ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None) }); - ToolCallResult::from(Err(error_data)) + DeferredToolCall::from(Err(error_data)) }) }; @@ -574,7 +565,7 @@ impl Agent { ( request_id, - Ok(ToolCallResult { + Ok(DeferredToolCall { notification_stream: result.notification_stream, result: Box::new( result @@ -750,6 +741,9 @@ impl Agent { } pub async fn subagents_enabled(&self, session_id: &str) -> bool { + if !is_extension_enabled(subagent_client::EXTENSION_NAME) { + return false; + } if self.config.goose_mode != GooseMode::Auto { return false; } @@ -788,7 +782,6 @@ impl Agent { .await .unwrap_or_default(); - let subagents_enabled = self.subagents_enabled(session_id).await; if (extension_name.is_none() || extension_name.as_deref() == Some("platform")) && self.config.scheduler_service.is_some() { @@ -799,12 +792,6 @@ impl Agent { if let Some(final_output_tool) = self.final_output_tool.lock().await.as_ref() { prefixed_tools.push(final_output_tool.tool()); } - - if subagents_enabled { - let sub_recipes = self.sub_recipes.lock().await; - let sub_recipes_vec: Vec<_> = sub_recipes.values().cloned().collect(); - prefixed_tools.push(create_subagent_tool(&sub_recipes_vec)); - } } prefixed_tools @@ -1252,20 +1239,20 @@ impl Agent { } let mut tool_futures = self.handle_approved_and_denied_tools( + &session_config.id, &permission_check_result, &request_to_response_map, cancel_token.clone(), - &session, ).await?; let tool_futures_arc = Arc::new(Mutex::new(tool_futures)); let mut tool_approval_stream = self.handle_approval_tool_requests( + &session_config.id, &permission_check_result.needs_approval, tool_futures_arc.clone(), &request_to_response_map, cancel_token.clone(), - &session, &inspection_results, ); diff --git a/crates/goose/src/agents/code_execution_extension.rs b/crates/goose/src/agents/code_execution_extension.rs index 93486ddee68b..0c92a76bdf51 100644 --- a/crates/goose/src/agents/code_execution_extension.rs +++ b/crates/goose/src/agents/code_execution_extension.rs @@ -756,6 +756,8 @@ impl McpClientTrait for CodeExecutionClient { - Call: toolName({ param1: value, param2: value }) - Result: record_result(value) - call this to return a value from the script - All calls are synchronous, return strings + - To capture output: const r = ; r + - No comments in code TOOL_GRAPH: Always provide tool_graph to describe the execution flow for the UI. Each node has: tool (server/name), description (what it does), depends_on (indices of dependencies). @@ -896,17 +898,22 @@ mod tests { use std::sync::Arc; use test_case::test_case; - #[tokio::test] - async fn test_execute_code_simple() { + fn create_test_context() -> PlatformExtensionContext { let temp_dir = tempfile::tempdir().unwrap(); let session_manager = Arc::new(crate::session::SessionManager::new( temp_dir.path().to_path_buf(), )); - let context = PlatformExtensionContext { + PlatformExtensionContext { extension_manager: None, session_manager, - }; - let client = CodeExecutionClient::new(context).unwrap(); + sub_recipes: None, + goose_mode: crate::config::GooseMode::Auto, + } + } + + #[tokio::test] + async fn test_execute_code_simple() { + let client = CodeExecutionClient::new(create_test_context()).unwrap(); let mut args = JsonObject::new(); args.insert( @@ -934,15 +941,7 @@ mod tests { #[tokio::test] async fn test_record_result_outputs_valid_json() { - let temp_dir = tempfile::tempdir().unwrap(); - let session_manager = Arc::new(crate::session::SessionManager::new( - temp_dir.path().to_path_buf(), - )); - let context = PlatformExtensionContext { - extension_manager: None, - session_manager, - }; - let client = CodeExecutionClient::new(context).unwrap(); + let client = CodeExecutionClient::new(create_test_context()).unwrap(); // Nested array in object - this triggers truncation with display() (e.g., "items: Array(3)") let mut args = JsonObject::new(); @@ -975,15 +974,7 @@ mod tests { #[tokio::test] async fn test_read_module_not_found() { - let temp_dir = tempfile::tempdir().unwrap(); - let session_manager = Arc::new(crate::session::SessionManager::new( - temp_dir.path().to_path_buf(), - )); - let context = PlatformExtensionContext { - extension_manager: None, - session_manager, - }; - let client = CodeExecutionClient::new(context).unwrap(); + let client = CodeExecutionClient::new(create_test_context()).unwrap(); let mut args = JsonObject::new(); args.insert( diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index aebb4fddae39..497620aafc97 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -3,8 +3,12 @@ use crate::agents::chatrecall_extension; use crate::agents::code_execution_extension; use crate::agents::extension_manager_extension; use crate::agents::skills_extension; +use crate::agents::subagent_client; use crate::agents::todo_extension; +use crate::recipe::SubRecipe; use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; use crate::agents::mcp_client::McpClientTrait; use crate::config; @@ -112,6 +116,16 @@ pub static PLATFORM_EXTENSIONS: Lazy }, ); + map.insert( + subagent_client::EXTENSION_NAME, + PlatformExtensionDef { + name: subagent_client::EXTENSION_NAME, + description: "Delegate tasks to independent subagents", + default_enabled: true, + client_factory: |ctx| Box::new(subagent_client::SubagentClient::new(ctx).unwrap()), + }, + ); + map }, ); @@ -121,6 +135,8 @@ pub struct PlatformExtensionContext { pub extension_manager: Option>, pub session_manager: std::sync::Arc, + pub sub_recipes: Option>>>, + pub goose_mode: crate::config::GooseMode, } impl PlatformExtensionContext { diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 13869241f09b..458997001b38 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -1,8 +1,8 @@ use anyhow::Result; use axum::http::{HeaderMap, HeaderName}; use chrono::{DateTime, Utc}; +use futures::future; use futures::stream::{FuturesUnordered, StreamExt}; -use futures::{future, FutureExt}; use rand::{distributions::Alphanumeric, Rng}; use rmcp::service::{ClientInitializeError, ServiceError}; use rmcp::transport::streamable_http_client::{ @@ -29,7 +29,7 @@ use super::extension::{ ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, PlatformExtensionContext, ToolInfo, PLATFORM_EXTENSIONS, }; -use super::tool_execution::ToolCallResult; +use super::tool_execution::DeferredToolCall; use super::types::SharedProvider; use crate::agents::extension::{Envs, ProcessExit}; use crate::agents::extension_malware_check; @@ -443,12 +443,18 @@ impl ExtensionManager { pub fn new( provider: SharedProvider, session_manager: Arc, + sub_recipes: Option< + Arc>>, + >, + goose_mode: crate::config::GooseMode, ) -> Self { Self { extensions: Mutex::new(HashMap::new()), context: PlatformExtensionContext { extension_manager: None, session_manager, + sub_recipes, + goose_mode, }, provider, tools_cache: Mutex::new(None), @@ -459,7 +465,12 @@ impl ExtensionManager { #[cfg(test)] pub fn new_without_provider(data_dir: std::path::PathBuf) -> Self { let session_manager = Arc::new(crate::session::SessionManager::new(data_dir)); - Self::new(Arc::new(Mutex::new(None)), session_manager) + Self::new( + Arc::new(Mutex::new(None)), + session_manager, + None, + crate::config::GooseMode::Auto, + ) } pub fn get_context(&self) -> &PlatformExtensionContext { @@ -1140,7 +1151,7 @@ impl ExtensionManager { session_id: &str, tool_call: CallToolRequestParams, cancellation_token: CancellationToken, - ) -> Result { + ) -> Result { // Some models strip the tool prefix, so auto-add it for known code_execution tools let tool_name_str = tool_call.name.to_string(); let prefixed_name = if !tool_name_str.contains("__") { @@ -1195,32 +1206,24 @@ impl ExtensionManager { } let arguments = tool_call.arguments.clone(); - let client = client.clone(); let notifications_receiver = client.lock().await.subscribe().await; - let session_id = session_id.to_string(); - let fut = async move { - tracing::debug!( - "dispatch_tool_call fut: calling client.call_tool tool={} session_id={}", - tool_name, - session_id - ); - let client_guard = client.lock().await; - client_guard - .call_tool(&session_id, &tool_name, arguments, cancellation_token) - .await - .map_err(|e| match e { - ServiceError::McpError(error_data) => error_data, - _ => { - ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), e.maybe_to_value()) - } - }) - }; + let mut result = client + .lock() + .await + .call_tool_deferred(session_id, &tool_name, arguments, cancellation_token) + .await + .map_err(|e| match e { + ServiceError::McpError(error_data) => error_data, + _ => ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), e.maybe_to_value()), + })?; - Ok(ToolCallResult { - result: Box::new(fut.boxed()), - notification_stream: Some(Box::new(ReceiverStream::new(notifications_receiver))), - }) + if result.notification_stream.is_none() { + result.notification_stream = + Some(Box::new(ReceiverStream::new(notifications_receiver))); + } + + Ok(result) } pub async fn list_prompts_from_extension( diff --git a/crates/goose/src/agents/final_output_tool.rs b/crates/goose/src/agents/final_output_tool.rs index 2c88f769dfe8..a92fc9d4550a 100644 --- a/crates/goose/src/agents/final_output_tool.rs +++ b/crates/goose/src/agents/final_output_tool.rs @@ -1,4 +1,4 @@ -use crate::agents::tool_execution::ToolCallResult; +use crate::agents::tool_execution::DeferredToolCall; use crate::recipe::Response; use indoc::formatdoc; use rmcp::model::{CallToolRequestParams, Content, ErrorCode, ErrorData, Tool, ToolAnnotations}; @@ -116,14 +116,17 @@ impl FinalOutputTool { } } - pub async fn execute_tool_call(&mut self, tool_call: CallToolRequestParams) -> ToolCallResult { + pub async fn execute_tool_call( + &mut self, + tool_call: CallToolRequestParams, + ) -> DeferredToolCall { match tool_call.name.to_string().as_str() { FINAL_OUTPUT_TOOL_NAME => { let result = self.validate_json_output(&tool_call.arguments.into()).await; match result { Ok(parsed_value) => { self.final_output = Some(Self::parsed_final_output_string(parsed_value)); - ToolCallResult::from(Ok(rmcp::model::CallToolResult { + DeferredToolCall::from(Ok(rmcp::model::CallToolResult { content: vec![Content::text( "Final output successfully collected.".to_string(), )], @@ -132,14 +135,14 @@ impl FinalOutputTool { meta: None, })) } - Err(error) => ToolCallResult::from(Err(ErrorData { + Err(error) => DeferredToolCall::from(Err(ErrorData { code: ErrorCode::INVALID_PARAMS, message: Cow::from(error), data: None, })), } } - _ => ToolCallResult::from(Err(ErrorData { + _ => DeferredToolCall::from(Err(ErrorData { code: ErrorCode::INVALID_REQUEST, message: Cow::from(format!("Unknown tool: {}", tool_call.name)), data: None, diff --git a/crates/goose/src/agents/mcp_client.rs b/crates/goose/src/agents/mcp_client.rs index 3a09e3affe83..921135e79d3b 100644 --- a/crates/goose/src/agents/mcp_client.rs +++ b/crates/goose/src/agents/mcp_client.rs @@ -1,4 +1,5 @@ use crate::action_required_manager::ActionRequiredManager; +use crate::agents::tool_execution::DeferredToolCall; use crate::agents::types::SharedProvider; use crate::session_context::SESSION_ID_HEADER; use rmcp::model::{ @@ -59,6 +60,25 @@ pub trait McpClientTrait: Send + Sync { cancel_token: CancellationToken, ) -> Result; + async fn call_tool_deferred( + &self, + session_id: &str, + name: &str, + arguments: Option, + cancel_token: CancellationToken, + ) -> Result { + let result = self + .call_tool(session_id, name, arguments, cancel_token) + .await; + Ok(DeferredToolCall { + result: Box::new(futures::future::ready(result.map_err(|e| match e { + ServiceError::McpError(error_data) => error_data, + _ => ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None), + }))), + notification_stream: None, + }) + } + fn get_info(&self) -> Option<&InitializeResult>; async fn list_resources( diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index a034957d22ec..b732d0e53bff 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -18,6 +18,7 @@ mod reply_parts; pub mod retry; mod schedule_tool; pub(crate) mod skills_extension; +pub(crate) mod subagent_client; pub mod subagent_execution_tool; pub mod subagent_handler; mod subagent_task_config; diff --git a/crates/goose/src/agents/subagent_client.rs b/crates/goose/src/agents/subagent_client.rs new file mode 100644 index 000000000000..3f01eb97b8fa --- /dev/null +++ b/crates/goose/src/agents/subagent_client.rs @@ -0,0 +1,238 @@ +use crate::agents::extension::PlatformExtensionContext; +use crate::agents::mcp_client::{Error, McpClientTrait}; +use crate::agents::subagent_task_config::TaskConfig; +use crate::agents::subagent_tool::{ + create_subagent_tool, handle_subagent_tool, SUBAGENT_TOOL_NAME, +}; +use crate::agents::tool_execution::DeferredToolCall; +use crate::agents::AgentConfig; +use crate::config::get_enabled_extensions; +use crate::config::{GooseMode, PermissionManager}; +use crate::session::SessionType; +use anyhow::Result; +use async_trait::async_trait; +use rmcp::model::{ + CallToolResult, Content, GetPromptResult, Implementation, InitializeResult, JsonObject, + ListPromptsResult, ListResourcesResult, ListToolsResult, ProtocolVersion, ReadResourceResult, + ServerCapabilities, ServerNotification, Tool, +}; +use serde_json::Value; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +pub const EXTENSION_NAME: &str = "subagent"; + +pub struct SubagentClient { + context: PlatformExtensionContext, + info: InitializeResult, +} + +impl SubagentClient { + pub fn new(context: PlatformExtensionContext) -> Result { + Ok(Self { + context, + info: InitializeResult { + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities::builder().enable_tools().build(), + server_info: Implementation { + name: EXTENSION_NAME.to_string(), + title: Some("Subagent".to_string()), + version: env!("CARGO_PKG_VERSION").to_string(), + icons: None, + website_url: None, + }, + instructions: Some( + "Delegate tasks to independent subagents for parallel or focused work." + .to_string(), + ), + }, + }) + } + + async fn get_provider(&self) -> Option> { + let em = self.context.extension_manager.as_ref()?.upgrade()?; + let provider_guard = em.get_provider().lock().await; + provider_guard.clone() + } + + async fn get_extensions(&self) -> Vec { + let extensions = if let Some(em) = self + .context + .extension_manager + .as_ref() + .and_then(|w| w.upgrade()) + { + em.get_extension_configs().await + } else { + get_enabled_extensions() + }; + extensions + .into_iter() + .filter(|ext| ext.name() != EXTENSION_NAME) + .collect() + } + + async fn get_sub_recipes(&self) -> std::collections::HashMap { + match &self.context.sub_recipes { + Some(recipes) => recipes.read().await.clone(), + None => std::collections::HashMap::new(), + } + } + + async fn build_tool(&self) -> Tool { + let sub_recipes = self.get_sub_recipes().await; + let sub_recipes_vec: Vec<_> = sub_recipes.values().cloned().collect(); + create_subagent_tool(&sub_recipes_vec) + } +} + +#[async_trait] +impl McpClientTrait for SubagentClient { + async fn list_resources( + &self, + _session_id: &str, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + Err(Error::TransportClosed) + } + + async fn read_resource( + &self, + _session_id: &str, + _uri: &str, + _cancellation_token: CancellationToken, + ) -> Result { + Err(Error::TransportClosed) + } + + async fn list_tools( + &self, + _session_id: &str, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + Ok(ListToolsResult { + tools: vec![self.build_tool().await], + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + session_id: &str, + name: &str, + arguments: Option, + cancellation_token: CancellationToken, + ) -> Result { + let deferred = self + .call_tool_deferred(session_id, name, arguments, cancellation_token) + .await?; + deferred.result.await.map_err(Error::McpError) + } + + async fn call_tool_deferred( + &self, + session_id: &str, + name: &str, + arguments: Option, + cancellation_token: CancellationToken, + ) -> Result { + if name != SUBAGENT_TOOL_NAME { + return Ok(DeferredToolCall::from(Ok(CallToolResult::error(vec![ + Content::text(format!("Unknown tool: {}", name)), + ])))); + } + + if self.context.goose_mode != GooseMode::Auto { + return Ok(DeferredToolCall::from(Ok(CallToolResult::error(vec![ + Content::text("Subagents are only available in Auto mode."), + ])))); + } + + // Check if this is already a subagent session + let session_manager = Arc::clone(&self.context.session_manager); + if let Ok(session) = session_manager.get_session(session_id, false).await { + if session.session_type == SessionType::SubAgent { + return Ok(DeferredToolCall::from(Ok(CallToolResult::error(vec![ + Content::text("Subagents cannot spawn subagents."), + ])))); + } + } + + let Some(provider) = self.get_provider().await else { + return Ok(DeferredToolCall::from(Ok(CallToolResult::error(vec![ + Content::text("No provider configured"), + ])))); + }; + + if provider.get_active_model_name().starts_with("gemini") { + return Ok(DeferredToolCall::from(Ok(CallToolResult::error(vec![ + Content::text("Subagents are not supported with Gemini models."), + ])))); + } + + let working_dir = session_manager + .get_session(session_id, false) + .await + .map(|s| s.working_dir) + .unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| ".".into())); + + let extensions = self.get_extensions().await; + let sub_recipes = self.get_sub_recipes().await; + let task_config = TaskConfig::new(provider, extensions); + let arguments_value = arguments + .map(Value::Object) + .unwrap_or(Value::Object(serde_json::Map::new())); + + // Create AgentConfig for the subagent + let agent_config = AgentConfig::new( + session_manager, + PermissionManager::instance(), + None, + GooseMode::Auto, + ); + + Ok(handle_subagent_tool( + &agent_config, + arguments_value, + task_config, + sub_recipes, + working_dir, + Some(cancellation_token), + )) + } + + async fn list_prompts( + &self, + _session_id: &str, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + Err(Error::TransportClosed) + } + + async fn get_prompt( + &self, + _session_id: &str, + _name: &str, + _arguments: Value, + _cancellation_token: CancellationToken, + ) -> Result { + Err(Error::TransportClosed) + } + + async fn subscribe(&self) -> mpsc::Receiver { + mpsc::channel(1).1 + } + + fn get_info(&self) -> Option<&InitializeResult> { + Some(&self.info) + } + + async fn get_moim(&self, _session_id: &str) -> Option { + None + } +} diff --git a/crates/goose/src/agents/subagent_task_config.rs b/crates/goose/src/agents/subagent_task_config.rs index 01c955d0c01c..7cdb54145829 100644 --- a/crates/goose/src/agents/subagent_task_config.rs +++ b/crates/goose/src/agents/subagent_task_config.rs @@ -2,21 +2,14 @@ use crate::agents::ExtensionConfig; use crate::providers::base::Provider; use std::env; use std::fmt; -use std::path::{Path, PathBuf}; use std::sync::Arc; -/// Default maximum number of turns for task execution pub const DEFAULT_SUBAGENT_MAX_TURNS: usize = 25; - -/// Environment variable name for configuring max turns 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 provider: Arc, - pub parent_session_id: String, - pub parent_working_dir: PathBuf, pub extensions: Vec, pub max_turns: Option, } @@ -25,8 +18,6 @@ impl fmt::Debug for TaskConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("TaskConfig") .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() @@ -34,16 +25,9 @@ impl fmt::Debug for TaskConfig { } impl TaskConfig { - pub fn new( - provider: Arc, - parent_session_id: &str, - parent_working_dir: &Path, - extensions: Vec, - ) -> Self { + pub fn new(provider: Arc, extensions: Vec) -> Self { Self { provider, - parent_session_id: parent_session_id.to_owned(), - parent_working_dir: parent_working_dir.to_owned(), extensions, max_turns: Some( env::var(GOOSE_SUBAGENT_MAX_TURNS_ENV_VAR) diff --git a/crates/goose/src/agents/subagent_tool.rs b/crates/goose/src/agents/subagent_tool.rs index 8df8b9a457e2..2991ab099626 100644 --- a/crates/goose/src/agents/subagent_tool.rs +++ b/crates/goose/src/agents/subagent_tool.rs @@ -11,14 +11,14 @@ use tokio_util::sync::CancellationToken; use crate::agents::subagent_handler::run_complete_subagent_task; use crate::agents::subagent_task_config::TaskConfig; -use crate::agents::tool_execution::ToolCallResult; +use crate::agents::tool_execution::DeferredToolCall; use crate::agents::AgentConfig; use crate::providers; use crate::recipe::build_recipe::build_recipe_from_template; use crate::recipe::local_recipes::load_local_recipe_file; use crate::recipe::{Recipe, SubRecipe}; -pub const SUBAGENT_TOOL_NAME: &str = "subagent"; +pub const SUBAGENT_TOOL_NAME: &str = "delegate"; const SUMMARY_INSTRUCTIONS: &str = r#" Important: Your parent agent will only receive your final message as a summary of your work. @@ -182,11 +182,11 @@ pub fn handle_subagent_tool( sub_recipes: HashMap, working_dir: PathBuf, cancellation_token: Option, -) -> ToolCallResult { +) -> DeferredToolCall { let parsed_params: SubagentParams = match serde_json::from_value(params) { Ok(p) => p, Err(e) => { - return ToolCallResult::from(Err(ErrorData { + return DeferredToolCall::from(Err(ErrorData { code: ErrorCode::INVALID_PARAMS, message: Cow::from(format!("Invalid parameters: {}", e)), data: None, @@ -195,7 +195,7 @@ pub fn handle_subagent_tool( }; if parsed_params.instructions.is_none() && parsed_params.subrecipe.is_none() { - return ToolCallResult::from(Err(ErrorData { + return DeferredToolCall::from(Err(ErrorData { code: ErrorCode::INVALID_PARAMS, message: Cow::from("Must provide 'instructions' or 'subrecipe' (or both)"), data: None, @@ -203,7 +203,7 @@ pub fn handle_subagent_tool( } if parsed_params.parameters.is_some() && parsed_params.subrecipe.is_none() { - return ToolCallResult::from(Err(ErrorData { + return DeferredToolCall::from(Err(ErrorData { code: ErrorCode::INVALID_PARAMS, message: Cow::from("'parameters' can only be used with 'subrecipe'"), data: None, @@ -213,7 +213,7 @@ pub fn handle_subagent_tool( let recipe = match build_recipe(&parsed_params, &sub_recipes) { Ok(r) => r, Err(e) => { - return ToolCallResult::from(Err(ErrorData { + return DeferredToolCall::from(Err(ErrorData { code: ErrorCode::INVALID_PARAMS, message: Cow::from(e.to_string()), data: None, @@ -222,7 +222,7 @@ pub fn handle_subagent_tool( }; let config = config.clone(); - ToolCallResult { + DeferredToolCall { notification_stream: None, result: Box::new( execute_subagent( @@ -434,13 +434,13 @@ mod tests { #[test] fn test_tool_name() { - assert_eq!(SUBAGENT_TOOL_NAME, "subagent"); + assert_eq!(SUBAGENT_TOOL_NAME, "delegate"); } #[test] fn test_create_tool_without_subrecipes() { let tool = create_subagent_tool(&[]); - assert_eq!(tool.name, "subagent"); + assert_eq!(tool.name, "delegate"); assert!(tool.description.as_ref().unwrap().contains("Ad-hoc")); assert!(!tool .description diff --git a/crates/goose/src/agents/tool_execution.rs b/crates/goose/src/agents/tool_execution.rs index 14ab3ec1153e..99a5af4ac2fc 100644 --- a/crates/goose/src/agents/tool_execution.rs +++ b/crates/goose/src/agents/tool_execution.rs @@ -13,14 +13,12 @@ use crate::mcp_utils::ToolResult; use crate::permission::Permission; use rmcp::model::{Content, ServerNotification}; -// ToolCallResult combines the result of a tool call with an optional notification stream that -// can be used to receive notifications from the tool. -pub struct ToolCallResult { +pub struct DeferredToolCall { pub result: Box> + Send + Unpin>, pub notification_stream: Option + Send + Unpin>>, } -impl From> for ToolCallResult { +impl From> for DeferredToolCall { fn from(result: ToolResult) -> Self { Self { result: Box::new(futures::future::ready(result)), @@ -32,7 +30,6 @@ impl From> for ToolCallResult { use super::agent::{tool_stream, ToolStream}; use crate::agents::Agent; use crate::conversation::message::{Message, ToolRequest}; -use crate::session::Session; use crate::tool_inspection::get_security_finding_id_from_results; pub const DECLINED_RESPONSE: &str = "The user has declined to run this tool. \ @@ -51,11 +48,11 @@ pub const CHAT_MODE_TOOL_SKIPPED_RESPONSE: &str = "Let the user know the tool ca impl Agent { pub(crate) fn handle_approval_tool_requests<'a>( &'a self, + session_id: &'a str, tool_requests: &'a [ToolRequest], tool_futures: Arc>>, request_to_response_map: &'a HashMap>>, cancellation_token: Option, - session: &'a Session, inspection_results: &'a [crate::tool_inspection::InspectionResult], ) -> BoxStream<'a, anyhow::Result> { try_stream! { @@ -96,7 +93,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(), session).await; + let (req_id, tool_result) = self.dispatch_tool_call(session_id, tool_call.clone(), request.id.clone(), cancellation_token.clone()).await; let mut futures = tool_futures.lock().await; futures.push((req_id, match tool_result { diff --git a/crates/goose/tests/mcp_integration_test.rs b/crates/goose/tests/mcp_integration_test.rs index 28d43ece795e..177778e9151e 100644 --- a/crates/goose/tests/mcp_integration_test.rs +++ b/crates/goose/tests/mcp_integration_test.rs @@ -243,7 +243,12 @@ async fn test_replayed_session( let session_manager = Arc::new(goose::session::SessionManager::new( temp_dir.path().to_path_buf(), )); - let extension_manager = Arc::new(ExtensionManager::new(provider, session_manager)); + let extension_manager = Arc::new(ExtensionManager::new( + provider, + session_manager, + None, + goose::config::GooseMode::Auto, + )); #[allow(clippy::redundant_closure_call)] let result = (async || -> Result<(), Box> { diff --git a/scripts/test_subrecipes.sh b/scripts/test_subrecipes.sh index 25d5e11190c4..18d78ea5534b 100755 --- a/scripts/test_subrecipes.sh +++ b/scripts/test_subrecipes.sh @@ -77,13 +77,13 @@ check_recipe_output() { local tmpfile=$1 local mode=$2 - # Check for unified subagent tool invocation (new format: "─── subagent |") - if grep -q "─── subagent" "$tmpfile"; then - echo "✓ SUCCESS: Subagent tool invoked" - RESULTS+=("✓ Subagent tool invocation ($mode)") + # Check for delegate tool invocation (format: "─── delegate | subagent") + if grep -q "─── delegate" "$tmpfile"; then + echo "✓ SUCCESS: Delegate tool invoked" + RESULTS+=("✓ Delegate tool invocation ($mode)") else - echo "✗ FAILED: No evidence of subagent tool invocation" - RESULTS+=("✗ Subagent tool invocation ($mode)") + echo "✗ FAILED: No evidence of delegate tool invocation" + RESULTS+=("✗ Delegate tool invocation ($mode)") fi # Check that both subrecipes were called (shown as "subrecipe: " in output)