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
+18
View File
@@ -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 {
+2
View File
@@ -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>,
}
+1
View File
@@ -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
+121
View File
@@ -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(())
}
}