mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(auto-drive): add per-turn model routing and schema rename
This commit is contained in:
@@ -54,6 +54,8 @@ const MESSAGE_LIMIT_FALLBACK: usize = 120;
|
||||
const DEBUG_JSON_MAX_CHARS: usize = 1200;
|
||||
const CLI_PROMPT_MIN_CHARS: usize = 4;
|
||||
const CLI_PROMPT_MAX_CHARS: usize = 600;
|
||||
const AUTO_DRIVE_CLI_MODELS: &[&str] = &["gpt-5.3-codex", "gpt-5.3-codex-spark"];
|
||||
const AUTO_DRIVE_CLI_REASONING_LEVELS: &[&str] = &["medium", "high", "xhigh"];
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("auto coordinator cancelled")]
|
||||
@@ -185,6 +187,8 @@ pub struct AutoTurnCliAction {
|
||||
pub prompt: String,
|
||||
pub context: Option<String>,
|
||||
pub suppress_ui_context: bool,
|
||||
pub model_override: Option<String>,
|
||||
pub reasoning_effort_override: Option<ReasoningEffort>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -473,8 +477,13 @@ mod tests {
|
||||
"status_sent_to_user property missing"
|
||||
);
|
||||
assert!(
|
||||
props.contains_key("prompt_sent_to_cli"),
|
||||
"prompt_sent_to_cli property missing"
|
||||
props.contains_key("cli_milestone_instruction"),
|
||||
"cli_milestone_instruction property missing"
|
||||
);
|
||||
assert!(props.contains_key("cli_model"), "cli_model property missing");
|
||||
assert!(
|
||||
props.contains_key("cli_reasoning_effort"),
|
||||
"cli_reasoning_effort property missing"
|
||||
);
|
||||
assert!(props.contains_key("agents"), "agents property missing");
|
||||
assert!(!props.contains_key("code_review"));
|
||||
@@ -490,7 +499,9 @@ mod tests {
|
||||
assert!(schema_required.contains(&json!("goal")));
|
||||
assert!(schema_required.contains(&json!("status_title")));
|
||||
assert!(schema_required.contains(&json!("status_sent_to_user")));
|
||||
assert!(schema_required.contains(&json!("prompt_sent_to_cli")));
|
||||
assert!(schema_required.contains(&json!("cli_milestone_instruction")));
|
||||
assert!(schema_required.contains(&json!("cli_model")));
|
||||
assert!(schema_required.contains(&json!("cli_reasoning_effort")));
|
||||
assert!(schema_required.contains(&json!("finish_evidence")));
|
||||
assert!(schema_required.contains(&json!("agents")));
|
||||
assert_eq!(
|
||||
@@ -549,15 +560,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_sets_prompt_sent_to_cli_min_without_max_length() {
|
||||
fn schema_sets_cli_milestone_instruction_min_without_max_length() {
|
||||
let active_agents: Vec<String> = Vec::new();
|
||||
let schema = build_schema(&active_agents, SchemaFeatures::default());
|
||||
let prompt_schema = schema
|
||||
.get("properties")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|obj| obj.get("prompt_sent_to_cli"))
|
||||
.and_then(|obj| obj.get("cli_milestone_instruction"))
|
||||
.and_then(|v| v.as_object())
|
||||
.expect("prompt_sent_to_cli schema");
|
||||
.expect("cli_milestone_instruction schema");
|
||||
|
||||
assert_eq!(
|
||||
prompt_schema.get("minLength"),
|
||||
@@ -652,12 +663,39 @@ mod tests {
|
||||
.and_then(|v| v.as_object())
|
||||
.expect("schema properties");
|
||||
assert!(!props.contains_key("agents"));
|
||||
assert!(props.contains_key("cli_model"));
|
||||
assert!(props.contains_key("cli_reasoning_effort"));
|
||||
let required = schema
|
||||
.get("required")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("required array");
|
||||
assert!(!required.contains(&json!("agents")));
|
||||
assert!(required.contains(&json!("goal")));
|
||||
assert!(required.contains(&json!("cli_model")));
|
||||
assert!(required.contains(&json!("cli_reasoning_effort")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_omits_cli_model_routing_when_disabled() {
|
||||
let schema = build_schema(
|
||||
&Vec::new(),
|
||||
SchemaFeatures {
|
||||
include_cli_model_routing: false,
|
||||
..SchemaFeatures::default()
|
||||
},
|
||||
);
|
||||
let props = schema
|
||||
.get("properties")
|
||||
.and_then(|v| v.as_object())
|
||||
.expect("schema properties");
|
||||
assert!(!props.contains_key("cli_model"));
|
||||
assert!(!props.contains_key("cli_reasoning_effort"));
|
||||
let required = schema
|
||||
.get("required")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("required array");
|
||||
assert!(!required.contains(&json!("cli_model")));
|
||||
assert!(!required.contains(&json!("cli_reasoning_effort")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -710,7 +748,7 @@ mod tests {
|
||||
"finish_status": "continue",
|
||||
"status_title": "Dispatching fix",
|
||||
"status_sent_to_user": "Ran smoke tests while validating the fix.",
|
||||
"prompt_sent_to_cli": "Apply the patch for the failing test",
|
||||
"cli_milestone_instruction": "Apply the patch for the failing test",
|
||||
"agents": {
|
||||
"timing": "blocking",
|
||||
"list": [
|
||||
@@ -719,7 +757,8 @@ mod tests {
|
||||
}
|
||||
}"#;
|
||||
|
||||
let (decision, _) = parse_decision(raw).expect("parse new schema decision");
|
||||
let (decision, _) = parse_decision(raw, DecisionParseOptions::default())
|
||||
.expect("parse new schema decision");
|
||||
assert_eq!(decision.status, AutoCoordinatorStatus::Continue);
|
||||
assert_eq!(
|
||||
decision.status_sent_to_user.as_deref(),
|
||||
@@ -746,19 +785,64 @@ mod tests {
|
||||
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_decision_requires_cli_model_and_reasoning_when_enabled() {
|
||||
let raw = r#"{
|
||||
"finish_status": "continue",
|
||||
"status_title": "Dispatching fix",
|
||||
"status_sent_to_user": "Running failing test loop.",
|
||||
"cli_milestone_instruction": "Run the failing test, apply a minimal fix, and iterate until green."
|
||||
}"#;
|
||||
|
||||
let err = parse_decision(
|
||||
raw,
|
||||
DecisionParseOptions {
|
||||
require_cli_model_routing: true,
|
||||
},
|
||||
)
|
||||
.expect_err("routing-enabled parse should require model fields");
|
||||
|
||||
assert!(err.to_string().contains("missing cli_model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_decision_accepts_cli_model_and_reasoning_when_enabled() {
|
||||
let raw = r#"{
|
||||
"finish_status": "continue",
|
||||
"status_title": "Fixing tests",
|
||||
"status_sent_to_user": "Running clear failing-test loops.",
|
||||
"cli_milestone_instruction": "Take the failing test from red to green and report the passing evidence.",
|
||||
"cli_model": "gpt-5.3-codex-spark",
|
||||
"cli_reasoning_effort": "high"
|
||||
}"#;
|
||||
|
||||
let (decision, _) = parse_decision(
|
||||
raw,
|
||||
DecisionParseOptions {
|
||||
require_cli_model_routing: true,
|
||||
},
|
||||
)
|
||||
.expect("routing-enabled decision should parse");
|
||||
|
||||
let cli = decision.cli.expect("cli action expected");
|
||||
assert_eq!(cli.model_override.as_deref(), Some("gpt-5.3-codex-spark"));
|
||||
assert_eq!(cli.reasoning_effort_override, Some(ReasoningEffort::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_decision_new_schema_array_backcompat() {
|
||||
let raw = r#"{
|
||||
"finish_status": "continue",
|
||||
"status_title": "Running tests",
|
||||
"status_sent_to_user": "Outlined fix before execution.",
|
||||
"prompt_sent_to_cli": "Run cargo test",
|
||||
"cli_milestone_instruction": "Run cargo test",
|
||||
"agents": [
|
||||
{"prompt": "Investigate benchmark", "write": false}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let (decision, _) = parse_decision(raw).expect("parse array-style agents");
|
||||
let (decision, _) = parse_decision(raw, DecisionParseOptions::default())
|
||||
.expect("parse array-style agents");
|
||||
assert_eq!(decision.status, AutoCoordinatorStatus::Continue);
|
||||
assert!(decision.cli.is_some());
|
||||
assert_eq!(decision.agents.len(), 1);
|
||||
@@ -775,10 +859,11 @@ mod tests {
|
||||
let raw = r#"{
|
||||
"finish_status": "continue",
|
||||
"progress": {"past": "Drafted fix", "current": "Running unit tests"},
|
||||
"prompt_sent_to_cli": "Run cargo test --package core"
|
||||
"cli_milestone_instruction": "Run cargo test --package core"
|
||||
}"#;
|
||||
|
||||
let (decision, _) = parse_decision(raw).expect("parse legacy decision");
|
||||
let (decision, _) = parse_decision(raw, DecisionParseOptions::default())
|
||||
.expect("parse legacy decision");
|
||||
assert_eq!(decision.status, AutoCoordinatorStatus::Continue);
|
||||
assert_eq!(
|
||||
decision.status_sent_to_user.as_deref(),
|
||||
@@ -803,7 +888,7 @@ mod tests {
|
||||
"finish_status": "continue",
|
||||
"status_title": "Implementing",
|
||||
"status_sent_to_user": "Driving the implementation milestone.",
|
||||
"prompt_sent_to_cli": "Deliver the feature end-to-end and validate.",
|
||||
"cli_milestone_instruction": "Deliver the feature end-to-end and validate.",
|
||||
"finish_evidence": {
|
||||
"primary_outcome_achieved": "done",
|
||||
"validation_checks_passed": ["cargo test"],
|
||||
@@ -811,7 +896,8 @@ mod tests {
|
||||
}
|
||||
}"#;
|
||||
|
||||
let err = parse_decision(raw).expect_err("continue should reject finish evidence");
|
||||
let err = parse_decision(raw, DecisionParseOptions::default())
|
||||
.expect_err("continue should reject finish evidence");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("finish_evidence must be null when finish_status is continue")
|
||||
@@ -824,10 +910,11 @@ mod tests {
|
||||
"finish_status": "finish_success",
|
||||
"status_title": "Completed",
|
||||
"status_sent_to_user": "Everything is green.",
|
||||
"prompt_sent_to_cli": null
|
||||
"cli_milestone_instruction": null
|
||||
}"#;
|
||||
|
||||
let err = parse_decision(raw).expect_err("finish status should require evidence");
|
||||
let err = parse_decision(raw, DecisionParseOptions::default())
|
||||
.expect_err("finish status should require evidence");
|
||||
assert!(err.to_string().contains("missing finish_evidence"));
|
||||
}
|
||||
|
||||
@@ -837,7 +924,7 @@ mod tests {
|
||||
"finish_status": "finish_success",
|
||||
"status_title": "Completed",
|
||||
"status_sent_to_user": "Everything is green.",
|
||||
"prompt_sent_to_cli": "Keep working",
|
||||
"cli_milestone_instruction": "Keep working",
|
||||
"finish_evidence": {
|
||||
"primary_outcome_achieved": "Resolved primary task.",
|
||||
"validation_checks_passed": ["cargo test --workspace"],
|
||||
@@ -845,8 +932,12 @@ mod tests {
|
||||
}
|
||||
}"#;
|
||||
|
||||
let err = parse_decision(raw).expect_err("finish statuses require null CLI prompt");
|
||||
assert!(err.to_string().contains("must set prompt_sent_to_cli to null"));
|
||||
let err = parse_decision(raw, DecisionParseOptions::default())
|
||||
.expect_err("finish statuses require null CLI prompt");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("must set cli_milestone_instruction to null")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -856,7 +947,7 @@ mod tests {
|
||||
"phase": "lockdown",
|
||||
"status_title": "Completed",
|
||||
"status_sent_to_user": "All checks are green.",
|
||||
"prompt_sent_to_cli": null,
|
||||
"cli_milestone_instruction": null,
|
||||
"finish_evidence": {
|
||||
"primary_outcome_achieved": "Resolved primary task end-to-end.",
|
||||
"validation_checks_passed": ["cargo test --workspace", "./build-fast.sh"],
|
||||
@@ -864,7 +955,8 @@ mod tests {
|
||||
}
|
||||
}"#;
|
||||
|
||||
let (decision, _) = parse_decision(raw).expect("finish decision should parse");
|
||||
let (decision, _) = parse_decision(raw, DecisionParseOptions::default())
|
||||
.expect("finish decision should parse");
|
||||
assert_eq!(decision.status, AutoCoordinatorStatus::Success);
|
||||
assert!(decision.cli.is_none());
|
||||
}
|
||||
@@ -875,7 +967,7 @@ mod tests {
|
||||
"finish_status": "finish_success",
|
||||
"status_title": "Completed",
|
||||
"status_sent_to_user": "All checks are green.",
|
||||
"prompt_sent_to_cli": null,
|
||||
"cli_milestone_instruction": null,
|
||||
"finish_evidence": {
|
||||
"primary_outcome_achieved": "Resolved primary task end-to-end.",
|
||||
"validation_checks_passed": [],
|
||||
@@ -883,7 +975,8 @@ mod tests {
|
||||
}
|
||||
}"#;
|
||||
|
||||
let err = parse_decision(raw).expect_err("finish decision should require validations");
|
||||
let err = parse_decision(raw, DecisionParseOptions::default())
|
||||
.expect_err("finish decision should require validations");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("validation_checks_passed must include at least one")
|
||||
@@ -892,17 +985,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn classify_missing_cli_prompt_is_recoverable() {
|
||||
let err = anyhow!("model response missing prompt_sent_to_cli for continue");
|
||||
let err = anyhow!("model response missing cli_milestone_instruction for continue");
|
||||
let info = classify_recoverable_decision_error(&err).expect("recoverable error");
|
||||
assert!(info
|
||||
.summary
|
||||
.contains("prompt_sent_to_cli"));
|
||||
.contains("cli_milestone_instruction"));
|
||||
assert!(
|
||||
info
|
||||
.guidance
|
||||
.as_ref()
|
||||
.expect("guidance")
|
||||
.contains("prompt_sent_to_cli")
|
||||
.contains("cli_milestone_instruction")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1039,7 +1132,11 @@ struct CoordinatorDecisionNew {
|
||||
#[serde(default)]
|
||||
progress: Option<ProgressPayload>,
|
||||
#[serde(default)]
|
||||
prompt_sent_to_cli: Option<String>,
|
||||
cli_milestone_instruction: Option<String>,
|
||||
#[serde(default)]
|
||||
cli_model: Option<String>,
|
||||
#[serde(default)]
|
||||
cli_reasoning_effort: Option<String>,
|
||||
#[serde(default)]
|
||||
agents: Option<AgentsField>,
|
||||
#[serde(default)]
|
||||
@@ -1145,11 +1242,18 @@ struct ParsedCoordinatorDecision {
|
||||
model_slug: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct DecisionParseOptions {
|
||||
require_cli_model_routing: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CliAction {
|
||||
prompt: String,
|
||||
context: Option<String>,
|
||||
suppress_ui_context: bool,
|
||||
model_override: Option<String>,
|
||||
reasoning_effort_override: Option<ReasoningEffort>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -1361,6 +1465,8 @@ fn run_auto_loop(
|
||||
prompt: seed.cli_prompt.clone(),
|
||||
context: Some(seed.goal_message.clone()),
|
||||
suppress_ui_context: true,
|
||||
model_override: None,
|
||||
reasoning_effort_override: None,
|
||||
};
|
||||
let event = AutoCoordinatorEvent::Decision {
|
||||
seq: decision_seq,
|
||||
@@ -1458,6 +1564,7 @@ fn run_auto_loop(
|
||||
&event_tx,
|
||||
&cancel_token,
|
||||
&active_model_slug,
|
||||
schema_features.include_cli_model_routing,
|
||||
) {
|
||||
Ok(ParsedCoordinatorDecision {
|
||||
status,
|
||||
@@ -1597,7 +1704,7 @@ fn run_auto_loop(
|
||||
if consecutive_decision_failures <= MAX_DECISION_RECOVERY_ATTEMPTS {
|
||||
let attempt = consecutive_decision_failures;
|
||||
|
||||
const OVERLONG_MSG: &str = "ERROR: Your last prompt_sent_to_cli was greater than 600 characters and was not sent to the CLI. Please try again with a shorter prompt. You must keep prompts succinct (<=600 chars) to give the CLI autonomy to decide how to best execute the task.";
|
||||
const OVERLONG_MSG: &str = "ERROR: Your last cli_milestone_instruction was greater than 600 characters and was not sent to the CLI. Please try again with a shorter prompt. You must keep prompts succinct (<=600 chars) to give the CLI autonomy to decide how to best execute the task.";
|
||||
|
||||
let mut already_shared_raw = false;
|
||||
if let Some(raw) = raw_output.as_ref() {
|
||||
@@ -1874,7 +1981,7 @@ fn build_initial_planning_seed(goal_text: &str, include_agents: bool) -> Option<
|
||||
};
|
||||
|
||||
let response_json = format!(
|
||||
"{{\"finish_status\":\"continue\",\"status_title\":\"Planning\",\"status_sent_to_user\":\"Started initial planning phase\",\"prompt_sent_to_cli\":\"{cli_prompt}\"}}"
|
||||
"{{\"finish_status\":\"continue\",\"status_title\":\"Planning\",\"status_sent_to_user\":\"Started initial planning phase\",\"cli_milestone_instruction\":\"{cli_prompt}\"}}"
|
||||
);
|
||||
|
||||
Some(InitialPlanningSeed {
|
||||
@@ -1928,6 +2035,7 @@ fn run_git_command<const N: usize>(args: [&str; N]) -> Option<String> {
|
||||
struct SchemaFeatures {
|
||||
include_agents: bool,
|
||||
include_goal_field: bool,
|
||||
include_cli_model_routing: bool,
|
||||
}
|
||||
|
||||
impl SchemaFeatures {
|
||||
@@ -1935,6 +2043,7 @@ impl SchemaFeatures {
|
||||
Self {
|
||||
include_agents: settings.agents_enabled,
|
||||
include_goal_field: false,
|
||||
include_cli_model_routing: settings.model_routing_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1944,6 +2053,7 @@ impl Default for SchemaFeatures {
|
||||
Self {
|
||||
include_agents: true,
|
||||
include_goal_field: false,
|
||||
include_cli_model_routing: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2036,14 +2146,36 @@ fn build_schema(active_agents: &[String], features: SchemaFeatures) -> Value {
|
||||
required.push(Value::String("status_sent_to_user".to_string()));
|
||||
|
||||
properties.insert(
|
||||
"prompt_sent_to_cli".to_string(),
|
||||
"cli_milestone_instruction".to_string(),
|
||||
json!({
|
||||
"type": ["string", "null"],
|
||||
"minLength": CLI_PROMPT_MIN_CHARS,
|
||||
"description": "Single milestone instruction to the CLI. Outcome-focused, non-procedural. Set to null ONLY when finishing."
|
||||
"description": "Single milestone instruction to the CLI. Outcome-focused, non-procedural. Keep this between 4 and 600 characters; set to null ONLY when finishing."
|
||||
}),
|
||||
);
|
||||
required.push(Value::String("prompt_sent_to_cli".to_string()));
|
||||
required.push(Value::String("cli_milestone_instruction".to_string()));
|
||||
|
||||
if features.include_cli_model_routing {
|
||||
properties.insert(
|
||||
"cli_model".to_string(),
|
||||
json!({
|
||||
"type": ["string", "null"],
|
||||
"enum": ["gpt-5.3-codex", "gpt-5.3-codex-spark", null],
|
||||
"description": "CLI model for this turn. Use gpt-5.3-codex for planning/problem-solving (xhigh/high/medium) and gpt-5.3-codex-spark for clear coding loops or failing-test iteration (high). Set to null only when finishing."
|
||||
}),
|
||||
);
|
||||
required.push(Value::String("cli_model".to_string()));
|
||||
|
||||
properties.insert(
|
||||
"cli_reasoning_effort".to_string(),
|
||||
json!({
|
||||
"type": ["string", "null"],
|
||||
"enum": ["medium", "high", "xhigh", null],
|
||||
"description": "Reasoning effort for the selected CLI model this turn. Prefer xhigh for hard planning, high for main coding loops, and medium for straightforward execution. Set to null only when finishing."
|
||||
}),
|
||||
);
|
||||
required.push(Value::String("cli_reasoning_effort".to_string()));
|
||||
}
|
||||
|
||||
if features.include_agents {
|
||||
properties.insert(
|
||||
@@ -2167,6 +2299,7 @@ fn request_coordinator_decision(
|
||||
event_tx: &AutoCoordinatorEventSender,
|
||||
cancel_token: &CancellationToken,
|
||||
preferred_model_slug: &str,
|
||||
require_cli_model_routing: bool,
|
||||
) -> Result<ParsedCoordinatorDecision, DecisionFailure> {
|
||||
let RequestStreamResult {
|
||||
output_text,
|
||||
@@ -2197,7 +2330,12 @@ fn request_coordinator_decision(
|
||||
Some(output_text),
|
||||
));
|
||||
}
|
||||
let (mut decision, value) = parse_decision(&output_text)
|
||||
let (mut decision, value) = parse_decision(
|
||||
&output_text,
|
||||
DecisionParseOptions {
|
||||
require_cli_model_routing,
|
||||
},
|
||||
)
|
||||
.map_err(|err| DecisionFailure::new(err, "coordinator_decision", Some(output_text.clone())))?;
|
||||
debug!("[Auto coordinator] model decision: {:?}", value);
|
||||
decision.response_items = response_items;
|
||||
@@ -2951,15 +3089,16 @@ fn classify_recoverable_decision_error(err: &anyhow::Error) -> Option<Recoverabl
|
||||
let text = err.to_string();
|
||||
let lower = text.to_ascii_lowercase();
|
||||
|
||||
if lower.contains("missing prompt_sent_to_cli")
|
||||
if lower.contains("missing cli_milestone_instruction")
|
||||
|| lower.contains("missing cli prompt for continue")
|
||||
|| lower.contains("missing cli prompt for `finish_status")
|
||||
|| lower.contains("missing cli prompt")
|
||||
{
|
||||
return Some(RecoverableDecisionError {
|
||||
summary: "missing `prompt_sent_to_cli` for `finish_status: \"continue\"`".to_string(),
|
||||
summary: "missing `cli_milestone_instruction` for `finish_status: \"continue\"`"
|
||||
.to_string(),
|
||||
guidance: Some(
|
||||
"Include a non-empty `prompt_sent_to_cli` string whenever `finish_status` is `\"continue\"`."
|
||||
"Include a non-empty `cli_milestone_instruction` string whenever `finish_status` is `\"continue\"`."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
@@ -2975,6 +3114,26 @@ fn classify_recoverable_decision_error(err: &anyhow::Error) -> Option<Recoverabl
|
||||
});
|
||||
}
|
||||
|
||||
if lower.contains("missing cli_model") {
|
||||
return Some(RecoverableDecisionError {
|
||||
summary: "missing `cli_model` for continue turn".to_string(),
|
||||
guidance: Some(
|
||||
"When Auto Drive model routing is enabled, include `cli_model` on every continue turn."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if lower.contains("missing cli_reasoning_effort") {
|
||||
return Some(RecoverableDecisionError {
|
||||
summary: "missing `cli_reasoning_effort` for continue turn".to_string(),
|
||||
guidance: Some(
|
||||
"When Auto Drive model routing is enabled, include `cli_reasoning_effort` on every continue turn."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if lower.contains("validation_checks_passed must include at least one") {
|
||||
return Some(RecoverableDecisionError {
|
||||
summary: "finish evidence is missing passing validation checks".to_string(),
|
||||
@@ -2995,11 +3154,33 @@ fn classify_recoverable_decision_error(err: &anyhow::Error) -> Option<Recoverabl
|
||||
});
|
||||
}
|
||||
|
||||
if lower.contains("must set prompt_sent_to_cli to null") {
|
||||
if lower.contains("must set cli_milestone_instruction to null") {
|
||||
return Some(RecoverableDecisionError {
|
||||
summary: "`prompt_sent_to_cli` must be null for finish statuses".to_string(),
|
||||
summary: "`cli_milestone_instruction` must be null for finish statuses".to_string(),
|
||||
guidance: Some(
|
||||
"When finishing (`finish_success`/`finish_failed`), set `prompt_sent_to_cli` to null and include `finish_evidence`."
|
||||
"When finishing (`finish_success`/`finish_failed`), set `cli_milestone_instruction` to null and include `finish_evidence`."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if lower.contains("must set cli_model to null")
|
||||
|| lower.contains("must set cli_reasoning_effort to null")
|
||||
{
|
||||
return Some(RecoverableDecisionError {
|
||||
summary: "CLI model routing fields must be null for finish statuses".to_string(),
|
||||
guidance: Some(
|
||||
"When finishing (`finish_success`/`finish_failed`), set `cli_model` and `cli_reasoning_effort` to null."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if lower.contains("unsupported cli_model") || lower.contains("unsupported cli_reasoning_effort") {
|
||||
return Some(RecoverableDecisionError {
|
||||
summary: "unsupported CLI model routing selection".to_string(),
|
||||
guidance: Some(
|
||||
"Use `cli_model` in {gpt-5.3-codex, gpt-5.3-codex-spark} and `cli_reasoning_effort` in {medium, high, xhigh}."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
@@ -3007,12 +3188,12 @@ fn classify_recoverable_decision_error(err: &anyhow::Error) -> Option<Recoverabl
|
||||
|
||||
if lower.contains("length limit")
|
||||
|| lower.contains("cut off")
|
||||
|| lower.contains("exceeds") && lower.contains("prompt_sent_to_cli")
|
||||
|| lower.contains("exceeds") && lower.contains("cli_milestone_instruction")
|
||||
{
|
||||
return Some(RecoverableDecisionError {
|
||||
summary: "model output was cut off by a length cap".to_string(),
|
||||
guidance: Some(
|
||||
"Regenerate with a shorter `prompt_sent_to_cli` (<=600 chars) and more concise status text so the response fits within provider limits."
|
||||
"Regenerate with a shorter `cli_milestone_instruction` (<=600 chars) and more concise status text so the response fits within provider limits."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
@@ -3115,7 +3296,7 @@ fn push_unique_guidance(guidance: &mut Vec<String>, message: &str) {
|
||||
}
|
||||
|
||||
|
||||
fn parse_decision(raw: &str) -> Result<(ParsedCoordinatorDecision, Value)> {
|
||||
fn parse_decision(raw: &str, options: DecisionParseOptions) -> Result<(ParsedCoordinatorDecision, Value)> {
|
||||
let value: Value = match serde_json::from_str(raw) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
@@ -3128,7 +3309,7 @@ fn parse_decision(raw: &str) -> Result<(ParsedCoordinatorDecision, Value)> {
|
||||
match serde_json::from_value::<CoordinatorDecisionNew>(value.clone()) {
|
||||
Ok(decision) => {
|
||||
let status = parse_finish_status(&decision.finish_status)?;
|
||||
let parsed = convert_decision_new(decision, status)?;
|
||||
let parsed = convert_decision_new(decision, status, options)?;
|
||||
Ok((parsed, value))
|
||||
}
|
||||
Err(new_err) => {
|
||||
@@ -3142,7 +3323,7 @@ fn parse_decision(raw: &str) -> Result<(ParsedCoordinatorDecision, Value)> {
|
||||
anyhow!("decoding coordinator decision failed: new_schema_err={new_err}; legacy_err={legacy_err}; payload_snippet={snippet}")
|
||||
})?;
|
||||
let status = parse_finish_status(&decision.finish_status)?;
|
||||
let parsed = convert_decision_legacy(decision, status)?;
|
||||
let parsed = convert_decision_legacy(decision, status, options)?;
|
||||
Ok((parsed, value))
|
||||
}
|
||||
}
|
||||
@@ -3161,6 +3342,7 @@ fn parse_finish_status(finish_status: &str) -> Result<AutoCoordinatorStatus> {
|
||||
fn convert_decision_new(
|
||||
decision: CoordinatorDecisionNew,
|
||||
status: AutoCoordinatorStatus,
|
||||
options: DecisionParseOptions,
|
||||
) -> Result<ParsedCoordinatorDecision> {
|
||||
let CoordinatorDecisionNew {
|
||||
finish_status: _,
|
||||
@@ -3168,7 +3350,9 @@ fn convert_decision_new(
|
||||
status_title,
|
||||
status_sent_to_user,
|
||||
progress,
|
||||
prompt_sent_to_cli,
|
||||
cli_milestone_instruction,
|
||||
cli_model,
|
||||
cli_reasoning_effort,
|
||||
agents: agent_payloads,
|
||||
finish_evidence,
|
||||
goal,
|
||||
@@ -3192,29 +3376,35 @@ fn convert_decision_new(
|
||||
|
||||
let goal = clean_optional(goal);
|
||||
|
||||
let cli_prompt = clean_optional(prompt_sent_to_cli);
|
||||
let cli_prompt = clean_optional(cli_milestone_instruction);
|
||||
let cli_model = clean_optional(cli_model);
|
||||
let cli_reasoning_effort = clean_optional(cli_reasoning_effort);
|
||||
|
||||
validate_finish_evidence_for_status(status, finish_evidence)?;
|
||||
let (cli_model, cli_reasoning_effort) =
|
||||
validate_cli_model_selection(status, cli_model, cli_reasoning_effort, options)?;
|
||||
|
||||
let cli = match (status, cli_prompt) {
|
||||
(AutoCoordinatorStatus::Continue, Some(prompt)) => {
|
||||
let prompt = clean_required(&prompt, "prompt_sent_to_cli")?;
|
||||
let prompt = clean_required(&prompt, "cli_milestone_instruction")?;
|
||||
ensure_cli_prompt_length(&prompt)?;
|
||||
|
||||
Some(CliAction {
|
||||
prompt,
|
||||
context: None,
|
||||
suppress_ui_context: false,
|
||||
model_override: cli_model,
|
||||
reasoning_effort_override: cli_reasoning_effort,
|
||||
})
|
||||
}
|
||||
(AutoCoordinatorStatus::Continue, None) => {
|
||||
return Err(anyhow!(
|
||||
"model response missing prompt_sent_to_cli for continue"
|
||||
"model response missing cli_milestone_instruction for continue"
|
||||
));
|
||||
}
|
||||
(_, Some(_prompt)) => {
|
||||
return Err(anyhow!(
|
||||
"model response must set prompt_sent_to_cli to null when finish_status is finish_success or finish_failed"
|
||||
"model response must set cli_milestone_instruction to null when finish_status is finish_success or finish_failed"
|
||||
));
|
||||
}
|
||||
(_, None) => None,
|
||||
@@ -3278,6 +3468,7 @@ fn convert_decision_new(
|
||||
fn convert_decision_legacy(
|
||||
decision: CoordinatorDecisionLegacy,
|
||||
status: AutoCoordinatorStatus,
|
||||
options: DecisionParseOptions,
|
||||
) -> Result<ParsedCoordinatorDecision> {
|
||||
let CoordinatorDecisionLegacy {
|
||||
finish_status: _,
|
||||
@@ -3299,18 +3490,26 @@ fn convert_decision_legacy(
|
||||
));
|
||||
}
|
||||
|
||||
if options.require_cli_model_routing {
|
||||
return Err(anyhow!(
|
||||
"legacy model response missing cli_model and cli_reasoning_effort for continue"
|
||||
));
|
||||
}
|
||||
|
||||
let cli = match (status, cli_prompt) {
|
||||
(AutoCoordinatorStatus::Continue, Some(prompt)) => Some(CliAction {
|
||||
prompt: clean_required(&prompt, "cli_prompt")?,
|
||||
context: context.clone(),
|
||||
suppress_ui_context: false,
|
||||
model_override: None,
|
||||
reasoning_effort_override: None,
|
||||
}),
|
||||
(AutoCoordinatorStatus::Continue, None) => {
|
||||
return Err(anyhow!("legacy model response missing cli_prompt for continue"));
|
||||
}
|
||||
(_, Some(_prompt)) => {
|
||||
return Err(anyhow!(
|
||||
"model response must set prompt_sent_to_cli to null when finish_status is finish_success or finish_failed"
|
||||
"model response must set cli_milestone_instruction to null when finish_status is finish_success or finish_failed"
|
||||
));
|
||||
}
|
||||
(_, None) => None,
|
||||
@@ -3406,6 +3605,77 @@ fn validate_phase(phase: Option<String>) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cli_reasoning_effort(value: &str) -> Result<ReasoningEffort> {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
if !AUTO_DRIVE_CLI_REASONING_LEVELS
|
||||
.iter()
|
||||
.any(|level| normalized == *level)
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"unsupported cli_reasoning_effort '{normalized}'; expected one of: {}",
|
||||
AUTO_DRIVE_CLI_REASONING_LEVELS.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
match normalized.as_str() {
|
||||
"medium" => Ok(ReasoningEffort::Medium),
|
||||
"high" => Ok(ReasoningEffort::High),
|
||||
"xhigh" => Ok(ReasoningEffort::XHigh),
|
||||
_ => Err(anyhow!("unsupported cli_reasoning_effort '{normalized}'")),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_cli_model(value: &str) -> Result<String> {
|
||||
let trimmed = value.trim();
|
||||
for model in AUTO_DRIVE_CLI_MODELS {
|
||||
if trimmed.eq_ignore_ascii_case(model) {
|
||||
return Ok((*model).to_string());
|
||||
}
|
||||
}
|
||||
Err(anyhow!(
|
||||
"unsupported cli_model '{trimmed}'; expected one of: {}",
|
||||
AUTO_DRIVE_CLI_MODELS.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
fn validate_cli_model_selection(
|
||||
status: AutoCoordinatorStatus,
|
||||
cli_model: Option<String>,
|
||||
cli_reasoning_effort: Option<String>,
|
||||
options: DecisionParseOptions,
|
||||
) -> Result<(Option<String>, Option<ReasoningEffort>)> {
|
||||
if !options.require_cli_model_routing {
|
||||
return Ok((None, None));
|
||||
}
|
||||
|
||||
match status {
|
||||
AutoCoordinatorStatus::Continue => {
|
||||
let model = cli_model
|
||||
.ok_or_else(|| anyhow!("model response missing cli_model for continue"))?;
|
||||
let reasoning = cli_reasoning_effort.ok_or_else(|| {
|
||||
anyhow!("model response missing cli_reasoning_effort for continue")
|
||||
})?;
|
||||
|
||||
let model = normalize_cli_model(&model)?;
|
||||
let reasoning = parse_cli_reasoning_effort(&reasoning)?;
|
||||
Ok((Some(model), Some(reasoning)))
|
||||
}
|
||||
AutoCoordinatorStatus::Success | AutoCoordinatorStatus::Failed => {
|
||||
if cli_model.is_some() {
|
||||
return Err(anyhow!(
|
||||
"model response must set cli_model to null when finish_status is finish_success or finish_failed"
|
||||
));
|
||||
}
|
||||
if cli_reasoning_effort.is_some() {
|
||||
return Err(anyhow!(
|
||||
"model response must set cli_reasoning_effort to null when finish_status is finish_success or finish_failed"
|
||||
));
|
||||
}
|
||||
Ok((None, None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_finish_evidence_for_status(
|
||||
status: AutoCoordinatorStatus,
|
||||
finish_evidence: Option<FinishEvidencePayload>,
|
||||
@@ -3458,12 +3728,12 @@ fn ensure_cli_prompt_length(prompt: &str) -> Result<()> {
|
||||
let len = prompt.chars().count();
|
||||
if len < CLI_PROMPT_MIN_CHARS {
|
||||
return Err(anyhow!(
|
||||
"prompt_sent_to_cli must be at least {CLI_PROMPT_MIN_CHARS} characters; keep it concise but not empty"
|
||||
"cli_milestone_instruction must be at least {CLI_PROMPT_MIN_CHARS} characters; keep it concise but not empty"
|
||||
));
|
||||
}
|
||||
if len > CLI_PROMPT_MAX_CHARS {
|
||||
return Err(anyhow!(
|
||||
"prompt_sent_to_cli exceeds {CLI_PROMPT_MAX_CHARS} characters; keep prompts succinct (<=600 chars) and let the CLI decide how to execute the task"
|
||||
"cli_milestone_instruction exceeds {CLI_PROMPT_MAX_CHARS} characters; keep prompts succinct (<=600 chars) and let the CLI decide how to execute the task"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -3475,6 +3745,8 @@ fn cli_action_to_event(action: &CliAction) -> AutoTurnCliAction {
|
||||
prompt: action.prompt.clone(),
|
||||
context: action.context.clone(),
|
||||
suppress_ui_context: action.suppress_ui_context,
|
||||
model_override: action.model_override.clone(),
|
||||
reasoning_effort_override: action.reasoning_effort_override,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use code_common::elapsed::format_duration;
|
||||
use code_core::config_types::ReasoningEffort;
|
||||
use code_core::protocol::ReviewContextMetadata;
|
||||
use code_core::protocol::ReviewOutputEvent;
|
||||
use code_core::review_coord::{bump_snapshot_epoch, try_acquire_lock};
|
||||
@@ -289,6 +290,8 @@ pub struct AutoDriveController {
|
||||
pub current_status_title: Option<String>,
|
||||
pub current_cli_prompt: Option<String>,
|
||||
pub current_cli_context: Option<String>,
|
||||
pub current_cli_model_override: Option<String>,
|
||||
pub current_cli_reasoning_effort_override: Option<ReasoningEffort>,
|
||||
pub hide_cli_context_in_ui: bool,
|
||||
pub suppress_next_cli_display: bool,
|
||||
pub current_display_line: Option<String>,
|
||||
@@ -528,6 +531,8 @@ impl AutoDriveController {
|
||||
self.current_status_title = None;
|
||||
self.current_cli_prompt = None;
|
||||
self.current_cli_context = None;
|
||||
self.current_cli_model_override = None;
|
||||
self.current_cli_reasoning_effort_override = None;
|
||||
self.hide_cli_context_in_ui = false;
|
||||
self.suppress_next_cli_display = false;
|
||||
self.current_display_line = None;
|
||||
@@ -606,6 +611,8 @@ impl AutoDriveController {
|
||||
let truncated_reason = Self::truncate_error(&reason);
|
||||
self.current_cli_prompt = None;
|
||||
self.current_cli_context = None;
|
||||
self.current_cli_model_override = None;
|
||||
self.current_cli_reasoning_effort_override = None;
|
||||
self.hide_cli_context_in_ui = false;
|
||||
self.suppress_next_cli_display = false;
|
||||
self.pending_agent_actions.clear();
|
||||
@@ -659,9 +666,13 @@ impl AutoDriveController {
|
||||
&mut self,
|
||||
decision_seq: u64,
|
||||
prompt_text: String,
|
||||
cli_model_override: Option<String>,
|
||||
cli_reasoning_effort_override: Option<ReasoningEffort>,
|
||||
countdown_override: Option<u8>,
|
||||
) -> Vec<AutoControllerEffect> {
|
||||
self.current_cli_prompt = Some(prompt_text);
|
||||
self.current_cli_model_override = cli_model_override;
|
||||
self.current_cli_reasoning_effort_override = cli_reasoning_effort_override;
|
||||
self.apply_phase(AutoRunPhase::AwaitingCoordinator { prompt_ready: true });
|
||||
self.countdown_override = countdown_override;
|
||||
self.reset_countdown();
|
||||
@@ -988,7 +999,7 @@ mod tests {
|
||||
fn countdown_tick_respects_decision_seq() {
|
||||
let mut controller = AutoDriveController::default();
|
||||
|
||||
let _effects = controller.schedule_cli_prompt(1, "Test prompt".to_string(), None);
|
||||
let _effects = controller.schedule_cli_prompt(1, "Test prompt".to_string(), None, None, None);
|
||||
let countdown_id = controller.countdown_id;
|
||||
|
||||
let effects = controller.handle_countdown_tick(countdown_id, 1, 5);
|
||||
@@ -1002,7 +1013,7 @@ mod tests {
|
||||
fn countdown_tick_final_emits_submit() {
|
||||
let mut controller = AutoDriveController::default();
|
||||
|
||||
let _effects = controller.schedule_cli_prompt(7, "Prompt".to_string(), None);
|
||||
let _effects = controller.schedule_cli_prompt(7, "Prompt".to_string(), None, None, None);
|
||||
let countdown_id = controller.countdown_id;
|
||||
|
||||
let effects = controller.handle_countdown_tick(countdown_id, 7, 0);
|
||||
@@ -1013,7 +1024,7 @@ mod tests {
|
||||
#[test]
|
||||
fn countdown_tick_ignores_stopped_phase() {
|
||||
let mut controller = AutoDriveController::default();
|
||||
let _effects = controller.schedule_cli_prompt(3, "Prompt".to_string(), None);
|
||||
let _effects = controller.schedule_cli_prompt(3, "Prompt".to_string(), None, None, None);
|
||||
let countdown_id = controller.countdown_id;
|
||||
controller.set_phase(AutoRunPhase::Idle);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ You direct the Code CLI (role: user) and an optional fleet of helper agents. You
|
||||
# The Single Most Important Rule: Milestones, Not Micromanagement
|
||||
You must provide ONE MILESTONE per turn to the CLI, not one tiny step. A milestone is a coherent outcome (e.g., "investigate + patch + validate").
|
||||
|
||||
In `prompt_sent_to_cli`, **DO** provide:
|
||||
In `cli_milestone_instruction`, **DO** provide:
|
||||
- The milestone outcome.
|
||||
- Constraints / scope boundaries.
|
||||
- Definition of done (what validation must be run).
|
||||
@@ -33,6 +33,13 @@ Agents work in isolated, parallel worktrees. Use them strategically based on the
|
||||
- **Parallel Coding:** Offload straightforward, well-scoped coding tasks to fast, efficient models (like `-spark` or `-flash` model). Launch them in parallel on different tasks to implement distinct components simultaneously while the main CLI focuses on integration.
|
||||
- **No Highly Dependent Chains:** Don't use agents if the task requires step-by-step stateful changes where each step depends on the previous one. The CLI's native loop is better for stateful persistence.
|
||||
|
||||
# CLI Model Routing
|
||||
When schema fields are available, pick `cli_model` and `cli_reasoning_effort` on every continue turn.
|
||||
- Use `gpt-5.3-codex` with `xhigh` for hard planning/problem-solving turns.
|
||||
- Use `gpt-5.3-codex` with `high` or `medium` for broad implementation/integration turns.
|
||||
- Use `gpt-5.3-codex-spark` with `high` for clear coding loops and failing-test iteration.
|
||||
- Only set these fields to `null` when finishing.
|
||||
|
||||
# Completion Gate
|
||||
Code completion is not task completion. Never set `finish_status` to `"finish_success"` unless you can explicitly populate the `finish_evidence` object with proof that:
|
||||
1. The primary task is fully resolved end-to-end.
|
||||
|
||||
@@ -877,6 +877,8 @@ pub fn set_auto_drive_settings(
|
||||
toml_edit::value(settings.observer_enabled);
|
||||
doc["auto_drive"]["coordinator_routing"] =
|
||||
toml_edit::value(settings.coordinator_routing);
|
||||
doc["auto_drive"]["model_routing_enabled"] =
|
||||
toml_edit::value(settings.model_routing_enabled);
|
||||
doc["auto_drive"]["model"] = toml_edit::value(settings.model.trim());
|
||||
doc["auto_drive"]["model_reasoning_effort"] = toml_edit::value(
|
||||
settings
|
||||
|
||||
@@ -804,6 +804,10 @@ pub struct AutoDriveSettings {
|
||||
#[serde(default = "default_true")]
|
||||
pub coordinator_routing: bool,
|
||||
|
||||
/// Allow the coordinator to select the CLI model and reasoning effort per turn.
|
||||
#[serde(default = "default_true")]
|
||||
pub model_routing_enabled: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub continue_mode: AutoDriveContinueMode,
|
||||
|
||||
@@ -835,6 +839,7 @@ impl Default for AutoDriveSettings {
|
||||
cross_check_enabled: true,
|
||||
observer_enabled: true,
|
||||
coordinator_routing: true,
|
||||
model_routing_enabled: true,
|
||||
continue_mode: AutoDriveContinueMode::TenSeconds,
|
||||
model: default_auto_drive_model(),
|
||||
model_reasoning_effort: default_auto_drive_reasoning_effort(),
|
||||
|
||||
@@ -879,6 +879,7 @@ impl App<'_> {
|
||||
agents_enabled,
|
||||
cross_check_enabled,
|
||||
qa_automation_enabled,
|
||||
model_routing_enabled,
|
||||
continue_mode,
|
||||
} => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
@@ -887,6 +888,7 @@ impl App<'_> {
|
||||
agents_enabled,
|
||||
cross_check_enabled,
|
||||
qa_automation_enabled,
|
||||
model_routing_enabled,
|
||||
continue_mode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,6 +235,7 @@ pub(crate) enum AppEvent {
|
||||
agents_enabled: bool,
|
||||
cross_check_enabled: bool,
|
||||
qa_automation_enabled: bool,
|
||||
model_routing_enabled: bool,
|
||||
continue_mode: AutoContinueMode,
|
||||
},
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ pub(crate) struct AutoDriveSettingsView {
|
||||
cross_check_enabled: bool,
|
||||
qa_automation_enabled: bool,
|
||||
diagnostics_enabled: bool,
|
||||
model_routing_enabled: bool,
|
||||
continue_mode: AutoContinueMode,
|
||||
closing: bool,
|
||||
}
|
||||
@@ -41,6 +42,7 @@ impl AutoDriveSettingsView {
|
||||
agents_enabled: bool,
|
||||
cross_check_enabled: bool,
|
||||
qa_automation_enabled: bool,
|
||||
model_routing_enabled: bool,
|
||||
continue_mode: AutoContinueMode,
|
||||
) -> Self {
|
||||
let diagnostics_enabled = qa_automation_enabled
|
||||
@@ -56,13 +58,14 @@ impl AutoDriveSettingsView {
|
||||
cross_check_enabled,
|
||||
qa_automation_enabled,
|
||||
diagnostics_enabled,
|
||||
model_routing_enabled,
|
||||
continue_mode,
|
||||
closing: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn option_count() -> usize {
|
||||
4
|
||||
5
|
||||
}
|
||||
|
||||
fn send_update(&self) {
|
||||
@@ -71,6 +74,7 @@ impl AutoDriveSettingsView {
|
||||
agents_enabled: self.agents_enabled,
|
||||
cross_check_enabled: self.cross_check_enabled,
|
||||
qa_automation_enabled: self.qa_automation_enabled,
|
||||
model_routing_enabled: self.model_routing_enabled,
|
||||
continue_mode: self.continue_mode,
|
||||
});
|
||||
}
|
||||
@@ -158,7 +162,11 @@ impl AutoDriveSettingsView {
|
||||
self.set_diagnostics(next);
|
||||
self.send_update();
|
||||
}
|
||||
3 => self.cycle_continue_mode(true),
|
||||
3 => {
|
||||
self.model_routing_enabled = !self.model_routing_enabled;
|
||||
self.send_update();
|
||||
}
|
||||
4 => self.cycle_continue_mode(true),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -201,6 +209,10 @@ impl AutoDriveSettingsView {
|
||||
self.diagnostics_enabled,
|
||||
),
|
||||
3 => (
|
||||
"Coordinator model routing (choose model + reasoning per turn)",
|
||||
self.model_routing_enabled,
|
||||
),
|
||||
4 => (
|
||||
"Auto-continue delay",
|
||||
matches!(self.continue_mode, AutoContinueMode::Manual),
|
||||
),
|
||||
@@ -240,14 +252,14 @@ impl AutoDriveSettingsView {
|
||||
}
|
||||
}
|
||||
}
|
||||
1 | 2 => {
|
||||
1 | 2 | 3 => {
|
||||
let checkbox = if enabled { "[x]" } else { "[ ]" };
|
||||
spans.push(Span::styled(
|
||||
format!("{checkbox} {label}"),
|
||||
label_style,
|
||||
));
|
||||
}
|
||||
3 => {
|
||||
4 => {
|
||||
spans.push(Span::styled(label.to_string(), label_style));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
@@ -269,6 +281,7 @@ impl AutoDriveSettingsView {
|
||||
lines.push(self.option_label(1));
|
||||
lines.push(self.option_label(2));
|
||||
lines.push(self.option_label(3));
|
||||
lines.push(self.option_label(4));
|
||||
lines.push(Line::default());
|
||||
|
||||
let footer_style = Style::default().fg(colors::text_dim());
|
||||
@@ -320,13 +333,13 @@ impl AutoDriveSettingsView {
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if self.selected_index == 2 {
|
||||
if self.selected_index == 4 {
|
||||
self.cycle_continue_mode(false);
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if self.selected_index == 2 {
|
||||
if self.selected_index == 4 {
|
||||
self.cycle_continue_mode(true);
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
@@ -376,13 +389,13 @@ impl<'a> BottomPaneView<'a> for AutoDriveSettingsView {
|
||||
pane.request_redraw();
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if self.selected_index == 2 {
|
||||
if self.selected_index == 4 {
|
||||
self.cycle_continue_mode(false);
|
||||
pane.request_redraw();
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if self.selected_index == 2 {
|
||||
if self.selected_index == 4 {
|
||||
self.cycle_continue_mode(true);
|
||||
pane.request_redraw();
|
||||
}
|
||||
@@ -396,7 +409,7 @@ impl<'a> BottomPaneView<'a> for AutoDriveSettingsView {
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
9
|
||||
10
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
|
||||
@@ -16821,6 +16821,7 @@ impl ChatWidget<'_> {
|
||||
let agents = self.auto_state.subagents_enabled;
|
||||
let cross = self.auto_state.cross_check_enabled;
|
||||
let qa = self.auto_state.qa_automation_enabled;
|
||||
let model_routing = self.config.auto_drive.model_routing_enabled;
|
||||
let mode = self.auto_state.continue_mode;
|
||||
let view = AutoDriveSettingsView::new(
|
||||
self.app_event_tx.clone(),
|
||||
@@ -16831,6 +16832,7 @@ impl ChatWidget<'_> {
|
||||
agents,
|
||||
cross,
|
||||
qa,
|
||||
model_routing,
|
||||
mode,
|
||||
);
|
||||
AutoDriveSettingsContent::new(view)
|
||||
@@ -18552,6 +18554,7 @@ fi\n\
|
||||
agents_enabled: bool,
|
||||
cross_check_enabled: bool,
|
||||
qa_automation_enabled: bool,
|
||||
model_routing_enabled: bool,
|
||||
continue_mode: AutoContinueMode,
|
||||
) {
|
||||
let mut changed = false;
|
||||
@@ -18571,6 +18574,10 @@ fi\n\
|
||||
self.auto_state.qa_automation_enabled = qa_automation_enabled;
|
||||
changed = true;
|
||||
}
|
||||
if self.config.auto_drive.model_routing_enabled != model_routing_enabled {
|
||||
self.config.auto_drive.model_routing_enabled = model_routing_enabled;
|
||||
changed = true;
|
||||
}
|
||||
if self.auto_state.continue_mode != continue_mode {
|
||||
let effects = self.auto_state.update_continue_mode(continue_mode);
|
||||
self.auto_apply_controller_effects(effects);
|
||||
@@ -18585,6 +18592,7 @@ fi\n\
|
||||
self.config.auto_drive.agents_enabled = agents_enabled;
|
||||
self.config.auto_drive.cross_check_enabled = cross_check_enabled;
|
||||
self.config.auto_drive.qa_automation_enabled = qa_automation_enabled;
|
||||
self.config.auto_drive.model_routing_enabled = model_routing_enabled;
|
||||
self.config.auto_drive.continue_mode = auto_continue_to_config(continue_mode);
|
||||
self.restore_auto_resolve_attempts_if_lost();
|
||||
|
||||
@@ -18632,6 +18640,8 @@ fi\n\
|
||||
self.auto_state.current_status_title = None;
|
||||
self.auto_state.current_cli_prompt = None;
|
||||
self.auto_state.current_cli_context = None;
|
||||
self.auto_state.current_cli_model_override = None;
|
||||
self.auto_state.current_cli_reasoning_effort_override = None;
|
||||
self.auto_state.hide_cli_context_in_ui = false;
|
||||
self.auto_state.last_broadcast_summary = None;
|
||||
self.auto_state.current_summary_index = None;
|
||||
@@ -18671,6 +18681,8 @@ fi\n\
|
||||
self.auto_state.current_status_title = None;
|
||||
self.auto_state.current_cli_prompt = None;
|
||||
self.auto_state.current_cli_context = None;
|
||||
self.auto_state.current_cli_model_override = None;
|
||||
self.auto_state.current_cli_reasoning_effort_override = None;
|
||||
self.auto_state.hide_cli_context_in_ui = false;
|
||||
self.auto_state.last_broadcast_summary = None;
|
||||
self.auto_state.current_summary_index = None;
|
||||
@@ -18823,8 +18835,16 @@ fi\n\
|
||||
.and_then(|action| action.context.clone());
|
||||
let cli_context = Self::normalize_status_field(cli_context_raw);
|
||||
let cli_prompt = cli.as_ref().map(|action| action.prompt.clone());
|
||||
let cli_model_override = cli
|
||||
.as_ref()
|
||||
.and_then(|action| action.model_override.clone());
|
||||
let cli_reasoning_effort_override = cli
|
||||
.as_ref()
|
||||
.and_then(|action| action.reasoning_effort_override);
|
||||
|
||||
self.auto_state.current_cli_context = cli_context.clone();
|
||||
self.auto_state.current_cli_model_override = cli_model_override.clone();
|
||||
self.auto_state.current_cli_reasoning_effort_override = cli_reasoning_effort_override;
|
||||
self.auto_state.hide_cli_context_in_ui = planning_turn;
|
||||
self.auto_state.suppress_next_cli_display = planning_turn;
|
||||
if let Some(ref prompt_text) = cli_prompt {
|
||||
@@ -18923,7 +18943,13 @@ fi\n\
|
||||
));
|
||||
}
|
||||
} else {
|
||||
self.schedule_auto_cli_prompt(seq, prompt_text);
|
||||
self.schedule_auto_cli_prompt_with_override(
|
||||
seq,
|
||||
prompt_text,
|
||||
cli_model_override,
|
||||
cli_reasoning_effort_override,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
AutoCoordinatorStatus::Success => {
|
||||
@@ -19070,19 +19096,33 @@ Have we met every part of this goal and is there no further work to do?"#
|
||||
}
|
||||
|
||||
fn schedule_auto_cli_prompt(&mut self, decision_seq: u64, prompt_text: String) {
|
||||
self.schedule_auto_cli_prompt_with_override(decision_seq, prompt_text, None);
|
||||
self.schedule_auto_cli_prompt_with_override(
|
||||
decision_seq,
|
||||
prompt_text,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
fn schedule_auto_cli_prompt_with_override(
|
||||
&mut self,
|
||||
decision_seq: u64,
|
||||
prompt_text: String,
|
||||
cli_model_override: Option<String>,
|
||||
cli_reasoning_effort_override: Option<ReasoningEffort>,
|
||||
countdown_override: Option<u8>,
|
||||
) {
|
||||
self.auto_state.suppress_next_cli_display = false;
|
||||
let effects = self
|
||||
.auto_state
|
||||
.schedule_cli_prompt(decision_seq, prompt_text, countdown_override);
|
||||
.schedule_cli_prompt(
|
||||
decision_seq,
|
||||
prompt_text,
|
||||
cli_model_override,
|
||||
cli_reasoning_effort_override,
|
||||
countdown_override,
|
||||
);
|
||||
self.auto_apply_controller_effects(effects);
|
||||
}
|
||||
|
||||
@@ -19711,6 +19751,8 @@ Have we met every part of this goal and is there no further work to do?"#
|
||||
}
|
||||
|
||||
self.auto_state.current_cli_context = None;
|
||||
self.auto_state.current_cli_model_override = None;
|
||||
self.auto_state.current_cli_reasoning_effort_override = None;
|
||||
self.auto_state.hide_cli_context_in_ui = false;
|
||||
self.auto_state.current_cli_prompt = Some(String::new());
|
||||
self.auto_pending_goal_request = true;
|
||||
@@ -19724,7 +19766,13 @@ Have we met every part of this goal and is there no further work to do?"#
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.schedule_auto_cli_prompt_with_override(0, String::new(), override_seconds);
|
||||
self.schedule_auto_cli_prompt_with_override(
|
||||
0,
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
override_seconds,
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -19762,6 +19810,9 @@ Have we met every part of this goal and is there no further work to do?"#
|
||||
} else {
|
||||
self.auto_turn_review_state = None;
|
||||
}
|
||||
|
||||
self.auto_apply_cli_turn_model_override();
|
||||
|
||||
self.bottom_pane.update_status_text(String::new());
|
||||
self.bottom_pane.set_task_running(false);
|
||||
let mut message: UserMessage = full_prompt.into();
|
||||
@@ -19774,11 +19825,30 @@ Have we met every part of this goal and is there no further work to do?"#
|
||||
self.submit_user_message(message);
|
||||
self.auto_state.pending_agent_actions.clear();
|
||||
self.auto_state.pending_agent_timing = None;
|
||||
self.auto_state.current_cli_model_override = None;
|
||||
self.auto_state.current_cli_reasoning_effort_override = None;
|
||||
self.auto_rebuild_live_ring();
|
||||
self.request_redraw();
|
||||
self.auto_state.suppress_next_cli_display = false;
|
||||
}
|
||||
|
||||
fn auto_apply_cli_turn_model_override(&mut self) {
|
||||
if !self.config.auto_drive.model_routing_enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(model) = self.auto_state.current_cli_model_override.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let effort = self
|
||||
.auto_state
|
||||
.current_cli_reasoning_effort_override
|
||||
.unwrap_or(ReasoningEffort::High);
|
||||
|
||||
self.apply_model_selection_inner(model, Some(effort), false, false);
|
||||
}
|
||||
|
||||
fn auto_pause_for_manual_edit(&mut self, force: bool) {
|
||||
if !self.auto_state.is_active() {
|
||||
return;
|
||||
@@ -24260,10 +24330,11 @@ Have we met every part of this goal and is there no further work to do?"#
|
||||
format!("Model: {}", model_text)
|
||||
};
|
||||
Some(format!(
|
||||
"{} · Agents: {} · Diagnostics: {} · Continue: {}",
|
||||
"{} · Agents: {} · Diagnostics: {} · Model routing: {} · Continue: {}",
|
||||
model_segment,
|
||||
Self::on_off_label(self.auto_state.subagents_enabled),
|
||||
Self::on_off_label(diagnostics_enabled),
|
||||
Self::on_off_label(self.config.auto_drive.model_routing_enabled),
|
||||
self.auto_state.continue_mode.label()
|
||||
))
|
||||
}
|
||||
@@ -31217,6 +31288,8 @@ use code_core::protocol::OrderMeta;
|
||||
prompt: "echo ready".to_string(),
|
||||
context: None,
|
||||
suppress_ui_context: false,
|
||||
model_override: None,
|
||||
reasoning_effort_override: None,
|
||||
}),
|
||||
None,
|
||||
Vec::new(),
|
||||
@@ -31253,6 +31326,8 @@ use code_core::protocol::OrderMeta;
|
||||
prompt: "echo start".to_string(),
|
||||
context: None,
|
||||
suppress_ui_context: false,
|
||||
model_override: None,
|
||||
reasoning_effort_override: None,
|
||||
}),
|
||||
None,
|
||||
Vec::new(),
|
||||
@@ -31340,6 +31415,8 @@ use code_core::protocol::OrderMeta;
|
||||
prompt: "echo work".to_string(),
|
||||
context: None,
|
||||
suppress_ui_context: false,
|
||||
model_override: None,
|
||||
reasoning_effort_override: None,
|
||||
}),
|
||||
None,
|
||||
Vec::new(),
|
||||
@@ -32066,6 +32143,8 @@ use code_core::protocol::OrderMeta;
|
||||
prompt: "Run cargo test".to_string(),
|
||||
context: Some("use --all-features".to_string()),
|
||||
suppress_ui_context: false,
|
||||
model_override: None,
|
||||
reasoning_effort_override: None,
|
||||
}),
|
||||
Some(AutoTurnAgentsTiming::Parallel),
|
||||
vec![AutoTurnAgentsAction {
|
||||
@@ -32102,10 +32181,80 @@ use code_core::protocol::OrderMeta;
|
||||
.iter()
|
||||
.any(|span| span.content.contains(notice))
|
||||
})
|
||||
});
|
||||
});
|
||||
assert!(write_notice_present);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_submit_prompt_applies_routed_cli_model_override() {
|
||||
let _runtime_guard = enter_test_runtime_guard();
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
let chat = harness.chat();
|
||||
|
||||
chat.auto_state.set_phase(AutoRunPhase::Active);
|
||||
chat.config.auto_drive.model_routing_enabled = true;
|
||||
chat.config.model = "gpt-5.3-codex".to_string();
|
||||
chat.config.model_reasoning_effort = ReasoningEffort::Medium;
|
||||
|
||||
chat.auto_handle_decision(
|
||||
5,
|
||||
AutoCoordinatorStatus::Continue,
|
||||
Some("Fixing tests".to_string()),
|
||||
Some("Running targeted loop.".to_string()),
|
||||
None,
|
||||
Some(AutoTurnCliAction {
|
||||
prompt: "Run the failing test and keep iterating until it passes.".to_string(),
|
||||
context: None,
|
||||
suppress_ui_context: false,
|
||||
model_override: Some("gpt-5.3-codex-spark".to_string()),
|
||||
reasoning_effort_override: Some(ReasoningEffort::High),
|
||||
}),
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
chat.auto_submit_prompt();
|
||||
|
||||
assert_eq!(chat.config.model, "gpt-5.3-codex-spark");
|
||||
assert_eq!(chat.config.model_reasoning_effort, ReasoningEffort::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_submit_prompt_preserves_model_when_routing_disabled() {
|
||||
let _runtime_guard = enter_test_runtime_guard();
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
let chat = harness.chat();
|
||||
|
||||
chat.auto_state.set_phase(AutoRunPhase::Active);
|
||||
chat.config.auto_drive.model_routing_enabled = false;
|
||||
chat.config.model = "gpt-5.3-codex".to_string();
|
||||
chat.config.model_reasoning_effort = ReasoningEffort::Medium;
|
||||
|
||||
chat.auto_handle_decision(
|
||||
6,
|
||||
AutoCoordinatorStatus::Continue,
|
||||
Some("Fixing tests".to_string()),
|
||||
Some("Running targeted loop.".to_string()),
|
||||
None,
|
||||
Some(AutoTurnCliAction {
|
||||
prompt: "Run the failing test and keep iterating until it passes.".to_string(),
|
||||
context: None,
|
||||
suppress_ui_context: false,
|
||||
model_override: Some("gpt-5.3-codex-spark".to_string()),
|
||||
reasoning_effort_override: Some(ReasoningEffort::High),
|
||||
}),
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
chat.auto_submit_prompt();
|
||||
|
||||
assert_eq!(chat.config.model, "gpt-5.3-codex");
|
||||
assert_eq!(chat.config.model_reasoning_effort, ReasoningEffort::Medium);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinator_router_emits_notice_for_status_question() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
|
||||
Reference in New Issue
Block a user