Add `--max-turns` CLI option

Resolved 💬 4 comments Opened Feb 20, 2026 by 0kenx Closed Feb 21, 2026
💡 Likely answer: A maintainer (etraut-openai, contributor) responded on this thread — see the highlighted reply below.

What variant of Codex are you using?

CLI

What feature would you like to see?

--max-turns
Limit the number of agentic turns. Exits with an error when the limit is reached. No limit by default.

Additional information

_No response_

View original on GitHub ↗

4 Comments

0kenx · 6 months ago

patch

diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index a384ffc..d5bf1a4 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -1772,6 +1772,18 @@ impl Session {
         state.set_previous_model(previous_model);
     }
 
+    /// Check if the session has reached the maximum number of turns.
+    pub(crate) async fn has_reached_max_turns(&self, max_turns: Option<u32>) -> bool {
+        let state = self.state.lock().await;
+        state.has_reached_max_turns(max_turns)
+    }
+
+    /// Increment the turn counter after a turn completes.
+    pub(crate) async fn increment_turn_count(&self) {
+        let mut state = self.state.lock().await;
+        state.increment_turn_count();
+    }
+
     fn maybe_refresh_shell_snapshot_for_cwd(
         &self,
         previous_cwd: &Path,
diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs
index a27508d..2fddf38 100644
--- a/codex-rs/core/src/config/mod.rs
+++ b/codex-rs/core/src/config/mod.rs
@@ -324,6 +324,10 @@ pub struct Config {
     /// Maximum nesting depth allowed for spawned agent threads.
     pub agent_max_depth: i32,
 
+    /// Maximum number of agentic turns before stopping.
+    /// When unset, no limit is enforced.
+    pub agent_max_turns: Option<u32>,
+
     /// User-defined role declarations keyed by role name.
     pub agent_roles: BTreeMap<String, AgentRoleConfig>,
 
@@ -1247,6 +1251,11 @@ pub struct AgentsToml {
     #[schemars(range(min = 1))]
     pub max_depth: Option<i32>,
 
+    /// Maximum number of agentic turns before stopping.
+    /// When unset, no limit is enforced.
+    #[schemars(range(min = 1))]
+    pub max_turns: Option<u32>,
+
     /// User-defined role declarations keyed by role name.
     ///
     /// Example:
@@ -1451,6 +1460,8 @@ pub struct ConfigOverrides {
     pub ephemeral: Option<bool>,
     /// Additional directories that should be treated as writable roots for this session.
     pub additional_writable_roots: Vec<PathBuf>,
+    /// Maximum number of agentic turns before stopping.
+    pub agent_max_turns: Option<u32>,
 }
 
 /// Resolves the OSS provider from CLI override, profile config, or global config.
@@ -1579,6 +1590,7 @@ impl Config {
             tools_web_search_request: override_tools_web_search_request,
             ephemeral,
             additional_writable_roots,
+            agent_max_turns: override_agent_max_turns,
         } = overrides;
 
         let active_profile_name = config_profile_key
@@ -1735,6 +1747,19 @@ impl Config {
                 "agents.max_depth must be at least 1",
             ));
         }
+        let agent_max_turns = override_agent_max_turns.or_else(|| {
+            cfg.agents
+                .as_ref()
+                .and_then(|agents| agents.max_turns)
+        });
+        if let Some(max_turns) = agent_max_turns {
+            if max_turns < 1 {
+                return Err(std::io::Error::new(
+                    std::io::ErrorKind::InvalidInput,
+                    "agents.max_turns must be at least 1",
+                ));
+            }
+        }
         let agent_roles = cfg
             .agents
             .as_ref()
@@ -1990,6 +2015,7 @@ impl Config {
             tool_output_token_limit: cfg.tool_output_token_limit,
             agent_max_threads,
             agent_max_depth,
+            agent_max_turns,
             agent_roles,
             memories: cfg.memories.unwrap_or_default().into(),
             codex_home,
diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs
index 76c365a..7f121aa 100644
--- a/codex-rs/core/src/state/session.rs
+++ b/codex-rs/core/src/state/session.rs
@@ -32,6 +32,8 @@ pub(crate) struct SessionState {
     pub(crate) startup_regular_task: Option<RegularTask>,
     pub(crate) active_mcp_tool_selection: Option<Vec<String>>,
     pub(crate) active_connector_selection: HashSet<String>,
+    /// Number of turns completed in this session.
+    turn_count: u32,
 }
 
 impl SessionState {
@@ -50,9 +52,28 @@ impl SessionState {
             startup_regular_task: None,
             active_mcp_tool_selection: None,
             active_connector_selection: HashSet::new(),
+            turn_count: 0,
         }
     }
 
+    /// Check if the session has reached the maximum number of turns.
+    pub(crate) fn has_reached_max_turns(&self, max_turns: Option<u32>) -> bool {
+        match max_turns {
+            Some(max) => self.turn_count >= max,
+            None => false,
+        }
+    }
+
+    /// Increment the turn counter after a turn completes.
+    pub(crate) fn increment_turn_count(&mut self) {
+        self.turn_count += 1;
+    }
+
+    /// Get the current turn count.
+    pub(crate) fn turn_count(&self) -> u32 {
+        self.turn_count
+    }
+
     // History helpers
     pub(crate) fn record_items<I>(&mut self, items: I, policy: TruncationPolicy)
     where
diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs
index eb04619..6bf58fb 100644
--- a/codex-rs/core/src/tasks/mod.rs
+++ b/codex-rs/core/src/tasks/mod.rs
@@ -121,6 +121,21 @@ impl Session {
     ) {
         self.abort_all_tasks(TurnAbortReason::Replaced).await;
         self.clear_connector_selection().await;
+
+        // Check if max turns limit has been reached before starting a new turn.
+        let max_turns = turn_context.config.agent_max_turns;
+        if self.has_reached_max_turns(max_turns).await {
+            self.send_event(
+                turn_context.as_ref(),
+                EventMsg::TurnAborted(TurnAbortedEvent {
+                    turn_id: None,
+                    reason: TurnAbortReason::MaxTurnsReached,
+                }),
+            )
+            .await;
+            return;
+        }
+
         self.seed_initial_context_if_needed(turn_context.as_ref())
             .await;
 
@@ -222,6 +237,8 @@ impl Session {
             self.record_conversation_items(turn_context.as_ref(), &pending_response_items)
                 .await;
         }
+        // Increment turn counter after a successful turn completion.
+        self.increment_turn_count().await;
         let event = EventMsg::TurnComplete(TurnCompleteEvent {
             turn_id: turn_context.sub_id.clone(),
             last_agent_message,
diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs
index 6583c19..30ac6c4 100644
--- a/codex-rs/exec/src/event_processor_with_human_output.rs
+++ b/codex-rs/exec/src/event_processor_with_human_output.rs
@@ -607,6 +607,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
                     TurnAbortReason::ReviewEnded => {
                         ts_msg!(self, "task aborted: review ended");
                     }
+                    TurnAbortReason::MaxTurnsReached => {
+                        ts_msg!(self, "task aborted: max turns reached");
+                    }
                 }
                 return CodexStatus::InitiateShutdown;
             }
diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs
index 70ddb70..a2b4437 100644
--- a/codex-rs/exec/src/lib.rs
+++ b/codex-rs/exec/src/lib.rs
@@ -263,6 +263,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
         tools_web_search_request: None,
         ephemeral: ephemeral.then_some(true),
         additional_writable_roots: add_dir,
+        agent_max_turns: None,
     };
 
     let config = ConfigBuilder::default()
diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs
index 5b3d418..0165567 100644
--- a/codex-rs/protocol/src/protocol.rs
+++ b/codex-rs/protocol/src/protocol.rs
@@ -2734,6 +2734,7 @@ pub enum TurnAbortReason {
     Interrupted,
     Replaced,
     ReviewEnded,
+    MaxTurnsReached,
 }
 
 #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 7dcab80..49d27c1 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -4048,6 +4048,9 @@ impl ChatWidget {
                 TurnAbortReason::ReviewEnded => {
                     self.on_interrupted_turn(ev.reason);
                 }
+                TurnAbortReason::MaxTurnsReached => {
+                    self.on_interrupted_turn(ev.reason);
+                }
             },
             EventMsg::PlanUpdate(update) => self.on_plan_update(update),
             EventMsg::ExecApprovalRequest(ev) => {
diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs
index 86bea97..2c1e23c 100644
--- a/codex-rs/tui/src/cli.rs
+++ b/codex-rs/tui/src/cli.rs
@@ -110,6 +110,10 @@ pub struct Cli {
     #[arg(long = "no-alt-screen", default_value_t = false)]
     pub no_alt_screen: bool,
 
+    /// Maximum number of agentic turns before stopping.
+    #[arg(long = "max-turns", value_name = "COUNT")]
+    pub max_turns: Option<u32>,
+
     #[clap(skip)]
     pub config_overrides: CliConfigOverrides,
 }
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index 20c9fc2..03562e4 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -282,6 +282,7 @@ pub async fn run_main(
         codex_linux_sandbox_exe,
         show_raw_agent_reasoning: cli.oss.then_some(true),
         additional_writable_roots: additional_dirs,
+        agent_max_turns: cli.max_turns,
         ..Default::default()
     };
 
etraut-openai contributor · 6 months ago

Can you explain your use case in more detail? You're proposing a solution to a problem, but it would be useful to understand the problem better.

0kenx · 6 months ago

Instead of relying on the LLM model to generate its own stop signal, I want to artificially limit the execution to a controlled length, then programmatically perform verifications and/or inject context before continuing execution with resume.

etraut-openai contributor · 6 months ago

Thanks for the additional details. It sounds like our upcoming event hooks feature will address your needs. Please upvote that if you'd like to see it.

I think it's unlikely that we'd expose a --max-turns CLI option, so I'm going to close this feature request.