diff --git a/apps/native/src-tauri/src/evolve/mod.rs b/apps/native/src-tauri/src/evolve/mod.rs index 3d9da27ef..3b60ae84d 100644 --- a/apps/native/src-tauri/src/evolve/mod.rs +++ b/apps/native/src-tauri/src/evolve/mod.rs @@ -330,6 +330,170 @@ fn escape_user_query(input: &str) -> String { .replace('>', ">") } +const LIMIT_DECISION_CONTINUE: &str = "Yes, keep going"; +const LIMIT_DECISION_STOP: &str = "Stop"; + +#[derive(Debug, Clone, Copy)] +enum EvolutionLimitKind { + NoProgress, + MaxIterations, + BuildAttempts, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LimitDecision { + Continue, + Stop, + Cancelled, +} + +impl EvolutionLimitKind { + fn attempts_label(self, attempts: usize) -> String { + match self { + Self::BuildAttempts => format!("{} build attempts", attempts), + Self::NoProgress | Self::MaxIterations => format!("{} attempts", attempts), + } + } + + fn prompt(self, attempts: usize) -> String { + format!( + "The AI has made {}. Keep going?", + self.attempts_label(attempts) + ) + } + + fn stop_summary(self, attempts: usize) -> String { + match self { + Self::NoProgress => format!( + "Evolution stopped after {} because the AI had not started making concrete changes.", + self.attempts_label(attempts) + ), + Self::MaxIterations => format!( + "Evolution stopped after reaching {}. The current conversation context was preserved.", + self.attempts_label(attempts) + ), + Self::BuildAttempts => format!( + "Evolution stopped after reaching {}. You can review the current changes or continue with a follow-up prompt.", + self.attempts_label(attempts) + ), + } + } +} + +fn should_continue_after_limit(answer: &str) -> bool { + let normalized = answer.trim().to_ascii_lowercase(); + normalized == "yes" + || normalized == "y" + || normalized == "continue" + || normalized == "keep going" + || normalized == LIMIT_DECISION_CONTINUE.to_ascii_lowercase() +} + +async fn ask_to_continue_after_limit( + app: &AppHandle, + start_time: i64, + iteration: usize, + limit_kind: EvolutionLimitKind, + attempts: usize, + interactive: bool, +) -> LimitDecision { + let prompt = limit_kind.prompt(attempts); + + if !interactive { + info!( + "{} Limit reached in a non-interactive context; stopping evolution.", + prompt + ); + emit_evolve_event( + app, + EvolveEvent::info( + start_time, + Some(iteration), + &format!( + "{} Defaulting to stop because this run cannot accept interactive input.", + prompt + ), + ), + ); + return LimitDecision::Stop; + } + + emit_evolve_event( + app, + EvolveEvent::question( + start_time, + iteration, + &prompt, + &Some(vec![ + LIMIT_DECISION_CONTINUE.to_string(), + LIMIT_DECISION_STOP.to_string(), + ]), + ), + ); + + info!("Limit reached; waiting for user decision: {}", prompt); + let answer = tokio::select! { + answer = session_control::wait_for_question_response() => answer, + _ = async { + loop { + if session_control::is_evolve_cancelled() { + break; + } + sleep(Duration::from_millis(100)).await; + } + } => { + warn!("Evolution cancelled while waiting for limit decision"); + return LimitDecision::Cancelled; + } + }; + + match answer { + Some(answer) if should_continue_after_limit(&answer) => { + info!("User chose to continue after reaching an evolution limit"); + emit_evolve_event( + app, + EvolveEvent::info(start_time, Some(iteration), "Continuing evolution..."), + ); + LimitDecision::Continue + } + Some(answer) => { + info!( + "User chose to stop after reaching an evolution limit: {}", + answer + ); + LimitDecision::Stop + } + None => { + warn!("Limit decision channel closed; stopping evolution"); + LimitDecision::Stop + } + } +} + +fn finish_after_limit_stop( + app: &AppHandle, + evolution: &mut Evolution, + start_time: i64, + iteration: usize, + limit_kind: EvolutionLimitKind, + attempts: usize, +) { + let summary = limit_kind.stop_summary(attempts); + info!("{}", summary); + emit_evolve_event( + app, + EvolveEvent::info(start_time, Some(iteration), &summary), + ); + emit_evolve_event(app, EvolveEvent::complete(start_time, iteration, &summary)); + + evolution.summary = Some(summary); + evolution.state = if evolution.edits.is_empty() { + EvolutionState::Conversational + } else { + EvolutionState::Generated + }; +} + /// Generate an evolution from a user prompt using OpenAI function calling. /// /// This runs an agentic loop where the model can read files, make edits, @@ -438,19 +602,25 @@ pub async fn generate_evolution( ); // Read configurable limits from store - let max_iterations = store::get_max_iterations(app).unwrap_or(store::DEFAULT_MAX_ITERATIONS); - let max_iterations_before_edit = std::cmp::max( + let mut max_iterations = + store::get_max_iterations(app).unwrap_or(store::DEFAULT_MAX_ITERATIONS); + let max_iterations_increment = max_iterations.max(1); + let mut max_iterations_before_edit = std::cmp::max( 1, (max_iterations * MAX_ITERATIONS_BEFORE_EDIT_PERCENT) / 100, ); - let max_build_attempts = + let max_iterations_before_edit_increment = max_iterations_before_edit.max(1); + let mut max_build_attempts = store::get_max_build_attempts(app).unwrap_or(DEFAULT_MAX_BUILD_ATTEMPTS); + let max_build_attempts_increment = max_build_attempts.max(1); + let interactive_limit_prompt = !banned_tools.contains(&"ask_user"); info!( - "Limits: max_iterations={}, max_iterations_before_edit={} ({}%), max_build_attempts={}", + "Limits: max_iterations={}, max_iterations_before_edit={} ({}%), max_build_attempts={}, interactive_limit_prompt={}", max_iterations, max_iterations_before_edit, MAX_ITERATIONS_BEFORE_EDIT_PERCENT, - max_build_attempts + max_build_attempts, + interactive_limit_prompt ); let tools = create_tools(banned_tools); @@ -1011,102 +1181,142 @@ Do not invent tool names and do not place tool invocations in assistant content. } // Safety limits -- Max Iterations Before Edit Check - if iteration == max_iterations_before_edit && !made_edit_or_build_check { + if iteration >= max_iterations_before_edit && !made_edit_or_build_check { warn!( - "⚠️ No edit or build_check by iteration {} - agent not making progress", - max_iterations_before_edit - ); - evolution.state = EvolutionState::Failed; - let message = format!( - "I've analyzed your configuration for {} iterations but haven't started making concrete changes yet. \ -This suggests I'm having difficulty understanding what modifications you'd like. \ -Could you provide more specific guidance on what aspects of your configuration need adjustment?", + "⚠️ No edit or build_check by iteration {} - asking whether to continue", max_iterations_before_edit ); - emit_evolve_event( + match ask_to_continue_after_limit( app, - EvolveEvent::error( - start_time, - Some(iteration), - &format!("Maximum iterations exceeded ({})", max_iterations), - &format!("Maximum iterations exceeded ({})", max_iterations), - ), - ); - // Track failure - if let Err(e) = statistics::record_evolution_failure(app, iteration) { - warn!("Failed to record evolution failure stats: {}", e); - } - return Err(EvolutionRunError::from_state( - message, - &evolution, + start_time, iteration, - build_attempts, - total_tokens, + EvolutionLimitKind::NoProgress, + iteration, + interactive_limit_prompt, ) - .into()); + .await + { + LimitDecision::Continue => { + max_iterations_before_edit += max_iterations_before_edit_increment; + max_iterations = max_iterations.max(max_iterations_before_edit); + info!( + "Extending no-progress limit to iteration {} and max iterations to {}", + max_iterations_before_edit, max_iterations + ); + } + LimitDecision::Stop => { + finish_after_limit_stop( + app, + &mut evolution, + start_time, + iteration, + EvolutionLimitKind::NoProgress, + iteration, + ); + break; + } + LimitDecision::Cancelled => { + evolution.state = EvolutionState::Failed; + return Err(EvolutionRunError::from_state( + session_control::EVOLUTION_CANCELLED_MSG, + &evolution, + iteration, + build_attempts, + total_tokens, + ) + .into()); + } + } } // Safety limits -- Max Iterations if iteration >= max_iterations { warn!( - "⚠️ Evolution exceeded maximum iterations ({}) - aborting", + "⚠️ Evolution reached maximum iterations ({}) - asking whether to continue", max_iterations ); - evolution.state = EvolutionState::Failed; - emit_evolve_event( + match ask_to_continue_after_limit( app, - EvolveEvent::error( - start_time, - Some(iteration), - &format!("Maximum iterations exceeded ({})", max_iterations), - &format!("Maximum iterations exceeded ({})", max_iterations), - ), - ); - // Track failure - if let Err(e) = statistics::record_evolution_failure(app, iteration) { - warn!("Failed to record evolution failure stats: {}", e); - } - return Err(EvolutionRunError::from_state( - format!("Evolution exceeded maximum iterations ({})", max_iterations), - &evolution, + start_time, iteration, - build_attempts, - total_tokens, + EvolutionLimitKind::MaxIterations, + iteration, + interactive_limit_prompt, ) - .into()); + .await + { + LimitDecision::Continue => { + max_iterations += max_iterations_increment; + info!("Extending max iterations to {}", max_iterations); + } + LimitDecision::Stop => { + finish_after_limit_stop( + app, + &mut evolution, + start_time, + iteration, + EvolutionLimitKind::MaxIterations, + iteration, + ); + break; + } + LimitDecision::Cancelled => { + evolution.state = EvolutionState::Failed; + return Err(EvolutionRunError::from_state( + session_control::EVOLUTION_CANCELLED_MSG, + &evolution, + iteration, + build_attempts, + total_tokens, + ) + .into()); + } + } } // Safety limits -- Max Build Attempts if build_attempts >= max_build_attempts { warn!( - "⚠️ Evolution exceeded maximum build attempts ({}) - aborting", + "⚠️ Evolution reached maximum build attempts ({}) - asking whether to continue", max_build_attempts ); - evolution.state = EvolutionState::Failed; - emit_evolve_event( + match ask_to_continue_after_limit( app, - EvolveEvent::error( - start_time, - Some(iteration), - &format!("Failed after {} build attempts", max_build_attempts), - &format!("Failed after {} build attempts", max_build_attempts), - ), - ); - // Track failure - if let Err(e) = statistics::record_evolution_failure(app, iteration) { - warn!("Failed to record evolution failure stats: {}", e); - } - return Err(EvolutionRunError::from_state( - format!( - "Failed to produce a valid configuration after {} build attempts", - max_build_attempts - ), - &evolution, + start_time, iteration, + EvolutionLimitKind::BuildAttempts, build_attempts, - total_tokens, + interactive_limit_prompt, ) - .into()); + .await + { + LimitDecision::Continue => { + max_build_attempts += max_build_attempts_increment; + info!("Extending max build attempts to {}", max_build_attempts); + } + LimitDecision::Stop => { + finish_after_limit_stop( + app, + &mut evolution, + start_time, + iteration, + EvolutionLimitKind::BuildAttempts, + build_attempts, + ); + break; + } + LimitDecision::Cancelled => { + evolution.state = EvolutionState::Failed; + return Err(EvolutionRunError::from_state( + session_control::EVOLUTION_CANCELLED_MSG, + &evolution, + iteration, + build_attempts, + total_tokens, + ) + .into()); + } + } } } diff --git a/apps/native/src-tauri/src/types.rs b/apps/native/src-tauri/src/types.rs index 229958fec..d4b9c7f48 100644 --- a/apps/native/src-tauri/src/types.rs +++ b/apps/native/src-tauri/src/types.rs @@ -204,7 +204,15 @@ impl EvolveEvent { choices: &Option>, ) -> Self { let raw = match choices { - Some(c) => format!("{}\nChoices: {}", question, c.join(", ")), + Some(c) => { + let choices_json = serde_json::to_string(c).unwrap_or_else(|_| "[]".to_string()); + format!( + "{}\nChoicesJson: {}\nChoices: {}", + question, + choices_json, + c.join(", ") + ) + } None => question.to_string(), }; Self::new( diff --git a/apps/native/src/components/widget/overlays/evolve-progress.tsx b/apps/native/src/components/widget/overlays/evolve-progress.tsx index c0f7b8e33..15f87de30 100644 --- a/apps/native/src/components/widget/overlays/evolve-progress.tsx +++ b/apps/native/src/components/widget/overlays/evolve-progress.tsx @@ -236,6 +236,21 @@ function EventItem({ event, isLatest }: EventItemProps) { // ============================================================================= function parseQuestionChoices(raw: string): string[] | null { + const jsonMatch = raw.match(/\nChoicesJson: (.+)\nChoices: /); + if (jsonMatch) { + try { + const parsed = JSON.parse(jsonMatch[1]); + if ( + Array.isArray(parsed) && + parsed.every((choice) => typeof choice === "string") + ) { + return parsed; + } + } catch { + // Fall back to the legacy comma-separated format below. + } + } + const match = raw.match(/\nChoices: (.+)$/); if (!match) return null; return match[1].split(", ").filter(Boolean);