From 99463f63844544f9fd989c8e5b54b1a6faf7936a Mon Sep 17 00:00:00 2001 From: arroghie Date: Mon, 10 Aug 2026 22:55:55 +0800 Subject: [PATCH] feat: make the per-dialog-turn round limit configurable (0 = unlimited) The CodeAgent execution loop was hard-coded to ExecutionEngineConfig::default() (DEFAULT_MAX_ROUNDS=200) at agentic system assembly; AIConfig.max_rounds existed but was never wired, so config changes had no effect. - core: resolve the AI config when assembling the engine and build the engine config from ai.max_rounds (min 1); accept 'max_turns' as a serde alias so both names configure the same limit. Add init_agentic_system_with_options(session_config, max_rounds_override) where Some(0) means unlimited (usize::MAX) and Some(n) pins the limit. - cli: add a global --max-rounds flag threaded through initialize_core_services into exec / interactive / session-resume startup; 0 = unlimited. - tests: resolve_max_rounds unit tests + clap parsing tests (before/after subcommand, zero, absent). --- src/apps/cli/src/agent/agentic_system.rs | 21 +++++- src/apps/cli/src/main.rs | 55 ++++++++++++-- src/apps/cli/src/root_handlers.rs | 13 +++- .../assembly/core/src/agentic/system.rs | 73 ++++++++++++++++++- .../assembly/core/src/service/config/types.rs | 3 +- 5 files changed, 148 insertions(+), 17 deletions(-) diff --git a/src/apps/cli/src/agent/agentic_system.rs b/src/apps/cli/src/agent/agentic_system.rs index d23897a73c..24e7ef0ca9 100644 --- a/src/apps/cli/src/agent/agentic_system.rs +++ b/src/apps/cli/src/agent/agentic_system.rs @@ -4,17 +4,30 @@ use bitfun_core::infrastructure::ai::AIClientFactory; use bitfun_core::service::config::initialize_global_config; pub use bitfun_core::agentic::system::{ - init_agentic_system, init_agentic_system_with_config, AgenticSystem, + init_agentic_system, init_agentic_system_with_config, init_agentic_system_with_options, + AgenticSystem, }; pub async fn init_agentic_system_for_cli() -> Result { + init_agentic_system_for_cli_with_options(None).await +} + +/// Initialize the agentic system for the CLI with an optional per-dialog-turn +/// round-limit override (`0` = unlimited). The override wins over the +/// configured `ai.max_rounds` / `ai.max_turns` value. +pub async fn init_agentic_system_for_cli_with_options( + max_rounds_override: Option, +) -> Result { initialize_global_config() .await .context("Failed to initialize global config service")?; AIClientFactory::initialize_global() .await .context("Failed to initialize global AIClientFactory")?; - init_agentic_system() - .await - .context("Failed to initialize agentic system") + init_agentic_system_with_options( + bitfun_core::agentic::session::SessionManagerConfig::default(), + max_rounds_override, + ) + .await + .context("Failed to initialize agentic system") } diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 7491c003fd..4a95e1c627 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -87,6 +87,11 @@ struct Cli { /// Disable file logging (stderr logging will still be used) #[arg(long, global = true)] no_log_file: bool, + + /// Override the per-dialog-turn round limit (overrides ai.max_rounds / + /// ai.max_turns config). 0 = unlimited. + #[arg(long, global = true)] + max_rounds: Option, } #[derive(Subcommand)] @@ -404,6 +409,7 @@ async fn initialize_core_services( skip_tool_confirmation: bool, suppress_title_generation: bool, disable_persistence: bool, + max_rounds_override: Option, ) -> Result<(agent::agentic_system::AgenticSystem, bool, bool)> { use bitfun_core::infrastructure::ai::AIClientFactory; @@ -458,11 +464,11 @@ async fn initialize_core_services( enable_persistence: false, ..Default::default() }; - agent::agentic_system::init_agentic_system_with_config(session_config) + agent::agentic_system::init_agentic_system_with_options(session_config, max_rounds_override) .await .expect("Failed to initialize agentic system") } else { - agent::agentic_system::init_agentic_system() + agent::agentic_system::init_agentic_system_for_cli_with_options(max_rounds_override) .await .expect("Failed to initialize agentic system") }; @@ -544,6 +550,7 @@ async fn run_interactive( _config: CliConfig, default_agent: String, _workspace_str: String, + max_rounds_override: Option, ) -> Result<()> { use ui::startup::{StartupPage, StartupResult}; @@ -556,7 +563,7 @@ async fn run_interactive( // 3. Initialize core services let (agentic_system, original_skip_confirmation, original_title_generation) = - initialize_core_services(true, false, false).await?; + initialize_core_services(true, false, false, max_rounds_override).await?; // 4. Show startup page (with full command support) let mut startup_page = StartupPage::new( @@ -643,7 +650,7 @@ async fn run_cli() -> Result<()> { match cli.command { Some(Commands::Chat { agent }) => { // Interactive mode with startup page, scoped to the current directory. - run_interactive(config, agent, ".".to_string()).await?; + run_interactive(config, agent, ".".to_string(), cli.max_rounds).await?; } Some(Commands::Exec { @@ -681,6 +688,7 @@ async fn run_cli() -> Result<()> { confirm, no_title, no_persist, + max_rounds: cli.max_rounds, }, ) .await?; @@ -688,7 +696,7 @@ async fn run_cli() -> Result<()> { Some(Commands::Sessions { action }) => { if let Some(session_id) = root_handlers::handle_session_action(action).await? { - run_interactive_with_session(config, session_id).await?; + run_interactive_with_session(config, session_id, cli.max_rounds).await?; } } @@ -802,20 +810,24 @@ async fn run_cli() -> Result<()> { let workspace_str = ".".to_string(); let default_agent = config.behavior.default_agent.clone(); - run_interactive(config, default_agent, workspace_str).await?; + run_interactive(config, default_agent, workspace_str, cli.max_rounds).await?; } } Ok(()) } -async fn run_interactive_with_session(config: CliConfig, session_id: String) -> Result<()> { +async fn run_interactive_with_session( + config: CliConfig, + session_id: String, + max_rounds_override: Option, +) -> Result<()> { let mut terminal = ui::init_terminal()?; ui::render_loading(&mut terminal, "Initializing system, please wait...")?; let workspace = setup_workspace(); let (agentic_system, original_skip_confirmation, original_title_generation) = - initialize_core_services(true, false, false).await?; + initialize_core_services(true, false, false, max_rounds_override).await?; let workspace_path = workspace .clone() .map(PathBuf::from) @@ -901,4 +913,31 @@ mod cli_tests { parse_exec_verification(&["bitfun", "exec", "--no-verify-final-changes", "task"]); assert!(!final_change_verification_enabled(verify, no_verify)); } + + #[test] + fn max_rounds_parses_after_subcommand() { + let cli = Cli::try_parse_from(&["bitfun", "exec", "task", "--max-rounds", "500"]) + .expect("CLI arguments should parse"); + assert_eq!(cli.max_rounds, Some(500)); + } + + #[test] + fn max_rounds_zero_parses_as_unlimited() { + let cli = Cli::try_parse_from(&["bitfun", "exec", "task", "--max-rounds", "0"]) + .expect("CLI arguments should parse"); + assert_eq!(cli.max_rounds, Some(0)); + } + + #[test] + fn max_rounds_parses_before_subcommand() { + let cli = Cli::try_parse_from(&["bitfun", "--max-rounds", "0", "exec", "task"]) + .expect("CLI arguments should parse"); + assert_eq!(cli.max_rounds, Some(0)); + } + + #[test] + fn max_rounds_absent_is_none() { + let cli = Cli::try_parse_from(&["bitfun", "exec", "task"]).expect("CLI arguments should parse"); + assert_eq!(cli.max_rounds, None); + } } diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 5cabf595aa..dc25944369 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -25,6 +25,8 @@ pub struct ExecCommandArgs { pub confirm: bool, pub no_title: bool, pub no_persist: bool, + /// Per-dialog-turn round-limit override (`0` = unlimited). + pub max_rounds: Option, } pub async fn handle_exec_command(config: CliConfig, args: ExecCommandArgs) -> Result<()> { @@ -56,9 +58,14 @@ pub async fn handle_exec_command(config: CliConfig, args: ExecCommandArgs) -> Re let skip_confirmation = !args.confirm; let (agentic_system, original_skip_confirmation, original_title_generation) = - crate::initialize_core_services(skip_confirmation, args.no_title, args.no_persist) - .await - .map_err(|error| { + crate::initialize_core_services( + skip_confirmation, + args.no_title, + args.no_persist, + args.max_rounds, + ) + .await + .map_err(|error| { emit_exit_diagnostic( ExitKind::ExecError, &error.to_string(), diff --git a/src/crates/assembly/core/src/agentic/system.rs b/src/crates/assembly/core/src/agentic/system.rs index f270b5d016..2b3f47c309 100644 --- a/src/crates/assembly/core/src/agentic/system.rs +++ b/src/crates/assembly/core/src/agentic/system.rs @@ -14,8 +14,42 @@ use crate::agentic::session; use crate::agentic::tools; use crate::infrastructure::ai::AIClientFactory; use crate::infrastructure::try_get_path_manager_arc; +use crate::service::config::get_global_config_service; +use crate::service::config::types::AIConfig; use crate::service::token_usage::{TokenUsageService, TokenUsageSubscriber}; +/// Resolve the effective per-dialog-turn round limit. +/// +/// An explicit override wins over the configured value: `Some(0)` means +/// unlimited (`usize::MAX`), `Some(n)` pins the limit to `n`. Without an +/// override the configured value is used (at least 1). +fn resolve_max_rounds(configured: usize, max_rounds_override: Option) -> usize { + match max_rounds_override { + Some(0) => usize::MAX, // unlimited + Some(n) => n, + None => configured.max(1), + } +} + +/// Resolve the per-dialog-turn round limit (`max_rounds`, configurable as +/// `max_turns` in the AI config) into an execution engine configuration. +/// +/// An explicit override wins over the configured value: `Some(0)` means +/// unlimited (`usize::MAX`), `Some(n)` pins the limit to `n`. Falls back to +/// the built-in default when the config service is unavailable. +async fn resolve_execution_engine_config( + max_rounds_override: Option, +) -> execution::ExecutionEngineConfig { + let ai_config: AIConfig = match get_global_config_service().await { + Ok(service) => service.get_config(Some("ai")).await.unwrap_or_default(), + Err(_) => AIConfig::default(), + }; + execution::ExecutionEngineConfig { + max_rounds: resolve_max_rounds(ai_config.max_rounds, max_rounds_override), + ..execution::ExecutionEngineConfig::default() + } +} + /// Agentic runtime state shared by host adapters. #[derive(Clone)] pub struct AgenticSystem { @@ -32,6 +66,17 @@ pub async fn init_agentic_system() -> Result { /// Initialize the agentic runtime with a custom session manager configuration. pub async fn init_agentic_system_with_config( session_config: session::SessionManagerConfig, +) -> Result { + init_agentic_system_with_options(session_config, None).await +} + +/// Initialize the agentic runtime with a custom session manager configuration +/// and an explicit per-dialog-turn round-limit override (`0` = unlimited). +/// The override takes precedence over the configured `ai.max_rounds` / +/// `ai.max_turns` value. +pub async fn init_agentic_system_with_options( + session_config: session::SessionManagerConfig, + max_rounds_override: Option, ) -> Result { info!("Initializing agentic system"); @@ -79,7 +124,7 @@ pub async fn init_agentic_system_with_config( event_queue.clone(), session_manager.clone(), context_compressor, - execution::ExecutionEngineConfig::default(), + resolve_execution_engine_config(max_rounds_override).await, )); let coordinator = Arc::new(coordination::ConversationCoordinator::new( @@ -118,3 +163,29 @@ pub async fn init_agentic_system_with_config( token_usage_service, }) } + +#[cfg(test)] +mod tests { + use super::resolve_max_rounds; + + #[test] + fn max_rounds_override_pins_exact_value() { + assert_eq!(resolve_max_rounds(200, Some(500)), 500); + assert_eq!(resolve_max_rounds(200, Some(1)), 1); + assert_eq!(resolve_max_rounds(200, Some(10_000)), 10_000); + } + + #[test] + fn max_rounds_override_zero_means_unlimited() { + assert_eq!(resolve_max_rounds(200, Some(0)), usize::MAX); + assert_eq!(resolve_max_rounds(50, Some(0)), usize::MAX); + } + + #[test] + fn max_rounds_without_override_uses_configured_value() { + assert_eq!(resolve_max_rounds(200, None), 200); + assert_eq!(resolve_max_rounds(500, None), 500); + // Configured 0 is clamped to 1 so the loop always makes progress. + assert_eq!(resolve_max_rounds(0, None), 1); + } +} diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 612f8bf1a7..8f20e51568 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -628,7 +628,8 @@ pub struct AIConfig { pub browser_control_preferred_browser: String, /// Maximum number of rounds per dialog turn before soft-pausing. - #[serde(default = "default_max_rounds")] + /// `max_turns` is accepted as an alias in config files. + #[serde(default = "default_max_rounds", alias = "max_turns")] pub max_rounds: usize, }