mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-17 22:24:21 +01:00
feat: Adds max_turns for the agent without user input (#3208)
This commit is contained in:
@@ -312,6 +312,15 @@ enum Command {
|
||||
)]
|
||||
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
|
||||
#[arg(
|
||||
long = "with-extension",
|
||||
@@ -449,6 +458,15 @@ enum Command {
|
||||
)]
|
||||
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
|
||||
#[command(flatten)]
|
||||
identifier: Option<Identifier>,
|
||||
@@ -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::<SessionSettings>,
|
||||
debug: false,
|
||||
max_tool_repetitions: None,
|
||||
max_turns: None,
|
||||
scheduled_job_id: None,
|
||||
interactive: true, // Default case is always interactive
|
||||
quiet: false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -846,6 +846,11 @@ pub async fn configure_settings_dialog() -> Result<(), Box<dyn Error>> {
|
||||
"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<dyn Error>> {
|
||||
"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<dyn Error>> {
|
||||
|
||||
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(¤t_max_turns.to_string())
|
||||
.default_input(¤t_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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,8 @@ pub struct SessionBuilderConfig {
|
||||
pub debug: bool,
|
||||
/// Maximum number of consecutive identical tool calls allowed
|
||||
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)
|
||||
pub scheduled_job_id: Option<String>,
|
||||
/// 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);
|
||||
|
||||
@@ -52,6 +52,7 @@ pub struct Session {
|
||||
debug: bool, // New field for debug mode
|
||||
run_mode: RunMode,
|
||||
scheduled_job_id: Option<String>, // ID of the scheduled job that triggered this session
|
||||
max_turns: Option<u32>,
|
||||
}
|
||||
|
||||
// Cache structure for completion data
|
||||
@@ -113,6 +114,7 @@ impl Session {
|
||||
session_file: Option<PathBuf>,
|
||||
debug: bool,
|
||||
scheduled_job_id: Option<String>,
|
||||
max_turns: Option<u32>,
|
||||
) -> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Option<Arc<dyn Provider>>>,
|
||||
@@ -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 {
|
||||
|
||||
@@ -26,4 +26,6 @@ pub struct SessionConfig {
|
||||
pub schedule_id: Option<String>,
|
||||
/// Execution mode for scheduled jobs: "foreground" or "background"
|
||||
pub execution_mode: Option<String>,
|
||||
/// Maximum number of turns (iterations) allowed without user input
|
||||
pub max_turns: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <NUMBER>`**
|
||||
|
||||
**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 <NUMBER>`**: 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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<number>(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 (
|
||||
<section id="mode" className="px-8">
|
||||
@@ -59,6 +82,24 @@ export const ModeSection = ({ setView }: ModeSectionProps) => {
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user