Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/apps/cli/src/agent/agentic_system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgenticSystem> {
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<usize>,
) -> Result<AgenticSystem> {
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")
}
55 changes: 47 additions & 8 deletions src/apps/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -404,6 +409,7 @@ async fn initialize_core_services(
skip_tool_confirmation: bool,
suppress_title_generation: bool,
disable_persistence: bool,
max_rounds_override: Option<usize>,
) -> Result<(agent::agentic_system::AgenticSystem, bool, bool)> {
use bitfun_core::infrastructure::ai::AIClientFactory;

Expand Down Expand Up @@ -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")
};
Expand Down Expand Up @@ -544,6 +550,7 @@ async fn run_interactive(
_config: CliConfig,
default_agent: String,
_workspace_str: String,
max_rounds_override: Option<usize>,
) -> Result<()> {
use ui::startup::{StartupPage, StartupResult};

Expand All @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -681,14 +688,15 @@ async fn run_cli() -> Result<()> {
confirm,
no_title,
no_persist,
max_rounds: cli.max_rounds,
},
)
.await?;
}

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?;
}
}

Expand Down Expand Up @@ -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<usize>,
) -> 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)
Expand Down Expand Up @@ -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);
}
}
13 changes: 10 additions & 3 deletions src/apps/cli/src/root_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,
}

pub async fn handle_exec_command(config: CliConfig, args: ExecCommandArgs) -> Result<()> {
Expand Down Expand Up @@ -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(),
Expand Down
73 changes: 72 additions & 1 deletion src/crates/assembly/core/src/agentic/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>) -> 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<usize>,
) -> 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 {
Expand All @@ -32,6 +66,17 @@ pub async fn init_agentic_system() -> Result<AgenticSystem> {
/// Initialize the agentic runtime with a custom session manager configuration.
pub async fn init_agentic_system_with_config(
session_config: session::SessionManagerConfig,
) -> Result<AgenticSystem> {
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<usize>,
) -> Result<AgenticSystem> {
info!("Initializing agentic system");

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
}
}
3 changes: 2 additions & 1 deletion src/crates/assembly/core/src/service/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down