From 1f62837d8140e6fa58153b89e64645fabede47a5 Mon Sep 17 00:00:00 2001 From: Son Seong Jun <166786347+SonAIengine@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:27:05 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=ED=99=94=ED=98=95=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=EC=9D=98=20=ED=84=B4=20=EC=98=88=EC=82=B0=EC=9D=84=20?= =?UTF-8?q?24=EB=A1=9C=20=EB=8A=98=EB=A6=AC=EA=B3=A0=20=EB=A9=88=EC=B6=98?= =?UTF-8?q?=20=EC=9D=B4=EC=9C=A0=EB=A5=BC=20=EB=B3=B4=EC=9D=B4=EA=B2=8C=20?= =?UTF-8?q?=ED=96=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실사용 시나리오에서 "기능 수정 + 테스트 추가 + 테스트 실행" 요청이 최종 응답 없이 `paused: quiescent`로 끝났다. REPL이 `max_model_turns`를 주지 않아 bare 작업공간 예산(턴 8)을 썼고, 모델이 한 턴에 한 단계씩 8단계(목록·읽기·수정·테스트·npm install)를 쓰고 나니 완료 응답 턴이 남지 않았다. 이유는 화면에 없었고 /resume 도 manifest 에 기록된 예산을 그대로 써서 즉시 같은 상태로 끝났으며, 새 요청은 active_run_paused 로 막혀 /clear 밖에 길이 없었다. - REPL 기본 턴 예산을 24로 올렸다. 프로젝트 eval 이 Node.js·Python 작업에 이미 쓰던 값이다. `xgen --max-model-turns N` 으로 바꿀 수 있다. - foreground tick 상한은 턴당 10으로 맞추고 MAX_TICKS(1024)에서 자른다. 32로 두면 큰 예산이 tick 에서 먼저 끊긴다. - PauseReason::Quiescent 가 AgentLoopQuiescence 를 담는다. 기계용 `XGEN_PAUSED reason=quiescent` 출력은 그대로 두고, REPL 은 `paused: quiescent (model_turn_budget_exhausted)` 처럼 이유를 함께 보여 준다. - 예산·컨텍스트 소진은 재개로 풀리지 않으므로 REPL 이 Run 을 떼어 내고 다음 요청을 바로 받는다. 다음 요청이 이전 단계를 모른다는 점도 안내한다. 의존성 대기 등 다른 quiescence 는 기존처럼 붙어 있다. - tick 소진 pause 에는 /resume 으로 이어진다는 안내를 붙였다. 검증 - cargo test -p xgen-cli 198개 통과(REPL 테스트 3개 추가), clippy 경고 0 - 같은 JS 저장소·같은 요청 재실행: 9단계 후 최종 응답, 38초. 결과물은 ava 4 passed, xo 통과로 직접 재확인 - `--max-model-turns 3` 으로 강제 소진: 이유와 안내가 보이고, /clear 없이 다음 요청이 시작됐다 Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + crates/xgen-cli/src/composition.rs | 34 ++++++++-- crates/xgen-cli/src/main.rs | 40 ++++++++--- crates/xgen-cli/src/repl.rs | 103 ++++++++++++++++++++++++++++- 4 files changed, 164 insertions(+), 16 deletions(-) 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();