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
50 changes: 45 additions & 5 deletions crates/xgeny-cli/src/composition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
}

impl LocalRunRequest {
Expand Down Expand Up @@ -156,6 +158,7 @@ impl LocalRunRequest {
allow_write: false,
allow_execute: false,
max_ticks: 32,
max_model_turns: None,
}
}
}
Expand Down Expand Up @@ -930,11 +933,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)?;
Expand Down Expand Up @@ -1829,6 +1828,21 @@ fn validate_goal(goal: &str) -> Result<(), PublicRunError> {
Ok(())
}

fn manifest_budget(
planning_constraints_required: bool,
max_model_turns: Option<u32>,
) -> Result<ManifestBudget, PublicRunError> {
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);
Expand Down Expand Up @@ -2686,6 +2700,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", "한", "🧪"] {
Expand Down Expand Up @@ -3033,6 +3071,7 @@ mod tests {
allow_write: false,
allow_execute: false,
max_ticks: 32,
max_model_turns: None,
};
assert_eq!(
run_local(request).unwrap(),
Expand Down Expand Up @@ -3088,6 +3127,7 @@ mod tests {
allow_write: false,
allow_execute: false,
max_ticks: 32,
max_model_turns: None,
};
assert!(matches!(
run_local_with_process_session_progress(
Expand Down
1 change: 1 addition & 0 deletions crates/xgeny-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ mod run_layout;

pub use composition::*;
pub use driver::*;
pub use manifest::MAX_HOST_MODEL_TURNS;
pub use model_profile::*;
13 changes: 13 additions & 0 deletions crates/xgeny-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
}

#[derive(Debug, Args)]
Expand Down Expand Up @@ -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}"),
Expand Down Expand Up @@ -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}"),
))
Expand Down
68 changes: 68 additions & 0 deletions crates/xgeny-cli/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Self, ManifestError> {
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, ManifestError> {
AgentLoopBudget::new(
self.max_model_turns,
Expand Down Expand Up @@ -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();
Expand Down
65 changes: 65 additions & 0 deletions crates/xgeny-cli/tests/environment_onboarding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading