diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 8f63894a104c..04e7141aed49 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -839,13 +839,6 @@ impl CliSession { let is_json_mode = self.output_format == "json"; let is_stream_json_mode = self.output_format == "stream-json"; - // Helper to emit a streaming JSON event - let emit_stream_event = |event: &StreamEvent| { - if let Ok(json) = serde_json::to_string(event) { - println!("{}", json); - } - }; - let session_config = SessionConfig { id: self.session_id.clone(), schedule_id: self.scheduled_job_id.clone(), @@ -883,91 +876,30 @@ impl CliSession { result = stream.next() => { match result { Some(Ok(AgentEvent::Message(message))) => { - let tool_call_confirmation = message.content.iter().find_map(|content| { - if let MessageContent::ActionRequired(action) = content { - #[allow(irrefutable_let_patterns)] // this is a one variant enum right now but it will have more - if let ActionRequiredData::ToolConfirmation { id, tool_name, arguments, prompt } = &action.data { - Some((id.clone(), tool_name.clone(), arguments.clone(), prompt.clone())) - } else { - None - } - } else { - None - } - }); - - let elicitation_request = message.content.iter().find_map(|content| { - if let MessageContent::ActionRequired(action) = content { - if let ActionRequiredData::Elicitation { id, message, requested_schema } = &action.data { - Some((id.clone(), message.clone(), requested_schema.clone())) - } else { - None - } - } else { - None - } - }); - - if let Some((id, _tool_name, _arguments, security_prompt)) = tool_call_confirmation { - output::hide_thinking(); - - // Format the confirmation prompt - use security message if present, otherwise use generic message - let prompt = if let Some(security_message) = &security_prompt { - println!("\n{}", security_message); - "Do you allow this tool call?".to_string() - } else { - "Goose would like to call the above tool, do you allow?".to_string() - }; - - // Get confirmation from user - let permission_result = if security_prompt.is_none() { - // No security message - show all options including "Always Allow" - cliclack::select(prompt) - .item(Permission::AllowOnce, "Allow", "Allow the tool call once") - .item(Permission::AlwaysAllow, "Always Allow", "Always allow the tool call") - .item(Permission::DenyOnce, "Deny", "Deny the tool call") - .item(Permission::Cancel, "Cancel", "Cancel the AI response and tool call") - .interact() - } else { - // Security message present - don't show "Always Allow" - cliclack::select(prompt) - .item(Permission::AllowOnce, "Allow", "Allow the tool call once") - .item(Permission::DenyOnce, "Deny", "Deny the tool call") - .item(Permission::Cancel, "Cancel", "Cancel the AI response and tool call") - .interact() - }; - - let permission = match permission_result { - Ok(p) => p, - Err(e) => { - if e.kind() == std::io::ErrorKind::Interrupted { - Permission::Cancel - } else { - return Err(e.into()); - } - } - }; + if let Some((id, security_prompt)) = find_tool_confirmation(&message) { + let permission = prompt_tool_confirmation(&security_prompt)?; if permission == Permission::Cancel { output::render_text("Tool call cancelled. Returning to chat...", Some(Color::Yellow), true); - let mut response_message = Message::user(); response_message.content.push(MessageContent::tool_response( - id.clone(), - Err(ErrorData { code: ErrorCode::INVALID_REQUEST, message: std::borrow::Cow::from("Tool call cancelled by user".to_string()), data: None }) + id, + Err(ErrorData { + code: ErrorCode::INVALID_REQUEST, + message: std::borrow::Cow::from("Tool call cancelled by user"), + data: None, + }), )); self.messages.push(response_message); cancel_token_clone.cancel(); drop(stream); break; - } else { - self.agent.handle_confirmation(id.clone(), PermissionConfirmation { - principal_type: PrincipalType::Tool, - permission, - }).await; } - } - else if let Some((elicitation_id, elicitation_message, schema)) = elicitation_request { + self.agent.handle_confirmation(id, PermissionConfirmation { + principal_type: PrincipalType::Tool, + permission, + }).await; + } else if let Some((elicitation_id, elicitation_message, schema)) = find_elicitation_request(&message) { output::hide_thinking(); let _ = progress_bars.hide(); @@ -975,25 +907,16 @@ impl CliSession { Ok(Some(user_data)) => { let user_data_value = serde_json::to_value(user_data) .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); - let response_message = Message::user() .with_content(MessageContent::action_required_elicitation_response( - elicitation_id.clone(), + elicitation_id, user_data_value, )) .with_visibility(false, true); - self.messages.push(response_message.clone()); // Elicitation responses return an empty stream - the response // unblocks the waiting tool call via ActionRequiredManager - let _ = self - .agent - .reply( - response_message, - session_config.clone(), - Some(cancel_token.clone()), - ) - .await?; + let _ = self.agent.reply(response_message, session_config.clone(), Some(cancel_token.clone())).await?; } Ok(None) => { output::render_text("Information request cancelled.", Some(Color::Yellow), true); @@ -1008,57 +931,13 @@ impl CliSession { break; } } - } - else { - for content in &message.content { - if let MessageContent::ToolRequest(tool_request) = content { - if let Ok(tool_call) = &tool_request.tool_call { - tracing::info!(counter.goose.tool_calls = 1, - tool_name = %tool_call.name, - "Tool call started" - ); - } - } - if let MessageContent::ToolResponse(tool_response) = content { - let tool_name = self.messages - .iter() - .rev() - .find_map(|msg| { - msg.content.iter().find_map(|c| { - if let MessageContent::ToolRequest(req) = c { - if req.id == tool_response.id { - if let Ok(tool_call) = &req.tool_call { - Some(tool_call.name.clone()) - } else { - None - } - } else { - None - } - } else { - None - } - }) - }) - .unwrap_or_else(|| "unknown".to_string().into()); - - let success = tool_response.tool_result.is_ok(); - let result_status = if success { "success" } else { "error" }; - tracing::info!( - counter.goose.tool_completions = 1, - tool_name = %tool_name, - result = %result_status, - "Tool call completed" - ); - } - } - + } else { + log_tool_metrics(&message, &self.messages); self.messages.push(message.clone()); - if interactive {output::hide_thinking()}; + if interactive { output::hide_thinking() }; let _ = progress_bars.hide(); - // Handle different output formats if is_stream_json_mode { emit_stream_event(&StreamEvent::Message { message: message.clone() }); } else if !is_json_mode { @@ -1066,181 +945,29 @@ impl CliSession { } } } - Some(Ok(AgentEvent::McpNotification((extension_id, message)))) => { - match &message { - ServerNotification::LoggingMessageNotification(notification) => { - let data = ¬ification.params.data; - let (formatted_message, subagent_id, message_notification_type) = match data { - Value::String(s) => (s.clone(), None, None), - Value::Object(o) => { - // Check for subagent notification structure first - if let Some(Value::String(msg)) = o.get("message") { - // Extract subagent info for better display - let subagent_id = o.get("subagent_id") - .and_then(|v| v.as_str()); - let notification_type = o.get("type") - .and_then(|v| v.as_str()); - - let formatted = match notification_type { - Some("subagent_created") | Some("completed") | Some("terminated") => { - format!("🤖 {}", msg) - } - Some("tool_usage") | Some("tool_completed") | Some("tool_error") => { - format!("🔧 {}", msg) - } - Some("message_processing") | Some("turn_progress") => { - format!("💭 {}", msg) - } - Some("response_generated") => { - // Check verbosity setting for subagent response content - let config = Config::global(); - let min_priority = config - .get_param::("GOOSE_CLI_MIN_PRIORITY") - .ok() - .unwrap_or(0.5); - - if min_priority > 0.1 && !self.debug { - // High/Medium verbosity: show truncated response - if let Some(response_content) = msg.strip_prefix("Responded: ") { - format!("🤖 Responded: {}", safe_truncate(response_content, 100)) - } else { - format!("🤖 {}", msg) - } - } else { - // All verbosity or debug: show full response - format!("🤖 {}", msg) - } - } - _ => { - msg.to_string() - } - }; - (formatted, subagent_id.map(str::to_string), notification_type.map(str::to_string)) - } else if let Some(Value::String(output)) = o.get("output") { - // Extract type if present (e.g., "shell_output") - let notification_type = o.get("type") - .and_then(|v| v.as_str()) - .map(str::to_string); - - (output.to_owned(), None, notification_type) - } else if let Some(result) = format_task_execution_notification(data) { - result - } else { - (data.to_string(), None, None) - } - }, - v => { - (v.to_string(), None, None) - }, - }; - - if is_stream_json_mode { - emit_stream_event(&StreamEvent::Notification { - extension_id: extension_id.clone(), - data: NotificationData::Log { message: formatted_message.clone() }, - }); - } - // Handle subagent notifications - show immediately - else if let Some(_id) = subagent_id { - if interactive { - let _ = progress_bars.hide(); - if !is_json_mode { - println!("{}", console::style(&formatted_message).green().dim()); - } - } else if !is_json_mode { - progress_bars.log(&formatted_message); - } - } else if let Some(ref notification_type) = message_notification_type { - if notification_type == TASK_EXECUTION_NOTIFICATION_TYPE { - if interactive { - let _ = progress_bars.hide(); - if !is_json_mode { - print!("{}", formatted_message); - std::io::stdout().flush().unwrap(); - } - } else if !is_json_mode { - print!("{}", formatted_message); - std::io::stdout().flush().unwrap(); - } - } else if notification_type == "shell_output" { - if interactive { - let _ = progress_bars.hide(); - } - if !is_json_mode { - println!("{}", formatted_message); - } - } - } - else if output::is_showing_thinking() { - output::set_thinking_message(&formatted_message); - } else { - progress_bars.log(&formatted_message); - } - }, - ServerNotification::ProgressNotification(notification) => { - let progress = notification.params.progress; - let text = notification.params.message.as_deref(); - let total = notification.params.total; - let token = ¬ification.params.progress_token; - - if is_stream_json_mode { - emit_stream_event(&StreamEvent::Notification { - extension_id: extension_id.clone(), - data: NotificationData::Progress { - progress, - total, - message: text.map(String::from), - }, - }); - } else { - progress_bars.update( - &token.0.to_string(), - progress, - total, - text, - ); - } - }, - _ => (), - } + Some(Ok(AgentEvent::McpNotification((extension_id, notification)))) => { + handle_mcp_notification( + &extension_id, + ¬ification, + &mut progress_bars, + is_stream_json_mode, + interactive, + is_json_mode, + self.debug, + ); } Some(Ok(AgentEvent::HistoryReplaced(updated_conversation))) => { self.messages = updated_conversation; } Some(Ok(AgentEvent::ModelChange { model, mode })) => { if is_stream_json_mode { - emit_stream_event(&StreamEvent::ModelChange { - model: model.clone(), - mode: mode.clone(), - }); + emit_stream_event(&StreamEvent::ModelChange { model: model.clone(), mode: mode.clone() }); } else if self.debug { eprintln!("Model changed to {} in {} mode", model, mode); } } - Some(Err(e)) => { - let error_msg = e.to_string(); - - if is_stream_json_mode { - emit_stream_event(&StreamEvent::Error { error: error_msg.clone() }); - } - - if e.downcast_ref::() - .map(|provider_error| matches!(provider_error, goose::providers::errors::ProviderError::ContextLengthExceeded(_))) - .unwrap_or(false) { - - if !is_stream_json_mode { - output::render_text( - "Compaction requested. Should have happened in the agent!", - Some(Color::Yellow), - true - ); - } - warn!("Compaction requested. Should have happened in the agent!"); - } - if !is_stream_json_mode { - eprintln!("Error: {}", error_msg); - } + handle_agent_error(&e, is_stream_json_mode); cancel_token_clone.cancel(); drop(stream); if let Err(e) = self.handle_interrupted_messages(false).await { @@ -1268,7 +995,6 @@ impl CliSession { } } - // Output based on format if is_json_mode { let metadata = match SessionManager::get_session(&self.session_id, false).await { Ok(session) => JsonMetadata { @@ -1280,12 +1006,10 @@ impl CliSession { status: "completed".to_string(), }, }; - let json_output = JsonOutput { messages: self.messages.messages().to_vec(), metadata, }; - println!("{}", serde_json::to_string_pretty(&json_output)?); } else if is_stream_json_mode { let total_tokens = SessionManager::get_session(&self.session_id, false) @@ -1617,6 +1341,328 @@ impl CliSession { } } +fn emit_stream_event(event: &StreamEvent) { + if let Ok(json) = serde_json::to_string(event) { + println!("{}", json); + } +} + +/// Prompt user for tool call confirmation, returns the Permission selected +fn prompt_tool_confirmation(security_prompt: &Option) -> Result { + output::hide_thinking(); + + let prompt = if let Some(security_message) = security_prompt { + println!("\n{}", security_message); + "Do you allow this tool call?".to_string() + } else { + "Goose would like to call the above tool, do you allow?".to_string() + }; + + let permission_result = if security_prompt.is_none() { + cliclack::select(prompt) + .item(Permission::AllowOnce, "Allow", "Allow the tool call once") + .item( + Permission::AlwaysAllow, + "Always Allow", + "Always allow the tool call", + ) + .item(Permission::DenyOnce, "Deny", "Deny the tool call") + .item( + Permission::Cancel, + "Cancel", + "Cancel the AI response and tool call", + ) + .interact() + } else { + cliclack::select(prompt) + .item(Permission::AllowOnce, "Allow", "Allow the tool call once") + .item(Permission::DenyOnce, "Deny", "Deny the tool call") + .item( + Permission::Cancel, + "Cancel", + "Cancel the AI response and tool call", + ) + .interact() + }; + + match permission_result { + Ok(p) => Ok(p), + Err(e) => { + if e.kind() == std::io::ErrorKind::Interrupted { + Ok(Permission::Cancel) + } else { + Err(e.into()) + } + } + } +} + +/// Extract tool confirmation request from a message +fn find_tool_confirmation(message: &Message) -> Option<(String, Option)> { + message.content.iter().find_map(|content| { + if let MessageContent::ActionRequired(action) = content { + if let ActionRequiredData::ToolConfirmation { id, prompt, .. } = &action.data { + return Some((id.clone(), prompt.clone())); + } + } + None + }) +} + +/// Extract elicitation request from a message +fn find_elicitation_request(message: &Message) -> Option<(String, String, Value)> { + message.content.iter().find_map(|content| { + if let MessageContent::ActionRequired(action) = content { + if let ActionRequiredData::Elicitation { + id, + message, + requested_schema, + } = &action.data + { + return Some((id.clone(), message.clone(), requested_schema.clone())); + } + } + None + }) +} + +/// Handle MCP notification event (logging or progress) +fn handle_mcp_notification( + extension_id: &str, + notification: &ServerNotification, + progress_bars: &mut output::McpSpinners, + is_stream_json_mode: bool, + interactive: bool, + is_json_mode: bool, + debug: bool, +) { + match notification { + ServerNotification::LoggingMessageNotification(log_notif) => { + let (formatted, subagent_id, notif_type) = + format_logging_notification(&log_notif.params.data, debug); + + if is_stream_json_mode { + emit_stream_event(&StreamEvent::Notification { + extension_id: extension_id.to_string(), + data: NotificationData::Log { + message: formatted.clone(), + }, + }); + } else { + display_log_notification( + &formatted, + subagent_id.as_deref(), + notif_type.as_deref(), + progress_bars, + interactive, + is_json_mode, + ); + } + } + ServerNotification::ProgressNotification(prog_notif) => { + if is_stream_json_mode { + emit_stream_event(&StreamEvent::Notification { + extension_id: extension_id.to_string(), + data: NotificationData::Progress { + progress: prog_notif.params.progress, + total: prog_notif.params.total, + message: prog_notif.params.message.clone(), + }, + }); + } else { + progress_bars.update( + &prog_notif.params.progress_token.0.to_string(), + prog_notif.params.progress, + prog_notif.params.total, + prog_notif.params.message.as_deref(), + ); + } + } + _ => (), + } +} + +/// Format a logging notification from MCP, returns (formatted_message, subagent_id, notification_type) +fn format_logging_notification( + data: &Value, + debug: bool, +) -> (String, Option, Option) { + match data { + Value::String(s) => (s.clone(), None, None), + Value::Object(o) => { + if let Some(Value::String(msg)) = o.get("message") { + let subagent_id = o.get("subagent_id").and_then(|v| v.as_str()); + let notification_type = o.get("type").and_then(|v| v.as_str()); + + let formatted = match notification_type { + Some("subagent_created") | Some("completed") | Some("terminated") => { + format!("🤖 {}", msg) + } + Some("tool_usage") | Some("tool_completed") | Some("tool_error") => { + format!("🔧 {}", msg) + } + Some("message_processing") | Some("turn_progress") => { + format!("💭 {}", msg) + } + Some("response_generated") => { + let config = Config::global(); + let min_priority = config + .get_param::("GOOSE_CLI_MIN_PRIORITY") + .ok() + .unwrap_or(0.5); + + if min_priority > 0.1 && !debug { + if let Some(response_content) = msg.strip_prefix("Responded: ") { + format!("🤖 Responded: {}", safe_truncate(response_content, 100)) + } else { + format!("🤖 {}", msg) + } + } else { + format!("🤖 {}", msg) + } + } + _ => msg.to_string(), + }; + ( + formatted, + subagent_id.map(str::to_string), + notification_type.map(str::to_string), + ) + } else if let Some(Value::String(output)) = o.get("output") { + let notification_type = o.get("type").and_then(|v| v.as_str()).map(str::to_string); + (output.to_owned(), None, notification_type) + } else if let Some(result) = format_task_execution_notification(data) { + result + } else { + (data.to_string(), None, None) + } + } + v => (v.to_string(), None, None), + } +} + +/// Display a logging notification based on its type and context +fn display_log_notification( + formatted_message: &str, + subagent_id: Option<&str>, + notification_type: Option<&str>, + progress_bars: &mut output::McpSpinners, + interactive: bool, + is_json_mode: bool, +) { + if subagent_id.is_some() { + if interactive { + let _ = progress_bars.hide(); + if !is_json_mode { + println!("{}", console::style(formatted_message).green().dim()); + } + } else if !is_json_mode { + progress_bars.log(formatted_message); + } + } else if let Some(ntype) = notification_type { + if ntype == TASK_EXECUTION_NOTIFICATION_TYPE { + if interactive { + let _ = progress_bars.hide(); + } + if !is_json_mode { + print!("{}", formatted_message); + std::io::stdout().flush().unwrap(); + } + } else if ntype == "shell_output" { + if interactive { + let _ = progress_bars.hide(); + } + if !is_json_mode { + println!("{}", formatted_message); + } + } + } else if output::is_showing_thinking() { + output::set_thinking_message(&formatted_message.to_string()); + } else { + progress_bars.log(formatted_message); + } +} + +/// Log tool request/response metrics +fn log_tool_metrics(message: &Message, messages: &Conversation) { + for content in &message.content { + if let MessageContent::ToolRequest(tool_request) = content { + if let Ok(tool_call) = &tool_request.tool_call { + tracing::info!( + counter.goose.tool_calls = 1, + tool_name = %tool_call.name, + "Tool call started" + ); + } + } + if let MessageContent::ToolResponse(tool_response) = content { + let tool_name = messages + .iter() + .rev() + .find_map(|msg| { + msg.content.iter().find_map(|c| { + if let MessageContent::ToolRequest(req) = c { + if req.id == tool_response.id { + req.tool_call.as_ref().ok().map(|tc| tc.name.clone()) + } else { + None + } + } else { + None + } + }) + }) + .unwrap_or_else(|| "unknown".to_string().into()); + + let result_status = if tool_response.tool_result.is_ok() { + "success" + } else { + "error" + }; + tracing::info!( + counter.goose.tool_completions = 1, + tool_name = %tool_name, + result = %result_status, + "Tool call completed" + ); + } + } +} + +/// Handle and display an agent error +fn handle_agent_error(e: &anyhow::Error, is_stream_json_mode: bool) { + let error_msg = e.to_string(); + + if is_stream_json_mode { + emit_stream_event(&StreamEvent::Error { + error: error_msg.clone(), + }); + } + + if e.downcast_ref::() + .map(|provider_error| { + matches!( + provider_error, + goose::providers::errors::ProviderError::ContextLengthExceeded(_) + ) + }) + .unwrap_or(false) + { + if !is_stream_json_mode { + output::render_text( + "Compaction requested. Should have happened in the agent!", + Some(Color::Yellow), + true, + ); + } + warn!("Compaction requested. Should have happened in the agent!"); + } + + if !is_stream_json_mode { + eprintln!("Error: {}", error_msg); + } +} + async fn get_reasoner() -> Result, anyhow::Error> { use goose::model::ModelConfig; use goose::providers::create;