feat: Adds max_turns for the agent without user input (#3208)

This commit is contained in:
Jarrod Sibbison
2025-07-03 11:57:25 +10:00
committed by GitHub
parent cf818ada89
commit 2c86a0eb6e
13 changed files with 284 additions and 2 deletions

View File

@@ -312,6 +312,15 @@ enum Command {
)] )]
max_tool_repetitions: Option<u32>, max_tool_repetitions: Option<u32>,
/// 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<u32>,
/// Add stdio extensions with environment variables and commands /// Add stdio extensions with environment variables and commands
#[arg( #[arg(
long = "with-extension", long = "with-extension",
@@ -449,6 +458,15 @@ enum Command {
)] )]
max_tool_repetitions: Option<u32>, max_tool_repetitions: Option<u32>,
/// 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<u32>,
/// Identifier for this run session /// Identifier for this run session
#[command(flatten)] #[command(flatten)]
identifier: Option<Identifier>, identifier: Option<Identifier>,
@@ -635,6 +653,7 @@ pub async fn cli() -> Result<()> {
history, history,
debug, debug,
max_tool_repetitions, max_tool_repetitions,
max_turns,
extensions, extensions,
remote_extensions, remote_extensions,
builtins, builtins,
@@ -683,6 +702,7 @@ pub async fn cli() -> Result<()> {
settings: None, settings: None,
debug, debug,
max_tool_repetitions, max_tool_repetitions,
max_turns,
scheduled_job_id: None, scheduled_job_id: None,
interactive: true, interactive: true,
quiet: false, quiet: false,
@@ -731,6 +751,7 @@ pub async fn cli() -> Result<()> {
no_session, no_session,
debug, debug,
max_tool_repetitions, max_tool_repetitions,
max_turns,
extensions, extensions,
remote_extensions, remote_extensions,
builtins, builtins,
@@ -826,6 +847,7 @@ pub async fn cli() -> Result<()> {
settings: session_settings, settings: session_settings,
debug, debug,
max_tool_repetitions, max_tool_repetitions,
max_turns,
scheduled_job_id, scheduled_job_id,
interactive, // Use the interactive flag from the Run command interactive, // Use the interactive flag from the Run command
quiet, quiet,
@@ -950,6 +972,7 @@ pub async fn cli() -> Result<()> {
settings: None::<SessionSettings>, settings: None::<SessionSettings>,
debug: false, debug: false,
max_tool_repetitions: None, max_tool_repetitions: None,
max_turns: None,
scheduled_job_id: None, scheduled_job_id: None,
interactive: true, // Default case is always interactive interactive: true, // Default case is always interactive
quiet: false, quiet: false,

View File

@@ -45,6 +45,7 @@ pub async fn agent_generator(
max_tool_repetitions: None, max_tool_repetitions: None,
interactive: false, // Benchmarking is non-interactive interactive: false, // Benchmarking is non-interactive
scheduled_job_id: None, scheduled_job_id: None,
max_turns: None,
quiet: false, quiet: false,
sub_recipes: None, sub_recipes: None,
final_output_response: None, final_output_response: None,

View File

@@ -846,6 +846,11 @@ pub async fn configure_settings_dialog() -> Result<(), Box<dyn Error>> {
"Tool Output", "Tool Output",
"Show more or less tool output", "Show more or less tool output",
) )
.item(
"max_turns",
"Max Turns",
"Set maximum number of turns without user input",
)
.item( .item(
"experiment", "experiment",
"Toggle Experiment", "Toggle Experiment",
@@ -876,6 +881,9 @@ pub async fn configure_settings_dialog() -> Result<(), Box<dyn Error>> {
"tool_output" => { "tool_output" => {
configure_tool_output_dialog()?; configure_tool_output_dialog()?;
} }
"max_turns" => {
configure_max_turns_dialog()?;
}
"experiment" => { "experiment" => {
toggle_experiments_dialog()?; toggle_experiments_dialog()?;
} }
@@ -1289,3 +1297,35 @@ fn configure_scheduler_dialog() -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
pub fn configure_max_turns_dialog() -> Result<(), Box<dyn Error>> {
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(&current_max_turns.to_string())
.default_input(&current_max_turns.to_string())
.validate(|input: &String| match input.parse::<u32>() {
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(())
}

View File

@@ -483,6 +483,7 @@ async fn process_message_streaming(
working_dir: std::env::current_dir()?, working_dir: std::env::current_dir()?,
schedule_id: None, schedule_id: None,
execution_mode: None, execution_mode: None,
max_turns: None,
}; };
// Get response from agent // Get response from agent

View File

@@ -41,6 +41,8 @@ pub struct SessionBuilderConfig {
pub debug: bool, pub debug: bool,
/// Maximum number of consecutive identical tool calls allowed /// Maximum number of consecutive identical tool calls allowed
pub max_tool_repetitions: Option<u32>, pub max_tool_repetitions: Option<u32>,
/// Maximum number of turns (iterations) allowed without user input
pub max_turns: Option<u32>,
/// ID of the scheduled job that triggered this session (if any) /// ID of the scheduled job that triggered this session (if any)
pub scheduled_job_id: Option<String>, pub scheduled_job_id: Option<String>,
/// Whether this session will be used interactively (affects debugging prompts) /// 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)); std::env::temp_dir().join(format!("goose_debug_extension_{}.jsonl", extension_name));
// Create the debugging session // 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 // Process the debugging request
println!("{}", style("Analyzing the extension failure...").yellow()); println!("{}", style("Analyzing the extension failure...").yellow());
@@ -367,6 +375,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session {
session_file.clone(), session_file.clone(),
session_config.debug, session_config.debug,
session_config.scheduled_job_id.clone(), session_config.scheduled_job_id.clone(),
session_config.max_turns,
); );
// Add extensions if provided // Add extensions if provided
@@ -516,6 +525,7 @@ mod tests {
settings: None, settings: None,
debug: true, debug: true,
max_tool_repetitions: Some(5), max_tool_repetitions: Some(5),
max_turns: None,
scheduled_job_id: None, scheduled_job_id: None,
interactive: true, interactive: true,
quiet: false, quiet: false,
@@ -528,6 +538,7 @@ mod tests {
assert_eq!(config.builtins.len(), 1); assert_eq!(config.builtins.len(), 1);
assert!(config.debug); assert!(config.debug);
assert_eq!(config.max_tool_repetitions, Some(5)); assert_eq!(config.max_tool_repetitions, Some(5));
assert!(config.max_turns.is_none());
assert!(config.scheduled_job_id.is_none()); assert!(config.scheduled_job_id.is_none());
assert!(config.interactive); assert!(config.interactive);
assert!(!config.quiet); assert!(!config.quiet);
@@ -547,6 +558,7 @@ mod tests {
assert!(config.additional_system_prompt.is_none()); assert!(config.additional_system_prompt.is_none());
assert!(!config.debug); assert!(!config.debug);
assert!(config.max_tool_repetitions.is_none()); assert!(config.max_tool_repetitions.is_none());
assert!(config.max_turns.is_none());
assert!(config.scheduled_job_id.is_none()); assert!(config.scheduled_job_id.is_none());
assert!(!config.interactive); assert!(!config.interactive);
assert!(!config.quiet); assert!(!config.quiet);

View File

@@ -52,6 +52,7 @@ pub struct Session {
debug: bool, // New field for debug mode debug: bool, // New field for debug mode
run_mode: RunMode, run_mode: RunMode,
scheduled_job_id: Option<String>, // ID of the scheduled job that triggered this session scheduled_job_id: Option<String>, // ID of the scheduled job that triggered this session
max_turns: Option<u32>,
} }
// Cache structure for completion data // Cache structure for completion data
@@ -113,6 +114,7 @@ impl Session {
session_file: Option<PathBuf>, session_file: Option<PathBuf>,
debug: bool, debug: bool,
scheduled_job_id: Option<String>, scheduled_job_id: Option<String>,
max_turns: Option<u32>,
) -> Self { ) -> Self {
let messages = if let Some(session_file) = &session_file { let messages = if let Some(session_file) = &session_file {
match session::read_messages(session_file) { match session::read_messages(session_file) {
@@ -135,6 +137,7 @@ impl Session {
debug, debug,
run_mode: RunMode::Normal, run_mode: RunMode::Normal,
scheduled_job_id, scheduled_job_id,
max_turns,
} }
} }
@@ -757,6 +760,7 @@ impl Session {
.expect("failed to get current session working directory"), .expect("failed to get current session working directory"),
schedule_id: self.scheduled_job_id.clone(), schedule_id: self.scheduled_job_id.clone(),
execution_mode: None, execution_mode: None,
max_turns: self.max_turns,
} }
}); });
let mut stream = self let mut stream = self

View File

@@ -184,6 +184,7 @@ async fn handler(
working_dir: PathBuf::from(session_working_dir), working_dir: PathBuf::from(session_working_dir),
schedule_id: request.scheduled_job_id.clone(), schedule_id: request.scheduled_job_id.clone(),
execution_mode: None, execution_mode: None,
max_turns: None,
}), }),
) )
.await .await
@@ -358,6 +359,7 @@ async fn ask_handler(
working_dir: PathBuf::from(session_working_dir), working_dir: PathBuf::from(session_working_dir),
schedule_id: request.scheduled_job_id.clone(), schedule_id: request.scheduled_job_id.clone(),
execution_mode: None, execution_mode: None,
max_turns: None,
}), }),
) )
.await .await

View File

@@ -55,6 +55,8 @@ use super::subagent_manager::SubAgentManager;
use super::subagent_tools; use super::subagent_tools;
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
const DEFAULT_MAX_TURNS: u32 = 1000;
/// The main goose Agent /// The main goose Agent
pub struct Agent { pub struct Agent {
pub(super) provider: Mutex<Option<Arc<dyn Provider>>>, pub(super) provider: Mutex<Option<Arc<dyn Provider>>>,
@@ -707,7 +709,23 @@ impl Agent {
Ok(Box::pin(async_stream::try_stream! { Ok(Box::pin(async_stream::try_stream! {
let _ = reply_span.enter(); 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 { 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 // Check for MCP notifications from subagents
let mcp_notifications = self.get_mcp_notifications().await; let mcp_notifications = self.get_mcp_notifications().await;
for notification in mcp_notifications { for notification in mcp_notifications {

View File

@@ -26,4 +26,6 @@ pub struct SessionConfig {
pub schedule_id: Option<String>, pub schedule_id: Option<String>,
/// Execution mode for scheduled jobs: "foreground" or "background" /// Execution mode for scheduled jobs: "foreground" or "background"
pub execution_mode: Option<String>, pub execution_mode: Option<String>,
/// Maximum number of turns (iterations) allowed without user input
pub max_turns: Option<u32>,
} }

View File

@@ -1203,6 +1203,7 @@ async fn run_scheduled_job_internal(
working_dir: current_dir.clone(), working_dir: current_dir.clone(),
schedule_id: Some(job.id.clone()), schedule_id: Some(job.id.clone()),
execution_mode: job.execution_mode.clone(), execution_mode: job.execution_mode.clone(),
max_turns: None,
}; };
match agent match agent

View File

@@ -638,3 +638,124 @@ mod final_output_tool_tests {
Ok(()) 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(())
}
}

View File

@@ -132,6 +132,18 @@ goose configure
goose session --name my-session --debug 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 <NUMBER>`**
**Usage:**
```bash
goose session --max-turns 50
```
--- ---
### session list [options] ### 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 - **`--debug`**: Output complete tool responses, detailed parameter values, and full file paths
- **`--explain`**: Show a recipe's title, description, and parameters - **`--explain`**: Show a recipe's title, description, and parameters
- **`--no-session`**: Run goose commands without creating or storing a session file - **`--no-session`**: Run goose commands without creating or storing a session file
- **`--max-turns <NUMBER>`**: Limit the maximum number of turns the agent can take before asking for user input to continue (default: 1000)
**Usage:** **Usage:**
@@ -319,6 +332,9 @@ goose run --recipe recipe.yaml --explain
#Run instructions from a file without session storage #Run instructions from a file without session storage
goose run --no-session -i instructions.txt 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
``` ```
--- ---

View File

@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
import { all_goose_modes, ModeSelectionItem } from './ModeSelectionItem'; import { all_goose_modes, ModeSelectionItem } from './ModeSelectionItem';
import { View, ViewOptions } from '../../../App'; import { View, ViewOptions } from '../../../App';
import { useConfig } from '../../ConfigContext'; import { useConfig } from '../../ConfigContext';
import { Input } from '../../ui/input';
interface ModeSectionProps { interface ModeSectionProps {
setView: (view: View, viewOptions?: ViewOptions) => void; setView: (view: View, viewOptions?: ViewOptions) => void;
@@ -9,6 +10,7 @@ interface ModeSectionProps {
export const ModeSection = ({ setView }: ModeSectionProps) => { export const ModeSection = ({ setView }: ModeSectionProps) => {
const [currentMode, setCurrentMode] = useState('auto'); const [currentMode, setCurrentMode] = useState('auto');
const [maxTurns, setMaxTurns] = useState<number>(1000);
const { read, upsert } = useConfig(); const { read, upsert } = useConfig();
const handleModeChange = async (newMode: string) => { const handleModeChange = async (newMode: string) => {
@@ -32,9 +34,30 @@ export const ModeSection = ({ setView }: ModeSectionProps) => {
} }
}, [read]); }, [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(() => { useEffect(() => {
fetchCurrentMode(); fetchCurrentMode();
}, [fetchCurrentMode]); fetchMaxTurns();
}, [fetchCurrentMode, fetchMaxTurns]);
return ( return (
<section id="mode" className="px-8"> <section id="mode" className="px-8">
@@ -59,6 +82,24 @@ export const ModeSection = ({ setView }: ModeSectionProps) => {
/> />
))} ))}
</div> </div>
<div className="mt-6 pt-6">
<h3 className="text-textStandard mb-4">Conversation Limits</h3>
<div className="flex items-center justify-between py-2 px-4">
<div>
<h4 className="text-textStandard">Max Turns</h4>
<p className="text-xs text-textSubtle mt-[2px]">
Maximum agent turns before Goose asks for user input
</p>
</div>
<Input
type="number"
min="1"
value={maxTurns}
onChange={(e) => handleMaxTurnsChange(Number(e.target.value))}
className="w-20"
/>
</div>
</div>
</div> </div>
</section> </section>
); );