diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index be1575cad8..efaad9e47f 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -720,6 +720,16 @@ enum Command { )] additional_sub_recipes: Vec, + /// Output format (text, json) + #[arg( + long = "output-format", + value_name = "FORMAT", + help = "Output format (text, json)", + default_value = "text", + value_parser = clap::builder::PossibleValuesParser::new(["text", "json"]) + )] + output_format: String, + /// Provider to use for this run (overrides environment variable) #[arg( long = "provider", @@ -974,6 +984,7 @@ pub async fn cli() -> anyhow::Result<()> { sub_recipes: None, final_output_response: None, retry_config: None, + output_format: "text".to_string(), }) .await; @@ -1054,6 +1065,7 @@ pub async fn cli() -> anyhow::Result<()> { scheduled_job_id, quiet, additional_sub_recipes, + output_format, provider, model, }) => { @@ -1183,6 +1195,7 @@ pub async fn cli() -> anyhow::Result<()> { .as_ref() .and_then(|r| r.final_output_response.clone()), retry_config: recipe_info.as_ref().and_then(|r| r.retry_config.clone()), + output_format, }) .await; @@ -1366,6 +1379,7 @@ pub async fn cli() -> anyhow::Result<()> { sub_recipes: None, final_output_response: None, retry_config: None, + output_format: "text".to_string(), }) .await; session.interactive(None).await?; diff --git a/crates/goose-cli/src/commands/bench.rs b/crates/goose-cli/src/commands/bench.rs index da6af17a78..c0005fa540 100644 --- a/crates/goose-cli/src/commands/bench.rs +++ b/crates/goose-cli/src/commands/bench.rs @@ -55,6 +55,7 @@ pub async fn agent_generator( sub_recipes: None, final_output_response: None, retry_config: None, + output_format: "text".to_string(), }) .await; diff --git a/crates/goose-cli/src/scenario_tests/scenario_runner.rs b/crates/goose-cli/src/scenario_tests/scenario_runner.rs index df09e5ebcf..8d7474d485 100644 --- a/crates/goose-cli/src/scenario_tests/scenario_runner.rs +++ b/crates/goose-cli/src/scenario_tests/scenario_runner.rs @@ -225,7 +225,17 @@ where SessionType::Hidden, ) .await?; - let mut cli_session = CliSession::new(agent, session.id, false, None, None, None, None).await; + let mut cli_session = CliSession::new( + agent, + session.id, + false, + None, + None, + None, + None, + "text".to_string(), + ) + .await; let mut error = None; for message in &messages { diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index bc15cdbc50..96321f4bce 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -24,7 +24,7 @@ use tokio::task::JoinSet; /// /// This struct contains all the parameters needed to create a new session, /// including session identification, extension configuration, and debug settings. -#[derive(Default, Clone, Debug)] +#[derive(Clone, Debug)] pub struct SessionBuilderConfig { /// Session id, optional need to deduce from context pub session_id: Option, @@ -68,6 +68,39 @@ pub struct SessionBuilderConfig { pub final_output_response: Option, /// Retry configuration for automated validation and recovery pub retry_config: Option, + /// Output format (text, json) + pub output_format: String, +} + +/// Manual implementation of Default to ensure proper initialization of output_format +/// This struct requires explicit default value for output_format field +impl Default for SessionBuilderConfig { + fn default() -> Self { + SessionBuilderConfig { + session_id: None, + resume: false, + no_session: false, + extensions: Vec::new(), + remote_extensions: Vec::new(), + streamable_http_extensions: Vec::new(), + builtins: Vec::new(), + extensions_override: None, + additional_system_prompt: None, + settings: None, + provider: None, + model: None, + debug: false, + max_tool_repetitions: None, + max_turns: None, + scheduled_job_id: None, + interactive: false, + quiet: false, + sub_recipes: None, + final_output_response: None, + retry_config: None, + output_format: "text".to_string(), + } + } } /// Offers to help debug an extension failure by creating a minimal debugging session @@ -139,8 +172,17 @@ async fn offer_extension_debugging_help( SessionType::Hidden, ) .await?; - let mut debug_session = - CliSession::new(debug_agent, session.id, false, None, None, None, None).await; + let mut debug_session = CliSession::new( + debug_agent, + session.id, + false, + None, + None, + None, + None, + "text".to_string(), + ) + .await; // Process the debugging request println!("{}", style("Analyzing the extension failure...").yellow()); @@ -459,6 +501,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { session_config.max_turns, edit_mode, session_config.retry_config.clone(), + session_config.output_format.clone(), ) .await; @@ -643,6 +686,7 @@ mod tests { sub_recipes: None, final_output_response: None, retry_config: None, + output_format: "text".to_string(), }; assert_eq!(config.extensions.len(), 1); diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 94192f86b6..35f8999d01 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -41,6 +41,7 @@ use goose::config::paths::Paths; use goose::conversation::message::{Message, MessageContent}; use rand::{distributions::Alphanumeric, Rng}; use rustyline::EditMode; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::path::PathBuf; @@ -50,6 +51,18 @@ use tokio; use tokio_util::sync::CancellationToken; use tracing::warn; +#[derive(Serialize, Deserialize, Debug)] +struct JsonOutput { + messages: Vec, + metadata: JsonMetadata, +} + +#[derive(Serialize, Deserialize, Debug)] +struct JsonMetadata { + total_tokens: Option, + status: String, +} + pub enum RunMode { Normal, Plan, @@ -66,6 +79,7 @@ pub struct CliSession { max_turns: Option, edit_mode: Option, retry_config: Option, + output_format: String, } // Cache structure for completion data @@ -120,6 +134,7 @@ pub async fn classify_planner_response( } impl CliSession { + #[allow(clippy::too_many_arguments)] pub async fn new( agent: Agent, session_id: String, @@ -128,6 +143,7 @@ impl CliSession { max_turns: Option, edit_mode: Option, retry_config: Option, + output_format: String, ) -> Self { let messages = SessionManager::get_session(&session_id, true) .await @@ -145,6 +161,7 @@ impl CliSession { max_turns, edit_mode, retry_config, + output_format, } } @@ -760,6 +777,9 @@ impl CliSession { ) -> Result<()> { let cancel_token_clone = cancel_token.clone(); + // Cache the output format check to avoid repeated string comparisons in the hot loop + let is_json_mode = self.output_format == "json"; + let session_config = SessionConfig { id: self.session_id.clone(), schedule_id: self.scheduled_job_id.clone(), @@ -891,11 +911,16 @@ impl CliSession { ); } } + self.messages.push(message.clone()); if interactive {output::hide_thinking()}; let _ = progress_bars.hide(); - output::render_message(&message, self.debug); + + // Don't render in JSON mode + if !is_json_mode { + output::render_message(&message, self.debug); + } } } Some(Ok(AgentEvent::McpNotification((_id, message)))) => { @@ -967,17 +992,21 @@ impl CliSession { // TODO: proper display for subagent notifications if interactive { let _ = progress_bars.hide(); - println!("{}", console::style(&formatted_message).green().dim()); - } else { + 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(); - print!("{}", formatted_message); - std::io::stdout().flush().unwrap(); - } else { + 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(); } @@ -1056,7 +1085,29 @@ impl CliSession { } } } - println!(); + + // Output JSON if requested + if is_json_mode { + let metadata = match SessionManager::get_session(&self.session_id, false).await { + Ok(session) => JsonMetadata { + total_tokens: session.total_tokens, + status: "completed".to_string(), + }, + Err(_) => JsonMetadata { + total_tokens: None, + status: "completed".to_string(), + }, + }; + + let json_output = JsonOutput { + messages: self.messages.messages().to_vec(), + metadata, + }; + + println!("{}", serde_json::to_string_pretty(&json_output)?); + } else { + println!(); + } Ok(()) }