From 68fd63aea73c386c79edb725b86c31edd425e8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=84=B1=EC=A4=80?= Date: Wed, 30 Sep 2026 14:55:49 +0900 Subject: [PATCH] feat: host-selected agent loop budget recorded in the Run manifest (--max-model-turns) Workspace discovery runs had a fixed 8 model turns / 16 calls / 8 planned steps / 8 tool calls. On expharness (09-29~30) 3 of 96 agentic candidate runs ended as PAUSED reason=quiescent at that bound; one had just passed its own check on turn 8. Resume could not help because the budget is recorded in the manifest and verified against the run state. - run --max-model-turns (1..=4096): model calls, planned steps and tool calls scale 2:1:1 like workspace_discovery; the context bound is unchanged - the budget is written into the manifest at run creation, so resume and verify_manifest_state keep it; runs without the option keep the built-in budget - refused (exit 64, before state) for single-shot runs, which have no agent loop to extend - advertised in run --help as XGENY_RUN_BUDGET=manifest-model-turns-v1 for host detection Tests: manifest ratio/bounds/round trip, budget selection, help marker, clap bounds, manifest record through a real run. cargo test -p xgeny-cli and clippy -D warnings pass. Co-Authored-By: Claude Opus 5.5 --- crates/xgeny-cli/src/composition.rs | 50 ++++++++++++-- crates/xgeny-cli/src/lib.rs | 1 + crates/xgeny-cli/src/main.rs | 13 ++++ crates/xgeny-cli/src/manifest.rs | 68 +++++++++++++++++++ .../xgeny-cli/tests/environment_onboarding.rs | 65 ++++++++++++++++++ 5 files changed, 192 insertions(+), 5 deletions(-) diff --git a/crates/xgeny-cli/src/composition.rs b/crates/xgeny-cli/src/composition.rs index f6884ce..101c531 100644 --- a/crates/xgeny-cli/src/composition.rs +++ b/crates/xgeny-cli/src/composition.rs @@ -126,6 +126,8 @@ pub struct LocalRunRequest { pub allow_write: bool, pub allow_execute: bool, pub max_ticks: u32, + /// Host-selected agent loop budget for workspace discovery; `None` keeps the built-in one. + pub max_model_turns: Option, } impl LocalRunRequest { @@ -156,6 +158,7 @@ impl LocalRunRequest { allow_write: false, allow_execute: false, max_ticks: 32, + max_model_turns: None, } } } @@ -926,11 +929,7 @@ where config.request_profile_digest(), catalog.catalog_digest(), &local_execution_profile_digest, - if planning_constraints_required { - ManifestBudget::workspace_discovery() - } else { - ManifestBudget::default() - }, + manifest_budget(planning_constraints_required, request.max_model_turns)?, ) .map_err(|_| PublicRunError::Configuration)?; let planner = remote_planner(config, request.credential)?; @@ -1810,6 +1809,21 @@ fn validate_goal(goal: &str) -> Result<(), PublicRunError> { Ok(()) } +fn manifest_budget( + planning_constraints_required: bool, + max_model_turns: Option, +) -> Result { + match (planning_constraints_required, max_model_turns) { + (true, Some(turns)) => { + ManifestBudget::with_model_turns(turns).map_err(|_| PublicRunError::Configuration) + } + (true, None) => Ok(ManifestBudget::workspace_discovery()), + // Single-shot runs have no agent loop to extend; never silently ignore the request. + (false, Some(_)) => Err(PublicRunError::Configuration), + (false, None) => Ok(ManifestBudget::default()), + } +} + fn validate_max_ticks(max_ticks: u32) -> Result<(), PublicRunError> { if max_ticks == 0 || max_ticks > MAX_TICKS { return Err(PublicRunError::Configuration); @@ -2664,6 +2678,30 @@ mod tests { use super::*; + #[test] + fn manifest_budget_extends_only_the_agent_loop() { + assert_eq!( + manifest_budget(true, None).unwrap(), + ManifestBudget::workspace_discovery() + ); + assert_eq!( + manifest_budget(true, Some(64)).unwrap(), + ManifestBudget::with_model_turns(64).unwrap() + ); + assert_eq!( + manifest_budget(false, None).unwrap(), + ManifestBudget::default() + ); + assert!(matches!( + manifest_budget(false, Some(64)), + Err(PublicRunError::Configuration) + )); + assert!(matches!( + manifest_budget(true, Some(0)), + Err(PublicRunError::Configuration) + )); + } + #[test] fn goal_bound_counts_utf8_bytes_without_truncation() { for unit in ["a", "ํ•œ", "๐Ÿงช"] { @@ -3006,6 +3044,7 @@ mod tests { allow_write: false, allow_execute: false, max_ticks: 32, + max_model_turns: None, }; assert_eq!( run_local(request).unwrap(), @@ -3061,6 +3100,7 @@ mod tests { allow_write: false, allow_execute: false, max_ticks: 32, + max_model_turns: None, }; assert!(matches!( run_local_with_process_session_progress( diff --git a/crates/xgeny-cli/src/lib.rs b/crates/xgeny-cli/src/lib.rs index f790077..e8af579 100644 --- a/crates/xgeny-cli/src/lib.rs +++ b/crates/xgeny-cli/src/lib.rs @@ -12,4 +12,5 @@ mod run_layout; pub use composition::*; pub use driver::*; +pub use manifest::MAX_HOST_MODEL_TURNS; pub use model_profile::*; diff --git a/crates/xgeny-cli/src/main.rs b/crates/xgeny-cli/src/main.rs index 13e2fbd..c485a4e 100644 --- a/crates/xgeny-cli/src/main.rs +++ b/crates/xgeny-cli/src/main.rs @@ -234,6 +234,17 @@ struct RunArgs { /// Bound work performed by this process invocation. #[arg(long, default_value_t = 32)] max_ticks: u32, + /// Agent loop budget recorded in the Run manifest for workspace discovery runs. + #[arg( + long, + value_name = "TURNS", + value_parser = clap::value_parser!(u32).range(1..=i64::from(xgeny_cli::MAX_HOST_MODEL_TURNS)), + help = format!( + "Agent loop budget recorded in the Run manifest (1..={}); model calls, planned steps and tool calls scale 2:1:1. Resume keeps the recorded budget. XGENY_RUN_BUDGET=manifest-model-turns-v1", + xgeny_cli::MAX_HOST_MODEL_TURNS + ) + )] + max_model_turns: Option, } #[derive(Debug, Args)] @@ -422,6 +433,7 @@ impl repl::ReplHost for InteractiveHost { allow_write: grants.write, allow_execute: grants.execute, max_ticks: REPL_MAX_TICKS, + max_model_turns: None, }, &process_session, |run_id| eprintln!("XGENY_STARTED run_id={run_id}"), @@ -601,6 +613,7 @@ fn run_command(args: RunArgs) -> ExitCode { allow_write: args.allow_write, allow_execute: args.allow_execute, max_ticks: args.max_ticks, + max_model_turns: args.max_model_turns, }, |run_id| eprintln!("XGENY_STARTED run_id={run_id}"), )) diff --git a/crates/xgeny-cli/src/manifest.rs b/crates/xgeny-cli/src/manifest.rs index 9f52a65..e1a1278 100644 --- a/crates/xgeny-cli/src/manifest.rs +++ b/crates/xgeny-cli/src/manifest.rs @@ -63,6 +63,10 @@ impl Default for ManifestBudget { } } +/// Upper bound for a host-selected model turn budget; planned steps stay within the +/// planner's source-step bound. +pub const MAX_HOST_MODEL_TURNS: u32 = 4_096; + impl ManifestBudget { pub(crate) const fn workspace_discovery() -> Self { Self { @@ -74,6 +78,24 @@ impl ManifestBudget { } } + /// Host-selected agent loop budget for workspace discovery, recorded in the Run manifest. + /// + /// Model calls, planned steps and tool calls keep the `workspace_discovery` ratio + /// (2:1:1 per model turn). The context bound is unchanged. Resume reads the manifest, so a + /// continued Run keeps the budget it was created with. + pub(crate) fn with_model_turns(max_model_turns: u32) -> Result { + if !(1..=MAX_HOST_MODEL_TURNS).contains(&max_model_turns) { + return Err(ManifestError::Invalid); + } + Ok(Self { + max_model_turns, + max_model_calls: max_model_turns * 2, + max_planned_steps: max_model_turns, + max_tool_calls: max_model_turns, + ..Self::workspace_discovery() + }) + } + pub(crate) fn agent_loop(&self) -> Result { AgentLoopBudget::new( self.max_model_turns, @@ -358,6 +380,52 @@ mod tests { ); } + #[test] + fn host_model_turn_budget_keeps_discovery_ratio_and_round_trips() { + let budget = ManifestBudget::with_model_turns(96).unwrap(); + assert_eq!( + ( + budget.max_model_turns, + budget.max_model_calls, + budget.max_planned_steps, + budget.max_tool_calls, + budget.max_context_bytes, + ), + ( + 96, + 192, + 96, + 96, + ManifestBudget::workspace_discovery().max_context_bytes + ) + ); + for turns in [0, MAX_HOST_MODEL_TURNS + 1] { + assert_eq!( + ManifestBudget::with_model_turns(turns).unwrap_err(), + ManifestError::Invalid + ); + } + let largest = ManifestBudget::with_model_turns(MAX_HOST_MODEL_TURNS).unwrap(); + assert!(largest.agent_loop().is_ok() && largest.model_calls().is_ok()); + let manifest = RunManifest::new( + "run-0123456789abcdef0123456789abcdef", + &WorkspaceId::new("ws-0123456789abcdef").unwrap(), + "xgeny.workspace-root.unix-file-id.v1", + DIGEST_A, + "xgeny.cli.openai", + "qwen3.8-27b", + "Qwen-Qwen3.8-27B-FP8", + DIGEST_B, + DIGEST_C, + DIGEST_D, + budget.clone(), + ) + .unwrap(); + let loaded = RunManifest::from_bytes(&manifest.to_bytes().unwrap()).unwrap(); + assert_eq!(loaded.budget(), &budget); + assert_ne!(loaded.authority(), fixture().authority()); + } + #[test] fn manifest_excludes_endpoint_paths_goal_and_credentials() { let text = String::from_utf8(fixture().to_bytes().unwrap()).unwrap(); diff --git a/crates/xgeny-cli/tests/environment_onboarding.rs b/crates/xgeny-cli/tests/environment_onboarding.rs index 47ac248..0f0188c 100644 --- a/crates/xgeny-cli/tests/environment_onboarding.rs +++ b/crates/xgeny-cli/tests/environment_onboarding.rs @@ -519,6 +519,71 @@ fn advertised_goal_bound_matches_validation_and_oversize_creates_no_state() { assert_configuration_before_state(&output, &state); } +#[test] +fn host_model_turn_budget_is_advertised_bounded_and_recorded_in_the_manifest() { + let fixture = tempdir().unwrap(); + let state = fixture.path().join("state"); + let help = xgeny(&state).args(["run", "--help"]).output().unwrap(); + assert!(help.status.success()); + let help = String::from_utf8(help.stdout).unwrap(); + assert!(help.contains("--max-model-turns")); + assert!(help.contains("XGENY_RUN_BUDGET=manifest-model-turns-v1")); + for turns in ["0", &(xgeny_cli::MAX_HOST_MODEL_TURNS + 1).to_string()] { + let output = xgeny(&state) + .current_dir(fixture.path()) + .env("XGENY_OPENAI_BASE_URL", "http://127.0.0.1:1/v1") + .env("XGENY_OPENAI_MODEL", MODEL) + .args(["run", "--allow-dir", ".", "--allow-remote-model-egress"]) + .args(["--max-model-turns", turns, "goal"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert!(!state.exists()); + } + + let workspace = fixture.path().join("workspace"); + fs::create_dir(&workspace).unwrap(); + fs::write(workspace.join("README.md"), "fixture").unwrap(); + // Single-shot runs have no agent loop; the request is refused, not ignored. + let single = xgeny(&state) + .current_dir(&workspace) + .env("XGENY_OPENAI_BASE_URL", "http://127.0.0.1:1/v1") + .env("XGENY_OPENAI_MODEL", MODEL) + .args([ + "run", + "--allow-file", + "README.md", + "--allow-remote-model-egress", + ]) + .args(["--max-model-turns", "64", "goal"]) + .output() + .unwrap(); + assert_configuration_before_state(&single, &state); + + let server = CompletionServer::spawn(); + let output = xgeny(&state) + .current_dir(&workspace) + .env("XGENY_OPENAI_BASE_URL", &server.base_url) + .env("XGENY_OPENAI_MODEL", MODEL) + .args(["run", "--allow-dir", ".", "--allow-remote-model-egress"]) + .args(["--max-model-turns", "64", "inspect the workspace"]) + .output() + .unwrap(); + let _ = server.handle.join(); + assert!(String::from_utf8_lossy(&output.stderr).contains("XGENY_STARTED")); + let manifests: Vec<_> = fs::read_dir(state.join("runs")) + .unwrap() + .map(|entry| entry.unwrap().path().join("manifest.json")) + .collect(); + assert_eq!(manifests.len(), 1); + let manifest: Value = serde_json::from_slice(&fs::read(&manifests[0]).unwrap()).unwrap(); + let budget = &manifest["record"]["budget"]; + assert_eq!(budget["maxModelTurns"], 64); + assert_eq!(budget["maxModelCalls"], 128); + assert_eq!(budget["maxPlannedSteps"], 64); + assert_eq!(budget["maxToolCalls"], 64); +} + fn xgeny(state: &Path) -> Command { let mut command = Command::new(env!("CARGO_BIN_EXE_xgeny")); command