Skip to content
Merged
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,16 @@ Set to a higher value if you want more retry attempts, or lower if you want fast

</details>

<details>
<summary><strong>Auto-Continue on Interrupt</strong></summary>

Set `auto_continue_on_interrupt: true` to continue after a per-turn request or
tool-failure limit without waiting for an interactive confirmation. Automatic
continuation is bounded and stops with a diagnostic after eight chained
interruptions.

</details>

<details>
<summary><strong>Max Requests Per Turn</strong></summary>

Expand Down
4 changes: 4 additions & 0 deletions crates/forge_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@ pub struct ForgeConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_requests_per_turn: Option<usize>,

/// Whether to automatically continue after a per-turn limit interruption.
#[serde(default)]
pub auto_continue_on_interrupt: bool,

/// Context compaction settings applied to all agents; falls back to each
/// agent's individual setting when absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down
78 changes: 72 additions & 6 deletions crates/forge_main/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ use crate::{TRACKER, banner, tracker};

// File-specific constants
const MISSING_AGENT_TITLE: &str = "<missing agent.title>";
const MAX_AUTO_CONTINUE_ATTEMPTS: usize = 8;

fn auto_continue_allowed(attempts: usize) -> bool {
attempts < MAX_AUTO_CONTINUE_ATTEMPTS
}

/// Detects the source of the conversation based on CLI arguments.
/// Returns "interactive", "forge-p", "headless", or the subcommand name.
Expand Down Expand Up @@ -141,6 +146,8 @@ pub struct UI<A: ConsoleWriter, F: Fn(ForgeConfig) -> A> {
// into the render loop.
#[allow(dead_code)]
interrupt_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// Number of automatic continuation requests in the current chain.
auto_continue_attempts: usize,
#[allow(dead_code)] // The guard is kept alive by being held in the struct
_guard: forge_tracker::Guard,
}
Expand Down Expand Up @@ -369,6 +376,7 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
hydration_handles: Vec::new(),
cache_generation: std::sync::atomic::AtomicU64::new(0),
interrupt_flag: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
auto_continue_attempts: 0,
_guard: forge_tracker::init_tracing(env.log_path(), TRACKER.clone())?,
})
}
Expand Down Expand Up @@ -4633,6 +4641,16 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
}

async fn on_message(&mut self, content: Option<String>) -> Result<()> {
self.auto_continue_attempts = 0;
self.on_message_inner(content, false).await
}

async fn on_message_inner(
&mut self,
content: Option<String>,
is_auto_continuation: bool,
) -> Result<()> {
debug_assert!(is_auto_continuation || self.auto_continue_attempts == 0);
let conversation_id = self.init_conversation().await?;

if self.config.auto_install_vscode_extension {
Expand Down Expand Up @@ -4922,16 +4940,44 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
writer.finish()?;
self.spinner.stop(None)?;

let title = match reason {
match reason {
InterruptionReason::MaxRequestPerTurnLimitReached { limit } => {
format!("Maximum request ({limit}) per turn achieved")
self.writeln_title(TitleFormat::action(format!(
"Maximum request ({limit}) per turn achieved"
)))?;
}
InterruptionReason::MaxToolFailurePerTurnLimitReached { limit, .. } => {
format!("Maximum tool failure limit ({limit}) reached for this turn")
InterruptionReason::MaxToolFailurePerTurnLimitReached { limit, errors } => {
self.writeln_title(TitleFormat::action(format!(
"Maximum tool failure limit ({limit}) reached for this turn"
)))?;
if !errors.is_empty() {
let mut failing_tools = errors
.iter()
.map(|(name, count)| format!("{name} x {count}"))
.collect::<Vec<_>>();
failing_tools.sort();
self.writeln_title(TitleFormat::action(format!(
"Failing tools: {}",
failing_tools.join(", ")
)))?;
}
}
};
}

if self.config.auto_continue_on_interrupt || Self::is_non_interactive() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor auto-continue opt-in in headless runs

When Forge is run with piped input or in CI, this condition auto-continues after max-request or tool-failure interrupts even when auto_continue_on_interrupt is left at its documented/default false. That makes the per-turn limits stop being a hard cost/runaway guard for non-interactive jobs; a headless invocation that hits max_requests_per_turn now silently starts up to eight more turns instead of stopping unless the user explicitly opted in.

Useful? React with 👍 / 👎.

if !auto_continue_allowed(self.auto_continue_attempts) {
self.auto_continue_attempts = 0;
self.writeln_title(TitleFormat::error(format!(
"Automatic continuation stopped after {MAX_AUTO_CONTINUE_ATTEMPTS} interruptions"
)))?;
return Ok(());
}
self.auto_continue_attempts += 1;
self.spinner.start(None)?;
Box::pin(self.on_message_inner(None, true)).await?;
return Ok(());
}

self.writeln_title(TitleFormat::action(title))?;
let continued = self.should_continue().await?;
if !continued && let Some(conversation_id) = self.state.conversation_id {
self.writeln_title(
Expand All @@ -4944,6 +4990,7 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
}
ChatResponse::TaskComplete => {
writer.finish()?;
self.auto_continue_attempts = 0;
if let Some(conversation_id) = self.state.conversation_id {
self.writeln_title(
TitleFormat::debug("Finished").sub_title(conversation_id.into_string()),
Expand All @@ -4958,6 +5005,15 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
Ok(())
}

fn is_non_interactive() -> bool {
use std::io::IsTerminal;

std::env::var_os("CI").is_some()
|| std::env::var_os("FORGE_NON_INTERACTIVE").is_some()
|| std::env::var_os("FORGE_AGENT_MODE").is_some()
|| !std::io::stdin().is_terminal()
}

async fn should_continue(&mut self) -> anyhow::Result<bool> {
let should_continue = ForgeWidget::confirm("Do you want to continue anyway?")
.with_default(true)
Expand Down Expand Up @@ -5928,6 +5984,16 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI

#[cfg(test)]
mod tests {
use super::{MAX_AUTO_CONTINUE_ATTEMPTS, auto_continue_allowed};

#[test]
fn automatic_continuation_has_a_hard_boundary() {
assert!(auto_continue_allowed(0));
assert!(auto_continue_allowed(MAX_AUTO_CONTINUE_ATTEMPTS - 1));
assert!(!auto_continue_allowed(MAX_AUTO_CONTINUE_ATTEMPTS));
assert!(!auto_continue_allowed(usize::MAX));
}

// Note: Tests for confirm_delete_conversation are disabled because
// ForgeSelect::confirm is not easily mockable in the current
// architecture. The functionality is tested through integration tests
Expand Down
5 changes: 5 additions & 0 deletions forge.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,11 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "Top-level Forge configuration merged from all sources (defaults, file,\nenvironment).",
"properties": {
"auto_continue_on_interrupt": {
"default": false,
"description": "Whether to automatically continue after a per-turn limit interruption.",
"type": "boolean"
},
"auto_dump": {
"anyOf": [
{
Expand Down
Loading