1use car_inference::adaptive_router::TaskComplexity;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum EngineChoice {
16 Auto,
18 Native,
20 External(String),
23 Foreman(String),
28}
29
30impl EngineChoice {
31 pub fn parse(s: &str) -> Result<Self, String> {
34 match s.trim() {
35 "auto" | "" => Ok(Self::Auto),
36 "native" => Ok(Self::Native),
37 "external" => Ok(Self::External(String::new())),
38 "foreman" => Ok(Self::Foreman(String::new())),
39 other => {
40 if let Some(id) = other.strip_prefix("external:") {
41 if !id.is_empty() {
42 return Ok(Self::External(id.to_string()));
43 }
44 }
45 if let Some(id) = other.strip_prefix("foreman:") {
46 if !id.is_empty() {
47 return Ok(Self::Foreman(id.to_string()));
48 }
49 }
50 Err(format!(
51 "unknown engine '{other}' (expected auto | native | external[:agent_id] | foreman[:agent_id])"
52 ))
53 }
54 }
55 }
56
57 pub fn label(&self) -> String {
58 match self {
59 Self::Auto => "auto".to_string(),
60 Self::Native => "native".to_string(),
61 Self::External(id) if id.is_empty() => "external".to_string(),
62 Self::External(id) => format!("external:{id}"),
63 Self::Foreman(id) if id.is_empty() => "foreman".to_string(),
64 Self::Foreman(id) => format!("foreman:{id}"),
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
71pub struct DetectedAgent {
72 pub id: String,
73 pub ready: bool,
75 pub unusable_reason: Option<String>,
82}
83
84impl DetectedAgent {
85 fn unavailable_reason(&self, kind: &str) -> Option<String> {
89 if let Some(why) = &self.unusable_reason {
90 return Some(format!(
91 "{kind} '{}' is installed but cannot be executed: {why}",
92 self.id
93 ));
94 }
95 if !self.ready {
96 return Some(format!(
97 "{kind} '{}' is installed but not ready (not authenticated?)",
98 self.id
99 ));
100 }
101 None
102 }
103}
104
105pub const DEFAULT_PREFERENCE: [&str; 3] = ["claude-code", "codex", "gemini"];
107
108#[derive(Debug, Clone, PartialEq)]
110pub struct ResolvedEngine {
111 pub engine: EngineChoice,
112 pub reason: String,
113}
114
115pub async fn detect_ready_agents() -> Vec<DetectedAgent> {
118 car_external_agents::detect_with_health(false)
119 .await
120 .into_iter()
121 .map(|spec| DetectedAgent {
122 ready: matches!(
123 &spec.health,
124 Some(h) if h.status == car_external_agents::HealthStatus::Ready
125 ),
126 unusable_reason: spec.unusable_reason().map(str::to_string),
127 id: spec.id,
128 })
129 .collect()
130}
131
132fn first_ready(preference: &[&str], detected: &[DetectedAgent]) -> Option<String> {
141 let usable = |d: &DetectedAgent| d.ready && car_external_agents::auto_selectable(d.id.as_str());
142 preference
143 .iter()
144 .find(|p| detected.iter().any(|d| usable(d) && d.id == **p))
145 .map(|p| p.to_string())
146 .or_else(|| detected.iter().find(|d| usable(d)).map(|d| d.id.clone()))
148}
149
150pub fn resolve_engine(
154 requested: &EngineChoice,
155 intent: &str,
156 detected: &[DetectedAgent],
157 preference: &[&str],
158) -> Result<ResolvedEngine, String> {
159 match requested {
160 EngineChoice::Native => Ok(ResolvedEngine {
161 engine: EngineChoice::Native,
162 reason: "explicitly requested".into(),
163 }),
164 EngineChoice::External(id) if !id.is_empty() => {
165 let agent = detected
166 .iter()
167 .find(|d| d.id == *id)
168 .ok_or_else(|| format!("external agent '{id}' is not installed"))?;
169 if let Some(why) = agent.unavailable_reason("external agent") {
170 return Err(why);
171 }
172 Ok(ResolvedEngine {
173 engine: EngineChoice::External(id.clone()),
174 reason: "explicitly requested".into(),
175 })
176 }
177 EngineChoice::External(_) => {
178 let id = first_ready(preference, detected).ok_or(
179 "external engine requested but no external agent CLI is installed and ready",
180 )?;
181 Ok(ResolvedEngine {
182 engine: EngineChoice::External(id),
183 reason: "first ready external agent".into(),
184 })
185 }
186 EngineChoice::Foreman(id) if !id.is_empty() => {
187 let agent = detected
188 .iter()
189 .find(|d| d.id == *id)
190 .ok_or_else(|| format!("foreman adapter '{id}' is not installed"))?;
191 if let Some(why) = agent.unavailable_reason("foreman adapter") {
192 return Err(why);
193 }
194 if !car_external_agents::auto_selectable(id.as_str()) {
201 return Err(format!(
202 "'{id}' is a measurement baseline, not a Foreman adapter — run it as \
203 `external:{id}` (see docs/coder-ab-guide.md)"
204 ));
205 }
206 Ok(ResolvedEngine {
207 engine: EngineChoice::Foreman(id.clone()),
208 reason: "explicitly requested".into(),
209 })
210 }
211 EngineChoice::Foreman(_) => {
212 let id = first_ready(preference, detected).ok_or(
213 "foreman engine requested but no external agent CLI is installed and ready",
214 )?;
215 Ok(ResolvedEngine {
216 engine: EngineChoice::Foreman(id),
217 reason: "first ready external agent".into(),
218 })
219 }
220 EngineChoice::Auto => {
221 let complexity = TaskComplexity::assess(intent);
228 let broad_scope = intent.split_whitespace().count() > 120;
229 let frontier_worthy = complexity == TaskComplexity::Complex
230 || (complexity == TaskComplexity::Code && broad_scope);
231 if frontier_worthy {
232 if let Some(id) = first_ready(preference, detected) {
233 return Ok(ResolvedEngine {
237 engine: EngineChoice::Foreman(id),
238 reason: format!(
239 "task assessed as {complexity:?} with broad scope and a frontier CLI is ready; \
240 foreman gates the parallel farm-out"
241 ),
242 });
243 }
244 }
245 Ok(ResolvedEngine {
246 engine: EngineChoice::Native,
247 reason: if frontier_worthy {
248 "task is complex but no external CLI is ready; using native loop".into()
249 } else {
250 format!("task assessed as {complexity:?}; native loop suffices")
251 },
252 })
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn agents(ready: &[&str], installed_not_ready: &[&str]) -> Vec<DetectedAgent> {
262 ready
263 .iter()
264 .map(|id| DetectedAgent {
265 id: id.to_string(),
266 ready: true,
267 unusable_reason: None,
268 })
269 .chain(installed_not_ready.iter().map(|id| DetectedAgent {
270 id: id.to_string(),
271 ready: false,
272 unusable_reason: None,
273 }))
274 .collect()
275 }
276
277 #[test]
283 fn explicit_engine_reports_unrunnable_over_auth() {
284 let detected = vec![DetectedAgent {
285 id: "codex".to_string(),
286 ready: false,
287 unusable_reason: Some("killed by signal 9".to_string()),
288 }];
289 let err = resolve_engine(
290 &EngineChoice::External("codex".to_string()),
291 "add a test",
292 &detected,
293 &DEFAULT_PREFERENCE,
294 )
295 .unwrap_err();
296 assert!(
297 err.contains("cannot be executed") && err.contains("signal 9"),
298 "want the executability diagnosis, got {err:?}"
299 );
300 assert!(
301 !err.contains("not authenticated"),
302 "must not blame auth for an unrunnable binary, got {err:?}"
303 );
304 }
305
306 fn complex_intent() -> String {
309 format!(
310 "Refactor the authentication architecture across the whole system, design and \
311 implement the migration step by step, then analyze the tradeoffs. {}",
312 "Consider every module and integration in depth. ".repeat(30)
313 )
314 }
315
316 #[test]
317 fn explicit_choice_always_wins() {
318 let detected = agents(&["claude-code"], &[]);
319 let r = resolve_engine(
320 &EngineChoice::Native,
321 &complex_intent(),
322 &detected,
323 &DEFAULT_PREFERENCE,
324 )
325 .unwrap();
326 assert_eq!(r.engine, EngineChoice::Native);
327
328 let r = resolve_engine(
329 &EngineChoice::External("claude-code".into()),
330 "tiny task",
331 &detected,
332 &DEFAULT_PREFERENCE,
333 )
334 .unwrap();
335 assert_eq!(r.engine, EngineChoice::External("claude-code".into()));
336 }
337
338 #[test]
339 fn explicit_external_fails_clearly_when_unavailable() {
340 let err = resolve_engine(
341 &EngineChoice::External("codex".into()),
342 "x",
343 &agents(&[], &["codex"]),
344 &DEFAULT_PREFERENCE,
345 )
346 .unwrap_err();
347 assert!(err.contains("not ready"), "{err}");
348
349 let err = resolve_engine(
350 &EngineChoice::External("codex".into()),
351 "x",
352 &agents(&[], &[]),
353 &DEFAULT_PREFERENCE,
354 )
355 .unwrap_err();
356 assert!(err.contains("not installed"), "{err}");
357 }
358
359 #[test]
360 fn auto_with_no_clis_is_native() {
361 let r = resolve_engine(
362 &EngineChoice::Auto,
363 &complex_intent(),
364 &[],
365 &DEFAULT_PREFERENCE,
366 )
367 .unwrap();
368 assert_eq!(r.engine, EngineChoice::Native);
369 assert!(r.reason.contains("no external CLI"), "{}", r.reason);
370 }
371
372 #[test]
376 fn an_unnamed_request_never_resolves_to_the_measurement_baseline() {
377 let detected = agents(&["mini-swe-agent"], &[]);
378
379 let bare = resolve_engine(
380 &EngineChoice::External(String::new()),
381 &complex_intent(),
382 &detected,
383 &DEFAULT_PREFERENCE,
384 );
385 assert!(
386 bare.is_err(),
387 "bare `external` must not fall back to the baseline: {bare:?}"
388 );
389
390 let auto = resolve_engine(
393 &EngineChoice::Auto,
394 &complex_intent(),
395 &detected,
396 &DEFAULT_PREFERENCE,
397 )
398 .unwrap();
399 assert_eq!(auto.engine, EngineChoice::Native, "{}", auto.reason);
400 }
401
402 #[test]
406 fn naming_the_baseline_outright_still_resolves() {
407 let detected = agents(&["mini-swe-agent"], &[]);
408 let r = resolve_engine(
409 &EngineChoice::External("mini-swe-agent".into()),
410 "fix the failing test",
411 &detected,
412 &DEFAULT_PREFERENCE,
413 )
414 .unwrap();
415 assert_eq!(
416 r.engine,
417 EngineChoice::External("mini-swe-agent".into()),
418 "{}",
419 r.reason
420 );
421 }
422
423 #[test]
428 fn the_measurement_baseline_is_refused_as_a_foreman_adapter() {
429 let detected = agents(&["mini-swe-agent"], &[]);
430 let err = resolve_engine(
431 &EngineChoice::Foreman("mini-swe-agent".into()),
432 &complex_intent(),
433 &detected,
434 &DEFAULT_PREFERENCE,
435 )
436 .expect_err("a control arm run through CAR's orchestrator measures the orchestrator");
437 assert!(err.contains("measurement baseline"), "{err}");
438 assert!(err.contains("external:mini-swe-agent"), "{err}");
440 }
441
442 #[test]
446 fn an_unnamed_request_still_resolves_to_a_production_agent() {
447 let detected = agents(&["some-future-cli"], &[]);
450 let r = resolve_engine(
451 &EngineChoice::External(String::new()),
452 &complex_intent(),
453 &detected,
454 &DEFAULT_PREFERENCE,
455 )
456 .unwrap();
457 assert_eq!(r.engine, EngineChoice::External("some-future-cli".into()));
458 }
459
460 #[test]
461 fn auto_simple_task_stays_native_even_with_clis() {
462 let detected = agents(&["claude-code"], &[]);
463 let r = resolve_engine(
464 &EngineChoice::Auto,
465 "fix typo in README",
466 &detected,
467 &DEFAULT_PREFERENCE,
468 )
469 .unwrap();
470 assert_eq!(r.engine, EngineChoice::Native, "{}", r.reason);
471 }
472
473 #[test]
474 fn auto_complex_task_delegates_foreman_first_in_preference_order() {
475 let detected = agents(&["gemini", "claude-code"], &["codex"]);
476 let r = resolve_engine(
477 &EngineChoice::Auto,
478 &complex_intent(),
479 &detected,
480 &DEFAULT_PREFERENCE,
481 )
482 .unwrap();
483 assert_eq!(
484 r.engine,
485 EngineChoice::Foreman("claude-code".into()),
486 "foreman-first with preference order: {}",
487 r.reason
488 );
489 }
490
491 #[test]
492 fn explicit_foreman_resolves_and_fails_clearly() {
493 let detected = agents(&["codex"], &["claude-code"]);
494 let r = resolve_engine(
495 &EngineChoice::Foreman(String::new()),
496 "x",
497 &detected,
498 &DEFAULT_PREFERENCE,
499 )
500 .unwrap();
501 assert_eq!(r.engine, EngineChoice::Foreman("codex".into()));
502
503 let err = resolve_engine(
504 &EngineChoice::Foreman("claude-code".into()),
505 "x",
506 &detected,
507 &DEFAULT_PREFERENCE,
508 )
509 .unwrap_err();
510 assert!(err.contains("not ready"), "{err}");
511
512 let err = resolve_engine(
513 &EngineChoice::Foreman(String::new()),
514 "x",
515 &agents(&[], &[]),
516 &DEFAULT_PREFERENCE,
517 )
518 .unwrap_err();
519 assert!(err.contains("no external agent"), "{err}");
520 }
521
522 #[test]
523 fn ready_agent_outside_preference_still_selected() {
524 let detected = agents(&["future-cli"], &[]);
525 let r = resolve_engine(
526 &EngineChoice::External(String::new()),
527 "x",
528 &detected,
529 &DEFAULT_PREFERENCE,
530 )
531 .unwrap();
532 assert_eq!(r.engine, EngineChoice::External("future-cli".into()));
533 }
534
535 #[test]
536 fn parse_round_trips() {
537 for (input, expect) in [
538 ("auto", EngineChoice::Auto),
539 ("", EngineChoice::Auto),
540 ("native", EngineChoice::Native),
541 ("external", EngineChoice::External(String::new())),
542 (
543 "external:claude-code",
544 EngineChoice::External("claude-code".into()),
545 ),
546 ("foreman", EngineChoice::Foreman(String::new())),
547 ("foreman:codex", EngineChoice::Foreman("codex".into())),
548 ] {
549 assert_eq!(EngineChoice::parse(input).unwrap(), expect);
550 }
551 assert!(EngineChoice::parse("warp-drive").is_err());
552 assert!(EngineChoice::parse("external:").is_err());
553 assert!(EngineChoice::parse("foreman:").is_err());
554 }
555}