diff --git a/README.md b/README.md index c887cb1..9c8bc5f 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,9 @@ xgen model check --compatibility # 상세 durable progress 로그가 필요할 때 xgen --debug + +# 요청 하나가 쓸 수 있는 model turn 수를 바꿀 때 (기본 24) +xgen --max-model-turns 48 ``` RC3의 workspace mode는 다음처럼 프로젝트 root와 실행 가능한 개발 도구를 명시적으로 허용한다. diff --git a/crates/xgen-cli/src/composition.rs b/crates/xgen-cli/src/composition.rs index 79ba434..4f0d076 100644 --- a/crates/xgen-cli/src/composition.rs +++ b/crates/xgen-cli/src/composition.rs @@ -89,7 +89,8 @@ pub const MAX_GOAL_BYTES: usize = 64 * 1024; /// Windows command lines have a lower OS bound; retain the legacy allowance. #[cfg(windows)] pub const MAX_GOAL_BYTES: usize = 16 * 1024; -const MAX_TICKS: u32 = 1_024; +/// Upper bound for one foreground invocation's tick budget. +pub const MAX_TICKS: u32 = 1_024; const MODEL_CHECK_TIMEOUT: Duration = Duration::from_secs(10); const LOCAL_EXECUTION_PROFILE_DOMAIN: &str = "xgeny.cli.local-execution-profile/v1"; const WORKSPACE_DISCOVERY_PROFILE_DOMAIN: &str = @@ -476,7 +477,8 @@ pub enum PauseReason { ExecuteApprovalRequired, UserCancelled, TickBudgetExhausted, - Quiescent, + /// The agent loop stopped making progress; the payload says why. + Quiescent(AgentLoopQuiescence), } impl PauseReason { @@ -489,9 +491,31 @@ impl PauseReason { Self::ExecuteApprovalRequired => "execute_approval_required", Self::UserCancelled => "user_cancelled", Self::TickBudgetExhausted => "tick_budget_exhausted", - Self::Quiescent => "quiescent", + Self::Quiescent(_) => "quiescent", } } + + /// Stable code for why a quiescent Run stopped; `None` for every other pause. + #[must_use] + pub const fn quiescence_code(self) -> Option<&'static str> { + let Self::Quiescent(reason) = self else { + return None; + }; + Some(match reason { + AgentLoopQuiescence::FailedSteps => "failed_steps", + AgentLoopQuiescence::ManualIntervention => "manual_intervention", + AgentLoopQuiescence::UnverifiedCompletion => "unverified_completion", + AgentLoopQuiescence::BlockedDependencies => "blocked_dependencies", + AgentLoopQuiescence::WaitingDependencies => "waiting_dependencies", + AgentLoopQuiescence::ModelTurnBudgetExhausted => "model_turn_budget_exhausted", + AgentLoopQuiescence::ModelCallBudgetExhausted => "model_call_budget_exhausted", + AgentLoopQuiescence::PlannedStepBudgetExhausted => "planned_step_budget_exhausted", + AgentLoopQuiescence::ToolCallBudgetExhausted => "tool_call_budget_exhausted", + AgentLoopQuiescence::ContextBudgetExceeded => "context_budget_exceeded", + AgentLoopQuiescence::ContextInputLimitExceeded => "context_input_limit_exceeded", + AgentLoopQuiescence::NoActionableWork => "no_actionable_work", + }) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -2315,9 +2339,9 @@ fn map_driver_outcome( run_id, reason: RecoveryReason::EffectOutcomeUnknown, }, - _ => LocalCommandResult::Paused { + reason => LocalCommandResult::Paused { run_id: Some(run_id), - reason: PauseReason::Quiescent, + reason: PauseReason::Quiescent(reason), }, }, DriverOutcome::ApprovalDenied { .. } => LocalCommandResult::Rejected { diff --git a/crates/xgen-cli/src/main.rs b/crates/xgen-cli/src/main.rs index 65920f8..2c000f5 100644 --- a/crates/xgen-cli/src/main.rs +++ b/crates/xgen-cli/src/main.rs @@ -42,6 +42,14 @@ struct Cli { /// Show durable progress events instead of the interactive Thinking display. #[arg(long)] debug: bool, + /// Model turns each interactive goal may use before it stops without an answer. + #[arg( + long, + value_name = "TURNS", + default_value_t = REPL_MODEL_TURNS, + value_parser = clap::value_parser!(u32).range(1..=i64::from(xgen_cli::MAX_HOST_MODEL_TURNS)) + )] + max_model_turns: u32, #[command(subcommand)] command: Option, } @@ -376,7 +384,7 @@ fn main() -> ExitCode { } match cli.command { Some(Command::Tools { command }) => tools::run(command), - None => interactive_command(cli.debug), + None => interactive_command(cli.debug, cli.max_model_turns), Some(Command::Licenses) => print_licenses(), Some(Command::Protocol { command: ProtocolCommand::Check, @@ -494,10 +502,25 @@ fn executable_catalog(named: Vec, include_path: bool) -> Vec { named.iter().cloned().chain(discovered).collect() } -const REPL_MAX_TICKS: u32 = 32; +// The bare workspace budget (8 turns) ends a read-edit-test task before it can answer. +// 24 turns is the budget the project evals already use for Node.js and Python work. +const REPL_MODEL_TURNS: u32 = 24; +// Ticks only bound one foreground invocation; the manifest budget bounds the Run. Ten +// per turn covers plan, admission, effect and verification with room for recovery. +const REPL_TICKS_PER_MODEL_TURN: u32 = 10; + +const fn repl_max_ticks(max_model_turns: u32) -> u32 { + let ticks = max_model_turns.saturating_mul(REPL_TICKS_PER_MODEL_TURN); + if ticks > xgen_cli::MAX_TICKS { + xgen_cli::MAX_TICKS + } else { + ticks + } +} struct InteractiveHost { detailed_progress: bool, + max_model_turns: u32, workspace: PathBuf, executable_specs: Vec, executable_ids: Vec, @@ -505,7 +528,7 @@ struct InteractiveHost { } impl InteractiveHost { - fn new(detailed_progress: bool) -> Result { + fn new(detailed_progress: bool, max_model_turns: u32) -> Result { let workspace = env::current_dir() .map_err(|_| repl::ReplFailure::new(PublicRunError::Configuration.code()))?; let (executable_ids, executable_specs) = discovered_executables() @@ -514,6 +537,7 @@ impl InteractiveHost { .unzip(); Ok(Self { detailed_progress, + max_model_turns, workspace, executable_specs, executable_ids, @@ -610,8 +634,8 @@ impl repl::ReplHost for InteractiveHost { allow_read: grants.read, allow_write: grants.write, allow_execute: grants.execute, - max_ticks: REPL_MAX_TICKS, - max_model_turns: None, + max_ticks: repl_max_ticks(self.max_model_turns), + max_model_turns: Some(self.max_model_turns), final_response_schema: None, completion_checks: None, // The interactive driver admits this contract regardless; said @@ -654,7 +678,7 @@ impl repl::ReplHost for InteractiveHost { allow_read: grants.read, allow_write: grants.write, allow_execute: grants.execute, - max_ticks: REPL_MAX_TICKS, + max_ticks: repl_max_ticks(self.max_model_turns), }; let mut resolution_error = None; let result = { @@ -686,7 +710,7 @@ impl repl::ReplHost for InteractiveHost { } } -fn interactive_command(debug: bool) -> ExitCode { +fn interactive_command(debug: bool, max_model_turns: u32) -> ExitCode { let terminal = std::io::stdin().is_terminal() && std::io::stdout().is_terminal() && std::io::stderr().is_terminal(); @@ -700,7 +724,7 @@ fn interactive_command(debug: bool) -> ExitCode { eprintln!("XGEN_ERROR code={}", PublicRunError::Internal.code()); return ExitCode::from(PublicRunError::Internal.exit_code()); } - let Ok(mut host) = InteractiveHost::new(!terminal || debug) else { + let Ok(mut host) = InteractiveHost::new(!terminal || debug, max_model_turns) else { eprintln!("XGEN_ERROR code={}", PublicRunError::Configuration.code()); return ExitCode::from(PublicRunError::Configuration.exit_code()); }; diff --git a/crates/xgen-cli/src/repl.rs b/crates/xgen-cli/src/repl.rs index f42facd..c610c38 100644 --- a/crates/xgen-cli/src/repl.rs +++ b/crates/xgen-cli/src/repl.rs @@ -11,6 +11,7 @@ use std::time::{Duration, Instant}; use xgen_cli::{ DriverProgress, DriverProgressControl, LocalCommandResult, PauseReason, RejectionReason, }; +use xgen_runtime::AgentLoopQuiescence; const MAX_REPL_LINE_BYTES: usize = 16 * 1024; const MAX_REPL_GOAL_BYTES: usize = 16 * 1024; @@ -716,8 +717,29 @@ fn handle_result( writeln!(output, "paused: user_cancelled")?; return Ok(()); } + if let Some(detail) = reason.quiescence_code() { + writeln!(output, "paused: {} ({detail})", reason.code())?; + if let Some(hint) = exhausted_quiescence_hint(reason) { + // The budget is recorded in the manifest, so /resume would stop at + // once. Detach so the next goal starts without /clear. + *active_run = None; + session_context.pending_goal = None; + writeln!(output, "{hint}")?; + writeln!( + output, + "Changes already made stay in the workspace, but the next goal does not see this goal's steps; restate what is left." + )?; + } + return Ok(()); + } let Some(kind) = pause_permission(reason) else { writeln!(output, "paused: {}", reason.code())?; + if reason == PauseReason::TickBudgetExhausted { + writeln!( + output, + "The goal is within its budget; /resume continues it." + )?; + } return Ok(()); }; let Some(run_id) = active_run.clone() else { @@ -1165,9 +1187,28 @@ const fn pause_permission(reason: PauseReason) -> Option { PauseReason::ReadApprovalRequired => Some(PermissionKind::Read), PauseReason::WriteApprovalRequired => Some(PermissionKind::Write), PauseReason::ExecuteApprovalRequired => Some(PermissionKind::Execute), - PauseReason::UserCancelled | PauseReason::TickBudgetExhausted | PauseReason::Quiescent => { - None - } + PauseReason::UserCancelled + | PauseReason::TickBudgetExhausted + | PauseReason::Quiescent(_) => None, + } +} + +const fn exhausted_quiescence_hint(reason: PauseReason) -> Option<&'static str> { + match reason { + PauseReason::Quiescent( + AgentLoopQuiescence::ModelTurnBudgetExhausted + | AgentLoopQuiescence::ModelCallBudgetExhausted + | AgentLoopQuiescence::PlannedStepBudgetExhausted + | AgentLoopQuiescence::ToolCallBudgetExhausted, + ) => Some( + "The goal used its whole budget before answering; /resume cannot extend it. \ + Start xgen with --max-model-turns N for a larger budget.", + ), + PauseReason::Quiescent( + AgentLoopQuiescence::ContextBudgetExceeded + | AgentLoopQuiescence::ContextInputLimitExceeded, + ) => Some("The goal's context no longer fits the model input; /resume cannot shrink it."), + _ => None, } } @@ -2277,6 +2318,62 @@ mod tests { assert!(!host.starts[1].0.contains("paused request")); } + #[test] + fn exhausted_budget_names_the_reason_and_lets_the_next_goal_start() { + let mut host = FakeHost::new([ + LocalCommandResult::Paused { + run_id: Some("run-a".to_owned()), + reason: PauseReason::Quiescent(AgentLoopQuiescence::ModelTurnBudgetExhausted), + }, + LocalCommandResult::Responded { + run_id: "run-b".to_owned(), + summary: "done".to_owned(), + }, + ]); + let mut input = + io::Cursor::new(b"/permissions model allow\nlong task\nfollow-up\n/status\n/exit\n"); + let mut output = Vec::new(); + run(&mut input, &mut output, &mut host, &Cancellation::default()).unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("paused: quiescent (model_turn_budget_exhausted)")); + assert!(output.contains("--max-model-turns N")); + assert!(!output.contains("active_run_paused")); + assert_eq!(host.starts.len(), 2); + assert!(host.starts[1].0.contains("follow-up")); + assert!(!host.starts[1].0.contains("long task")); + assert!(output.contains("last_run: run-b")); + assert!(host.resumes.is_empty()); + } + + #[test] + fn other_quiescence_keeps_the_run_attached() { + let mut host = FakeHost::new([LocalCommandResult::Paused { + run_id: Some("run-a".to_owned()), + reason: PauseReason::Quiescent(AgentLoopQuiescence::WaitingDependencies), + }]); + let mut input = io::Cursor::new(b"/permissions model allow\ntask\nnext\n/exit\n"); + let mut output = Vec::new(); + run(&mut input, &mut output, &mut host, &Cancellation::default()).unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("paused: quiescent (waiting_dependencies)")); + assert!(output.contains("error: active_run_paused; use /resume or /clear")); + assert_eq!(host.starts.len(), 1); + } + + #[test] + fn tick_budget_pause_points_to_resume() { + let mut host = FakeHost::new([LocalCommandResult::Paused { + run_id: Some("run-a".to_owned()), + reason: PauseReason::TickBudgetExhausted, + }]); + let mut input = io::Cursor::new(b"/permissions model allow\ntask\n/exit\n"); + let mut output = Vec::new(); + run(&mut input, &mut output, &mut host, &Cancellation::default()).unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("paused: tick_budget_exhausted")); + assert!(output.contains("/resume continues it")); + } + #[test] fn context_is_bounded_utf8_json_and_never_truncates_the_current_request() { let mut context = SessionContext::default();