From 2c86a0eb6e20d314f019d9faf2be75c3babc0dc0 Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison <72240382+jsibbison-square@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:57:25 +1000 Subject: [PATCH] feat: Adds max_turns for the agent without user input (#3208) --- crates/goose-cli/src/cli.rs | 23 ++++ crates/goose-cli/src/commands/bench.rs | 1 + crates/goose-cli/src/commands/configure.rs | 40 ++++++ crates/goose-cli/src/commands/web.rs | 1 + crates/goose-cli/src/session/builder.rs | 14 +- crates/goose-cli/src/session/mod.rs | 4 + crates/goose-server/src/routes/reply.rs | 2 + crates/goose/src/agents/agent.rs | 18 +++ crates/goose/src/agents/types.rs | 2 + crates/goose/src/scheduler.rs | 1 + crates/goose/tests/agent.rs | 121 ++++++++++++++++++ .../docs/guides/goose-cli-commands.md | 16 +++ .../components/settings/mode/ModeSection.tsx | 43 ++++++- 13 files changed, 284 insertions(+), 2 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 5e3a95a9..8229fe92 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -312,6 +312,15 @@ enum Command { )] max_tool_repetitions: Option, + /// Maximum number of turns (iterations) allowed in a single response + #[arg( + long = "max-turns", + value_name = "NUMBER", + help = "Maximum number of turns allowed without user input (default: 1000)", + long_help = "Set a limit on how many turns (iterations) the agent can take without asking for user input to continue." + )] + max_turns: Option, + /// Add stdio extensions with environment variables and commands #[arg( long = "with-extension", @@ -449,6 +458,15 @@ enum Command { )] max_tool_repetitions: Option, + /// Maximum number of turns (iterations) allowed in a single response + #[arg( + long = "max-turns", + value_name = "NUMBER", + help = "Maximum number of turns allowed without user input (default: 1000)", + long_help = "Set a limit on how many turns (iterations) the agent can take without asking for user input to continue." + )] + max_turns: Option, + /// Identifier for this run session #[command(flatten)] identifier: Option, @@ -635,6 +653,7 @@ pub async fn cli() -> Result<()> { history, debug, max_tool_repetitions, + max_turns, extensions, remote_extensions, builtins, @@ -683,6 +702,7 @@ pub async fn cli() -> Result<()> { settings: None, debug, max_tool_repetitions, + max_turns, scheduled_job_id: None, interactive: true, quiet: false, @@ -731,6 +751,7 @@ pub async fn cli() -> Result<()> { no_session, debug, max_tool_repetitions, + max_turns, extensions, remote_extensions, builtins, @@ -826,6 +847,7 @@ pub async fn cli() -> Result<()> { settings: session_settings, debug, max_tool_repetitions, + max_turns, scheduled_job_id, interactive, // Use the interactive flag from the Run command quiet, @@ -950,6 +972,7 @@ pub async fn cli() -> Result<()> { settings: None::, debug: false, max_tool_repetitions: None, + max_turns: None, scheduled_job_id: None, interactive: true, // Default case is always interactive quiet: false, diff --git a/crates/goose-cli/src/commands/bench.rs b/crates/goose-cli/src/commands/bench.rs index 42cd75af..9d774098 100644 --- a/crates/goose-cli/src/commands/bench.rs +++ b/crates/goose-cli/src/commands/bench.rs @@ -45,6 +45,7 @@ pub async fn agent_generator( max_tool_repetitions: None, interactive: false, // Benchmarking is non-interactive scheduled_job_id: None, + max_turns: None, quiet: false, sub_recipes: None, final_output_response: None, diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index f6180fd3..526aa447 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -846,6 +846,11 @@ pub async fn configure_settings_dialog() -> Result<(), Box> { "Tool Output", "Show more or less tool output", ) + .item( + "max_turns", + "Max Turns", + "Set maximum number of turns without user input", + ) .item( "experiment", "Toggle Experiment", @@ -876,6 +881,9 @@ pub async fn configure_settings_dialog() -> Result<(), Box> { "tool_output" => { configure_tool_output_dialog()?; } + "max_turns" => { + configure_max_turns_dialog()?; + } "experiment" => { toggle_experiments_dialog()?; } @@ -1289,3 +1297,35 @@ fn configure_scheduler_dialog() -> Result<(), Box> { Ok(()) } + +pub fn configure_max_turns_dialog() -> Result<(), Box> { + let config = Config::global(); + + let current_max_turns: u32 = config.get_param("GOOSE_MAX_TURNS").unwrap_or(1000); + + let max_turns_input: String = + cliclack::input("Set maximum number of agent turns without user input:") + .placeholder(¤t_max_turns.to_string()) + .default_input(¤t_max_turns.to_string()) + .validate(|input: &String| match input.parse::() { + Ok(value) => { + if value < 1 { + Err("Value must be at least 1") + } else { + Ok(()) + } + } + Err(_) => Err("Please enter a valid number"), + }) + .interact()?; + + let max_turns: u32 = max_turns_input.parse()?; + config.set_param("GOOSE_MAX_TURNS", Value::from(max_turns))?; + + cliclack::outro(format!( + "Set maximum turns to {} - Goose will ask for input after {} consecutive actions", + max_turns, max_turns + ))?; + + Ok(()) +} diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs index 3ca4f77d..72b5d32b 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -483,6 +483,7 @@ async fn process_message_streaming( working_dir: std::env::current_dir()?, schedule_id: None, execution_mode: None, + max_turns: None, }; // Get response from agent diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 9ab727b6..b5d3da1d 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -41,6 +41,8 @@ pub struct SessionBuilderConfig { pub debug: bool, /// Maximum number of consecutive identical tool calls allowed pub max_tool_repetitions: Option, + /// Maximum number of turns (iterations) allowed without user input + pub max_turns: Option, /// ID of the scheduled job that triggered this session (if any) pub scheduled_job_id: Option, /// Whether this session will be used interactively (affects debugging prompts) @@ -122,7 +124,13 @@ async fn offer_extension_debugging_help( std::env::temp_dir().join(format!("goose_debug_extension_{}.jsonl", extension_name)); // Create the debugging session - let mut debug_session = Session::new(debug_agent, Some(temp_session_file.clone()), false, None); + let mut debug_session = Session::new( + debug_agent, + Some(temp_session_file.clone()), + false, + None, + None, + ); // Process the debugging request println!("{}", style("Analyzing the extension failure...").yellow()); @@ -367,6 +375,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session { session_file.clone(), session_config.debug, session_config.scheduled_job_id.clone(), + session_config.max_turns, ); // Add extensions if provided @@ -516,6 +525,7 @@ mod tests { settings: None, debug: true, max_tool_repetitions: Some(5), + max_turns: None, scheduled_job_id: None, interactive: true, quiet: false, @@ -528,6 +538,7 @@ mod tests { assert_eq!(config.builtins.len(), 1); assert!(config.debug); assert_eq!(config.max_tool_repetitions, Some(5)); + assert!(config.max_turns.is_none()); assert!(config.scheduled_job_id.is_none()); assert!(config.interactive); assert!(!config.quiet); @@ -547,6 +558,7 @@ mod tests { assert!(config.additional_system_prompt.is_none()); assert!(!config.debug); assert!(config.max_tool_repetitions.is_none()); + assert!(config.max_turns.is_none()); assert!(config.scheduled_job_id.is_none()); assert!(!config.interactive); assert!(!config.quiet); diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index b9db39be..4b1485e0 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -52,6 +52,7 @@ pub struct Session { debug: bool, // New field for debug mode run_mode: RunMode, scheduled_job_id: Option, // ID of the scheduled job that triggered this session + max_turns: Option, } // Cache structure for completion data @@ -113,6 +114,7 @@ impl Session { session_file: Option, debug: bool, scheduled_job_id: Option, + max_turns: Option, ) -> Self { let messages = if let Some(session_file) = &session_file { match session::read_messages(session_file) { @@ -135,6 +137,7 @@ impl Session { debug, run_mode: RunMode::Normal, scheduled_job_id, + max_turns, } } @@ -757,6 +760,7 @@ impl Session { .expect("failed to get current session working directory"), schedule_id: self.scheduled_job_id.clone(), execution_mode: None, + max_turns: self.max_turns, } }); let mut stream = self diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 81e1d7fc..5aae8e1f 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -184,6 +184,7 @@ async fn handler( working_dir: PathBuf::from(session_working_dir), schedule_id: request.scheduled_job_id.clone(), execution_mode: None, + max_turns: None, }), ) .await @@ -358,6 +359,7 @@ async fn ask_handler( working_dir: PathBuf::from(session_working_dir), schedule_id: request.scheduled_job_id.clone(), execution_mode: None, + max_turns: None, }), ) .await diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 4e6cee8c..f8d5217c 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -55,6 +55,8 @@ use super::subagent_manager::SubAgentManager; use super::subagent_tools; use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; +const DEFAULT_MAX_TURNS: u32 = 1000; + /// The main goose Agent pub struct Agent { pub(super) provider: Mutex>>, @@ -707,7 +709,23 @@ impl Agent { Ok(Box::pin(async_stream::try_stream! { let _ = reply_span.enter(); + let mut turns_taken = 0u32; + let max_turns = session + .as_ref() + .and_then(|s| s.max_turns) + .unwrap_or_else(|| { + config.get_param("GOOSE_MAX_TURNS").unwrap_or(DEFAULT_MAX_TURNS) + }); + loop { + turns_taken += 1; + if turns_taken > max_turns { + yield AgentEvent::Message(Message::assistant().with_text( + "I've reached the maximum number of actions I can do without user input. Would you like me to continue?" + )); + break; + } + // Check for MCP notifications from subagents let mcp_notifications = self.get_mcp_notifications().await; for notification in mcp_notifications { diff --git a/crates/goose/src/agents/types.rs b/crates/goose/src/agents/types.rs index 32c4b15e..41711cd0 100644 --- a/crates/goose/src/agents/types.rs +++ b/crates/goose/src/agents/types.rs @@ -26,4 +26,6 @@ pub struct SessionConfig { pub schedule_id: Option, /// Execution mode for scheduled jobs: "foreground" or "background" pub execution_mode: Option, + /// Maximum number of turns (iterations) allowed without user input + pub max_turns: Option, } diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index ab9629b2..64648722 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -1203,6 +1203,7 @@ async fn run_scheduled_job_internal( working_dir: current_dir.clone(), schedule_id: Some(job.id.clone()), execution_mode: job.execution_mode.clone(), + max_turns: None, }; match agent diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index f03473b0..3f7cff0c 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -638,3 +638,124 @@ mod final_output_tool_tests { Ok(()) } } + +#[cfg(test)] +mod max_turns_tests { + use super::*; + use async_trait::async_trait; + use goose::message::MessageContent; + use goose::model::ModelConfig; + use goose::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage}; + use goose::providers::errors::ProviderError; + use goose::session::storage::Identifier; + use mcp_core::tool::{Tool, ToolCall}; + use std::path::PathBuf; + + struct MockToolProvider {} + + impl MockToolProvider { + fn new() -> Self { + Self {} + } + } + + #[async_trait] + impl Provider for MockToolProvider { + async fn complete( + &self, + _system_prompt: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let tool_call = ToolCall::new("test_tool", serde_json::json!({"param": "value"})); + let message = Message::assistant().with_tool_request("call_123", Ok(tool_call)); + + let usage = ProviderUsage::new( + "mock-model".to_string(), + Usage::new(Some(10), Some(5), Some(15)), + ); + + Ok((message, usage)) + } + + fn get_model_config(&self) -> ModelConfig { + ModelConfig::new("mock-model".to_string()) + } + + fn metadata() -> ProviderMetadata { + ProviderMetadata { + name: "mock".to_string(), + display_name: "Mock Provider".to_string(), + description: "Mock provider for testing".to_string(), + default_model: "mock-model".to_string(), + known_models: vec![], + model_doc_link: "".to_string(), + config_keys: vec![], + } + } + } + + #[tokio::test] + async fn test_max_turns_limit() -> Result<()> { + let agent = Agent::new(); + let provider = Arc::new(MockToolProvider::new()); + agent.update_provider(provider).await?; + // The mock provider will call a non-existent tool, which will fail and allow the loop to continue + + // Create session config with max_turns = 1 + let session_config = goose::agents::SessionConfig { + id: Identifier::Name("test_session".to_string()), + working_dir: PathBuf::from("/tmp"), + schedule_id: None, + execution_mode: None, + max_turns: Some(1), + }; + let messages = vec![Message::user().with_text("Hello")]; + + let reply_stream = agent.reply(&messages, Some(session_config)).await?; + tokio::pin!(reply_stream); + + let mut responses = Vec::new(); + while let Some(response_result) = reply_stream.next().await { + match response_result { + Ok(AgentEvent::Message(response)) => { + if let Some(MessageContent::ToolConfirmationRequest(ref req)) = + response.content.first() + { + agent.handle_confirmation( + req.id.clone(), + goose::permission::PermissionConfirmation { + principal_type: goose::permission::permission_confirmation::PrincipalType::Tool, + permission: goose::permission::Permission::AllowOnce, + } + ).await; + } + responses.push(response); + } + Ok(AgentEvent::McpNotification(_)) => {} + Ok(AgentEvent::ModelChange { .. }) => {} + Err(e) => { + return Err(e); + } + } + } + + assert!( + responses.len() >= 1, + "Expected at least 1 response, got {}", + responses.len() + ); + + // Look for the max turns message as the last response + let last_response = responses.last().unwrap(); + let last_content = last_response.content.first().unwrap(); + if let MessageContent::Text(text_content) = last_content { + assert!(text_content.text.contains( + "I've reached the maximum number of actions I can do without user input" + )); + } else { + panic!("Expected text content in last message"); + } + Ok(()) + } +} diff --git a/documentation/docs/guides/goose-cli-commands.md b/documentation/docs/guides/goose-cli-commands.md index 4489b492..15fe0b78 100644 --- a/documentation/docs/guides/goose-cli-commands.md +++ b/documentation/docs/guides/goose-cli-commands.md @@ -132,6 +132,18 @@ goose configure goose session --name my-session --debug ``` +- Limit the maximum number of turns the agent can take before asking for user input to continue + + **Options:** + + **`--max-turns `** + + **Usage:** + + ```bash + goose session --max-turns 50 + ``` + --- ### session list [options] @@ -293,6 +305,7 @@ Execute commands from an instruction file or stdin. Check out the [full guide](/ - **`--debug`**: Output complete tool responses, detailed parameter values, and full file paths - **`--explain`**: Show a recipe's title, description, and parameters - **`--no-session`**: Run goose commands without creating or storing a session file +- **`--max-turns `**: Limit the maximum number of turns the agent can take before asking for user input to continue (default: 1000) **Usage:** @@ -319,6 +332,9 @@ goose run --recipe recipe.yaml --explain #Run instructions from a file without session storage goose run --no-session -i instructions.txt + +#Run with a limit of 25 turns before asking for user input +goose run --max-turns 25 -i plan.md ``` --- diff --git a/ui/desktop/src/components/settings/mode/ModeSection.tsx b/ui/desktop/src/components/settings/mode/ModeSection.tsx index 73d4197f..1f67e30b 100644 --- a/ui/desktop/src/components/settings/mode/ModeSection.tsx +++ b/ui/desktop/src/components/settings/mode/ModeSection.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react'; import { all_goose_modes, ModeSelectionItem } from './ModeSelectionItem'; import { View, ViewOptions } from '../../../App'; import { useConfig } from '../../ConfigContext'; +import { Input } from '../../ui/input'; interface ModeSectionProps { setView: (view: View, viewOptions?: ViewOptions) => void; @@ -9,6 +10,7 @@ interface ModeSectionProps { export const ModeSection = ({ setView }: ModeSectionProps) => { const [currentMode, setCurrentMode] = useState('auto'); + const [maxTurns, setMaxTurns] = useState(1000); const { read, upsert } = useConfig(); const handleModeChange = async (newMode: string) => { @@ -32,9 +34,30 @@ export const ModeSection = ({ setView }: ModeSectionProps) => { } }, [read]); + const fetchMaxTurns = useCallback(async () => { + try { + const turns = (await read('GOOSE_MAX_TURNS', false)) as number; + if (turns) { + setMaxTurns(turns); + } + } catch (error) { + console.error('Error fetching max turns:', error); + } + }, [read]); + + const handleMaxTurnsChange = async (value: number) => { + try { + await upsert('GOOSE_MAX_TURNS', value, false); + setMaxTurns(value); + } catch (error) { + console.error('Error updating max turns:', error); + } + }; + useEffect(() => { fetchCurrentMode(); - }, [fetchCurrentMode]); + fetchMaxTurns(); + }, [fetchCurrentMode, fetchMaxTurns]); return (
@@ -59,6 +82,24 @@ export const ModeSection = ({ setView }: ModeSectionProps) => { /> ))} +
+

Conversation Limits

+
+
+

Max Turns

+

+ Maximum agent turns before Goose asks for user input +

+
+ handleMaxTurnsChange(Number(e.target.value))} + className="w-20" + /> +
+
);