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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ xgen model check --compatibility

# 상세 durable progress 로그가 필요할 때
xgen --debug

# 요청 하나가 쓸 수 있는 model turn 수를 바꿀 때 (기본 24)
xgen --max-model-turns 48
```

RC3의 workspace mode는 다음처럼 프로젝트 root와 실행 가능한 개발 도구를 명시적으로 허용한다.
Expand Down
34 changes: 29 additions & 5 deletions crates/xgen-cli/src/composition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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 {
Expand All @@ -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)]
Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 32 additions & 8 deletions crates/xgen-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Command>,
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -494,18 +502,33 @@ fn executable_catalog(named: Vec<String>, include_path: bool) -> Vec<String> {
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<String>,
executable_ids: Vec<String>,
process_session: Option<LocalProcessSession>,
}

impl InteractiveHost {
fn new(detailed_progress: bool) -> Result<Self, repl::ReplFailure> {
fn new(detailed_progress: bool, max_model_turns: u32) -> Result<Self, repl::ReplFailure> {
let workspace = env::current_dir()
.map_err(|_| repl::ReplFailure::new(PublicRunError::Configuration.code()))?;
let (executable_ids, executable_specs) = discovered_executables()
Expand All @@ -514,6 +537,7 @@ impl InteractiveHost {
.unzip();
Ok(Self {
detailed_progress,
max_model_turns,
workspace,
executable_specs,
executable_ids,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
Expand All @@ -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());
};
Expand Down
103 changes: 100 additions & 3 deletions crates/xgen-cli/src/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -716,8 +717,29 @@ fn handle_result<R: BufRead, W: Write + Send, H: ReplHost>(
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 {
Expand Down Expand Up @@ -1165,9 +1187,28 @@ const fn pause_permission(reason: PauseReason) -> Option<PermissionKind> {
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,
}
}

Expand Down Expand Up @@ -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();
Expand Down