From 1e645c0212e66c600e52ccfba28a85a0be9f7cdb Mon Sep 17 00:00:00 2001 From: Rabi Mishra Date: Wed, 4 Feb 2026 02:26:59 +0530 Subject: [PATCH] feat: display subagent tool calls in CLI and UI (#6535) Signed-off-by: rabi --- crates/goose-cli/src/session/mod.rs | 44 +++ crates/goose-cli/src/session/output.rs | 107 +++++- crates/goose/src/agents/subagent_handler.rs | 357 +++++++++++++----- crates/goose/src/agents/subagent_tool.rs | 23 +- .../src/components/ToolCallWithResponse.tsx | 69 ++++ 5 files changed, 486 insertions(+), 114 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 7d49aed5d4..b758a83344 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -21,6 +21,7 @@ use tokio_util::task::AbortOnDropHandle; pub use self::export::message_to_markdown; pub use builder::{build_session, SessionBuilderConfig}; use console::Color; +use goose::agents::subagent_handler::SUBAGENT_TOOL_REQUEST_TYPE; use goose::agents::AgentEvent; use goose::permission::permission_confirmation::PrincipalType; use goose::permission::Permission; @@ -1537,6 +1538,49 @@ fn handle_mcp_notification( ) { match notification { ServerNotification::LoggingMessageNotification(log_notif) => { + if let Some(obj) = log_notif.params.data.as_object() { + if obj.get("type").and_then(|v| v.as_str()) == Some(SUBAGENT_TOOL_REQUEST_TYPE) { + if let (Some(subagent_id), Some(tool_call)) = ( + obj.get("subagent_id").and_then(|v| v.as_str()), + obj.get("tool_call").and_then(|v| v.as_object()), + ) { + let tool_name = tool_call + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let arguments = tool_call + .get("arguments") + .and_then(|v| v.as_object()) + .cloned(); + + if interactive { + let _ = progress_bars.hide(); + } + if is_stream_json_mode { + emit_stream_event(&StreamEvent::Notification { + extension_id: extension_id.to_string(), + data: NotificationData::Log { + message: output::format_subagent_tool_call_message( + subagent_id, + tool_name, + ), + }, + }); + return; + } + if !is_json_mode { + output::render_subagent_tool_call( + subagent_id, + tool_name, + arguments.as_ref(), + debug, + ); + return; + } + } + } + } + let (formatted, subagent_id, notif_type) = format_logging_notification(&log_notif.params.data, debug); diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 0f7e43ab7e..b196329393 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -613,21 +613,108 @@ fn render_default_request(call: &CallToolRequestParams, debug: bool) { println!(); } +fn split_tool_name(tool_name: &str) -> (String, String) { + let parts: Vec<_> = tool_name.rsplit("__").collect(); + let tool = parts.first().copied().unwrap_or("unknown"); + let extension = parts + .split_first() + .map(|(_, s)| s.iter().rev().copied().collect::>().join("__")) + .unwrap_or_default(); + (tool.to_string(), extension) +} + +pub fn format_subagent_tool_call_message(subagent_id: &str, tool_name: &str) -> String { + let short_id = subagent_id.rsplit('_').next().unwrap_or(subagent_id); + let (tool, extension) = split_tool_name(tool_name); + + if extension.is_empty() { + format!("[subagent:{}] {}", short_id, tool) + } else { + format!("[subagent:{}] {} | {}", short_id, tool, extension) + } +} + +pub fn render_subagent_tool_call( + subagent_id: &str, + tool_name: &str, + arguments: Option<&JsonObject>, + debug: bool, +) { + if tool_name == "code_execution__execute_code" { + let tool_graph = arguments + .and_then(|args| args.get("tool_graph")) + .and_then(Value::as_array) + .filter(|arr| !arr.is_empty()); + if let Some(tool_graph) = tool_graph { + return render_subagent_tool_graph(subagent_id, tool_graph); + } + } + let tool_header = format!( + "─── {} ──────────────────────────", + style(format_subagent_tool_call_message(subagent_id, tool_name)) + .magenta() + .dim() + ); + println!(); + println!("{}", tool_header); + print_params(&arguments.cloned(), 0, debug); + println!(); +} + +fn render_subagent_tool_graph(subagent_id: &str, tool_graph: &[Value]) { + let short_id = subagent_id.rsplit('_').next().unwrap_or(subagent_id); + let count = tool_graph.len(); + let plural = if count == 1 { "" } else { "s" }; + println!(); + println!( + "─── {} {} tool call{} | {} ──────────────────────────", + style(format!("[subagent:{}]", short_id)).cyan(), + style(count).cyan(), + plural, + style("execute_code").magenta().dim() + ); + + for (i, node) in tool_graph.iter().filter_map(Value::as_object).enumerate() { + let tool = node + .get("tool") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let desc = node + .get("description") + .and_then(Value::as_str) + .unwrap_or(""); + let deps: Vec<_> = node + .get("depends_on") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_u64) + .map(|d| (d + 1).to_string()) + .collect(); + let deps_str = if deps.is_empty() { + String::new() + } else { + format!(" (uses {})", deps.join(", ")) + }; + println!( + " {}. {}: {}{}", + style(i + 1).dim(), + style(tool).cyan(), + style(desc).green(), + style(deps_str).dim() + ); + } + println!(); +} + // Helper functions fn print_tool_header(call: &CallToolRequestParams) { - let parts: Vec<_> = call.name.rsplit("__").collect(); + let (tool, extension) = split_tool_name(&call.name); let tool_header = format!( "─── {} | {} ──────────────────────────", - style(parts.first().unwrap_or(&"unknown")), - style( - parts - .split_first() - .map(|(_, s)| s.iter().rev().copied().collect::>().join("__")) - .unwrap_or_else(|| "unknown".to_string()) - ) - .magenta() - .dim(), + style(tool), + style(extension).magenta().dim(), ); println!(); println!("{}", tool_header); diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index d4ee6fba7a..126e224d20 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -1,12 +1,18 @@ use crate::{ agents::{subagent_task_config::TaskConfig, Agent, AgentConfig, AgentEvent, SessionConfig}, - conversation::{message::Message, Conversation}, + conversation::{ + message::{Message, MessageContent}, + Conversation, + }, prompt_template::render_template, recipe::Recipe, }; use anyhow::{anyhow, Result}; use futures::StreamExt; -use rmcp::model::{ErrorCode, ErrorData}; +use rmcp::model::{ + ErrorCode, ErrorData, LoggingLevel, LoggingMessageNotification, + LoggingMessageNotificationMethod, LoggingMessageNotificationParam, ServerNotification, +}; use serde::Serialize; use std::future::Future; use std::pin::Pin; @@ -15,18 +21,17 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, info}; #[derive(Serialize)] -struct SubagentPromptContext { - max_turns: usize, - subagent_id: String, - task_instructions: String, - tool_count: usize, - available_tools: String, +pub struct SubagentPromptContext { + pub max_turns: usize, + pub subagent_id: String, + pub task_instructions: String, + pub tool_count: usize, + pub available_tools: String, } type AgentMessagesFuture = Pin)>> + Send>>; -/// Standalone function to run a complete subagent task with output options pub async fn run_complete_subagent_task( config: AgentConfig, recipe: Recipe, @@ -35,16 +40,43 @@ pub async fn run_complete_subagent_task( session_id: String, cancellation_token: Option, ) -> Result { - let (messages, final_output) = - get_agent_messages(config, recipe, task_config, session_id, cancellation_token) - .await - .map_err(|e| { - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to execute task: {}", e), - None, - ) - })?; + run_complete_subagent_task_with_notifications( + config, + recipe, + task_config, + return_last_only, + session_id, + cancellation_token, + None, + ) + .await +} + +pub async fn run_complete_subagent_task_with_notifications( + config: AgentConfig, + recipe: Recipe, + task_config: TaskConfig, + return_last_only: bool, + session_id: String, + cancellation_token: Option, + notification_tx: Option>, +) -> Result { + let (messages, final_output) = get_agent_messages_with_notifications( + config, + recipe, + task_config, + session_id, + cancellation_token, + notification_tx, + ) + .await + .map_err(|e| { + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + format!("Failed to execute task: {}", e), + None, + ) + })?; if let Some(output) = final_output { return Ok(output); @@ -67,40 +99,35 @@ pub async fn run_complete_subagent_task( let all_text_content: Vec = messages .iter() .flat_map(|message| { - message.content.iter().filter_map(|content| { - match content { - crate::conversation::message::MessageContent::Text(text_content) => { - Some(text_content.text.clone()) - } - crate::conversation::message::MessageContent::ToolResponse( - tool_response, - ) => { - // Extract text from tool response - if let Ok(result) = &tool_response.tool_result { - let texts: Vec = result - .content - .iter() - .filter_map(|content| { - if let rmcp::model::RawContent::Text(raw_text_content) = - &content.raw - { - Some(raw_text_content.text.clone()) - } else { - None - } - }) - .collect(); - if !texts.is_empty() { - Some(format!("Tool result: {}", texts.join("\n"))) - } else { - None - } + message.content.iter().filter_map(|content| match content { + crate::conversation::message::MessageContent::Text(text_content) => { + Some(text_content.text.clone()) + } + crate::conversation::message::MessageContent::ToolResponse(tool_response) => { + if let Ok(result) = &tool_response.tool_result { + let texts: Vec = result + .content + .iter() + .filter_map(|content| { + if let rmcp::model::RawContent::Text(raw_text_content) = + &content.raw + { + Some(raw_text_content.text.clone()) + } else { + None + } + }) + .collect(); + if !texts.is_empty() { + Some(format!("Tool result: {}", texts.join("\n"))) } else { None } + } else { + None } - _ => None, } + _ => None, }) }) .collect(); @@ -111,12 +138,15 @@ pub async fn run_complete_subagent_task( Ok(response_text) } -fn get_agent_messages( +pub const SUBAGENT_TOOL_REQUEST_TYPE: &str = "subagent_tool_request"; + +fn get_agent_messages_with_notifications( config: AgentConfig, recipe: Recipe, task_config: TaskConfig, session_id: String, cancellation_token: Option, + notification_tx: Option>, ) -> AgentMessagesFuture { Box::pin(async move { let system_instructions = recipe.instructions.clone().unwrap_or_default(); @@ -128,11 +158,11 @@ fn get_agent_messages( let agent = Arc::new(Agent::with_config(config)); agent - .update_provider(task_config.provider, &session_id) + .update_provider(task_config.provider.clone(), &session_id) .await .map_err(|e| anyhow!("Failed to set provider on sub agent: {}", e))?; - for extension in task_config.extensions { + for extension in &task_config.extensions { if let Err(e) = agent.add_extension(extension.clone(), &session_id).await { debug!( "Failed to add extension '{}' to subagent: {}", @@ -147,24 +177,8 @@ fn get_agent_messages( .apply_recipe_components(recipe.sub_recipes.clone(), recipe.response.clone(), true) .await; - let tools = agent.list_tools(&session_id, None).await; - let subagent_prompt = render_template( - "subagent_system.md", - &SubagentPromptContext { - max_turns: task_config - .max_turns - .expect("TaskConfig always sets max_turns"), - subagent_id: session_id.clone(), - task_instructions: system_instructions, - tool_count: tools.len(), - available_tools: tools - .iter() - .map(|t| t.name.to_string()) - .collect::>() - .join(", "), - }, - ) - .map_err(|e| anyhow!("Failed to render subagent system prompt: {}", e))?; + let subagent_prompt = + build_subagent_prompt(&agent, &task_config, &session_id, system_instructions).await?; agent.override_system_prompt(subagent_prompt).await; let user_message = Message::user().with_text(user_task); @@ -182,35 +196,186 @@ fn get_agent_messages( retry_config: recipe.retry, }; - let mut stream = agent - .reply(user_message, session_config, cancellation_token) - .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)) => conversation.push(msg), - Ok(AgentEvent::McpNotification(_)) | Ok(AgentEvent::ModelChange { .. }) => {} - Ok(AgentEvent::HistoryReplaced(updated_conversation)) => { - conversation = updated_conversation; - } - Err(e) => { - tracing::error!("Error receiving message from subagent: {}", e); - break; - } - } - } + conversation = run_subagent_stream( + agent.clone(), + user_message, + session_config, + cancellation_token, + &session_id, + ¬ification_tx, + conversation, + ) + .await?; - let final_output = if has_response_schema { - agent - .final_output_tool - .lock() - .await - .as_ref() - .and_then(|tool| tool.final_output.clone()) - } else { - None - }; + let final_output = get_final_output(&agent, has_response_schema).await; Ok((conversation, final_output)) }) } + +async fn build_subagent_prompt( + agent: &Agent, + task_config: &TaskConfig, + session_id: &str, + system_instructions: String, +) -> Result { + let tools = agent.list_tools(session_id, None).await; + render_template( + "subagent_system.md", + &SubagentPromptContext { + max_turns: task_config + .max_turns + .expect("TaskConfig always sets max_turns"), + subagent_id: session_id.to_string(), + task_instructions: system_instructions, + tool_count: tools.len(), + available_tools: tools + .iter() + .map(|t| t.name.to_string()) + .collect::>() + .join(", "), + }, + ) + .map_err(|e| anyhow!("Failed to render subagent system prompt: {}", e)) +} + +async fn run_subagent_stream( + agent: Arc, + user_message: Message, + session_config: SessionConfig, + cancellation_token: Option, + session_id: &str, + notification_tx: &Option>, + mut conversation: Conversation, +) -> Result { + let mut stream = crate::session_context::with_session_id(Some(session_id.to_string()), async { + agent + .reply(user_message, session_config, cancellation_token) + .await + }) + .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)) => { + if let Some(ref tx) = notification_tx { + for content in &msg.content { + if let Some(notif) = create_tool_notification(content, session_id) { + if tx.send(notif).is_err() { + debug!("Notification receiver dropped for subagent {}", session_id); + } + } + } + } + conversation.push(msg); + } + Ok(AgentEvent::McpNotification(_)) => {} + Ok(AgentEvent::ModelChange { .. }) => {} + Ok(AgentEvent::HistoryReplaced(updated_conversation)) => { + conversation = updated_conversation; + } + Err(e) => { + tracing::error!("Error receiving message from subagent: {}", e); + break; + } + } + } + + Ok(conversation) +} + +async fn get_final_output(agent: &Agent, has_response_schema: bool) -> Option { + if has_response_schema { + agent + .final_output_tool + .lock() + .await + .as_ref() + .and_then(|tool| tool.final_output.clone()) + } else { + None + } +} + +fn create_tool_notification( + content: &MessageContent, + subagent_id: &str, +) -> Option { + if let MessageContent::ToolRequest(req) = content { + let tool_call = req.tool_call.as_ref().ok()?; + + Some(ServerNotification::LoggingMessageNotification( + LoggingMessageNotification { + method: LoggingMessageNotificationMethod, + params: LoggingMessageNotificationParam { + level: LoggingLevel::Info, + logger: Some(format!("subagent:{}", subagent_id)), + data: serde_json::json!({ + "type": SUBAGENT_TOOL_REQUEST_TYPE, + "subagent_id": subagent_id, + "tool_call": { + "name": tool_call.name, + "arguments": tool_call.arguments + } + }), + }, + extensions: Default::default(), + }, + )) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::{create_tool_notification, SUBAGENT_TOOL_REQUEST_TYPE}; + use crate::conversation::message::MessageContent; + use rmcp::model::{CallToolRequestParams, ServerNotification}; + use serde_json::json; + + #[test] + fn create_tool_notification_for_tool_request() { + let tool_call = CallToolRequestParams { + meta: None, + task: None, + name: "developer__shell".to_string().into(), + arguments: Some(json!({"command": "ls"}).as_object().unwrap().clone()), + }; + let content = MessageContent::tool_request("req1", Ok(tool_call)); + let notification = + create_tool_notification(&content, "session_1").expect("expected notification"); + + let ServerNotification::LoggingMessageNotification(log_notif) = notification else { + panic!("expected logging notification"); + }; + let data = log_notif + .params + .data + .as_object() + .expect("expected object data"); + assert_eq!( + data.get("type").and_then(|v| v.as_str()), + Some(SUBAGENT_TOOL_REQUEST_TYPE) + ); + assert_eq!( + data.get("subagent_id").and_then(|v| v.as_str()), + Some("session_1") + ); + let tool_call = data + .get("tool_call") + .and_then(|v| v.as_object()) + .expect("expected tool_call object"); + assert_eq!( + tool_call.get("name").and_then(|v| v.as_str()), + Some("developer__shell") + ); + } + + #[test] + fn create_tool_notification_ignores_non_tool_request() { + let content = MessageContent::text("hello"); + assert!(create_tool_notification(&content, "session_1").is_none()); + } +} diff --git a/crates/goose/src/agents/subagent_tool.rs b/crates/goose/src/agents/subagent_tool.rs index a043736207..6c19fe53bc 100644 --- a/crates/goose/src/agents/subagent_tool.rs +++ b/crates/goose/src/agents/subagent_tool.rs @@ -4,12 +4,14 @@ use std::path::PathBuf; use anyhow::{anyhow, Result}; use futures::FutureExt; -use rmcp::model::{Content, ErrorCode, ErrorData, Tool}; +use rmcp::model::{Content, ErrorCode, ErrorData, ServerNotification, Tool}; use serde::Deserialize; use serde_json::{json, Value}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_util::sync::CancellationToken; -use crate::agents::subagent_handler::run_complete_subagent_task; +use crate::agents::subagent_handler::run_complete_subagent_task_with_notifications; use crate::agents::subagent_task_config::TaskConfig; use crate::agents::tool_execution::ToolCallResult; use crate::agents::AgentConfig; @@ -31,7 +33,7 @@ Make sure your last message provides a comprehensive summary of: Be concise but complete. "#; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct SubagentParams { pub instructions: Option, pub subrecipe: Option, @@ -46,7 +48,7 @@ fn default_summary() -> bool { true } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct SubagentSettings { pub provider: Option, pub model: Option, @@ -224,29 +226,33 @@ pub fn handle_subagent_tool( }; let config = config.clone(); + let (notification_tx, notification_rx) = mpsc::unbounded_channel(); + ToolCallResult { - notification_stream: None, + notification_stream: Some(Box::new(UnboundedReceiverStream::new(notification_rx))), result: Box::new( - execute_subagent( + execute_subagent_with_notifications( config, recipe, task_config, parsed_params, working_dir, cancellation_token, + notification_tx, ) .boxed(), ), } } -async fn execute_subagent( +async fn execute_subagent_with_notifications( config: AgentConfig, recipe: Recipe, task_config: TaskConfig, params: SubagentParams, working_dir: PathBuf, cancellation_token: Option, + notification_tx: mpsc::UnboundedSender, ) -> Result { let session = config .session_manager @@ -270,13 +276,14 @@ async fn execute_subagent( data: None, })?; - let result = run_complete_subagent_task( + let result = run_complete_subagent_task_with_notifications( config, recipe, task_config, params.summary, session.id, cancellation_token, + Some(notification_tx), ) .await; diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index a7608c1b4c..2fcca040bb 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -291,10 +291,79 @@ interface Progress { message?: string; } +interface SubagentToolRequestData { + type: 'subagent_tool_request'; + subagent_id: string; + tool_call: { + name: string; + arguments?: { tool_graph?: ToolGraphNode[] }; + }; +} + +const isSubagentToolRequestData = (data: unknown): data is SubagentToolRequestData => { + if (!data || typeof data !== 'object') { + return false; + } + const record = data as Record; + if (record.type !== 'subagent_tool_request') { + return false; + } + if (typeof record.subagent_id !== 'string') { + return false; + } + if (!record.tool_call || typeof record.tool_call !== 'object') { + return false; + } + const toolCall = record.tool_call as Record; + return typeof toolCall.name === 'string'; +}; + +const formatSubagentToolCall = (data: SubagentToolRequestData): string => { + const subagentId = data.subagent_id; + const toolCall = data.tool_call; + const toolCallName = toolCall.name; + + const shortId = subagentId?.split('_').pop() || subagentId; + + const parts = toolCallName.split('__').reverse(); + const toolName = parts[0] || 'unknown'; + const extensionName = parts.slice(1).reverse().join('__') || ''; + const toolGraph = toolCall.arguments?.tool_graph; + + if (toolName === 'execute_code' && toolGraph && toolGraph.length > 0) { + const plural = toolGraph.length === 1 ? '' : 's'; + const header = `[subagent:${shortId}] ${toolGraph.length} tool call${plural} | execute_code`; + const lines = toolGraph.map((node, idx) => { + const deps = + node.depends_on && node.depends_on.length > 0 + ? ` (uses ${node.depends_on.map((d) => d + 1).join(', ')})` + : ''; + return ` ${idx + 1}. ${node.tool}: ${node.description}${deps}`; + }); + return [header, ...lines].join('\n'); + } + + return extensionName + ? `[subagent:${shortId}] ${toolName} | ${extensionName}` + : `[subagent:${shortId}] ${toolName}`; +}; + const logToString = (logMessage: NotificationEvent) => { const message = logMessage.message as { method: string; params: unknown }; const params = message.params as Record; + if ( + params && + params.data && + typeof params.data === 'object' && + 'type' in params.data && + params.data.type === 'subagent_tool_request' + ) { + if (isSubagentToolRequestData(params.data)) { + return formatSubagentToolCall(params.data); + } + } + // Special case for the developer system shell logs if ( params &&