Skip to content
Closed
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,20 @@ 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>

Control whether Forge automatically continues a conversation when a per-turn limit (such as the tool failure or request limit) is reached, instead of blocking on a "Do you want to continue anyway?" confirmation prompt. Recommended for long-running and non-interactive conversations.

```yaml
# forge.yaml
auto_continue_on_interrupt: true # Continue without a y/n prompt on limits
```

When disabled (default), Forge asks for confirmation and stops if you decline. The prompt is also skipped automatically when stdin is not a TTY, in CI, or when `FORGE_NON_INTERACTIVE` / `FORGE_AGENT_MODE` is set.

</details>

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

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

/// Whether to automatically continue the conversation (without a
/// confirmation prompt) when a per-turn limit — such as the tool failure
/// or request limit — is reached. Recommended for long-running and
/// non-interactive conversations so the session does not stall on a y/n
/// prompt.
#[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
76 changes: 70 additions & 6 deletions crates/forge_main/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ use crate::{TRACKER, banner, tracker};

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

/// Detects the source of the conversation based on CLI arguments.
/// Returns "interactive", "forge-p", "headless", or the subcommand name.
Expand Down Expand Up @@ -140,6 +141,8 @@ pub struct UI<A: ConsoleWriter, F: Fn(ForgeConfig) -> A> {
// WIP: Claude-style status bar / prompt-loop plumbing (PRs #27/#29/#30), not yet fully wired 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 @@ -368,6 +371,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 @@ -4626,6 +4630,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 @@ -4915,16 +4929,50 @@ 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"
)))?;

// UX: surface which tools hit the limit instead of a
// bare title, so the failure is actionable.
if !errors.is_empty() {
let mut failing_tools = errors
.iter()
.map(|(name, count)| format!("{name} \u{00d7} {count}"))
.collect::<Vec<_>>();
failing_tools.sort();
self.writeln_title(TitleFormat::action(format!(
"Failing tools: {}",
failing_tools.join(", ")
)))?;
}
}
};
}

// Auto-continue without a y/n prompt when configured for
// long-running sessions or when the session is non-interactive
// (piped stdin, CI, or agent mode).
if self.config.auto_continue_on_interrupt || Self::is_non_interactive() {
if self.auto_continue_attempts >= MAX_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 @@ -4937,6 +4985,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 Down Expand Up @@ -4965,6 +5014,21 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
}
}

/// Whether the current session is non-interactive (piped stdin, CI, or
/// agent mode). Confirmation prompts cannot be answered in these contexts,
/// so per-turn limit interruptions auto-continue instead of blocking.
fn is_non_interactive() -> bool {
use std::io::IsTerminal;

if 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()
{
return true;
}
!std::io::stdin().is_terminal()
}

async fn on_show_conv_info(&mut self, conversation: Conversation) -> anyhow::Result<()> {
self.spinner.start(Some("Loading Summary"))?;

Expand Down
11 changes: 5 additions & 6 deletions crates/forge_main/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,10 @@ async fn execute_update_command(api: Arc<impl API>, auto_update: bool) {
.execute_shell_command_raw(&format!("curl -fsSL {primary} | sh"))
.await
{
Ok(o) => o,
Ok(o) => Ok(o),
Err(_) => api
.execute_shell_command_raw(&format!("curl -fsSL {fallback} | sh"))
.await
.unwrap_or_else(|e| e),
.await,
};

match output {
Expand Down Expand Up @@ -138,11 +137,11 @@ pub async fn on_update(api: Arc<impl API>, update: Option<&Update>) {
let primary_repo =
std::env::var("HELIOSLITE_REPO").unwrap_or_else(|_| "KooshaPari/heliosLite".to_string());
let legacy_repo = "KooshaPari/forgecode";
let interval: std::time::Duration = frequency.clone().into();
let informer_primary =
update_informer::new(registry::GitHub, primary_repo.as_str(), VERSION)
.interval(frequency.into());
update_informer::new(registry::GitHub, primary_repo.as_str(), VERSION).interval(interval);
let informer_legacy = update_informer::new(registry::GitHub, legacy_repo, VERSION)
.interval(frequency.into());
.interval(interval);

if let Some(version) = informer_primary
.check_version()
Expand Down
15 changes: 6 additions & 9 deletions crates/forge_tracker/src/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,12 @@ pub fn init_tracing(log_path: PathBuf, tracker: Tracker) -> anyhow::Result<Guard

tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_env(
// Additive rename: HELIOSLITE_LOG wins, falls back to FORGE_LOG
// (which is the upstream / pre-rename env name).
std::env::var("HELIOSLITE_LOG")
.or_else(|_| std::env::var("FORGE_LOG"))
.ok()
.as_deref(),
)
.unwrap_or(level),
// Additive rename: HELIOSLITE_LOG wins, falls back to FORGE_LOG
// (which is the upstream / pre-rename env name).
std::env::var("HELIOSLITE_LOG")
.or_else(|_| std::env::var("FORGE_LOG"))
.map(tracing_subscriber::EnvFilter::new)
.unwrap_or(level),
)
.with(fmt_layer)
.init();
Expand Down
5 changes: 5 additions & 0 deletions forge.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
"description": "Top-level Forge configuration merged from all sources (defaults, file,\nenvironment).",
"type": "object",
"properties": {
"auto_continue_on_interrupt": {
"description": "Whether to automatically continue the conversation (without a\nconfirmation prompt) when a per-turn limit — such as the tool failure\nor request limit — is reached. Recommended for long-running and\nnon-interactive conversations so the session does not stall on a y/n\nprompt.",
"type": "boolean",
"default": false
},
"auto_dump": {
"description": "Format used when automatically creating a session dump after task\ncompletion; disabled when absent.",
"anyOf": [
Expand Down
Loading